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/startand/confirm(AUTH4D-13). The page shows theresend_aftercountdown, disables code entry onceexpires_inhas passed, and mapsinvalid_code,slow_downand 403access_deniedto 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’serror=access_deniedanderror=temporarily_unavailableare shown as recoverable alerts.POST /t/{tenantId}/guest/linking-intentand/guest/upgrade(AUTH4D-15), with a fresh randombrowser_flow_idper attempt.409 identity_in_useshows a conflict page that states nothing was merged and offers to link a different credential.POST /device/approvaland/device/denial(AUTH4D-16) with theX-CSRF-Tokenheader.POST /oauth/logout(AUTH4D-12) withredirect: "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 topost_logout_redirect_uriwithstate. A 400 offers “Sign out without returning”. The page never sendsid_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:
- Posts
{type:"auth4:guest-upgrade:ready", tenant_id, client_id}to the opener for each registered origin. - Accepts one
{type:"auth4:guest-upgrade:token", access_token}reply. The reply must come fromwindow.openerand a registered origin. Without one, the page shows “Open this page from your game” after 15 seconds. - Shows the requesting origin and asks for explicit confirmation before it creates a linking intent.
- 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