Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/memory/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
48 changes: 48 additions & 0 deletions docs/memory/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/backend/enterprise
165 changes: 165 additions & 0 deletions src/frontend/src/components/settings/SsoPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<template>
<div class="space-y-8">
<!-- Header -->
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Single Sign-On (OIDC)</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Let users sign in through your identity provider (Okta, Entra ID, Google Workspace).
SAML support is coming separately.
</p>
</div>

<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>

<!-- Providers -->
<section class="space-y-3">
<h4 class="text-sm font-medium text-gray-800 dark:text-gray-200">Identity providers</h4>

<div v-if="!providers.length" class="text-sm text-gray-500 dark:text-gray-400">
No providers configured yet.
</div>

<div
v-for="p in providers"
:key="p.id"
class="flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-lg"
>
<div class="min-w-0">
<p class="font-medium text-gray-900 dark:text-gray-100 truncate">
{{ p.name }}
<span v-if="!p.enabled" class="ml-2 text-xs text-gray-400">(disabled)</span>
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ p.issuer }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button @click="test(p)" class="text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700">Test</button>
<button @click="remove(p)" class="text-xs px-2 py-1 border border-red-300 text-red-600 rounded hover:bg-red-50 dark:hover:bg-red-900/30">Delete</button>
</div>
</div>

<p v-if="testResult" class="text-xs text-green-600 dark:text-green-400">{{ testResult }}</p>
</section>

<!-- Add provider -->
<section class="space-y-3 p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="text-sm font-medium text-gray-800 dark:text-gray-200">Add provider</h4>
<form @submit.prevent="add" class="space-y-3">
<input v-model="form.name" placeholder="Display name (e.g. Okta)" required :class="inputCls" />
<input v-model="form.issuer" placeholder="Issuer / discovery URL (https://…)" required :class="inputCls" />
<input v-model="form.client_id" placeholder="Client ID" required :class="inputCls" />
<input v-model="form.client_secret" type="password" placeholder="Client secret" required :class="inputCls" />
<input v-model="form.scopes" placeholder="Scopes" :class="inputCls" />
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="form.enabled" type="checkbox" /> Enabled
</label>
<button type="submit" :disabled="busy" :class="btnCls">{{ busy ? 'Saving…' : 'Add provider' }}</button>
</form>
</section>

<!-- Policy -->
<section class="space-y-3">
<h4 class="text-sm font-medium text-gray-800 dark:text-gray-200">Policy</h4>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="cfg.allow_password_fallback" type="checkbox" @change="saveConfig" />
Keep email / admin password login available
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="cfg.auto_provision" type="checkbox" @change="saveConfig" />
Auto-provision new users from SSO (otherwise the email must be whitelisted)
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
Default role for provisioned users
<select v-model="cfg.default_role" @change="saveConfig" class="ml-2 text-sm border border-gray-300 dark:border-gray-600 rounded px-2 py-1 bg-white dark:bg-gray-700">
<option value="user">user</option>
<option value="operator">operator</option>
<option value="creator">creator</option>
<option value="admin">admin</option>
</select>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">Admin break-glass password login always remains available.</p>
</section>
</div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'
import axios from 'axios'
import { useAuthStore } from '../../stores/auth'

const authStore = useAuthStore()
const BASE = '/api/enterprise/sso'
const cfgHeaders = () => ({ headers: { Authorization: `Bearer ${authStore.token}` } })

const inputCls = 'block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm'
const btnCls = 'w-full py-2 px-4 rounded-lg text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-sm'

const providers = ref([])
const cfg = reactive({ allow_password_fallback: true, auto_provision: false, default_role: 'user' })
const form = reactive({ name: '', issuer: '', client_id: '', client_secret: '', scopes: 'openid email profile', enabled: true })
const busy = ref(false)
const error = ref('')
const testResult = ref('')

async function loadProviders() {
const r = await axios.get(`${BASE}/providers`, cfgHeaders())
providers.value = r.data.providers || []
}

async function loadConfig() {
const r = await axios.get(`${BASE}/config`, cfgHeaders())
Object.assign(cfg, r.data)
}

async function add() {
busy.value = true
error.value = ''
try {
await axios.post(`${BASE}/providers`, { ...form }, cfgHeaders())
Object.assign(form, { name: '', issuer: '', client_id: '', client_secret: '', scopes: 'openid email profile', enabled: true })
await loadProviders()
} catch (e) {
error.value = e.response?.data?.detail || 'Failed to add provider'
} finally {
busy.value = false
}
}

async function remove(p) {
if (!confirm(`Delete SSO provider "${p.name}"?`)) return
error.value = ''
try {
await axios.delete(`${BASE}/providers/${p.id}`, cfgHeaders())
await loadProviders()
} catch (e) {
error.value = e.response?.data?.detail || 'Failed to delete provider'
}
}

async function test(p) {
testResult.value = ''
error.value = ''
try {
const r = await axios.post(`${BASE}/providers/${p.id}/test`, {}, cfgHeaders())
testResult.value = `${p.name}: discovery OK (${r.data.jwks_keys} signing keys)`
} catch (e) {
error.value = e.response?.data?.detail || `Connectivity test failed for ${p.name}`
}
}

async function saveConfig() {
error.value = ''
try {
await axios.put(`${BASE}/config`, { ...cfg }, cfgHeaders())
} catch (e) {
error.value = e.response?.data?.detail || 'Failed to save policy'
}
}

onMounted(async () => {
try {
await Promise.all([loadProviders(), loadConfig()])
} catch (e) {
error.value = e.response?.data?.detail || 'Failed to load SSO settings'
}
})
</script>
32 changes: 32 additions & 0 deletions src/frontend/src/stores/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions src/frontend/src/views/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,21 @@
🔐 Admin Login
</button>
</div>

<!-- #32 — Enterprise SSO (OIDC). Buttons appear only when the `sso`
feature is entitled and at least one provider is enabled.
Full-page nav to the backend login endpoint → IdP → callback. -->
<div v-if="!codeSent && ssoProviders.length" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700 space-y-2">
<p class="text-xs text-center text-gray-500 dark:text-gray-400">Or sign in with</p>
<a
v-for="p in ssoProviders"
:key="p.id"
:href="`/api/enterprise/sso/login/${p.id}`"
class="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
🔑 {{ p.name }}
</a>
</div>
</div>

<!-- Admin Login: Password Only (username is fixed as 'admin') -->
Expand Down Expand Up @@ -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'
)
Expand Down Expand Up @@ -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')
Expand Down
5 changes: 5 additions & 0 deletions src/frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
<!-- #5 — Security / Two-Factor (enterprise, gated by `2fa`) -->
<TwoFactorPanel v-if="activeTab === 'security'" />

<!-- #32 — Single Sign-On (enterprise, gated by `sso`) -->
<SsoPanel v-if="activeTab === 'sso'" />

<!-- Platform Section -->
<div v-if="activeTab === 'general'" class="bg-white dark:bg-gray-800 shadow dark:shadow-gray-900 rounded-lg">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down