Summary
The OAuth authorization server rotates refresh tokens and preserves a family_id across rotations, and its doc-comment claims this "protects against refresh-token theft via family-revoke." But no code path ever reads family_id to revoke a family. When a stolen refresh token is redeemed, the legitimate client's later reuse of the now-stale token is detected (returns invalid_grant) yet triggers no revocation — the attacker's rotated token stays valid indefinitely. This is insufficient session/credential invalidation (CWE-613): a stolen refresh token cannot be contained.
Details
- Store:
api/oauth/token.ts:178-239 writes family_id alongside each token.
- Rotate:
api/oauth/token.ts:431-569 uses Redis GETDEL to atomically consume+rotate a single token and carries family_id forward unchanged.
The PoC classifies every family_id occurrence in token.ts: 5 writes + 9 type-declarations/comments, 0 revoke-reads. No index maps family_id → tokens, and no branch revokes a family on detected reuse.
When an attacker redeems a stolen refresh token, it rotates to a fresh token (attacker-held) and the stolen one is GETDEL-consumed. The legitimate client's subsequent attempt to use its (now stale) copy hits a GETDEL-miss and receives 400 invalid_grant — this is the classic reuse signal a spec-correct AS uses to revoke the entire token family. Here it is discarded: the attacker's rotated token keeps rotating with a fresh 7-day sliding TTL. Access is silent and indefinite.
PoC
poc-F4-oauth-refresh-family.mjs proves family_id is write-only (5 writes / 0 revoke-reads) and models the rotation timeline:
attacker redeems stolen RT -> new RT_a issued, family_id preserved
victim redeems stale RT -> 400 invalid_grant (reuse DETECTED)
family revocation triggered? -> NO (no code reads family_id to revoke)
attacker RT_a still valid? -> YES (rotates indefinitely, 7-day sliding TTL)
Impact
An attacker who has obtained a victim's refresh token (theft is a precondition, out of scope to achieve here) retains persistent, silent access that the reuse-detection signal should have terminated. The impact of this defect is the failure to contain an already-compromised session — not the initial compromise. Rated Low: it requires a pre-stolen high-value credential and adds persistence rather than new access.
Remediation
Maintain a family_id → tokens index (or an oauth:family:<F> generation counter). On a GETDEL-miss for a token belonging to a known family (the reuse signal), revoke the entire family so both the attacker's and the victim's tokens are invalidated, forcing re-authentication. A tested unified-diff patch (F4-refresh-token-family.patch) and an end-to-end revocation harness are available in the coordinated-disclosure package.
Maintainer validation (2026-07-04): Valid historically, but no longer live on current origin/main. OAuth refresh-token rotation now tracks family pointers/revocation, rejects reused/legacy refresh tokens by revoking the family, and has regression coverage in tests/oauth-token.test.mjs. Published advisories cannot be closed through the GitHub API, so this is marked in-description as fixed/no longer active.
Summary
The OAuth authorization server rotates refresh tokens and preserves a
family_idacross rotations, and its doc-comment claims this "protects against refresh-token theft via family-revoke." But no code path ever readsfamily_idto revoke a family. When a stolen refresh token is redeemed, the legitimate client's later reuse of the now-stale token is detected (returnsinvalid_grant) yet triggers no revocation — the attacker's rotated token stays valid indefinitely. This is insufficient session/credential invalidation (CWE-613): a stolen refresh token cannot be contained.Details
api/oauth/token.ts:178-239writesfamily_idalongside each token.api/oauth/token.ts:431-569uses RedisGETDELto atomically consume+rotate a single token and carriesfamily_idforward unchanged.The PoC classifies every
family_idoccurrence intoken.ts: 5 writes + 9 type-declarations/comments, 0 revoke-reads. No index mapsfamily_id → tokens, and no branch revokes a family on detected reuse.When an attacker redeems a stolen refresh token, it rotates to a fresh token (attacker-held) and the stolen one is
GETDEL-consumed. The legitimate client's subsequent attempt to use its (now stale) copy hits aGETDEL-miss and receives400 invalid_grant— this is the classic reuse signal a spec-correct AS uses to revoke the entire token family. Here it is discarded: the attacker's rotated token keeps rotating with a fresh 7-day sliding TTL. Access is silent and indefinite.PoC
poc-F4-oauth-refresh-family.mjsprovesfamily_idis write-only (5 writes / 0 revoke-reads) and models the rotation timeline:Impact
An attacker who has obtained a victim's refresh token (theft is a precondition, out of scope to achieve here) retains persistent, silent access that the reuse-detection signal should have terminated. The impact of this defect is the failure to contain an already-compromised session — not the initial compromise. Rated Low: it requires a pre-stolen high-value credential and adds persistence rather than new access.
Remediation
Maintain a
family_id → tokensindex (or anoauth:family:<F>generation counter). On aGETDEL-miss for a token belonging to a known family (the reuse signal), revoke the entire family so both the attacker's and the victim's tokens are invalidated, forcing re-authentication. A tested unified-diff patch (F4-refresh-token-family.patch) and an end-to-end revocation harness are available in the coordinated-disclosure package.Maintainer validation (2026-07-04): Valid historically, but no longer live on current origin/main. OAuth refresh-token rotation now tracks family pointers/revocation, rejects reused/legacy refresh tokens by revoking the family, and has regression coverage in tests/oauth-token.test.mjs. Published advisories cannot be closed through the GitHub API, so this is marked in-description as fixed/no longer active.