AUTH4D-67 native guest upgrade without window.opener
Native games (Unity desktop and mobile) cannot receive the postMessage that the hosted upgrade
window uses (AUTH4D-15 (repository file docs/api-spec/auth4d-15.md), AUTH4D-23 (repository file docs/api-spec/auth4d-23.md)). They upgrade a guest through an
upgrade intent instead, using polling semantics modelled on device authorization
(RFC 8628). Intents are separate from device codes and are
never accepted by /oauth/token.
- The game creates an intent with its guest access token. It receives a non-secret intent handle and a poll secret.
- The game opens the system browser at
upgrade_uri(/login/upgrade?…&intent={handle}). - The hosted page binds the intent to that browser. The player then links an email address or a Discord account and explicitly confirms the link.
- The game polls with the poll secret. It gets
authorization_pendinguntil the page finishes, then the replacement token set exactly once.
The handle is not a secret: it appears in a URL and the browser history. On its own it can only bind the intent to the first browser that opens it and run the linking step there. Tokens are released only to a request that presents the poll secret, which never leaves the game. The API stores only SHA-256 hashes of the handle and the poll secret.
Since AUTH4D-343 (repository file docs/api-spec/auth4d-343.md), binding also needs the one-time browser verifier from the page
URI’s fragment, so the handle alone binds nothing, and begin also labels the guest account.
All responses are JSON with Cache-Control: no-store. Every state change for one intent runs under
a per-intent lock in the tenant’s Durable Object. Intents live for
GUEST_UPGRADE_INTENT_EXPIRY_SECONDS (600 seconds) and are keyed by tenant, so a handle never
resolves under another tenant.
Create an intent (game)
POST /t/{tenantId}/guest/upgrade-intent
Authorization: Bearer {guest access token}
Content-Type: application/json
{ "client_id": "cli_…" }
The bearer must be a live guest access token for this tenant and client (audience client_id), and
the user must still have a guest identity and must not be disabled. The intent is bound to that
tenant, client, user and session, and to the token’s scopes. Intents are budgeted at 10 per guest per
hour.
{
"intent": "gui_…",
"poll_secret": "gps_…",
"upgrade_uri": "https://auth.auth4.dev/login/upgrade?tenant_id=ten_…&client_id=cli_…&intent=gui_…",
"expires_in": 600,
"interval": 5
}
| Status | error |
When |
|---|---|---|
| 400 | invalid_request |
Malformed body |
| 401 | unauthorized_client |
Unknown, disabled or server client, or inactive tenant |
| 401 | invalid_session |
Missing, expired or revoked token, or the user is no longer guest |
| 429 | slow_down |
Intent budget exhausted |
| 503 | temporarily_unavailable |
Session or atomic state unavailable |
Wire shapes: GuestUpgradeIntentCreateRequestSchema and GuestUpgradeIntentCreateResponseSchema in
packages/contracts. The handle matches ^gui_[A-Za-z0-9_-]{32,64}$ and the poll secret matches
^gps_[A-Za-z0-9_-]{43,86}$.
Poll for the result (game)
POST /t/{tenantId}/guest/upgrade-intent/token
Content-Type: application/json
{ "client_id": "cli_…", "intent": "gui_…", "poll_secret": "gps_…" }
The first poll is due interval seconds after creation. Polling earlier returns slow_down and adds
5 seconds to the interval (up to 60). The current interval is returned with slow_down and
authorization_pending.
| Status | Body | Meaning |
|---|---|---|
| 400 | { "error": "authorization_pending", "interval" } |
The player has not finished yet |
| 400 | { "error": "slow_down", "interval" } |
Polled faster than the interval, or another poll is in progress |
| 400 | { "error": "access_denied" } |
The player chose Not now; reported once |
| 400 | { "error": "identity_in_use" } |
The credential belongs to another account; reported once |
| 400 | { "error": "expired_token" } |
The intent expired, or there is no such intent in this tenant |
| 400 | { "error": "invalid_grant" } |
Wrong poll secret, or the result was already collected (replay) |
| 401 | { "error": "invalid_client" } |
Unknown, disabled or server client, or inactive tenant |
| 429 | { "error": "slow_down" } |
Poll budget exhausted (12 per intent and 60 per IP each minute) |
| 200 | Replacement token set | Returned exactly once |
{
"user_id": "usr_…",
"identity": { "identity_id": "idn_…", "provider": "email" },
"access_token": "…",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "rfr_…",
"id_token": "…",
"scope": "openid offline_access",
"session_id": "ses_…"
}
The replacement session is created when the poll succeeds; tokens are never stored. It has the same
user, client and scopes as the guest session. The access token no longer carries auth4_guest
(AUTH4D-64 (repository file docs/api-spec/auth4d-64.md)). After any terminal result the intent remains as a tombstone until it
expires, so a replayed poll gets invalid_grant and never another token set. A poll with a wrong
secret changes nothing.
Hosted page steps (browser)
These endpoints are called by /login/upgrade in intent mode. They require
Origin: {AUTH_ORIGIN} (otherwise 403 access_denied). They never accept or return tokens.
POST /t/{tenantId}/guest/upgrade-intent/begin
POST /t/{tenantId}/guest/upgrade-intent/complete
POST /t/{tenantId}/guest/upgrade-intent/deny
Content-Type: application/json
{ "client_id": "cli_…", "intent": "gui_…", "browser_flow_id": "flw_…" }
complete also takes "proof_id": "prf_…" (GuestUpgradeIntentCompleteRequestSchema). The other
two use GuestUpgradeIntentBrowserRequestSchema.
- begin binds a pending intent to this browser. It creates a guest-linking browser flow and sets
__Host-auth4_flow_{browser_flow_id}(HttpOnly; Secure; SameSite=Lax), then returns{ "status": "pending", "expires_in" }. Calling it again from the same browser with the same flow and cookie (for example after a reload) returns the same result. Any other browser gets403 access_denied. - The page then runs the existing email-code step (
/t/{tenantId}/email/verification/startand/confirm) or Discord (/t/{tenantId}/identity/discord/start) with thatbrowser_flow_id. For native clients, these endpoints accept a guest-linking flow as well as a device sign-in flow, and issue an identity proof just as they do for browser linking flows. - complete requires the bound flow cookie. It consumes the proof (issued after binding, for the
same flow) and links the credential to the guest using the AUTH4D-15 rules, so accounts are never
merged. It then revokes the guest session that created the intent, writes a
guest.upgradedaudit event and returns{ "status": "approved", "identity": { "identity_id", "provider" } }. If the credential already belongs to another account, it returns409 identity_in_useand ends the intent. A rejected or expired proof returns400 access_deniedand leaves the intent pending, so the player can try again. - deny requires the bound flow cookie, ends the intent and returns
{ "status": "denied" }.
Errors common to the three steps: 400 expired_token (expired or unknown in this tenant),
403 access_denied (wrong origin, or another browser), 409 access_denied (already completed,
denied or collected), 401 unauthorized_client and 429 slow_down (step in progress, or the upgrade
budget is exhausted).
Hosted page
/login/upgrade?tenant_id={tenantId}&client_id={clientId}&intent={handle} runs intent mode. A
malformed handle shows the invalid-link page. The page:
- binds the intent and shows Save your guest progress, with Link an email address, Link a Discord account and Not now;
- after the email code or the Discord return, asks for explicit confirmation (Link {credential}?,
then Link and save progress) before calling
complete; - shows the saved, conflict, cancelled, expired and opened-in-another-browser outcomes, and tells the player to return to the game;
- keeps only the non-secret flow ID (keyed by handle) and the Discord return marker in
sessionStorage. It never sees a token or the poll secret.
Tests
tests/integration/auth4d-67.native-upgrade.test.ts: success with a real email-code step, exactly-once delivery and a replayed poll,identity_in_use, expiry, a handle without the poll secret, first-browser binding and origin checks, and a cross-tenant handle. Run withpnpm --filter @auth4/integration exec vitest run --config vitest.config.ts auth4d-67.tests/e2e/auth4d-67.native-upgrade.spec.ts(port 5207): the page in intent mode. Run withpnpm exec playwright test -c tests/e2e/auth4d-67.playwright.config.ts.
Source: docs/api-spec/auth4d-67.md