Spec: Human Authentication — OIDC/SSO for Operators & End Users¶
File: .ai/specs/app-layer/2026-07-05-sso-auth.md Status: Approved (review 2026-07-05 by J. Porter — v1 boundaries confirmed as drafted: server-side sessions, deny-by-default mapping, one IdP per tenant, aad_oid PERSON linkage, demo auth deleted; deferrals confirmed with the deny-by-default rule singled out as the position to defend hardest) Owners: App-layer lane; reviewers: security (session + token handling), integration lane (PERSON linkage) Related: entra-identity-connector spec (PERSON/alias substrate; aad_oid is the join key), temporal-clarification spec (steward/editor/viewer roles this maps into), chat-delivery-surface spec (RBAC roles shared), field-encryption spec (no interaction with machine key hierarchy — human sessions never hold DEKs), PR-042 RBAC (rbac.ts allowlists are the enforcement this feeds).
1. Overview¶
Defines how humans authenticate to the Telha app layer: OIDC authorization-code flow with PKCE against the tenant's IdP (Entra ID first, generic OIDC second), the server-side session model, how IdP claims map to Telha roles (viewer/editor/admin/steward), and how a login binds to the PERSON identity substrate. Replaces mercato's demo cookie auth. Machine authentication (API keys, signed tenant/worker/clarify tokens) is explicitly out of scope and unchanged.
2. Problem Statement¶
The app layer's current login is a demo: a signed cookie naming a user, with roles hardcoded per fixture. No enterprise will connect real data to that. Concretely: (a) passwords or ad-hoc logins mean Telha owns credential lifecycle it has no business owning; (b) without IdP group mapping, role assignment is manual and drifts from the org's actual structure; (c) without PERSON linkage, the human answering in chat and the human logged into the web app are unrelated identities, so audit trails split; (d) a departed employee keeps a working session unless someone remembers to revoke it; (e) admin surfaces (cost caps, erasure requests) gated by the same weak session are a compliance finding waiting to happen.
3. Proposed Solution¶
Normative decisions:
- Telha never stores or verifies passwords. Human authn is delegated to the tenant's IdP via OIDC authorization-code + PKCE; the app layer is a confidential client. Entra ID is the first-class provider; any spec-compliant OIDC provider is configurable second.
- Sessions are server-side records referenced by an opaque, HttpOnly, Secure, SameSite=Lax cookie. No JWT-in-cookie sessions: revocation must be immediate and server-side, and role changes must take effect on the next request (PR-082 acceptance test).
- Role mapping is deny-by-default and group-driven: per-tenant config maps IdP group object-ids to Telha roles (
viewer | editor | admin | steward). A user with no mapped group gets NO session (403 at callback, audited) — not a default-viewer session; silent read access for the whole directory is how confidential graphs leak. - Roles are resolved at login from the ID token's
groupsclaim (or Graph lookup on overage, Entra's documented behavior when a user exceeds the groups-claim limit) and re-resolved on a fixed refresh interval; the session stores the resolved roles plus the resolution time. Role downgrade takes effect on the next request after refresh; there is no role escalation without a fresh login. - Login binds the session to a PERSON when one exists: Entra logins carry
oid, which is the entra-identity-connector'saad_oidjoin key. No PERSON yet (connector hasn't synced, or generic-OIDC tenant) → the session still works for web access, but PERSON-attributed actions (clarification answers via web, steward overrides) require the link and return a typedIDENTITY_NOT_LINKEDerror until sync creates it. Sessions never auto-create PERSON nodes — identity truth belongs to the connector. - Session lifetime: absolute expiry (8h default) plus idle expiry (1h default), both configurable within ceilings; logout destroys the server-side record immediately. Disabled-in-IdP users are cut off at the role-refresh interval at the latest (
account_enabled = falsefrom the connector also fails delivery/binding paths independently). - The demo cookie auth is deleted, not feature-flagged. Dev/test uses a
ScriptedIdptest double implementing the same OIDC surface (mirrors the ScriptedProvider pattern from PR-042).
4. Architecture¶
browser ─▶ /auth/login?tenant → 302 IdP authorize (PKCE challenge, state, nonce)
IdP ─▶ /auth/callback?code&state
├─ verify state; exchange code (PKCE verifier) at token endpoint
├─ validate ID token (issuer, audience, nonce, signature via JWKS)
├─ resolve groups → roles (deny-by-default; none ⇒ 403 + audit)
├─ resolve aad_oid → PERSON (link if found; session works unlinked)
└─ create server-side session; set opaque cookie; audit login
request middleware: cookie → session lookup → roles (refresh if stale)
→ request context {tenant, roles, person_id?} → existing rbac.ts checks
/auth/logout → destroy session record → clear cookie → audit
5. Data Models¶
Session record (app-layer PostgreSQL): {sid (random 256-bit), tenant, idp_sub, aad_oid?, person_id?, roles: [..], roles_resolved_at, created_at, last_seen_at, absolute_expires_at, ip_created, ua_hash}. Cookie carries sid only. Role-mapping config per tenant: {idp: entra | oidc, issuer, client_id, client_secret_env, group_role_map: {group_object_id: role}, role_refresh_minutes}. Audit rows (PR-042 sink) for login, refused login (no mapped group), logout, role change observed at refresh, and expired-session touch.
6. API Contracts¶
GET /auth/login (starts the flow; tenant resolved from host/config, never from a user-supplied id once sessions exist), GET /auth/callback (code exchange; all failure modes → generic 403 page + specific audit event; no IdP error text echoed to the browser), POST /auth/logout. Middleware contract for every app route: no session → 401/redirect to login; session present → {tenant, roles, person_id?} injected; role checks stay in the existing rbac.ts allowlists (this spec feeds them, it does not replace them). CSRF: state+nonce on the OIDC flow; SameSite=Lax plus origin-checked POSTs elsewhere. Machine endpoints (SDK/gRPC/MCP) are untouched: session cookies are never accepted there, and machine tokens are never accepted on browser routes.
7. UI/UX¶
Login page = one button per configured IdP ("Sign in with Microsoft"); error page for refused logins tells the user to contact their admin (no group names leaked). Session expiry redirects to login with a return-to path. mercato's user menu shows display name, mapped roles, and logout. Operator docs (PR-085) cover the Entra app registration (redirect URIs, groups claim configuration, secret rotation).
8. Configuration¶
Per tenant: [auth] provider (entra | oidc), issuer, client_id, client_secret_env (env-var name only, consistent with every other secret in the system), group_role_map, session_absolute_hours (8, ceiling 24), session_idle_minutes (60), role_refresh_minutes (15). Deliberately NOT configurable: deny-by-default mapping, server-side sessions, cookie flags, PKCE, the no-auto-PERSON rule, machine/human credential separation.
9. Alternatives Considered¶
- Password auth as a fallback. Rejected: credential custody, breach liability, and enterprise buyers have an IdP; a tenant without one is not the target customer.
- JWT sessions (stateless). Rejected: revocation and role downgrade become TTL-bounded promises instead of immediate facts; the session table is trivial at this scale.
- NextAuth/Auth.js dependency. Considered; rejected for v1: the flow needed is small and the framework's session/adapter abstractions obscure exactly the properties this spec pins (server-side record shape, refresh semantics). Vendor JWKS/OIDC verification libraries ARE used (no hand-rolled crypto), mirroring the chat-delivery stance on minimal vendor libs.
- Auto-provisioning PERSON on first login. Rejected: the connector owns identity truth; login-created persons would race and duplicate the sync (the placeholder-merge machinery exists for content, not sessions).
- Default-viewer for unmapped users. Rejected: silent org-wide read access to a confidential memory layer; deny-by-default is the same posture as every RBAC surface in the product.
- SCIM-driven session revocation. Deferred with the connector's SCIM stance; the role-refresh interval bounds the exposure window meanwhile.
10. Implementation Approach¶
One PR (PR-082): this spec → packages/core/src/auth/ (OIDC client, JWKS cache, session store + middleware, role mapper) → mercato login/logout/error pages + user menu → delete demo cookie auth, migrate /api/chat and /contracts to session context → ScriptedIdp test double → tests per §12. Touches no core-engine code.
11. Migration Path¶
Demo cookie auth is deleted in the same PR (greenfield deployments only; nothing production uses it). Session table is new. Generic-OIDC tenants without a groups claim configure group_role_map against whatever claim the IdP exposes (mapping key configurable per provider entry) — additive follow-up if a pilot IdP needs it.
12. Success Metrics¶
sso_login_roundtrip: full code+PKCE flow against ScriptedIdp yields a session with mapped roles and linked person_id.sso_deny_by_default: authenticated user with no mapped group → 403, audited, no session row.sso_role_downgrade: removing a group at the IdP → next request after refresh interval carries the reduced role; a stale session cannot approve steward actions.sso_session_revocation: logout invalidates immediately (subsequent request 401); absolute and idle expiries enforced.sso_csrf_state: tamperedstateor replayednonce→ rejected, audited.sso_person_linkage: Entra login binds person_id via aad_oid; unlinked session hitting a PERSON-attributed action getsIDENTITY_NOT_LINKED, not a silent write.sso_machine_separation: a session cookie on a machine endpoint and a machine token on a browser route are both rejected.
13. Open Questions¶
OQ-1: Back-channel logout / IdP-initiated revocation?Resolved 2026-07-05 (J. Porter): not v1 — server-side sessions + 15-min role refresh + 8h absolute bound are a defensible position; build it when a named enterprise review demands it, not before.OQ-2: Step-up auth for destructive admin actions?Resolved 2026-07-05 (J. Porter): seam-only in v1 (auth_timestored). Real step-up belongs to concrete destructive workflows (erasure dual-approval, PR-081); generic step-up middleware without a consumer is surface area without a threat model.OQ-3: Multiple IdPs per tenant?Resolved 2026-07-05 (J. Porter): no for v1; one issuer per tenant keeps the trust model explainable in one sentence. The merger/complex-enterprise customer can pay for the complexity when they arrive.
14. Changelog¶
- 2026-07-06 — v0.3: As-built (PR-082). Shipped as specified:
packages/core/src/auth/(roles, config, errors, oidc, store, entities, audit, service, scripted_idp) per §10; mercato gained/auth/login,/auth/callback(generic 403 page on every refusal),/auth/logout(origin-checked POST), the SSO login page, and a user menu (display name, mapped roles, sign out); the demo cookie auth was deleted with no parallel path (lib/session.ts,lib/roles.ts,DEMO_USERSremoved;/api/chat,/api/trace/[id],/contracts,/chatall migrated to the server-side session context). Config loads fromTELHA_AUTH_CONFIG(path or inline JSON, clarify-bot convention) with camelCase keys mirroring §8: per tenantorganizationId,provider(entra | oidc),issuer,clientId,clientSecretEnv(env-var name only),redirectUri,groupsClaim(defaultgroups, per §11),groupRoleMap,sessionAbsoluteHours(8, ceiling 24),sessionIdleMinutes(60, bounded by the absolute lifetime),roleRefreshMinutes(15),hosts(host-based tenant resolution), plus optional top-leveldefaultTenant. Design decisions within spec latitude: (1) the pending login flow (state, nonce, PKCE verifier) is a one-shot server-side transaction, not a signed cookie, so state replay dies with no extra signing secret; (2) role re-resolution uses the OIDC refresh token (offline_access) when the IdP grants one and falls back to re-mapping the stored groups, sogroup_role_mapchanges land at the refresh interval either way; (3) the "no escalation without fresh login" rule is enforced by capping refreshed roles at the chat authority the session already held (steward only kept if already held; an empty capped result revokes the session); (4) auth audit events (login, refused login, logout, role change, expired touch, revocation) are written through a structural adapter into the PR-042ai_tool_invocationssink; (5) an aad_oid PERSON lookup that errors or is ambiguous (2+ matches) leaves the session unlinked rather than blocking web access. One deviation: Entra groups-claim overage refuses the login with a typed, auditedGROUPS_OVERAGEerror instead of the §3.4 Graph lookup (needs Graph permissions and a live tenant; follow-up when a pilot org exceeds the claim limit).auth_timeis stored on every session (OQ-2 seam). Machine auth untouched. Tests (offline,@telha/core, ScriptedIdp serving real loopback discovery/JWKS/token endpoints):sso_login_roundtrip,sso_deny_by_default,sso_role_downgrade(incl. the pinned config-change and no-escalation legs and disabled-user cutoff),sso_session_revocation,sso_csrf_state(tampered state, replayed callback, spliced nonce, wrong-verifier PKCE legs),sso_person_linkage(incl.IDENTITY_NOT_LINKED),sso_machine_separation,sso_tenant_isolation(pinned cross-tenant leg),sso_config; 47 new tests, whole workspace green. Live Entra click-through still requires a real app registration (PR-085 operator docs). - 2026-07-05 — v0.2: Approved (review by J. Porter, same day). All three OQs resolved as drafted (no back-channel logout, seam-only step-up, single IdP per tenant). Review notes recorded: deny-by-default (403, never default-viewer) is the load-bearing security decision — "access should be intentionally granted, never accidentally inherited";
IDENTITY_NOT_LINKEDconfirmed as the correct failure mode for attributed actions (authentication proves who the IdP says you are, not that you are an authorised PERSON); demo auth deleted with no parallel path (demo auth survives too long and becomes an unreviewed bypass). PR-082 unblocked. - 2026-07-05 — v0.1: initial draft for review (PR-082). Deny-by-default group mapping; server-side sessions; PERSON linkage without auto-provisioning; demo auth deleted.