diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index df826bee..939d038a 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -699,6 +699,7 @@ Open-core seam: enterprise backend code lives in the private `trinity-enterprise | `user_management` | `enterprise/backend/user_management/` (#995) | Org lifecycle: invite (whitelist + email), deactivate/reactivate (over the OSS `users.suspended_at` primitive), per-user activity view (reads OSS `audit_log`). `/api/enterprise/user-management/*`; Settings → User Management UI | | `siem` | `enterprise/backend/siem/` (#997) | SIEM log export — ships OSS `audit_log` to a customer SIEM over HTTP/JSON webhook. Private `enterprise_siem_config` (destination + AES-encrypted token + export cursor); Redis-lock-serialised background pusher; at-least-once (cursor advances only on successful POST). `/api/enterprise/siem/*`; no OSS/UI surface | | `2fa` | `enterprise/backend/two_factor/` (#5) | Two-factor auth via **TOTP** (RFC 6238; Google Authenticator is one compatible app — chosen over Google-OIDC to avoid a runtime IdP dependency). Private `enterprise_user_2fa` (AES-256-GCM secret + monotonic `last_used_step` replay guard), `enterprise_2fa_recovery_codes` (single-use, SHA-256-hashed), `enterprise_2fa_config` (per-role policy). `/api/enterprise/2fa/*` — self-service enroll/confirm/disable/recovery + admin policy + the `login/*` challenge-completion endpoints. Login seam is edition-agnostic: `services/mfa_gate.py` (no provider in OSS → no-op, fail-open) + `create_mfa_challenge_token`/`decode_mfa_challenge` in `dependencies.py`; on a required second factor the OSS auth routers (`/token`, `/api/auth/email/verify`) return a short-lived challenge token instead of an access token. Settings → Security UI | +| `sso` | `enterprise/backend/sso/` (trinity-enterprise#32) | Single Sign-On via **OIDC** (authorization-code + PKCE; SAML is a tracked follow-up). Private `enterprise_sso_providers` (per-IdP config; AES-256-GCM `client_secret`, `protocol`-tagged for future SAML rows) + single-row `enterprise_sso_config` (password-fallback / auto-provision / default role). `/api/enterprise/sso/*` — admin provider CRUD + connectivity test + policy (admin-gated), `GET /public-providers` (unauth, `{id,name}` of enabled IdPs for login buttons), and the unauth `login/{id}` → IdP → `callback` flow. Dependency-free (httpx + python-jose, already in-image). State/nonce/PKCE-verifier held single-use in Redis (`sso_state:*`, GETDEL, TTL); id_token verified against IdP JWKS (asymmetric algs only — no `none`/`HS*`), `iss`/`aud`/`exp`/`nonce` checked. Edition-agnostic mint: the callback exchanges the validated assertion for a **standard platform JWT** via OSS `create_access_token` (no second session mechanism) and runs `mfa_gate` so local 2FA still applies; JIT provisioning honors the OSS whitelist + `default_role` (#314, no silent elevation). Settings → SSO UI + login-page buttons | --- diff --git a/docs/memory/requirements.md b/docs/memory/requirements.md index 78d44834..681f8d07 100644 --- a/docs/memory/requirements.md +++ b/docs/memory/requirements.md @@ -2876,6 +2876,54 @@ Standalone mobile-friendly admin page for managing agents on the go. Designed as --- +## 40. Enterprise Single Sign-On — OIDC (trinity-enterprise#32) + +> Section number provisional — this branch is based on the 2FA branch (behind +> `dev`); the final number resolves on rebase to `dev`. Enterprise feature on +> the #847 seam; most logic lives in the private `enterprise/backend/sso/` +> submodule. This documents the OSS-visible contract + the security rules. + +**Description**: Enterprise customers sign in through their identity provider +(Okta, Entra ID, Google Workspace) instead of (or alongside) the OSS +email-code / admin-password flow. **OIDC-first**; SAML 2.0 is a tracked +follow-up (needs a native xmlsec dependency). Ships as an enterprise module — +`register_module("sso")`, endpoints gated by `requires_entitlement("sso")`, +zero OSS behavior change when the submodule is absent. + +- **FR-1 — Provider config (admin)**: CRUD over multiple named OIDC providers + (issuer/discovery URL, client id, client secret, scopes, enabled) + + connectivity test. Client secret is **write-only** (AES-256-GCM at rest, + Invariant #12) — never returned. Settings → SSO UI, admin-gated. +- **FR-2 — OIDC login**: authorization-code flow with **PKCE**, single-use + `state` + `nonce` held in Redis (`GETDEL`, TTL). `id_token` validated against + the IdP JWKS restricted to **asymmetric** algorithms (no `none`/`HS*`); + `iss`/`aud`/`exp` checked by python-jose, `nonce` matched. Dependency-free + (httpx + python-jose, already in-image). +- **FR-3 — Single mint path**: the callback exchanges the validated assertion + for a **standard platform JWT** via the OSS `create_access_token` — no second + session mechanism (Invariant: OSS token issuance stays the single mint path). +- **FR-4 — 2FA composition (#5)**: after IdP auth the OSS `mfa_gate` still + runs, so a local second factor can be demanded; the callback returns a 2FA + challenge instead of a token in that case. +- **FR-5 — JIT provisioning (#314)**: an unknown email is rejected unless + `auto_provision` is enabled, then added to the whitelist at the configured + `default_role` — never silently elevated. Suspended accounts are refused. +- **FR-6 — Coexistence + break-glass**: OSS email/admin login stays available + (configurable `allow_password_fallback`); admin password login always works. +- **FR-7 — Audit**: `login_success` / `login_failure` / `mfa_challenge_issued` + written to the OSS `audit_log` (`method: "sso"`). + +**API** (enterprise, gated): `GET/POST/PUT/DELETE /api/enterprise/sso/providers`, +`POST .../providers/{id}/test`, `GET/PUT .../config` (admin); `GET +.../public-providers` (unauth, `{id,name}` of enabled IdPs); `GET +.../login/{id}` → IdP and `GET .../callback` (unauth login flow). + +**Out of scope (follow-up)**: SAML 2.0 (separate issue — native xmlsec dep + +signature-wrap review); SCIM auto-provisioning; per-provider role mapping from +IdP groups. + +--- + ## Out of Scope - Multi-tenant deployment (single org only) diff --git a/src/backend/enterprise b/src/backend/enterprise index 1e916f60..87c8f975 160000 --- a/src/backend/enterprise +++ b/src/backend/enterprise @@ -1 +1 @@ -Subproject commit 1e916f60483ad2a11121ce997a0b9efa609477c1 +Subproject commit 87c8f975d3ba5db87b40c63536fd0ac29cef1cdf diff --git a/src/frontend/src/components/settings/SsoPanel.vue b/src/frontend/src/components/settings/SsoPanel.vue new file mode 100644 index 00000000..34441266 --- /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 3b095060..6f7002ab 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 5bb82c0e..548a326c 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 }} + +
@@ -267,6 +282,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' ) @@ -371,6 +387,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 fixed as 'admin') diff --git a/src/frontend/src/views/Settings.vue b/src/frontend/src/views/Settings.vue index 7115c82b..19ee67af 100644 --- a/src/frontend/src/views/Settings.vue +++ b/src/frontend/src/views/Settings.vue @@ -45,6 +45,9 @@ + + +
@@ -1911,6 +1914,7 @@ import { useEnterpriseStore } from '../stores/enterprise' import NavBar from '../components/NavBar.vue' import McpKeysTab from '../components/settings/McpKeysTab.vue' import TwoFactorPanel from '../components/settings/TwoFactorPanel.vue' +import SsoPanel from '../components/settings/SsoPanel.vue' import ConfirmDialog from '../components/ConfirmDialog.vue' const router = useRouter() @@ -1942,6 +1946,7 @@ const ALL_TABS = [ { id: 'integrations', label: 'Integrations', adminOnly: true }, { id: 'mcp-keys', label: 'MCP Keys', adminOnly: false }, { id: 'security', label: 'Security', adminOnly: false, requires: '2fa' }, + { id: 'sso', label: 'SSO', adminOnly: true, requires: 'sso' }, { id: 'agents', label: 'Agents', adminOnly: true }, ] const { isAdmin } = useRole()