AUTH4D-17 console sign-in and management authentication

The console uses a confidential client in the immutable control-plane realm. Its issuer is fixed to {AUTH_ORIGIN}/t/control-plane and its audience is urn:auth4:management. The API bootstraps the client row from CONTROL_PLANE_CONSOLE_CLIENT_ID and CONTROL_PLANE_CONSOLE_CLIENT_SECRET; a conflicting row or missing control-plane seed fails closed. Neither value is sent to browser code.

Sign-in protocol

POST {CONSOLE_ORIGIN}/api/auth/start requires an exact same-origin Origin header. The BFF generates OAuth state and an S256 PKCE verifier, stores them encrypted in its SQLite-backed ConsoleSessionStore Durable Object, sets a short-lived HttpOnly flow cookie, and redirects to:

GET {AUTH_ORIGIN}/management-auth/authorize?response_type=code&client_id=cli_...&redirect_uri={CONSOLE_ORIGIN}%2Fapi%2Fauth%2Fcallback&scope=tenant%3Aread%20...&state=...&code_challenge=...&code_challenge_method=S256

The API accepts only the configured client, exact callback URI, unique management scopes, and code_challenge_method=S256. The hosted form posts email-code requests to:

POST /management-auth/email/start
Origin: {AUTH_ORIGIN}
Cookie: __Host-auth4_control_flow=...
Content-Type: application/json

{"flow_id":"flw_...","email":"developer@example.test"}

It returns HTTP 202 with {"expires_in":600} for permitted requests. Verification uses the same origin and cookie:

POST /management-auth/email/confirm
Origin: {AUTH_ORIGIN}
Cookie: __Host-auth4_control_flow=...
Content-Type: application/json

{"flow_id":"flw_...","email":"developer@example.test","code":"381042"}

A successful verification creates or resolves the control-plane email identity and returns {"redirect_to":"{CONSOLE_ORIGIN}/api/auth/callback?code=...&state=..."}. The six-digit code is stored as an HMAC in control-plane Durable Object state, expires after ten minutes, and allows five attempts. Email-send, IP and per-flow attempt budgets use AtomicStateStore.consumeBudget with control-plane rate-limit keys whose subjects are HMAC-pseudonymized with AUTH_PRIVACY_KEY under a management-rate-limit domain separator (the shared createRateLimitKey helper is customer-realm only by design).

The BFF callback consumes its encrypted pending state once, checks the returned state, and sends the code and PKCE verifier to POST /management-auth/token through the fixed API service binding. The token endpoint requires HTTP Basic authentication for the configured confidential client and accepts only authorization-code and refresh-token grants. It returns an RS256 management access token (five minutes), a rotating refresh token, and an internal session ID. The BFF stores all token material only as AES-GCM ciphertext in ConsoleSessionStore SQLite state.

API session routes

These are called only by the BFF over the API service binding; responses are Cache-Control: no-store.

  • GET /management-auth/current with Authorization: Bearer <management access token> returns { "user": { "id", "email", "email_verified": true } }, or 401 unauthorized with WWW-Authenticate: Bearer error="invalid_token".
  • POST /management-auth/revoke with the same bearer token revokes that token’s control-plane session and returns { "revoked": boolean }; an invalid token returns 401 unauthorized.
  • POST /management-auth/revoke-session requires HTTP Basic authentication for the console client and the strict body { "session_id": "ses_..." }. It returns { "revoked": boolean }, 401 invalid_client, 400 invalid_request, or 503 temporarily_unavailable when the client row or session authority is unavailable. The BFF uses it at sign-out, so revocation does not depend on holding an unexpired access token.

BFF session routes

  • GET /api/auth/current returns only { "user": { "id", "email", "email_verified" } }.
  • POST /api/auth/sign-out requires the exact console Origin, revokes the authoritative control-plane session, clears the Durable Object record, and expires the HttpOnly cookie.
  • GET /api/management/... and same-origin state-changing methods proxy only fixed management paths to the API service binding. They add a server-side bearer token; browser payloads never receive access tokens, refresh tokens, or client credentials.

The __Host-auth4_console cookie is Secure, HttpOnly, SameSite=Strict, host-only, and expires within one day. Console access tokens remain behind the BFF. The BFF Durable Object coalesces concurrent refreshes, rotates the stored refresh token, and enforces an eight-hour idle and twenty-four-hour absolute session lifetime. Ciphertext is bound (AES-GCM additional data) to the purpose and the session object name, so rows cannot be replayed into another session.

Failure semantics: an API 400/401 on refresh, or an upstream 401 on any proxied call, drops the session and expires the cookie (replayed refresh tokens revoke the whole control-plane session). Transient API failures return 503 temporarily_unavailable and keep the session. The proxy only forwards Content-Type, WWW-Authenticate and X-Request-Id response headers; upstream Set-Cookie is never relayed.

Code layout

  • apps/console/worker/app.ts — BFF routes; apps/console/src/worker.ts re-exports it as the Wrangler entry point.
  • apps/console/worker/session-do.ts — ConsoleSessionCore (logic over Durable Object SQLite) and the thin ConsoleSessionStore Durable Object shell.
  • apps/api/src/modules/management-auth/ — API routes, management-actor validation and the ControlPlaneSessionAuthority Durable Object.
  • Tests: tests/integration/auth4d-17.management-auth.test.ts (run pnpm test).

Management authorization

Management routes accept only a signed token with the fixed control-plane issuer, the exact urn:auth4:management audience, token_use=management, the configured client ID, an active control-plane email identity, and a live session in ControlPlaneSessionAuthority. Customer access tokens and ID tokens fail validation. Tenant-specific management requests also require a current membership row for that tenant; GET /management/tenants lists active tenants from current memberships. Tenant creation and client-management operations remain unavailable until their management feature ticket implements them.

Runtime wiring

The API Worker needs CONTROL_PLANE_CONSOLE_CLIENT_SECRET, AUTH_SIGNING_KEY_RING, and AUTH_PRIVACY_KEY secrets. The console Worker needs the same confidential-client secret plus CONSOLE_SESSION_KEY, a base64url-encoded random 32-byte key. Keep these values out of Wrangler vars and source control. Staging and production use separate secret values and DO storage.

The feature calls Auth4Services.email.sendVerificationCode for control-plane delivery. The shared runtime email adapter is currently fail-closed and its customer-only type does not yet accept the reserved realm, so a deployment must wire a control-plane-compatible AUTH4D-10 email adapter before developers can receive codes. Tests inject the local mailbox adapter. Project ownership is still granted only by the project-creation transaction.

Source: docs/api-spec/auth4d-17.md