AUTH4D-304 refresh-token reuse window

POST /oauth/token with grant_type=refresh_token still rotates the refresh token on every success (AUTH4D-11 (repository file docs/api-spec/auth4d-11.md), AUTH4D-63 (repository file docs/api-spec/auth4d-63.md)). What changes is how a second presentation of the token a rotation just retired is answered. A refresh response lost on a mobile network, a retry, two browser tabs or two server instances refreshing at the same moment no longer sign the player out.

Behaviour

The window is fixed at 30 seconds (REFRESH_REUSE_GRACE_SECONDS in packages/sessions). It is not configurable.

When refresh token T0 rotates to T1:

  • Presenting T0 again within 30 seconds of that rotation returns 200 with a new token set. Its refresh token T2 is a sibling of T1 in the same family: the same session, scopes, client, idle expiry rules and absolute expiry. The session stays active and T1 keeps working.
  • Each rotation allows at most three siblings beyond T1 (REFRESH_REUSE_MAX_SIBLINGS). A fourth in-window reuse of T0 returns 400 invalid_grant, issues no tokens and does not revoke the family.
  • When any live sibling (T1, T2, …) is presented and rotates, the others are retired. Presenting a retired sibling later is a replay.
  • Only the immediate predecessor has a window. Presenting T0 30 seconds or more after the rotation, or any token two or more rotations old, is a replay.

A replay is answered exactly as before: 400 invalid_grant, and the whole refresh family and its session are revoked, including every live sibling and the session behind outstanding access tokens. The family must also still be live: a revoked or expired family, a revoked session or another client’s token is refused with invalid_grant whether or not it is inside a window.

All other responses are unchanged, including Cache-Control: no-store on every response.

Example

POST /oauth/token  refresh_token=T0          -> 200, refresh_token=T1
POST /oauth/token  refresh_token=T0  (+10 s) -> 200, refresh_token=T2   (sibling of T1)
POST /oauth/token  refresh_token=T2  (+60 s) -> 200, refresh_token=T3   (T1 is retired)
POST /oauth/token  refresh_token=T1  (+61 s) -> 400 invalid_grant       (family and session revoked)

Storage

The session authority Durable Object (packages/sessions/src/authority-do.ts) keeps one auth4_refresh_grace row per family, keyed by (tenant_id, family_id): the hash of the token the latest rotation retired, the end of its window, and the hashes of the live successor and its siblings. The table is created with CREATE TABLE IF NOT EXISTS, so no D1 migration is needed, and rows are removed by the existing cleanup alarm when their family expires. Every check and update happens inside the rotation’s single transactionSync. Only SHA-256 token hashes are stored, and nothing about tokens is logged.

Audit

Refresh does not write audit events, and the window does not add any. An in-window reuse records no replay or revocation. Replays outside the window revoke the family and session as before.

SDKs

@auth4/nextjs keeps sharing one refresh result per server instance for 60 seconds. Combined with the server window, several instances refreshing one session at the same moment no longer sign the player out. The browser, Unity, Godot and Unreal SDKs need no changes.

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