AUTH4D-140 Silent re-authorization (prompt=none) and browser session restore

Browser SDK tokens live in memory, so a page reload loses them. A returning player can now be signed back in without any UI: the SDK sends the page to /oauth/authorize with prompt=none, and the API answers with a code when the player’s hosted-login session and consent are still valid. It is a top-level redirect, not a hidden iframe, so it works when third-party cookies are blocked. This follows OIDC Core 3.1.2.6.

Discovery

GET /t/{tenantId}/.well-known/openid-configuration adds:

{ "prompt_values_supported": ["none"] }

OidcDiscoveryDocumentSchema accepts the field as optional, so earlier documents stay valid.

Authorization request

GET /oauth/authorize accepts one more optional parameter:

Parameter Values Effect
prompt none Answer at once with a code or an error. Never show a page.

Every other check runs first and is unchanged: tenant, client, exact redirect_uri match, state (16–512 characters), S256 PKCE, scopes and the browser origin. So an unregistered redirect URI or a missing or invalid state still returns 400 (or the hosted error page for a browser navigation) and is never redirected to. Native applications still get unauthorized_client.

A repeated prompt returns 400 invalid_request. Any other value, including login, consent and lists such as none login, is redirected to the registered redirect URI as error=invalid_request with the state.

Outcomes for prompt=none

The API reads the hosted-login session cookie (__Host-auth4_session) on the auth origin. Because the request is a top-level navigation, the cookie is first-party and SameSite=Lax sends it.

Condition Redirect to redirect_uri
No cookie, or the session is unknown, expired, revoked or belongs to another project error=login_required&state=…
The player is disabled error=login_required&state=…
The player is an un-upgraded guest (only a guest identity) error=login_required&state=…
Consent for this application is missing, revoked or lacks a requested scope error=consent_required&state=…
Session state is unavailable error=temporarily_unavailable&state=…
Otherwise code=ac_…&state=…

interaction_required is reserved: the API does not return it today, and clients should treat it like login_required. Error redirects carry only error and state (no error_description) and set no cookie. No hosted-login flow is started, and no consent is granted or changed.

A successful response is a normal one-time authorization code, bound to the request’s redirect_uri, code_challenge, scopes and nonce. The ID token’s auth_time is the original sign-in time. As with the interactive flow, the response also sets a fresh __Host-auth4_session handle, bound to the session the code creates when it is redeemed. Signing out through the SDK (which revokes that session) therefore ends the hosted-login session too, and the next prompt=none returns login_required. The previous session is not revoked, so another open tab keeps working until its own session ends.

HTTP/1.1 302 Found
Location: https://game.example/callback?code=ac_…&state=…
Set-Cookie: __Host-auth4_session=…; Path=/; Max-Age=…; HttpOnly; Secure; SameSite=Lax
Cache-Control: no-store
HTTP/1.1 302 Found
Location: https://game.example/callback?state=…&error=login_required
Cache-Control: no-store

Tokens never appear in a URL: the code is exchanged at POST /oauth/token with the PKCE verifier, as before.

Browser SDK (@auth4/sdk-browser)

restoreSession(options?: RestoreSessionOptions): Promise<RestoreSessionResult>;

interface RestoreSessionOptions {
  scope?: string | readonly string[]; // must already be consented; defaults as for login()
  url?: string | URL; // the current page URL; defaults to window.location.href
  signal?: AbortSignal;
}

type RestoreSessionResult =
  | { status: "signed_in"; session: BrowserSession }
  | { status: "signed_out"; reason: "login_required" | "consent_required" | "interaction_required" }
  | { status: "redirecting" };

Call it on every page load:

  • With a live in-memory session it resolves signed_in at once.
  • On the redirect URI with an authorization response, it completes the callback (state, PKCE and ID-token checks as in handleCallback()) and removes the response from the address bar. A code gives signed_in. An interaction error answering the SDK’s own prompt=none request gives signed_out with that reason. Any other error, or a state the SDK did not issue, rejects as handleCallback() does.
  • Anywhere else it stores the usual short-lived PKCE and state record in sessionStorage, marked as silent, navigates the top-level page to the authorization endpoint with prompt=none, and resolves redirecting. The player then lands on the redirect URI, where the next call finishes.
  • It rejects with authorization_failed before navigating if discovery does not list none in prompt_values_supported.
  • A restored session is never a guest. If a token set carries the guest claim, it is dropped and the result is signed_out with login_required.

Tokens stay in memory. Nothing new is written to storage, and the refresh option works as before.

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

Tests

  • cd tests/integration && pnpm exec vitest run auth4d-140:
    • auth4d-140.silent-authorization.test.ts: discovery; code without UI; login_required without a session, after logout, for disabled players and for un-upgraded guests (restorable after upgrade); consent_required for new scopes, revoked consent and another application; the SDK restoring against the real API. Security: no redirect to unregistered or look-alike redirect URIs, state required and echoed exactly, other prompt values and duplicates rejected, S256 PKCE enforced and bound, no tokens in the redirect, and no cross-project sessions.
    • auth4d-140.restore-session.test.ts: the SDK’s redirect, callback handling, signed-out reasons, forged state, guest refusal and the discovery check.
  • pnpm exec playwright test -c tests/e2e/auth4d-140.playwright.config.ts (ports 5260 and 5361): real reloads in Chromium restore the player with one silent top-level redirect and no iframe; sign-out then reload gives login_required; a forged callback exchanges nothing; no token reaches a URL, sessionStorage, localStorage or document.cookie.

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