Skip to content

fix(coderd/oauth2provider): pin revoked-token refreshes and stop echoing a scope that names nothing - #28753

Merged
BobbyHo merged 2 commits into
plat481-2-single-use-refreshfrom
plat481-3-revoked-token-error-class
Sep 6, 2026
Merged

fix(coderd/oauth2provider): pin revoked-token refreshes and stop echoing a scope that names nothing#28753
BobbyHo merged 2 commits into
plat481-2-single-use-refreshfrom
plat481-3-revoked-token-error-class

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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_keys row 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.

PR What it does
#28751 A refresh may name a narrower scope.
#28752 Refresh token redemption is single-use under concurrency.
this A revoked token's refresh is pinned as invalid_grant, and a stored scope that names nothing stops being echoed back.

The error class (FR12, AC13, AC14)

  • The fix lives in fix: make OAuth2 refresh token redemption single-use under concurrency #28752. Earlier revisions of this PR mapped sql.ErrNoRows from GetAPIKeyByID to errBadToken. 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 answers invalid_grant when it finds nothing.
  • The two revocation paths a client can reach both cascade the oauth2_provider_app_tokens row away, so the prefix lookup answers errBadToken first. 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.
  • The case that answered 500 is a token row whose api_key_id names no key. The FK cascade makes that unreachable through any API, so TestOAuth2RefreshKeyMissing disables 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.
  • FR12 names a third path, deleting the app. It never reaches the grant: the client_id no longer resolves, so ExtractOAuth2ProviderAppWithOAuth2Errors answers 401 invalid_client first. AppDeleted pins that, and the spec is corrected.

The scope that names nothing

  • CHECK (scope <> '') admits a whitespace-only scope, and scopeStringToAPIKeyScopes echoed it into error_description as an empty pair of quotes. There is no name to report, so the rejection carries a fixed message instead.
  • This PR no longer carries the error_description charset 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 at writeTokenError, and feat: re-check the app allowlist at OAuth2 code redemption #28740 landed checkScopeStillCovered returning its sentinel bare rather than naming the app's registered scopes, so both halves are below this PR now.
  • StoredScopeOutsideEnumRejectedOnRefresh covers 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.

@linear-code

linear-code Bot commented Aug 29, 2026

Copy link
Copy Markdown

PLAT-470

…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 BobbyHo changed the title fix(coderd/oauth2provider): answer invalid_grant when a refreshed token's key is gone fix(coderd/oauth2provider): pin revoked-token refreshes and stop echoing a scope that names nothing Sep 6, 2026
@BobbyHo
BobbyHo force-pushed the plat481-3-revoked-token-error-class branch from ee3999b to 9a7fba4 Compare September 6, 2026 03:20
@BobbyHo
BobbyHo merged commit 9a7fba4 into plat481-2-single-use-refresh Sep 6, 2026
25 of 26 checks passed
@BobbyHo
BobbyHo deleted the plat481-3-revoked-token-error-class branch September 6, 2026 03:23
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 6, 2026
@BobbyHo

BobbyHo commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Folded into #28752. Review there (CRF-13) removed the GetAPIKeyByID read this PR originally mapped, which fixed the 500 at its source, so what remained here was the pinning tests and the scope message change. Both commits were fast-forwarded onto #28752's branch, which is why GitHub shows this as merged into plat481-2-single-use-refresh. Nothing here has reached main on its own.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant