Skip to content

PPT-2536: refuse revoked refresh tokens (IR-07) - #20

Merged
camreeves merged 2 commits into
masterfrom
PPT-2536-refresh-revocation
Jul 31, 2026
Merged

PPT-2536: refuse revoked refresh tokens (IR-07)#20
camreeves merged 2 commits into
masterfrom
PPT-2536-refresh-revocation

Conversation

@camreeves

Copy link
Copy Markdown
Contributor

The gap

POST /auth/revoke and /auth/logout were effectively no-ops for refresh tokens. Upstream RefreshToken#validate_code! only checks that the JWT decodes — it never consults the token store, and the refresh jti is never written by store_token_metadata (refresh tokens are stateless JWTs even with persist_jwt_tokens on).

So a revoked token kept minting access tokens for its full 30-day TTL: revocation and logout did not end a session, and a stolen refresh token could not be revoked short of rotating JWT_SECRET. Doorkeeper had no such gap — access and refresh were columns of one row.

Found independently by two reviewers. Verified as upstream authly behaviour, not introduced by PR #10's override of that method.

Why it isn't a one-liner

Rotation also revokes. revoke_old_refresh_token marks the presented token before returning its replacement, so a naive "revoked ⇒ reject" breaks RF-05 (P0): the ts-client boot race legitimately submits the same refresh token twice within milliseconds, and the second arrival would be refused — logging every SPA out on startup.

The two are now recorded distinctly:

Path Marker Behaviour
Rotation token_type = "refresh_rotated" redeemable for REFRESH_REVOCATION_GRACE_SECONDS (default 30)
Explicit revoke / logout none refused immediately

token_type is used as the discriminator because revocation-marker rows never carry one — no migration required. An already-revoked token is never downgraded to a rotation by a later redemption.

No weaker than what it replaces: Doorkeeper's previous_refresh_token kept the old token usable until its successor was used, typically a longer window than 30s.

Specs

Three move from pinning the gap to asserting the fix:

  • explicit revoke → refresh now 400 invalid_grant (was 200)
  • the revocation-endpoint case is un-pended
  • rotation replay covers both sides of the boundary — immediate replay still succeeds (RF-05), the same replay with the window collapsed to zero is refused (RFC 6819 §5.2.2.3)

238 examples, 0 failures, 5 pending.

Note

The first push failed CI (expected 400, got 200) because the patch scoping the grace to rotations silently no-op'd — crystal tool format had normalised the whitespace the string match relied on, leaving the grace unconditional so logout still didn't take effect. Caught by the very specs meant to prove it, which is the argument for flipping pinned gaps rather than deleting them.

🤖 Generated with Claude Code

camreeves and others added 2 commits July 31, 2026 14:39
`POST /auth/revoke` and `/auth/logout` were effectively no-ops for refresh
tokens. Upstream `RefreshToken#validate_code!` only checks that the JWT
decodes; it never consults the token store, and the refresh `jti` is never
written by `store_token_metadata` (refresh tokens are stateless JWTs even with
persist_jwt_tokens on). So a revoked token kept minting access tokens for its
full 30-day TTL — revocation and logout did not end a session, and a stolen
refresh token could not be revoked short of rotating JWT_SECRET. Doorkeeper had
no such gap: both tokens were columns of one row.

The complication is that ROTATION also revokes — `revoke_old_refresh_token`
marks the presented token before returning its replacement. A naive
"revoked => reject" therefore breaks RF-05 (P0): the ts-client boot race
legitimately submits the same refresh token twice within milliseconds, and the
second arrival would be refused, logging every SPA out on startup.

So the two are now recorded distinctly. Rotation writes
TokenStore::ROTATED_MARKER into `token_type` (revocation-marker rows never
carry one, so no migration is needed) and stays redeemable for
REFRESH_REVOCATION_GRACE_SECONDS, default 30. A deliberate revocation gets no
grace at all and takes effect immediately — otherwise logout would still mean
nothing. An already-revoked token is never downgraded to a rotation by a later
redemption.

This is no weaker than what it replaces: Doorkeeper's previous_refresh_token
kept the old token usable until its successor was used, typically a longer
window than 30s.

Three specs move from pinning the gap to asserting the fix:
  * explicit revoke then refresh -> 400 invalid_grant (was 200)
  * revocation-endpoint case un-pended
  * rotation replay now covers BOTH sides of the boundary — immediate replay
    still succeeds (RF-05), and the same replay with the window collapsed to
    zero is refused (RFC 6819 §5.2.2.3)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard was written but the patch that scoped it to rotations silently
failed to apply — crystal tool format had normalised the whitespace the
string match relied on, so the grace stayed unconditional and an explicitly
revoked token was still accepted for 30 seconds. CI caught it: both
explicit-revoke specs expected 400 and got 200.

Grace is now gated on TokenStore::ROTATED_MARKER, so a deliberate revocation
takes effect immediately while the ts-client boot race (RF-05) keeps its
tolerance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@camreeves
camreeves merged commit 4178ff5 into master Jul 31, 2026
6 of 7 checks passed
camreeves added a commit that referenced this pull request Aug 1, 2026
Ameba `notice` introduced by #20 that has been failing the Ameba check
on master since it merged — the check is separate from the crystal-style
job in ci.yml, which is why it slipped through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant