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.

  1. The game creates an intent with its guest access token. It receives a non-secret intent handle and a poll secret.
  2. The game opens the system browser at upgrade_uri (/login/upgrade?…&intent={handle}).
  3. 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.
  4. The game polls with the poll secret. It gets authorization_pending until 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 gets 403 access_denied.
  • The page then runs the existing email-code step (/t/{tenantId}/email/verification/start and /confirm) or Discord (/t/{tenantId}/identity/discord/start) with that browser_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.upgraded audit event and returns { "status": "approved", "identity": { "identity_id", "provider" } }. If the credential already belongs to another account, it returns 409 identity_in_use and ends the intent. A rejected or expired proof returns 400 access_denied and 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 with pnpm --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 with pnpm exec playwright test -c tests/e2e/auth4d-67.playwright.config.ts.

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