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:
- creates a guest user with a
guestidentity, writes theguest.createdaudit event (metadataclient_id) and publishesuser.created, exactly asPOST /t/{tenantId}/guestdoes; - records the guest’s consent for this application and exactly the requested scopes;
- issues a one-time authorization code bound to the redirect URI, PKCE challenge, scopes and
nonce, and publishes
user.signed_inwith methodguest; - sets a fresh
__Host-auth4_sessionhandle (HttpOnly; Secure; SameSite=Lax; Path=/), bound to the session the code creates when it is redeemed atPOST /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’sauth_timeis 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/lifecycleis 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 answerslogin_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.tscovers 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’sloginAsGuest()andrestoreSession()against the real API with nothing left in app storage.auth4d-140.silent-authorization.test.tsandauth4d-140.restore-session.test.tsnow expect a guest with a hosted session to be restored.
Source: docs/api-spec/auth4d-167.md