Skip to main content

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 — throws 401 {error:'unauthorized'} if there is no user.
  • requireAdmin — throws 401 if unauthenticated, or 403 {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.

  1. 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 cookie clippy_pkce, and 302-redirects to the provider.
  2. GET /api/auth/callback reads the PKCE cookie (400 if missing) and rebuilds the callback URL against APP_URL — behind a TLS-terminating proxy request.url is http://, and openid-client derives redirect_uri from it, so without the rebuild the provider rejects the grant with invalid_grant. On any IdP/user failure (tampered cookie, state mismatch, denied consent, expired code) it returns 400, never 500. On success it upserts the profile, creates a session storing the (encrypted) provider tokens, sets clippy_session, clears clippy_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 a user_profiles row 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):

  1. Verifies the JWT with jose.jwtVerify against the provider's remote JWKS (${KC_ISSUER}/protocol/openid-connect/certs, cached).
  2. Options: issuer: KC_ISSUER, algorithms: ['RS256'], requiredClaims: ['sub','scope'].
  3. The scope gate: splits the scope claim on spaces; if it does not contain M2M_SCOPE (default clippy-api), it throws missing required scope clippy-api.
  4. The audience gate, when M2M_AUDIENCE is set: aud joins requiredClaims and must contain that value, so a correctly scoped token minted for another audience in the same realm is rejected. Production sets it to stack-clippy. Unset means audience is unchecked and the scope gate stands alone — the app logs one M2M_AUDIENCE unset warning 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.

Get a machine token and call the API
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_session is HttpOnly, SameSite=Lax, and Secure when APP_URL is 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), layout iv(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 answers 401 reauth (or an SSE error with code reauth mid-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.