diff --git a/src/frontend/src/components/settings/SsoPanel.vue b/src/frontend/src/components/settings/SsoPanel.vue
new file mode 100644
index 000000000..344412667
--- /dev/null
+++ b/src/frontend/src/components/settings/SsoPanel.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
Single Sign-On (OIDC)
+
+ Let users sign in through your identity provider (Okta, Entra ID, Google Workspace).
+ SAML support is coming separately.
+
+
+
+
{{ error }}
+
+
+
+ Identity providers
+
+
+ No providers configured yet.
+
+
+
+
+
+ {{ p.name }}
+ (disabled)
+
+
{{ p.issuer }}
+
+
+
+
+
+
+
+ {{ testResult }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/frontend/src/stores/auth.js b/src/frontend/src/stores/auth.js
index 2ccc35c53..b08eb14ba 100644
--- a/src/frontend/src/stores/auth.js
+++ b/src/frontend/src/stores/auth.js
@@ -300,6 +300,38 @@ export const useAuthStore = defineStore('auth', {
this.mfaChallenge = null
},
+ // #32 — enabled SSO providers for the login page (id + name only). Returns
+ // [] in OSS builds (endpoint 404s when the `sso` module isn't entitled).
+ async fetchSsoProviders() {
+ try {
+ const r = await axios.get('/api/enterprise/sso/public-providers')
+ return r.data?.providers || []
+ } catch (e) {
+ return []
+ }
+ },
+
+ // Complete an SSO (OIDC) login from the callback URL fragment the backend
+ // redirects to: `/login#sso=ok&access_token=…`, `…sso=mfa&challenge_token=…`,
+ // or `…sso=error&reason=…` (#32). Reuses the same finalize / 2FA-challenge
+ // paths as password/email login. Returns {ok, mfa?}.
+ async completeSsoLogin(params) {
+ const status = params.get('sso')
+ if (status === 'ok') {
+ await this._finalizeLogin(params.get('access_token'))
+ return { ok: true }
+ }
+ if (status === 'mfa') {
+ this._setMfaChallenge({
+ challenge_token: params.get('challenge_token'),
+ enrollment_required: params.get('enroll') === '1',
+ })
+ return { ok: true, mfa: true }
+ }
+ this.authError = params.get('reason') || 'SSO login failed'
+ return { ok: false }
+ },
+
// Complete login by verifying a TOTP or recovery code against the
// outstanding challenge. Returns true on success.
async verifyMfaCode(code) {
diff --git a/src/frontend/src/views/Login.vue b/src/frontend/src/views/Login.vue
index 5be4eab03..92b546ba1 100644
--- a/src/frontend/src/views/Login.vue
+++ b/src/frontend/src/views/Login.vue
@@ -185,6 +185,21 @@
🔐 Admin Login
+
+
+
@@ -275,6 +290,7 @@ const showAdminLogin = ref(false)
const mfaCode = ref('')
const mfaEnroll = ref(null) // provisioning payload during forced enrollment
const mfaRecoveryCodes = ref([])
+const ssoProviders = ref([]) // #32 — enabled SSO IdPs (login buttons)
const mfaMode = computed(() =>
authStore.mfaChallenge?.enrollmentRequired ? 'enroll' : 'verify'
)
@@ -379,6 +395,23 @@ onMounted(async () => {
router.push('/')
return
}
+
+ // #32 — handle an SSO (OIDC) callback redirect: the backend lands us back at
+ // /login with the result in the URL fragment. Consume it, then strip it from
+ // the address bar so a refresh/back can't replay it.
+ if (window.location.hash.includes('sso=')) {
+ const params = new URLSearchParams(window.location.hash.slice(1))
+ history.replaceState(null, '', window.location.pathname + window.location.search)
+ const res = await authStore.completeSsoLogin(params)
+ if (res.ok && !res.mfa) {
+ router.push('/')
+ return
+ }
+ // mfa → the existing 2FA challenge UI takes over; error → authError shows.
+ }
+
+ // Populate SSO login buttons (no-op / empty in OSS-only builds).
+ ssoProviders.value = await authStore.fetchSsoProviders()
})
// Handle admin login (username 'admin' OR the admin's registered email — #82 Phase 1)
diff --git a/src/frontend/src/views/Settings.vue b/src/frontend/src/views/Settings.vue
index 4fb1f26f3..a6501801a 100644
--- a/src/frontend/src/views/Settings.vue
+++ b/src/frontend/src/views/Settings.vue
@@ -45,6 +45,9 @@
+
+
+