Skip to content

SSO & authentication

Architecture

Normative spec

This page documents .ai/specs/app-layer/2026-07-05-sso-auth.md (Status: Approved, v0.3 as-built for PR-082) and the app-layer/packages/core/src/auth/ implementation (config.ts, oidc.ts, store.ts, entities.ts, service.ts, roles.ts, errors.ts, audit.ts, scripted_idp.ts), plus the mercato routes that consume it (apps/mercato/src/app/auth/{login,callback,logout}/route.ts and apps/mercato/src/lib/auth.ts).

Telha never stores or verifies a human password. Every web login is delegated to the tenant's identity provider over OIDC, and the roles a session carries come from IdP group membership, never from anything the user or the browser asserts.

Overview

The app layer authenticates humans (the app's chat, contracts, and trace UI) with the OIDC authorization-code flow plus PKCE, against Entra ID as the first-class provider (any spec-compliant OIDC provider is configurable second). This replaces the app's former demo cookie auth, which named a user in a signed cookie with roles hardcoded per fixture; the demo path was deleted outright in PR-082, with no parallel path or feature flag.

Machine authentication (API keys, signed tenant/worker/clarify tokens) is a separate system, untouched by this spec: session cookies are never accepted on machine endpoints (gRPC, MCP, SDK routes), and machine tokens are never accepted on browser routes. See Tenancy & security for how audience-bound service tokens fence machine credentials the same way sessions fence human ones.

One IdP per tenant

A tenant configures exactly one issuer. Multiple IdPs per tenant were considered and rejected for v1 (spec OQ-3): one issuer keeps the trust model explainable in one sentence.

Design

The login flow: OIDC authorization code + PKCE

GET /auth/login resolves the tenant from the request's Host header against each tenant's configured hosts list, or a configured defaultTenant, never from a tenant id the caller supplies. It then starts the flow: OidcClient.beginAuthorization() mints a random state, nonce, and PKCE codeVerifier (all 256-bit, base64url), persists them server-side as a one-shot LoginTransaction, and 302s the browser to the IdP's authorization endpoint with code_challenge / code_challenge_method=S256, scope=openid profile offline_access, state, and nonce.

GET /auth/callback is where AuthService.completeLogin() does the real work:

  1. Consume the login transaction. state is looked up and deleted in the same operation (consumeLoginTransaction), so a replayed callback finds nothing. An unknown, expired, or already-consumed state fails as STATE_MISMATCH.
  2. Exchange the code. OidcClient.exchangeCode() posts to the token endpoint with the PKCE code_verifier, client_id, and client_secret.
  3. Verify the ID token. jose's jwtVerify checks issuer, audience (the tenant's client_id), and signature against the IdP's JWKS (fetched once via discovery, then cached in createRemoteJWKSet). The token's nonce claim must match the one minted at login start (NONCE_MISMATCH otherwise).
  4. Detect groups-claim overage. If the token carries _claim_names.groups instead of a groups array (Entra's documented behavior once a user exceeds the groups-claim limit), the login is refused with a typed, audited GROUPS_OVERAGE error rather than guessing at membership. See As-built notes.
  5. Map groups to roles, deny-by-default. Covered in detail below.
  6. Resolve aad_oid to a PERSON. Covered in detail below.
  7. Create the session and set the cookie.

Every failure branch writes a specific audit event (auth.login_refused with an errorCode) and then throws; every branch renders the same generic 403 page to the browser. No IdP error text, and no reason for the refusal, is ever echoed back to the browser: mercato/.../auth/callback/route.ts catches every thrown AuthFlowError and serves one static "Sign-in refused" page regardless of cause.

sequenceDiagram
    autonumber
    participant B as Browser
    participant M as mercato (/auth/*)
    participant Svc as AuthService
    participant IdP as Tenant IdP (Entra/OIDC)
    participant DB as auth_sessions / auth_login_txns (Postgres)

    B->>M: GET /auth/login (Host header resolves tenant)
    M->>Svc: beginLogin(tenantId, returnTo)
    Svc->>DB: createLoginTransaction(state, nonce, code_verifier)
    Svc-->>M: IdP authorize URL (PKCE S256, state, nonce)
    M-->>B: 302 to IdP
    B->>IdP: authenticate
    IdP-->>B: 302 /auth/callback?code&state
    B->>M: GET /auth/callback?code&state
    M->>Svc: completeLogin({code, state})
    Svc->>DB: consumeLoginTransaction(state) [one-shot]
    Svc->>IdP: POST token endpoint (code, code_verifier, client_secret)
    IdP-->>Svc: id_token (+ refresh_token if offline_access granted)
    Svc->>Svc: jwtVerify (issuer, audience, signature via JWKS) + nonce check
    Svc->>Svc: mapGroupsToRoles(groups, groupRoleMap), deny-by-default
    alt no mapped role
        Svc-->>M: throw NO_MAPPED_ROLE (audited, no session row)
        M-->>B: 403 generic refusal page
    else at least one role
        Svc->>Svc: findPersonIdByAadOid(aad_oid) [optional link]
        Svc->>DB: createSession(sid, roles, groups, expiries, ...)
        Svc-->>M: {sid, returnTo}
        M-->>B: 303 redirect + Set-Cookie telha_sid (opaque, HttpOnly)
    end
    B->>M: subsequent request, Cookie telha_sid
    M->>Svc: authenticate(sid)
    Svc->>DB: getSession(sid)
    Svc->>Svc: check absolute/idle expiry, refresh roles if stale
    Svc-->>M: AuthContext {tenant, roles, chatRole, personId?}

The session model

Sessions are server-side records, not JWTs-in-a-cookie. The spec is explicit about why (§9, alternatives considered): a stateless JWT session makes revocation and role downgrade TTL-bounded promises instead of immediate facts, and the session table is trivial at this project's scale. The cookie (telha_sid, SESSION_COOKIE_NAME in service.ts) carries only the opaque 256-bit session id (newSessionId(), base64url, no dots, so it cannot even parse as a signed machine token) and nothing else: no claims, no roles, no expiry, all of that lives in the row.

Cookie flags are pinned, not configurable (SESSION_COOKIE_FLAGS in apps/mercato/src/lib/auth.ts):

Flag Value
httpOnly true
secure true
sameSite lax
path /

Every request to an authenticated route calls AuthService.authenticate(sid), which:

  • Returns null (401 / redirect to login) if the cookie is missing or the row doesn't exist.
  • Destroys the session and returns null if now >= absoluteExpiresAt or now >= idleExpiresAt (an auth.session_expired audit event records which bound tripped).
  • Destroys the session if its tenant has been de-configured (auth.session_revoked, errorCode: "tenant_removed").
  • Re-resolves roles if rolesResolvedAt is older than the tenant's roleRefreshMinutes (see role mapping below).
  • Otherwise touches lastSeenAt and slides idleExpiresAt forward by another sessionIdleMinutes, and returns an AuthContext.

Logout (POST /auth/logout, origin-checked: the Origin header must match Host) calls AuthService.logout(sid), which deletes the session row outright. Because the record is server-side, this is immediately effective on every subsequent request from anywhere, with no TTL to wait out.

Only opaque sids are ever honored

authenticate() looks up sid as a literal row key. A machine token (JWT-shaped, dotted, signed with grpc.token_key) presented as a cookie value can never match a session row, and a session cookie presented to a machine endpoint is never even read as a token. This is the sso_machine_separation success metric (spec §12), made true by the two credential types not sharing a parsing path, not by a runtime check that could be skipped.

Data model

Session record

SessionRecord (app-layer/packages/core/src/auth/store.ts), persisted to PostgreSQL by MikroOrmSessionStore (entities.ts) into the auth_sessions table (unique on sid):

interface SessionRecord {
  sid: string;               // opaque 256-bit id; the cookie carries this and nothing else
  tenantId: string;
  organizationId: string;
  idpSub: string;            // subject at the tenant's issuer
  aadOid: string | null;     // Entra object id (the PERSON join key)
  personId: string | null;   // linked PERSON, when the connector has synced one
  displayName: string | null;
  roles: TelhaRole[];
  groups: string[];          // raw IdP groups as last resolved
  rolesResolvedAt: Date;
  authTime: Date;            // when the IdP authenticated the human (step-up seam)
  createdAt: Date;
  lastSeenAt: Date;
  idleExpiresAt: Date;
  absoluteExpiresAt: Date;
  ipCreated: string | null;
  uaHash: string | null;     // sha256(user-agent), truncated
  refreshToken: string | null; // IdP refresh token, server-side only, never leaves the store
}

The auth_sessions table (AuthSessionEntity) stores roles and groups as JSON text columns (roles_json, groups_json) and indexes (tenantId, idpSub); sid is the only unique key, because the cookie value is the whole lookup path and the tenant is not known until the row is read. Lookups against this table run with MikroORM's tenant filter explicitly disabled (NO_FILTERS), since a request's tenant isn't established yet at the point the session is fetched, then every downstream consumer scopes by the row's own tenantId/organizationId.

The pending OIDC flow is its own one-shot record, auth_login_txns (AuthLoginTxnEntity, unique on state):

interface LoginTransaction {
  state: string;
  tenantId: string;
  organizationId: string;
  nonce: string;
  codeVerifier: string;
  returnTo: string | null;   // same-site path only; sanitizeReturnTo() rejects anything else
  createdAt: Date;
  expiresAt: Date;           // 10 minutes from creation
}

consumeLoginTransaction fetches and deletes the row in one operation, so a replayed state (a tampered or resent callback) finds nothing. This is the as-built design choice noted in the spec's changelog: the pending flow needed no separate signed cookie or secret, because the one-shot database row already kills replay.

Role-mapping config

Per tenant, loaded from TELHA_AUTH_CONFIG (a path or inline JSON, mirroring the clarify-bot config convention) by loadAuthConfig() / parseAuthConfig() in config.ts:

{
  "tenants": {
    "11111111-1111-4111-8111-111111111111": {
      "organizationId": "22222222-2222-4222-8222-222222222222",
      "provider": "entra",
      "issuer": "https://login.microsoftonline.com/<entra-tenant-id>/v2.0",
      "clientId": "<app registration client id>",
      "clientSecretEnv": "SSO_CLIENT_SECRET_T1",
      "redirectUri": "https://<app-host>/auth/callback",
      "groupsClaim": "groups",
      "groupRoleMap": {
        "<group object id>": "editor",
        "<group object id>": "steward"
      },
      "sessionAbsoluteHours": 8,
      "sessionIdleMinutes": 60,
      "roleRefreshMinutes": 15,
      "hosts": ["telha.example.com"]
    }
  },
  "defaultTenant": "11111111-1111-4111-8111-111111111111"
}

TenantAuthConfig is the parsed, in-memory shape (config.ts):

interface TenantAuthConfig {
  tenantId: string;
  organizationId: string;
  provider: "entra" | "oidc";
  issuer: string;
  clientId: string;
  clientSecret: string;       // resolved from the env var named by clientSecretEnv
  redirectUri: string;
  groupsClaim: string;        // default "groups"
  groupRoleMap: Record<string, TelhaRole>;
  sessionAbsoluteHours: number;
  sessionIdleMinutes: number;
  roleRefreshMinutes: number;
  hosts: string[];
}

No secret material lives in the config file itself: clientSecretEnv names an environment variable, and the loader resolves it at parse time, failing loudly (auth config (tenant ...): env var X (client secret) is not set) if it's absent.

Algorithms & invariants

Deny-by-default group-to-role mapping

mapGroupsToRoles() (roles.ts) is the entire mapping mechanism: it walks the ID token's raw group ids and keeps only the roles that groupRoleMap names for a group actually present. There is no wildcard, no fallback role, and no "everyone gets viewer" branch anywhere in this function:

export function mapGroupsToRoles(
  groups: readonly string[],
  groupRoleMap: Readonly<Record<string, TelhaRole>>,
): TelhaRole[] {
  const resolved = new Set<TelhaRole>();
  for (const group of groups) {
    const role = groupRoleMap[group];
    if (role) resolved.add(role);
  }
  return TELHA_ROLES.filter((role) => resolved.has(role));
}

If this returns an empty array, AuthService.completeLogin() throws NO_MAPPED_ROLE before any session row is written: the user gets a 403, an auth.login_refused audit event (errorCode: "no_mapped_group"), and no session record at all, not a session carrying a reduced or default role. This is verified directly against the code, not inferred: the check sits between group mapping and session creation in service.ts, and sso_config.test.ts separately asserts that the config loader itself refuses to boot with an empty groupRoleMap ("deny-by-default would lock everyone out silently").

Roles are viewer | editor | admin | steward (TelhaRole in roles.ts). viewer/editor/admin feed the ai-assistant chat RBAC allowlists (PR-042); steward feeds the clarification surfaces and is orthogonal to chat access. chatRoleFor() picks the single highest chat-authority role (admin > editor > viewer) for the RBAC allowlist check; a session holding only steward has no chat role, by the same deny-by-default posture.

Deny-by-default is the load-bearing decision

Per the spec's review notes (§14, v0.2), this is the position the spec singles out to defend hardest: "access should be intentionally granted, never accidentally inherited." Default-viewer-for-unmapped-users was considered and rejected (§9) as silent org-wide read access to a confidential memory layer.

Role refresh and the no-escalation rule

Roles are re-resolved on a fixed interval (roleRefreshMinutes, default 15), not on every request. AuthService.authenticate() checks now - rolesResolvedAt >= roleRefreshMinutes and, if stale, calls refreshRoles():

  1. If the session holds a refreshToken, OidcClient.refreshIdentity() asks the IdP for fresh groups. An IdP rejection here (invalid_grant, e.g. a disabled/departed user) deletes the session immediately (auth.session_revoked, errorCode: "refresh_rejected").
  2. If there's no refresh token, the stored groups are re-mapped against the current groupRoleMap, so a config change (removing a group's mapping) still takes effect at the next refresh even without IdP round-tripping.
  3. capRolesAtRefresh() (roles.ts) enforces "no role escalation without a fresh login": fresh roles are kept only if their chat-authority rank is at or below what the session already held; steward survives refresh only if the session already carried it. A capped result of zero roles revokes the session (errorCode: "no_mapped_group").
  4. If the resulting roles differ from what the session held, an auth.role_change audit event records the from/to sets.
export function capRolesAtRefresh(
  freshRoles: readonly TelhaRole[],
  currentRoles: readonly TelhaRole[],
): TelhaRole[] {
  const heldRank = Math.max(0, ...currentRoles.map((role) => CHAT_RANK[role] ?? 0));
  return freshRoles.filter((role) =>
    role === "steward" ? currentRoles.includes("steward") : (CHAT_RANK[role] ?? 0) <= heldRank,
  );
}

A downgrade (a group removed at the IdP) therefore lands on the very next request after the refresh interval elapses; an upgrade (a group added) does not take effect until the user logs in again.

PERSON linkage: verified identity, not auto-provisioned

Entra ID tokens carry oid, mapped to aadOid on VerifiedIdentity. When present, completeLogin() calls the injected PersonDirectory (TelhaPersonDirectory in production) to look up a PERSON by that aad_oid. Exactly one match links the session (personId set); zero matches, an ambiguous 2+ match, or a lookup error all leave the session unlinked, not blocked:

async findPersonIdByAadOid(aadOid: string, scope: TenantSession): Promise<string | null> {
  const client = this.connector.forSession(scope);
  const result = await client.query({ find: "PERSON", where: { aad_oid: { $eq: aadOid } }, limit: 2 });
  return result.records.length === 1 ? result.records[0]!.id : null;
}

An unlinked session still works for ordinary web access. It only fails when a PERSON-attributed action is attempted, a clarification answer via web, a steward override, guarded by requirePersonId():

export function requirePersonId(context: AuthContext): string {
  if (!context.personId) {
    throw new AuthFlowError("IDENTITY_NOT_LINKED", "this action requires a linked PERSON identity");
  }
  return context.personId;
}

Sessions never auto-create PERSON nodes: identity truth belongs to the Entra connector, and a login-created person would race the connector's own sync. This is the same principle Clarifications relies on for chat-bound answers: a claimed identity only carries binding authority once it is matched, with evidence, to a verified corporate identity, never a chat display name or, here, a bare IdP subject.

Invariants this spec upholds

  • Deny-by-default RBAC: an unmapped user gets zero permissions and no session row, never a reduced default role.
  • No escalation without a fresh login: a role upgrade at the IdP only takes effect on the next full login; a downgrade takes effect at the next role-refresh tick.
  • Server-side revocation is immediate: logout and forced expiry are row deletes, not a TTL a stale JWT would otherwise honor.
  • Audience-bound tokens, machine/human separation: an opaque session sid can never be read as a signed machine token and vice versa (see Tenancy & security for the same posture applied to service tokens).
  • Identity truth is the connector's, not the session's: PERSON linkage is looked up, never created, at login.
  • No IdP error text reaches the browser: every callback failure mode collapses to one generic refusal page; the specific reason lives only in the audit trail.

Configuration

Per-tenant keys, as loaded by parseAuthConfig() (config.ts):

Key Required Default Notes
organizationId yes (none) Organization scope minted into the session
provider no entra entra or oidc
issuer yes (none) Trailing slash stripped
clientId yes (none) OIDC client id
clientSecretEnv yes (none) Env-var name; the loader resolves the value, no secret in the file
redirectUri yes (none) Must match the app registration's redirect URI
groupsClaim no "groups" ID-token claim carrying group ids; configurable per provider for a non-Entra IdP (spec §11)
groupRoleMap yes, non-empty (none) Group object id to role; an empty map fails config load outright
sessionAbsoluteHours no 8 Clamped to a ceiling of 24
sessionIdleMinutes no 60 Clamped to the absolute lifetime in minutes
roleRefreshMinutes no 15 Clamped to the absolute lifetime in minutes
hosts no [] Hostnames (lowercased) that resolve to this tenant at login

Top-level defaultTenant selects the tenant when no host matches (single-tenant deployments); it must name a configured tenant or the config fails to load.

Deliberately not configurable (spec §8, restated in config.ts's own doc comment): deny-by-default mapping, server-side sessions, cookie flags, PKCE, the no-auto-PERSON rule, and machine/human credential separation.

Dev/test double

ScriptedIdp (scripted_idp.ts) is a deterministic in-process OIDC provider mirroring the ScriptedProvider pattern from PR-042. It serves real discovery/JWKS/token endpoints over loopback HTTP, so the production OidcClient runs unmodified and fully offline in tests. It can mutate a scripted user's groups (simulating an IdP-side group change between refreshes) and disable a user (simulating a departed employee, whose next refresh grant fails).

As-built notes

Per §14 of the spec, PR-082 shipped as specified, with these design decisions recorded as within spec latitude:

  • The pending login flow (state, nonce, PKCE verifier) is a one-shot server-side transaction (auth_login_txns), not a signed cookie, so state replay dies with no extra signing secret.
  • Role re-resolution uses the OIDC refresh token (offline_access) when the IdP grants one, and falls back to re-mapping the stored groups otherwise, so a groupRoleMap config change lands at the refresh interval either way.
  • The no-escalation rule is enforced by capping refreshed roles at the chat authority the session already held (capRolesAtRefresh); an empty capped result revokes the session.
  • 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 (OrmAuthAuditSink in mercato's lib/auth.ts), since @telha/core cannot depend on @telha/ai-assistant.
  • An aad_oid PERSON lookup that errors or is ambiguous (2+ matches) leaves the session unlinked rather than blocking web access.

One known deviation from the spec's §3.4 prose: an Entra groups-claim overage (a user in more groups than the token's claim limit) refuses the login outright with a typed, audited GROUPS_OVERAGE error, instead of falling back to a Microsoft Graph membership lookup as originally described. The Graph lookup needs additional Graph API permissions and a live tenant to build against; it is a named follow-up for when a pilot organization actually exceeds the claim limit, not a v1 gap that was missed.

Tests are offline throughout, using ScriptedIdp to serve a real loopback discovery/JWKS/token endpoint set: sso_login_roundtrip, sso_deny_by_default, sso_role_downgrade (including a pinned config-change leg, a no-escalation leg, and a disabled-user cutoff leg), sso_session_revocation, sso_csrf_state (tampered state, replayed callback, spliced nonce, wrong-verifier PKCE), sso_person_linkage (including IDENTITY_NOT_LINKED), sso_machine_separation, sso_tenant_isolation, and sso_config. 47 new tests, whole workspace green at the time of the as-built.

Live Entra click-through still requires a real app registration (redirect URI matching redirectUri, groups claim enabled on the app's token configuration): this has not been exercised against a live tenant yet. See the clarify bot runbook for the same caveat applied to the bot's own operator-facing setup notes.

  • Concepts › Tenancy & security - the deny-by-default RBAC posture and audience-bound tokens this spec's session model mirrors for human credentials.
  • Concepts › Clarifications - why a verified identity, not a claimed one, is what carries binding authority in the clarification loop, resolved through the same aad_oid join key.
  • Clarify bot runbook - the operator-facing web SSO configuration notes (TELHA_AUTH_CONFIG keys) for the same app layer, alongside the bot's own service-token setup.
  • Connector framework - the Entra identity substrate that PERSON linkage and group resolution depend on.
  • Clarification engine - where the steward role and verified PERSON identity are spent.