Enforce API token revocation on the auth path#153
Merged
Conversation
The bearer credential is a self-contained JWT whose jti is the account_api_tokens
row id, so a revoked (soft-deleted) token kept authenticating until its exp (up
to a year). Add account_api_token_is_active(jti, account) and check it on every
authenticated request, so DELETE /api-tokens/{id}/ takes effect immediately.
Verified live: a token returns 200, is revoked (204), then returns 401 on reuse
while the caller's other sessions keep working.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
Summary
Fixes a security gap surfaced while investigating the deferred API-token identity question: revoking a token didn't actually revoke it.
The bearer credential is a self-contained, signed JWT — there is no separate opaque secret, and the JWT's
jticlaim is theaccount_api_tokensrow id. The auth path (AuthState::from_unverified_header) validated the signature,exp, and account status, but never re-checked the backing token row. SoDELETE /api-tokens/{id}/soft-deleted the row and the management UI stopped listing it, yet the JWT kept authenticating until itsexp— up to a year. A leaked or compromised token could not be killed.Fix
account_api_token_is_active(token_id, account_id)— a cheapSELECT exists(…)scoped to the token's account, returning false once the row is soft-deleted.verify_token_activeruns on every authenticated request (alongside the existing account-status and privilege-consistency checks), rejecting a revoked token with401.Revocation is now immediate (no cache, so no staleness window — the right default for a security control). It costs one indexed PK lookup per request, on top of the account lookup already performed.
Verification
check/clippy -D warnings/nightly fmt/test --all-features(13 suites).DELETE(204) → reuse (401, was 200 before) → caller's other session still works (200). Confirmedjti == returned token id.Note on the identity question (no code change)
The investigation also resolves the parked "should the token id be a hash?" decision: keep the UUID. The id is load-bearing — it's the JWT
jti, the management-endpoint address, and (now) the revocation join key — but never human-facing. A hash/short-id would add no value and would breakjti == idsymmetry.🤖 Generated with Claude Code