Findings
MCP OAuth 2.1 — SSO resumption + two-token model
Date: 2026-05-31
Area: packages/server/src/auth/oauth-server/*, packages/server/src/api/oauth-routes.ts, packages/server/src/auth/google.ts, packages/server/src/api/routes.ts
a-workbench is now an OAuth 2.1 Authorization and Resource Server for /mcp, so MCP clients (Claude Code, etc.) authenticate via the standard browser flow. Two non-obvious mechanics are worth recording.
1. State-ticket SSO resumption#
The OAuth /authorize endpoint needs an authenticated user, but the browser hitting it has no portal session (the portal session JWT lives in localStorage, unreadable on a top-level navigation). So user auth is delegated to the existing Google SSO, and the in-progress authorize request is round-tripped through it:
GET /authorizevalidates the request (registered client,redirect_uri∈ client's list, PKCES256), stores it inpending_authunder a random hex ticket (integration = '__oauth_authorize__', JSON insession_data), then starts Google SSO with the ticket appended to the OAuthstate:`${baseState}.${ticket}`.buildAuthUrl(ticket?)keeps the nonce keyed by the full state (baseState.ticket);createAuthStatestored onlybaseStateinpending_auth.handleCallbacksplits the returnedstateon the first.: the base is used forverifyAuthState, the full state for the nonce lookup. For a normal (non-OAuth) login there is no., so base === full and behaviour is unchanged — this is what keeps the existing Google tests green.- The callback extracts the ticket and calls
resumeAuthorize(ticket, userId, binding), which mints a single-use PKCE auth code and 302s to the client'sredirect_uriwithcode(+state).
Gotcha — the nonce coupling: the original plan said to call handleCallback(code, baseState). That would have broken the nonce lookup, because the nonce is keyed by the full state. The fix is to pass the full state and split inside handleCallback.
2. Login-CSRF binding (security)#
Without binding, the resume flow is exploitable: an attacker starts /authorize with their own MCP client (so they hold the PKCE verifier), sends the Google URL to a victim, the victim completes SSO, and a victim-scoped auth code is delivered to the attacker's client — PKCE doesn't help because the attacker initiated the flow.
Fix: /authorize sets an httpOnly, SameSite=Lax, Path=/, Max-Age=600 cookie awb_oauth_binding=<random> and stores the same value in the pending row. resumeAuthorize requires the cookie value (read from the callback request) to match the stored binding (constant-time compare) before minting a code; the pending row is deleted on any attempt (single-use). A victim's browser never visited our /authorize, so it has no matching cookie and the resume is refused. SameSite=Lax is deliberate — the cookie must be sent on Google's top-level redirect back to the callback.
3. Two-token model#
/mcp accepts three credentials, resolved in order (auth/oauth-server/resolve.ts):
| Credential | Header | Lifetime | For |
|---|---|---|---|
| API key | x-workbench-api-key | long-lived, bcrypt-hashed | headless / scripts |
| OAuth access token | Authorization: Bearer | short JWT, aud=<SERVER_PUBLIC_URL>/mcp | interactive clients (browser flow) |
| Portal session JWT | Authorization: Bearer | 24h, aud=a-workbench | the portal itself |
The API key is not accepted over Authorization: Bearer — that header is OAuth/session only. The distinct JWT audiences (/mcp vs a-workbench) mean a session token can't be replayed as an access token, and vice-versa. A no-/bad-credential /mcp request returns a JSON-RPC -32001 error with WWW-Authenticate: Bearer realm="a-workbench", resource_metadata="…/.well-known/oauth-protected-resource" — the discovery entry point.
Takeaway#
Delegating user-auth in /authorize to an existing SSO is clean, but the carrier (state) is also used for CSRF + nonce — splitting it needs care, and the resume ticket must be bound to the initiating browser or the whole flow is a login-CSRF. Tests run under esbuild (tolerant); npm run build (tsc, nodenext) rejected an extensionless dynamic import() (TS2835) that esbuild accepted — always run the real build before declaring an integration task done.