AUTH4D-63 rotating refresh tokens for public browser clients

Public browser (SPA) applications can opt in to rotating refresh tokens so players are not sent back to the hosted login every five minutes. Applications that have not opted in behave as before this ticket was documented: offline_access is refused with invalid_scope. Rotation, reuse detection and family revocation are the same code path as for native games (packages/sessions); only the lifetimes differ.

Opting in

POST /management/tenants/{tenantId}/clients and PATCH /management/tenants/{tenantId}/clients/{clientId} (AUTH4D-18 (repository file docs/api-spec/auth4d-18.md)) accept an optional boolean allowBrowserRefresh:

{ "allowBrowserRefresh": true }
  • Browser applications only, default false. Sending it for a native application returns 400 with browser_refresh_unsupported (native applications always have rotating refresh tokens).
  • The opt-in is stored as the refresh_token entry in the client’s grantTypes, so responses show it there: ["authorization_code", "refresh_token"] when opted in, ["authorization_code"] otherwise. grantTypes itself stays a reserved, non-writable field.
  • A PATCH that does not mention allowBrowserRefresh keeps the current setting.
  • management.client.created and management.client.updated audit events for browser applications carry browser_refresh: "enabled" | "disabled".
  • The application still needs offline_access in its allowedScopes to request it.

Opting out takes effect immediately: refresh requests return unauthorized_client and the presented refresh family is revoked, so opting back in later does not revive it.

Migration 0010_browser_refresh_opt_in.sql resets every existing browser application to the opted-out default (see docs/operations/d1-schema.md).

Authorization and token exchange

GET /oauth/authorize with scope=openid offline_access (plus any other allowed scopes) and S256 PKCE is accepted for opted-in browser applications. For applications that have not opted in, the redirect carries error=invalid_scope and the request’s state, as before.

POST /oauth/token with grant_type=authorization_code then returns a refresh_token alongside the access and ID tokens. grant_type=refresh_token rotates it: every success returns a new refresh token and retires the presented one. Presenting a retired token revokes the whole family and its session, including the newest refresh token and outstanding access tokens’ session (invalid_grant). Responses are Cache-Control: no-store.

Lifetimes

Defined in DEFAULT_EXPIRY_SECONDS (packages/contracts):

Client type Idle (…Idle) Absolute (…Absolute)
Browser (opted in) 1 day (browserRefreshTokenIdle = 86 400) 7 days (browserRefreshTokenAbsolute = 604 800)
Native 7 days (refreshTokenIdle = 604 800) 30 days (refreshTokenAbsolute = 2 592 000)

Each rotation extends the idle deadline but never past the absolute deadline fixed at sign-in.

CORS on the token and revocation endpoints

POST /oauth/token and POST /oauth/revoke decide CORS per client instead of using the deployment-wide CLIENT_ORIGINS list:

  • A request with an Origin header is accepted only when the calling client is a browser application and the origin is one of its registered allowedOrigins. The response then carries Access-Control-Allow-Origin: {origin} and Vary: Origin, never Access-Control-Allow-Credentials.
  • Any other origin gets 403 invalid_request (“The browser origin is not registered.”) without CORS headers. The check runs before the authorization code is consumed, so a rejected request does not burn the code. Device-code polls with an Origin header are refused the same way (native applications register no origins).
  • Requests without an Origin header (servers, native games) are unchanged.
  • Preflights (OPTIONS) carry no client identity, so they are answered for any canonical HTTPS (or loopback HTTP) origin for POST with at most the Content-Type header, without credentials. Browsers send these form posts without a preflight.

Not included

  • Browser SDK support (refresh: true in packages/sdk-browser) shipped separately in AUTH4D-65.
  • prompt=none silent re-authorization on page reload is not supported by /oauth/authorize and is left to a follow-up.

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