Authentication
Clippy Chat resolves identity inside the app, not just at the edge. Every API request runs
through resolveUser(req) (src/lib/auth/middleware.ts), which supports three independent
auth modes.
resolveUser(req)
├─ ensureBootstrap() # seed local admin (once)
├─ Authorization: Bearer … → verifyBearer() # Mode C: machine JWT
├─ Cookie clippy_session → findSession() # Mode A/B: web + local admin
└─ otherwise → null # → 401
Two guards build on it:
requireUser— throws401 {error:'unauthorized'}if there is no user.requireAdmin— throws401if unauthenticated, or403 {error:'forbidden'}if the user is not an admin.
Mode A — Keycloak OIDC web users (auth code + PKCE)
For humans. Standard OpenID Connect authorization-code flow with PKCE, via openid-client.
GET /api/auth/login→startLogin()generates a PKCE verifier +state, builds the authorization URL (redirect_uri = ${APP_URL}/api/auth/callback,scope: openid profile email,code_challenge_method: S256), stores{verifier, state}in an HttpOnly, SameSite=Lax, Max-Age=600 cookieclippy_pkce, and 302-redirects to the provider.GET /api/auth/callbackreads the PKCE cookie (400if missing) and rebuilds the callback URL againstAPP_URL— behind a TLS-terminating proxyrequest.urlishttp://, andopenid-clientderivesredirect_urifrom it, so without the rebuild the provider rejects the grant withinvalid_grant. On any IdP/user failure (tampered cookie, state mismatch, denied consent, expired code) it returns400, never500. On success it upserts the profile, creates a session storing the (encrypted) provider tokens, setsclippy_session, clearsclippy_pkce, and 302s to/.
:::info Audience is intentionally not checked
verifyBearer (Mode C) checks issuer, algorithm, and required claims, but not aud, pending
an audience-mapper design. See src/lib/auth/bearer.ts.
:::
Mode B — Local break-glass admin
For getting in without a live OIDC provider.
ensureAdminUser()seeds auser_profilesrow on first request:authProvider: 'local',username = ADMIN_USERNAME,passwordHash = argon2id(ADMIN_PASSWORD),isAdmin: true. The first-boot race across replicas is handled via a partial unique index on local usernames.POST /api/auth/local-login({username, password}) verifies with argon2 and, on success, creates a session with no provider tokens.
:::warning Rotating ADMIN_PASSWORD does not update an existing admin
The password is only set when the row is first seeded. Changing ADMIN_PASSWORD afterward has
no effect until the row is deleted and re-seeded.
:::
:::note Timing side-channel defense
If the username/hash is absent, local-login still runs one argon2 verify against a dummy hash
before returning 401, so "no such user" costs the same as "wrong password".
:::
Mode C — Machine bearer JWTs (client_credentials, scope clippy-api)
For service accounts — and the red-team adapter. Callers present
Authorization: Bearer <access token> obtained from the IdP's client_credentials grant.
verifyBearer(token) (src/lib/auth/bearer.ts):
- Verifies the JWT with
jose.jwtVerifyagainst the provider's remote JWKS (${KC_ISSUER}/protocol/openid-connect/certs, cached). - Options:
issuer: KC_ISSUER,algorithms: ['RS256'],requiredClaims: ['sub','scope']. - The scope gate: splits the
scopeclaim on spaces; if it does not containM2M_SCOPE(defaultclippy-api), it throwsmissing required scope clippy-api. - The audience gate, when
M2M_AUDIENCEis set:audjoinsrequiredClaimsand must contain that value, so a correctly scoped token minted for another audience in the same realm is rejected. Production sets it tostack-clippy. Unset means audience is unchecked and the scope gate stands alone — the app logs oneM2M_AUDIENCE unsetwarning per process to keep that visible.
:::note Picking the audience value
Arming it against a value the caller does not carry 401s every machine caller, this adapter
included, and there is no warn-on-mismatch mode. Disarmed by default lets the realm and the app
change in either order.
Today clippy-m2m tokens carry the bare string aud: "stack-clippy" — the per-stack audience —
while clippy-mcp-client carries ["clippy", "stack-clippy"], adding a per-service one. So
stack-clippy would enforce with no realm change; clippy-api needs one audience mapper and
completes the per-service pattern. Check a real token before setting the value:
k8s/m2m-audience-rollout.md.
:::
A valid bearer upserts a service-account profile (isServiceAccount: true). A failed verify
logs and returns null (→ 401) rather than throwing.
TOKEN=$(curl -s https://auth.example.com/realms/myrealm/protocol/openid-connect/token \
-d grant_type=client_credentials \
-d client_id=clippy-m2m -d client_secret="$CLIENT_SECRET" \
-d scope=clippy-api | jq -r .access_token)
curl -N -X POST https://chat.example.com/api/chat \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d "{\"conversationId\":\"$(uuidgen | tr A-Z a-z)\",\"message\":\"hello clippy\"}"
This is exactly the flow the Red-Team Adapter automates.
Sessions & token encryption
-
Session id: a 32-byte base64url random string. Cookie
clippy_sessionis HttpOnly, SameSite=Lax, andSecurewhenAPP_URLis https. -
Sliding TTL: 7 days idle. On each
findSession, if less than half the TTL remains, expiry is bumped; if expired, the row is deleted. -
Provider-token encryption: stored KC tokens are encrypted with AES-256-GCM, key =
sha256(SESSION_SECRET), layoutiv(12) || authTag(16) || ciphertext, base64. The AAD is the session id, so a token blob copied to another session fails to decrypt. On decrypt failure (secret rotation / tamper) the session is destroyed and the user simply re-logs-in. -
Refresh: the chat route refreshes the stored Keycloak tokens (
freshUserTokens) when less than 60s of the access token remains, single-flighted per session, and persists the rotated pair. A refresh Keycloak rejects — the realm's SSO idle timeout (30 minutes) has passed, or the session was revoked — destroys the Clippy session and answers401 reauth(or an SSEerrorwith codereauthmid-turn); the browser returns to/login. -
Delegation: the stored access token is the subject of a per-route token exchange, so inference and MCP calls carry the user's identity. See User-bound gateway tokens.
MCP authorization is a separate token contract
The chat API's clippy-api bearer path above does not authorize an MCP request. Clippy MCP uses
the clippy-mcp-client confidential client, audience clippy, scope mcp.invoke, and workspace
ws-produc-985697 — as the service identity for machine and local-admin callers, and as the
token-exchange requester that binds a browser user's identity to that same contract. External requests also pass AIRS gateway authentication and per-registration
JWT validation before the bearer reaches clippy-mcp.
See AI Gateway and MCP security handoff for exact headers, claims, customer Auth0 guidance, architecture, requests/responses, and the production test matrix.