Repository navigation
fix(auth): replay a refresh token within the 30s grace period - #137
Merged
Merged
Conversation
Exchanging a refresh token deleted it, so concurrent refreshes of the same token (a hot reload firing several requests at once) all failed after the first with invalid_grant and signed the user out locally, where the hosted API lets them converge. The public session-resilience docs describe a 30-second replay grace period: within it, replaying a just-exchanged token returns the same rotated tokens as the first exchange; after it, the old token is terminally invalid and a replay returns invalid_grant. The emulator now retires an exchanged token instead of deleting it, records which token the exchange minted, and answers a replay inside the window with that same refresh token (walking the chain so a client several rotations behind converges too) plus a fresh access token for the same session. Past the window, or once the session is revoked, the retired token is swept and the replay fails as before.
A retired token was only swept when it was replayed, but a client that kept up never sends it again, so every normal rotation left a row behind for good: a dev server refreshing every few minutes accumulated one per refresh per session. A rotation now also deletes the session's retired tokens whose grace window has closed. Chain walks are unaffected, since anything older in a chain than a swept token was exchanged earlier and is past its own window already.
[Critical impact] The PR appears safe to merge; the previous expired-token issue is fixed and no new blocking issues were found. SummaryAdds refresh-token replay so concurrent requests converge on the same rotated token and session.
|
…tent A replay inside the grace period that also carried an organization_id minted the access token and reported the requested organization, but handed back the live token still bound to the previous one, so the next plain refresh silently switched the session back. The live token now moves to the organization the response reports. Failing a refresh for a revoked session swept only the presented token and the live one it resolved to, so with A -> B -> C a replay of A left B behind with no rotation ever able to sweep it. Every token the session still has is deleted instead. The concurrent-replay test now starts both refreshes before awaiting either, so it exercises the overlap the issue reported rather than a sequential replay.
…lay-grace # Conflicts: # SUPPORTED.md
Production retires an exchanged refresh token only once the token it was exchanged for has itself been exchanged, plus the grace period, and never retires a token whose successor was never used: that is the client whose rotation response was lost. The emulator measured the window from the presented token's own exchange, so it signed out exactly the client production protects. The replay now resolves from the successor, caps the chain walk at 50 links, and the rotation sweep deletes a retired row only once its successor is gone or was exchanged more than the grace period ago. A retired replay past the window now answers "Refresh token already exchanged." and keeps its row, and a replay that carries an organization_id no longer persists the switch: production's replay returns before the exchange hook that records the session's organization.
Production's retirement delay is not a constant: it is the feature flag userland-refresh-token-grace-period-in-milliseconds with a code default of 0 ms. Saying the window is "measured the way production measures it" invited a reader to look for a hard-coded 30 s in the monorepo, not find one, and "correct" the emulator to 0. The anchor (the successor's exchange) is production's; the length is the public docs'. The replayed-organization-switch comment also sat in the response tail, run together with the retire-on-success comment, far from the organizationId assignment it explains.
…lay-grace # Conflicts: # SUPPORTED.md
A retired token whose successor is unused replays indefinitely, so without checking the presented token's own expiry first, a token rotated just before its 30-day lifetime ended could ride that unused successor past it. The emulator gives every token its own lifetime (production bounds them by the session instead), so the lifetime has to hold for a retired token the same as for a live one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #131
Problem
From the issue, by @davidfou:
The
refresh_tokengrant onPOST /user_management/authenticatedeleted the presented token as soon as it rotated it, so every concurrent or retried refresh after the first gotinvalid_grantand the SDK tore the session down.Behavior
The public contract is the Session resilience docs, "The replay grace period":
The mechanics are production's
resolveExchangedToken(packages/api-services/src/userland-sessions/userland-session-tokens.service.ts): the window is anchored on the successor's exchange, not the presented token's; a retired token whose successor has never been used is never retired; the chain walk is capped at 50 links; the refusal isinvalid_grant/Refresh token already exchanged.. The length comes from a feature flag there (code default 0 ms), so the emulator uses the documented 30 seconds.What the emulator does now:
200with the same rotatedrefresh_tokenthe first exchange returned, a freshly minted access token for the same session (sidunchanged), the sameuser/organization_idcontext, and theauthentication_methoda normal refresh reports. No new session, nosession.created, noauthentication.*event.{"error": "invalid_grant", "error_description": "Refresh token already exchanged."}. The row is kept, so a second replay answers the same way rather than as an unknown token.sessions/revoke,sessions/logout) fails every token of the session with the unknown-token shape,Invalid refresh token., checked before the token's own state, and deletes the whole chain, retired links included, since no rotation will ever sweep it.organization_idmints the access token for that organization and reports it, but the refresh token handed back keeps the organization the original exchange bound it to. Production's replay returns before the exchange hook that records the session's organization, so the switch is not persisted and a plain refresh of that token is back on the previous organization.What changed
src/workos/entities.ts:WorkOSRefreshTokengainsexchanged_atandexchanged_for_id, internal bookkeeping never serialized into a response.src/workos/routes/auth.ts:REFRESH_TOKEN_REPLAY_GRACE_MS(30 s) andREFRESH_TOKEN_REPLAY_MAX_CHAIN_DEPTH(50), each documented with its source, pluswithinReplayGrace,replayableRefreshToken(the chain walk) andreplayGraceClosed(the sweep predicate, the same check the walk makes first).refresh_tokenbranch checks the session still exists before anything else (deleting every token of a session that is gone), then resolves a retired token to the live end of its chain or refuses it as already exchanged. The presented token is marked for retirement instead of deleted.exchanged_at/exchanged_for_idon the token it rotated, so a refresh that fails leaves the token live to retry. It then sweeps the session's retired rows whose window has closed.README.md: the "Refresh tokens always rotate" section is now "Refresh tokens rotate, with a 30-second replay grace period" and describes the above; the failure-shape table row says "already exchanged".scripts/gen-supported-lib.ts+ regeneratedSUPPORTED.md: the Authentication note no longer claims the emulator is stricter than production here.Tests
src/workos/routes/auth.spec.ts:setSystemTime), then asserts the old token is refused withRefresh token already exchanged.Promise.all) both return200, the samerefresh_token, the sameuser.idandorganization_id, access tokens with the samesid,authentication_methodof the original sign-in; session count,session.createdandauthentication.*events do not grow and only two tokens exist in the store.400/Refresh token already exchanged., the row is kept so a repeat answers the same way, and the live token still rotates; that rotation sweeps the original token but not the one whose successor was only just exchanged.Invalid refresh token.and empties the chain, the never-presented link included; the live token fails the same way.organization_idwith its role claim and the samesid, and a plain refresh of that token afterwards is back on the original organization.Verification
At
3b3b983(main merged,SUPPORTED.mdregenerated):