fix(coderd/oauth2provider): pin revoked-token refreshes and stop echoing a scope that names nothing - #28753
Merged
BobbyHo merged 2 commits intoSep 6, 2026
Conversation
BobbyHo
force-pushed
the
plat481-2-single-use-refresh
branch
from
September 5, 2026 17:10
717e257 to
3a3ed82
Compare
BobbyHo
force-pushed
the
plat481-3-revoked-token-error-class
branch
5 times, most recently
from
September 6, 2026 01:22
120ff70 to
ee3999b
Compare
BobbyHo
force-pushed
the
plat481-2-single-use-refresh
branch
from
September 6, 2026 01:22
f499e46 to
81bad9e
Compare
…y is gone A refresh presented for a revoked token must answer invalid_grant, not a server fault. The two revocation paths a client can reach, deleting the API key and deleting the app secret, both cascade the token row away, so the prefix lookup refuses them; deleting the app never reaches the grant because the client_id stops resolving. All three are pinned as responses. The case that used to answer HTTP 500 is a token row whose api_key_id names no key. The refresh no longer reads that key before deleting it, so the returning-row delete finds nothing and answers invalid_grant. The FK cascade makes the row unreachable through any API, so its test disables the constraints to seed one.
CHECK (scope <> '') admits a whitespace-only scope, which scopeStringToAPIKeyScopes echoed into error_description as an empty pair of quotes. There is no name to report, so the rejection carries a fixed message instead. Tests widen the whitespace table and pin that the message does not vary with the value it rejected, and cover an unmintable stored scope reached through a refresh as well as through an authorization code.
BobbyHo
force-pushed
the
plat481-3-revoked-token-error-class
branch
from
September 6, 2026 03:20
ee3999b to
9a7fba4
Compare
Contributor
Author
|
Folded into #28752. Review there (CRF-13) removed the |
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
TL;DR
Last of three in PLAT-481, which closes PLAT-470. Refreshing a revoked token returned HTTP 500 until #28752 stopped reading the
api_keysrow ahead of deleting it. This PR pins the response a client sees on every revocation path, and stops echoing a stored scope that names nothing.scope.invalid_grant, and a stored scope that names nothing stops being echoed back.The error class (FR12, AC13, AC14)
sql.ErrNoRowsfromGetAPIKeyByIDtoerrBadToken. Review of fix: make OAuth2 refresh token redemption single-use under concurrency #28752 pointed out that the read itself was redundant, since the token row carries both the key id and the user id, and removing it collapsed the race window instead of relabeling its error. The returning-row delete is now the first statement to touch the key and already answersinvalid_grantwhen it finds nothing.oauth2_provider_app_tokensrow away, so the prefix lookup answerserrBadTokenfirst. Deleting the API key and deleting the app secret are pinned here anyway: the property a client depends on is the response, not which statement notices it, and the cascades that make them pass are schema this function does not control.api_key_idnames no key. The FK cascade makes that unreachable through any API, soTestOAuth2RefreshKeyMissingdisables the constraints to seed it, and takes a database of its own because disabling them applies to every table. It now reaches the delete and answers 400.client_idno longer resolves, soExtractOAuth2ProviderAppWithOAuth2Errorsanswers 401invalid_clientfirst.AppDeletedpins that, and the spec is corrected.The scope that names nothing
CHECK (scope <> '')admits a whitespace-only scope, andscopeStringToAPIKeyScopesechoed it intoerror_descriptionas an empty pair of quotes. There is no name to report, so the rejection carries a fixed message instead.error_descriptioncharset work the earlier revisions did. feat: let an OAuth2 refresh narrow the granted scope #28751 sanitizes every token-endpoint description to the RFC 6749 §5.2 set atwriteTokenError, and feat: re-check the app allowlist at OAuth2 code redemption #28740 landedcheckScopeStillCoveredreturning its sentinel bare rather than naming the app's registered scopes, so both halves are below this PR now.StoredScopeOutsideEnumRejectedOnRefreshcovers an unmintable stored scope reached through a refresh rather than through an authorization code, which is the likelier way a name dropped from the enum surfaces: a grant outlives the code that issued it.Stack: #28237, #28740, #28744, #28751, #28752, this PR.