AUTH4D-23 hosted login and device approval pages

The hosted pages are static assets built from apps/login and served by the API Worker’s ASSETS binding on the authentication origin. The build emits the same shell at /, /login, /login/upgrade, /login/device and /login/logout, because the assets binding has no SPA fallback. Built pages carry a Content-Security-Policy that allows only same-origin scripts, styles and requests. All API calls are same-origin, use credentials: "same-origin" and send Accept: application/json.

Pages

Path Query Purpose
/login tenant_id, client_id, flow_id, optional prompt=consent, proof_id+proof_purpose, error Method choice, email code, Discord return, consent
/login/upgrade tenant_id, client_id Guest upgrade in a window opened by the game
/login/device tenant_id, optional user_code, optional client_id Device code entry, confirmation, approval and denial
/login/logout tenant_id, client_id, optional post_logout_redirect_uri, state Logout confirmation

Duplicate or malformed identifiers show a “link is not valid” page and make no API calls. After reading the Discord callback’s proof_id, proof_purpose or error, the page removes them from the address bar with history.replaceState.

Existing endpoints used

  • POST /t/{tenantId}/email/verification/start and /confirm (AUTH4D-13). The page shows the resend_after countdown, disables code entry once expires_in has passed, and maps invalid_code, slow_down and 403 access_denied to field errors, a rate-limit alert and the expired-flow page.
  • GET /t/{tenantId}/identity/discord/start (AUTH4D-14) by top-level navigation. The callback’s error=access_denied and error=temporarily_unavailable are shown as recoverable alerts.
  • POST /t/{tenantId}/guest/linking-intent and /guest/upgrade (AUTH4D-15), with a fresh random browser_flow_id per attempt. 409 identity_in_use shows a conflict page that states nothing was merged and offers to link a different credential.
  • POST /device/approval and /device/denial (AUTH4D-16) with the X-CSRF-Token header.
  • POST /oauth/logout (AUTH4D-12) with redirect: "manual". A 204 shows the signed-out page. Because the API redirects only to an exact registered URI, an opaque redirect response makes the page navigate to post_logout_redirect_uri with state. A 400 offers “Sign out without returning”. The page never sends id_token_hint, so no token appears in a URL.

Required API additions (not implemented by this ticket)

The pages are built and tested against these contracts. The live flows need them in the API Worker.

Login context

GET /t/{tenantId}/login/context?client_id={clientId}[&flow_id={flowId}]
Accept: application/json
Cookie: __Host-auth4_flow_{flowId}=...   (when flow_id is present)
{
  "tenant": { "tenant_id": "ten_…", "display_name": "Starfall Studios" },
  "application": {
    "client_id": "cli_…",
    "display_name": "Starfall Arena",
    "origins": ["https://play.example.test"]
  },
  "branding": {
    "accent_color": "#1d4ed8",
    "support_url": "https://…",
    "privacy_policy_url": "https://…"
  },
  "login_methods": { "email": true, "discord": false },
  "requested_scopes": ["openid", "profile", "email"]
}

With flow_id, the API should require the matching flow cookie and return the flow’s stored scopes and client. origins are the client’s registered allowed origins; the upgrade page accepts guest tokens only from these. The page validates the response. It requires the tenant and client to match the query, takes names as text up to 100 and 80 characters, and removes control and bidi format characters. It accepts only #rrggbb accent colors with sufficient contrast and only https: URLs, and ignores every other branding field. 400 and 404 show the invalid-link page; 403 shows the expired-flow page.

JSON continuation

POST /oauth/authorize/continue currently answers with a 302 to the client. Script cannot read the location of a fetch redirect, and the flow and proof are consumed before the redirect, so the page needs a JSON mode when the request has Accept: application/json:

{ "redirect_to": "https://play.example.test/callback?code=…&state=…" }

The page follows only absolute http:/https: targets. Any non-network error is shown as the expired-flow page, because the continuation consumes the flow first.

Device verification data

GET /device/verify?tenant_id=…&user_code=…[&client_id=…] with Accept: application/json should return the verification data instead of HTML, and set the same CSRF cookie:

{
  "tenant": { "tenant_id": "ten_…", "display_name": "…" },
  "application": { "client_id": "cli_…", "display_name": "…" },
  "branding": {},
  "user_code": "ABCD1234",
  "scopes": ["openid", "offline_access"],
  "browser_flow_id": "flw_…",
  "csrf_token": "…"
}

HTML requests to /device/verify should redirect to /login/device with the same query. The page keeps the CSRF token in memory only. A 401 or login_required decision asks the user to sign in and keeps the request open. expired_token, invalid_request, invalid_grant and access_denied show the expired-code page.

Guest upgrade window protocol

The game opens /login/upgrade?tenant_id=…&client_id=… with window.open, without noopener. The page then:

  1. Posts {type:"auth4:guest-upgrade:ready", tenant_id, client_id} to the opener for each registered origin.
  2. Accepts one {type:"auth4:guest-upgrade:token", access_token} reply. The reply must come from window.opener and a registered origin. Without one, the page shows “Open this page from your game” after 15 seconds.
  3. Shows the requesting origin and asks for explicit confirmation before it creates a linking intent.
  4. On success, posts {type:"auth4:guest-upgrade:complete", result} with the upgrade response (replacement token set) to that origin only. On failure it posts {type:"auth4:guest-upgrade:error", error:"identity_in_use"|"invalid_session"}, and on “Not now” it posts {type:"auth4:guest-upgrade:cancelled"}.

Tokens never enter the URL, storage, cookies or the DOM. For the Discord round trip, the page stores only a non-secret {tenantId, clientId} marker in sessionStorage, keyed by the flow ID. On return it repeats the handshake to get the token again.

Tests

pnpm exec playwright test -c tests/e2e/auth4d-23.playwright.config.ts builds the pages into apps/login/.wrangler/auth4d-23-dist, serves them on port 5193 and mocks the API, Discord and the opening game. The suite covers these flows:

  • Email login using only the keyboard, with labeled field errors.
  • Resend timing and code expiry, using a fake clock.
  • Rate limits and expired flows.
  • Discord approval and denial, and a cancelled Discord login.
  • Consent with an existing session.
  • Disabled methods and malformed links.
  • Hostile branding.
  • Guest upgrade by email and by Discord, identity conflicts and untrusted openers.
  • Device approval and denial, expired codes and a missing session.
  • All logout variants.
  • No horizontal scrolling at a 360px viewport.

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