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 returns400withbrowser_refresh_unsupported(native applications always have rotating refresh tokens). - The opt-in is stored as the
refresh_tokenentry in the client’sgrantTypes, so responses show it there:["authorization_code", "refresh_token"]when opted in,["authorization_code"]otherwise.grantTypesitself stays a reserved, non-writable field. - A PATCH that does not mention
allowBrowserRefreshkeeps the current setting. management.client.createdandmanagement.client.updatedaudit events for browser applications carrybrowser_refresh: "enabled" | "disabled".- The application still needs
offline_accessin itsallowedScopesto 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
Originheader is accepted only when the calling client is a browser application and the origin is one of its registeredallowedOrigins. The response then carriesAccess-Control-Allow-Origin: {origin}andVary: Origin, neverAccess-Control-Allow-Credentials. - Any other origin gets
403invalid_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 anOriginheader are refused the same way (native applications register no origins). - Requests without an
Originheader (servers, native games) are unchanged. - Preflights (
OPTIONS) carry no client identity, so they are answered for any canonical HTTPS (or loopback HTTP) origin forPOSTwith at most theContent-Typeheader, without credentials. Browsers send these form posts without a preflight.
Not included
- Browser SDK support (
refresh: trueinpackages/sdk-browser) shipped separately in AUTH4D-65. prompt=nonesilent re-authorization on page reload is not supported by/oauth/authorizeand is left to a follow-up.
Source: docs/api-spec/auth4d-63.md