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:
- Consume the login transaction.
stateis looked up and deleted in the same operation (consumeLoginTransaction), so a replayed callback finds nothing. An unknown, expired, or already-consumedstatefails asSTATE_MISMATCH. - Exchange the code.
OidcClient.exchangeCode()posts to the token endpoint with the PKCEcode_verifier,client_id, andclient_secret. - Verify the ID token.
jose'sjwtVerifychecks issuer, audience (the tenant'sclient_id), and signature against the IdP's JWKS (fetched once via discovery, then cached increateRemoteJWKSet). The token'snonceclaim must match the one minted at login start (NONCE_MISMATCHotherwise). - Detect groups-claim overage. If the token carries
_claim_names.groupsinstead of agroupsarray (Entra's documented behavior once a user exceeds the groups-claim limit), the login is refused with a typed, auditedGROUPS_OVERAGEerror rather than guessing at membership. See As-built notes. - Map groups to roles, deny-by-default. Covered in detail below.
- Resolve
aad_oidto a PERSON. Covered in detail below. - 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
nullifnow >= absoluteExpiresAtornow >= idleExpiresAt(anauth.session_expiredaudit 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
rolesResolvedAtis older than the tenant'sroleRefreshMinutes(see role mapping below). - Otherwise touches
lastSeenAtand slidesidleExpiresAtforward by anothersessionIdleMinutes, and returns anAuthContext.
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():
- 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"). - 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. 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;stewardsurvives refresh only if the session already carried it. A capped result of zero roles revokes the session (errorCode: "no_mapped_group").- If the resulting roles differ from what the session held, an
auth.role_changeaudit event records thefrom/tosets.
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 agroupRoleMapconfig 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_invocationssink (OrmAuthAuditSinkin mercato'slib/auth.ts), since@telha/corecannot depend on@telha/ai-assistant. - An
aad_oidPERSON 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.
Related¶
- 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_oidjoin key. - Clarify bot runbook - the operator-facing web SSO configuration notes (
TELHA_AUTH_CONFIGkeys) 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
stewardrole and verified PERSON identity are spent.