Skip to content

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:

  1. 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.
  2. 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).
  3. 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.
  4. Roles are resolved at login from the ID token's groups claim (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.
  5. Login binds the session to a PERSON when one exists: Entra logins carry oid, which is the entra-identity-connector's aad_oid join 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 typed IDENTITY_NOT_LINKED error until sync creates it. Sessions never auto-create PERSON nodes — identity truth belongs to the connector.
  6. 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 = false from the connector also fails delivery/binding paths independently).
  7. The demo cookie auth is deleted, not feature-flagged. Dev/test uses a ScriptedIdp test 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: tampered state or replayed nonce → rejected, audited.
  • sso_person_linkage: Entra login binds person_id via aad_oid; unlinked session hitting a PERSON-attributed action gets IDENTITY_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_time stored). 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_USERS removed; /api/chat, /api/trace/[id], /contracts, /chat all migrated to the server-side session context). Config loads from TELHA_AUTH_CONFIG (path or inline JSON, clarify-bot convention) with camelCase keys mirroring §8: per tenant organizationId, provider (entra | oidc), issuer, clientId, clientSecretEnv (env-var name only), redirectUri, groupsClaim (default groups, per §11), groupRoleMap, sessionAbsoluteHours (8, ceiling 24), sessionIdleMinutes (60, bounded by the absolute lifetime), roleRefreshMinutes (15), hosts (host-based tenant resolution), plus optional top-level defaultTenant. 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, so group_role_map changes 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-042 ai_tool_invocations sink; (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, audited GROUPS_OVERAGE error 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_time is 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_LINKED confirmed 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.