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 @@ + + + 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 + + +
+

Or sign in with

+ + 🔑 {{ p.name }} + +
@@ -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 @@ + + +