fix: authenticate confidential clients on the OAuth2 refresh grant - #29207
fix: authenticate confidential clients on the OAuth2 refresh grant#29207BobbyHo wants to merge 9 commits into
Conversation
refreshTokenGrant never read client_secret. Anyone holding a stolen refresh token and the app's public client_id could mint new tokens without the app's credentials, which RFC 6749 section 6 and OAuth 2.1 section 3.2.1 require confidential clients to present. The token to app ownership check landed earlier; this is the authentication half. The grant now calls authenticateClient before it parses the refresh token, so a caller without the secret answers 401 invalid_client regardless of whether the token is live, and learns nothing about it. Public clients are unchanged: they hold no secret, and the AppID binding plus single-use rotation tie their refresh to the client. A refused refresh mints nothing and leaves the token usable. Client authentication failures at the token endpoint are now logged at warn with the grant type and app id and nothing from the body. BREAKING CHANGE: a confidential client that refreshes without its client_secret, or with a wrong one, now receives 401 invalid_client instead of 200. Sending the secret, in the form or as HTTP Basic, succeeds. Public clients are unaffected. Two existing expectations flip because authentication now runs first: a refresh presenting another app's secret is invalid_client rather than invalid_grant, and refreshing after the presented secret was deleted is invalid_client rather than invalid_grant. Closes PLAT-506 (GHSA-whr4-xxrp-33vc) and the refresh half of SEC-348.
Docs previewCheck off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here. |
…refresh-client-auth
…refresh-client-auth
…refresh-client-auth
…refresh-client-auth
|
/coder-agents-review |
|
Chat: Review in progress (19/19 reviewers complete) | View chat deep-review v0.9.0 | Round 2 | Last posted: Round 2, 9 findings (1 P2, 1 P3, 3 Nit, 4 Note), COMMENT. Review Finding inventoryFinding inventory - PR #29207Findings
Round logRound 1Netero-only first pass. 1 P2. Reviewed against 314e184..0f74b2a. P2 gate: panel deferred until mechanical finding is addressed. Round 2Churn guard: PROCEED. CRF-1 addressed in 17f5833. Panel round (17 reviewers). Reviewed against 314e184..17f5833. Netero: no new findings. 1 P3, 3 Nit, 4 Note new. No P0-P2. Panel converged that the security change is correct. Convergence: CRF-2 (Razor P3 / Chopper Note, log amplification), CRF-6 (Pariston/Ryosuke/Meruem, per-handler auth structural alternative), CRF-7 (Gon/Zoro, seedPublicApp naming). Knov's cross-grant error-code divergence (refresh 401 invalid_client vs code grant 400 invalid_request for a missing secret) folded into the review body as an observation on untouched code. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
First-pass review only. These are mechanical findings from Netero; the full review panel has not yet reviewed this PR. The panel will review after this finding is addressed. This defect is worth fixing before the panel spends parallel review time.
The change itself is clean: the refresh grant reuses the existing authenticateClient helper rather than copying the secret check, authentication runs before the token is parsed so an unauthenticated caller cannot distinguish a live token from a dead one, and every refusal path asserts the token row survived and a correct retry succeeds. Test density is 84.7% and the targeted tests pass against the built worktree. Docs cover the behavior change, the new 401, and a security bullet.
One P2, no P0/P1. Netero on the orphaned comment: "The api_key/FK-cascade/HTTP-500 comment now sits directly above TestOAuth2RefreshClientAuthentication... so it reads as documentation for the client-authentication test. That test uses the shared dbtestutil.NewDB(t) and disables no constraints, so the comment's claims are false for the function it now heads."
coderd/oauth2provider/tokens_test.go:670
P2 [CRF-1] Orphaned comment now misheads the new security test (Netero)
The api_key/FK-cascade/HTTP-500 comment (670-674) now sits directly above
TestOAuth2RefreshClientAuthenticationwith no blank line separating it from that test's own comment (676-678), so it reads as documentation for the client-authentication test. That test uses the shareddbtestutil.NewDB(t)and disables no constraints, so the comment's claims (own database, constraints off, HTTP 500 on api_key read) are false for the function it now heads.
Inserting TestOAuth2RefreshClientAuthentication between this comment block and its original function (TestOAuth2RefreshKeyMissing, now at line 844) split the comment from what it documents. Two consequences: the new security test is headed by false claims about database isolation and HTTP 500, and TestOAuth2RefreshKeyMissing lost the non-obvious rationale for why it disables FK constraints and takes its own database. Move the 670-674 block down to immediately precede func TestOAuth2RefreshKeyMissing, leaving only the 676-678 comment above the new test.
🤖
🤖 This review was automatically generated with Coder Agents.
…KeyMissing Inserting TestOAuth2RefreshClientAuthentication split the key-missing test from its doc comment, so the new test read as if it disabled FK constraints. Move the comment back and shorten both.
|
Addressed CRF-1 in 17f5833. The api_key/FK-cascade comment is back above |
|
/coder-agents-review |
There was a problem hiding this comment.
Full panel review (17 reviewers). CRF-1 from the first pass is verified fixed at the root: the FK-cascade rationale heads TestOAuth2RefreshKeyMissing again and the new security test carries its own accurate comment.
The security change holds up under hard scrutiny. refreshTokenGrant authenticates confidential clients before the token is parsed, which closes the oracle a token-first order would open: an unauthenticated caller gets a uniform 401 invalid_client for both a live and a dead token, and WrongSecretUnknownToken pins that. It reuses authenticateClient verbatim rather than copying the four-step secret check, so the app-binding logic cannot drift from the code grant. Public clients fail closed via !app.IsPublic(), and the refused token is provably not consumed (requireRefused reads the row back and redeems it). The WrongApp split preserves the ownership-check coverage the new auth check would otherwise mask. Test density is 84.9% spent on behavior that matters. Hisoka put it well: "The tests test the right failures, not the easy ones, and the ordering was chosen for the leak I would have gone looking for."
Counts: 0 P0-P2, 1 P3, 3 Nits, 4 Notes. Nothing blocking.
The P3 is a log-level/amplification question on the new warn line; Razor and Chopper split on whether it needs changing (P3 vs Note), so it is your call with the tradeoff laid out inline. Three reviewers (Pariston, Ryosuke, Meruem) independently converged on the same structural observation: confidential-client authentication now lives in per-handler discipline across a four-PR stack, and a future grant can silently omit it. Not a defect in this PR, but the durable fix (authenticate once in Tokens before the switch) is worth weighing before the client-credentials grant lands.
One cross-grant observation on untouched code, from Knov: a missing client_secret now answers 401 invalid_client on refresh, while the code grant's parser answers 400 invalid_request for the same omission (tokens.go:206). Per RFC 6749 §5.2 the refresh path is the more correct of the two; flagging only because a client library that branches on error code will see different responses for the same failure class across grants. Not this PR's job to reconcile.
coderd/oauth2provider/tokens.go:813
Note [CRF-8] The new comment leans on rotation binding that outlives secret rotation (Knov)
[the comment] is accurate (line 813 copies
dbToken.AppSecretID), but it means a token stays bound to its minting secret even after the client rotates to a different valid secret. Concrete consequence: app holds secrets A and B, token minted under A, client refreshes using B (authenticates fine), the rotated row still references A; deleting A then cascades away a session the client is actively driving with a live secret B.
This is pre-existing rotation behavior (#28752), not introduced here, and revoking sessions minted under a deleted secret is a defensible choice. Flagging because this PR's new comment now leans on the behavior; if the intent were "the authenticating secret binds the token," the code would pass dbSecret.ID through as the code grant does. Worth confirming the current binding is the intended one.
🤖
🤖 This review was automatically generated with Coder Agents.
… test helpers - Reword the failed-client-authentication log comment to cover missing, wrong, and foreign secrets. - Name the app-binding check in the SecretOfAnotherApp test comment instead of its position in authenticateClient. - Extend tryTokenRequest with request options and the response header so postForm no longer copies the request construction. - Spell OAuth2 correctly in the newly added nolint rationale.
…t' into coder-plat-506-refresh-client-auth # Conflicts: # docs/admin/integrations/oauth2-provider.md
TL;DR
Second of three for SEC-348. The refresh grant never checked
client_secret. Anyone holding a stolen refresh token and the app's publicclient_idcould get new tokens without the app's credentials. RFC 6749 section 6 requires confidential clients to authenticate here. The token to app ownership check landed in #27712; this is the authentication half. Closes PLAT-506 (GHSA-whr4-xxrp-33vc).invalid_clientpath already exists for the code grant.Implementation Details
The check
refreshTokenGrantcallsauthenticateClientfor confidential clients before it parses the refresh token. A public client has no secret, and the existing app binding plus single-use rotation are what tie its refresh to the client.invalid_grant) from a live one (invalid_client). Now every unauthenticated request gets the same 401 and learns nothing about the token.invalid_client, which is a 401 withWWW-Authenticate. The parser's 400 for the code grant is left as is.Logging
Two expectations flip
client_idwasinvalid_grantfrom the ownership check. It is nowinvalid_client, because authentication runs first. The ownership check keeps its coverage through a new row where the other app authenticates as itself.invalid_granton fix: make OAuth2 refresh token redemption single-use under concurrency #28752, because the cascade removed the token. It is nowinvalid_client, because the deleted secret fails authentication first.Docs
Tests
TestOAuth2RefreshClientAuthentication: missing secret, wrong secret, another app's valid secret, wrong secret with an unknown token (401, not 400), HTTP Basic, Basic and body in conflict, public client with no secret, public client with a secret sent anyway. Each refusal also checks the token row survived and a correct retry succeeds.TestOAuth2ProviderTokenRefresh: theWrongApprow split into own-secret (invalid_grant) and other-secret (invalid_client), plus a wrong-secret row.TestOAuth2RefreshRevokedToken/AppSecretDeletednow expectsinvalid_client.refreshedPublicClientSession, every refresh test that already sent the secret, and every token parser test.Stack: #28752, #29206, this PR, #29208.