AUTH4D-167 Guests that survive a page reload

Browser SDK tokens live in memory. Until now a guest started with signInAsGuest() was lost on a page reload, because prompt=none (AUTH4D-140) can only restore a player who has a hosted-login session on the auth origin, and a guest created by POST /t/{tenantId}/guest from the game’s origin never gets one. The owner chose option (b): guests can now be started through a top-level navigation to the auth origin, which sets that hosted session. Nothing is stored in the game origin’s storage, and the “tokens stay in memory” rule is unchanged.

Hosted guest start

GET /t/{tenantId}/guest/authorize is a top-level navigation. It takes the same parameters as GET /oauth/authorize, except tenant_id (it is in the path), prompt and provider_hint:

Parameter Required Notes
response_type yes code
client_id yes An enabled browser application of this project with authorization_code
redirect_uri yes Exact match with a registered redirect URI
scope yes Must include openid. Only openid, profile, email, offline_access
state yes 16–512 characters, echoed exactly
code_challenge yes S256 PKCE
code_challenge_method yes S256
nonce no Copied to the ID token

The checks match the authorization endpoint. An unknown, missing or unregistered client or redirect URI, an invalid state, an unknown or repeated parameter, or a registered Origin mismatch returns 400 (or the hosted error page for a browser navigation) and is never redirected to. Everything else is answered at the registered redirect URI with the state:

Condition Redirect to redirect_uri
Malformed request (missing openid, PKCE not S256, response_type not code) error=invalid_request&state=…
A native or server application error=unauthorized_client&state=…
A scope outside the built-in four or the application’s allowed scopes error=invalid_scope&state=…
offline_access without the refresh option on the application error=invalid_scope&state=…
The browser already has a hosted session for a signed-in (non-guest) player error=interaction_required&state=…
Guest creation budget used up, or session state unavailable error=temporarily_unavailable&state=…
A pre_registration or post_login flow denies it error=access_denied&state=…
Otherwise code=ac_…&state=…

Error redirects set no cookie and create no guest. On success the API:

  1. creates a guest user with a guest identity, writes the guest.created audit event (metadata client_id) and publishes user.created, exactly as POST /t/{tenantId}/guest does;
  2. records the guest’s consent for this application and exactly the requested scopes;
  3. issues a one-time authorization code bound to the redirect URI, PKCE challenge, scopes and nonce, and publishes user.signed_in with method guest;
  4. sets a fresh __Host-auth4_session handle (HttpOnly; Secure; SameSite=Lax; Path=/), bound to the session the code creates when it is redeemed at POST /oauth/token.
HTTP/1.1 302 Found
Location: https://game.example/callback?code=ac_…&state=…
Set-Cookie: __Host-auth4_session=…; Path=/; Max-Age=43200; HttpOnly; Secure; SameSite=Lax
Cache-Control: no-store

The token response for that code is a normal browser token set. The access token carries auth4_guest: true until the guest upgrades. No token or session handle ever appears in a URL.

A hosted session that belongs to a signed-in player is never replaced by a guest, so a link to this endpoint cannot sign someone out. A guest’s own hosted session may be replaced by a new guest.

Abuse limits

The start shares the POST /t/{tenantId}/guest budgets, per project: 10 guests per application and client address per hour (guest:create:client_ip) and the per-address request budget (guest:create:ip). Budget keys are hashed with the privacy key; without it the start answers temporarily_unavailable.

Restoring a guest

GET /oauth/authorize?prompt=none (AUTH4D-140) now restores a guest the same way as any other player. In the AUTH4D-140 outcome table, the row “The player is an un-upgraded guest” is removed:

  • With the guest’s hosted session and consent for the application, the answer is a code. The access token carries auth4_guest: true, and the ID token’s auth_time is the guest start.
  • Another application of the same project has no consent from the guest: consent_required.
  • A disabled guest (for example one the inactive-guest retention task has handed to deletion) gets login_required.
  • After an upgrade the same user is restored, without auth4_guest.

A guest’s hosted session is used only to restore that guest. An interactive GET /oauth/authorize with a guest’s session does not offer to continue as the guest (no prompt=consent on the hosted login page), and POST /oauth/authorize/continue without an identity proof answers 401 login_required, as if there were no session.

Expiry and retention

  • The hosted session follows the usual browser-session rules: it ends after 12 hours without a restore and after 7 days at most. Each successful restore issues a fresh handle for a new session, so a guest who returns at least every 12 hours stays signed in. After that the guest is gone for the player unless it was upgraded.
  • Refresh tokens (with the refresh option) keep their own limits: 7 days idle and 30 days absolute by default.
  • The inactive-guest retention task in packages/lifecycle is unchanged. Every restore creates a session record, which counts as activity, so a guest who keeps returning is kept. A guest with no activity for the retention window (30 days by default) is disabled and deleted, and its hosted session then answers login_required.
  • Logging out through the SDK revokes the session, which ends the hosted session too.

Browser SDK (@auth4/sdk-browser)

loginAsGuest(options?: LoginOptions): Promise<string>;

loginAsGuest() stores the usual short-lived PKCE and state record in sessionStorage, navigates the top-level page to {issuer}/guest/authorize and resolves with the URL. provider is ignored. On the redirect URI, handleCallback() (or restoreSession()) completes it; the session has isGuest: true. After a reload, restoreSession() restores the guest; it no longer refuses a token set with the guest claim. signInAsGuest() is unchanged: its guest still lives in memory only, which suits games that never reload or do not want a cookie on the auth origin.

const result = await auth4.restoreSession();
if (result.status === "signed_out") await auth4.loginAsGuest();

Tests

  • cd tests/integration && npx vitest run --config vitest.config.ts auth4d-167: auth4d-167.guest-restore.test.ts covers the cookie, code, audit and consent of a start; restore after a reload with a rotated handle; restore after the upgrade; consent per application and the disabled guest; no interactive reuse of a guest’s session; no replacement of a signed-in player’s session; the guest creation budget; unregistered redirect URIs, malformed parameters, native applications and tenant isolation; and the SDK’s loginAsGuest() and restoreSession() against the real API with nothing left in app storage.
  • auth4d-140.silent-authorization.test.ts and auth4d-140.restore-session.test.ts now expect a guest with a hosted session to be restored.

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