RefreshTokenFamilyRotation composition: builds a fresh
RefreshTokenFamily aggregate on register, and translates
RefreshTokenFamilyStore.updateFamily outcomes into the 4-variant
RefreshTokenFamilyRotationOutcome on rotate.
Replay is revoked inside the CAS, not after it (#274)
The replay branch used to abort the CAS and report replayed, leaving the
caller (packages/oauth/src/grants/refreshToken.mts) to revoke the family
in a SECOND store write. RFC 6819 §5.2.2.3 wants the whole family dead on
replay, and two writes cannot deliver that: between "replay detected,
aborted" and "family revoked" a parallel request holding the still-active
sibling token could complete its rotation and walk away with a fresh access
token, which is most of what the family-revoke defence exists to stop.
So the replay branch now COMMITS { ...current, revoked: true } and tags
the decision REASON_REPLAY_REVOKED. The store's compare-and-swap does the
rest: the revocation is applied to exactly the state that was inspected, or
the CAS loses and re-reads. A sibling rotation is therefore ordered either
strictly before the replay was classified (in which case it was a
legitimate rotation of the then-active token) or strictly after the family
was revoked (in which case it is refused). There is no longer an "in
between" for it to land in.
This is also why the return contract needed a third state: a committed
replay-revocation and a committed ordinary rotation are the same
{ outcome: "committed" }, and a closure variable cannot tell them apart
given the adapter is free to invoke the updater more than once per call.
The already-revoked branch still ABORTS — there is nothing to write, and
re-committing an unchanged aggregate would amplify writes on the one path
an attacker can drive at will.
Defence-in-depth on the aborted branch: any abort whose reason is not
REASON_ALREADY_REVOKED classifies as replayed. If a future code path
adds an abort case and forgets its reason, the safest classification is a
reject-class outcome (the caller rejects either way, and replayed also
drives the family-revoke fallback).
RefreshTokenFamilyRotation composition: builds a fresh RefreshTokenFamily aggregate on
register, and translates RefreshTokenFamilyStore.updateFamily outcomes into the 4-variant RefreshTokenFamilyRotationOutcome onrotate.Replay is revoked inside the CAS, not after it (#274)
The replay branch used to abort the CAS and report
replayed, leaving the caller (packages/oauth/src/grants/refreshToken.mts) to revoke the family in a SECOND store write. RFC 6819 §5.2.2.3 wants the whole family dead on replay, and two writes cannot deliver that: between "replay detected, aborted" and "family revoked" a parallel request holding the still-active sibling token could complete its rotation and walk away with a fresh access token, which is most of what the family-revoke defence exists to stop.So the replay branch now COMMITS
{ ...current, revoked: true }and tags the decisionREASON_REPLAY_REVOKED. The store's compare-and-swap does the rest: the revocation is applied to exactly the state that was inspected, or the CAS loses and re-reads. A sibling rotation is therefore ordered either strictly before the replay was classified (in which case it was a legitimate rotation of the then-active token) or strictly after the family was revoked (in which case it is refused). There is no longer an "in between" for it to land in.This is also why the return contract needed a third state: a committed replay-revocation and a committed ordinary rotation are the same
{ outcome: "committed" }, and a closure variable cannot tell them apart given the adapter is free to invoke the updater more than once per call.The already-revoked branch still ABORTS — there is nothing to write, and re-committing an unchanged aggregate would amplify writes on the one path an attacker can drive at will.
Defence-in-depth on the aborted branch: any abort whose reason is not
REASON_ALREADY_REVOKEDclassifies asreplayed. If a future code path adds an abort case and forgets its reason, the safest classification is a reject-class outcome (the caller rejects either way, andreplayedalso drives the family-revoke fallback).Per A3 §6.1 + #274.