Skip to content

fix: reject client_secret in the OAuth2 token and revocation URL - #29508

Draft
BobbyHo wants to merge 16 commits into
mainfrom
fix/oauth2-client-secret-query-string
Draft

BobbyHo wants to merge 16 commits into
mainfrom
fix/oauth2-client-secret-query-string

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

TL;DR

The token and revocation endpoints read client credentials from the merged form, so a client_secret in the URL query string authenticated. OAuth 2.1 section 2.4.1 is explicit that the client authentication parameters "can only be transmitted in the request content and MUST NOT be included in the request URI." Finding D6-04 in the OAuth 2.1 spec compliance matrix.

  • Both endpoints now answer 400 invalid_request when the query string names client_secret, whatever the body holds.
  • A correct copy in the body does not change the answer.
  • No migration, no new error code.
  • Breaking for a client that sends the secret in the URL. Moving it to the body or the Authorization header recovers it. The refusal consumes nothing. Rotate the secret that was in the URL: it is still valid and has already been recorded outside Coder.

Scope

client_id in the query string is still accepted. Section 2.4.1's prohibition covers it too, but ExtractOAuth2ProviderAppWithOAuth2Errors reads client_id from the query to look up the app without parsing the body, and site/src/api/api.ts:2045 relies on it, so moving it is a larger change than this one.

refresh_token, code, and the revocation token also remain readable from the query, because r.Form is the merged map. Those rest on section 3.2.2's positive requirement that token endpoint parameters travel in the request content rather than on a MUST NOT, and accepting them concedes no privilege: the caller already holds the credential it put in the URL. Tracked separately in PLAT-660: https://linear.app/codercom/issue/PLAT-660/oauth2-token-and-revocation-endpoints-accept-request-parameters-from.


Implementation Details
  • One helper checks the URL query for client_secret. The token endpoint and the revocation endpoint both call it. It reads the URL query rather than r.Form, which cannot tell a body value from a query value.
  • Both handlers log the refusal with the app id and never the value, matching the neighbouring credential refusals, so an operator can find the affected integrations.
  • Docs: the refusal is documented for both endpoints, plus a troubleshooting entry beside the neighbouring invalid_client case.
Tests
  • Refresh grant: secret only in the URL, and secret in both the URL and the body. Each refusal checks the token row survived and the body-only retry succeeds.
  • Revocation: secret only in the URL, and secret in both the URL and the body. The session survives the refusal.

- The token and revocation endpoints read credentials from the merged
  form, so a client_secret in the URL query string authenticated.
- OAuth 2.1 §2.4.1 allows the secret only in the request body or the
  Authorization header. URLs end up in proxy and access logs.
- Both endpoints now answer 400 invalid_request when the query string
  names client_secret, before reading the body.
- A correct copy in the body does not change the answer.
- Tests cover refresh and revocation. A refused request leaves the
  token usable and the body-only retry succeeds.
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check 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.

@BobbyHo

BobbyHo commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@BobbyHo

BobbyHo commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Chat: Review in progress | View chat
Requested: 2026-09-18 23:58 UTC by @BobbyHo

deep-review v0.9.0 | Round 3 | ceed0df..bc45163

Last posted: Round 3, 29 findings (1 P1, 7 P2, 9 P3, 2 P4, 7 Nit, 3 Note), COMMENT. Review

Finding inventory

Finding inventory, PR #29508

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Author contested; panel closed R3 (11/11 accept deferral to PLAT-660; ground 3 rejected) coderd/oauth2provider/tokens.go:172 refresh_token and code still accepted from the URL query, same leak as client_secret R1 Netero Yes
CRF-2 P2 Author contested; panel closed R3 (11/11 accept deferral to PLAT-660; ground 3 rejected) coderd/oauth2provider/revoke.go:43 Revocation still accepts the token to revoke from the URL query R1 Netero Yes
CRF-3 P1 Addressed upstream R2 (merge base eec4915, #29568 renumbered to 000597) coderd/database/migrations/000596_template_usage_stats_session_usage.down.sql Duplicate migration number 000596 makes every Postgres-backed test in the package fail, including this PR's three new tests. Not caused by this diff R1 Netero Yes
CRF-4 P3 Author fixed (a2daa20) docs/admin/integrations/oauth2-provider.md:150 New sentence sits under a token-endpoint-scoped heading, so the /oauth2/revoke breaking change is undocumented R1 Netero Yes
CRF-5 Nit Author fixed (a2daa20) coderd/oauth2provider/tokens.go:164 Guard inside extractTokenRequest adds a non-nil r.URL precondition; a hand-built request now panics instead of failing with a message R1 Netero Yes
CRF-6 Nit Author fixed (a2daa20) coderd/oauth2provider/tokens.go:163 Guard inserted after p := httpapi.NewQueryParamParser(), so the reject path builds an unused parser R1 Netero Yes
CRF-7 Note Author fixed (a2daa20) coderd/oauth2_test.go:1449 Revocation lacks the URL-plus-body case that tokens_test.go covers R1 Netero Yes
CRF-8 Note Author fixed (a2daa20) coderd/oauth2provider/revoke.go:90 Error description literal duplicated verbatim in tokens.go:356 R1 Netero Yes
CRF-9 P2 Open coderd/oauth2provider/tokens_test.go:830 SecretInQueryStringAndBody passes with the guard deleted; pre-existing duplicate-parameter rejection produces the same code R3 Bisky P2, Chopper P2, Pariston P2, Gon P2, Hisoka P3, Mafuuu P3, Razor P3, Kite P3, Robin P3, Meruem P3, Zoro P3 Yes
CRF-10 P2 Open coderd/oauth2provider/tokens_test.go:820 SecretInQueryStringAndBody omits the body-only retry the PR description claims every refresh refusal verifies R3 Mafu-san Yes
CRF-11 P2 Open docs/admin/integrations/oauth2-provider.md:606 "before the body is read" is false whenever client_id is in the body; measured 413 instead of the documented 400 R3 Mafu-san P2, Netero P3, Mafuuu P3, Razor P3, Hisoka Nit, Ryosuke Nit, Knov Nit, Meruem Nit, Zoro Nit Yes
CRF-12 P2 Open coderd/oauth2provider/tokens.go:331 The refusal emits no server-side log, and Coder's request logger never records the query, so a disclosed secret is invisible to the operator R3 Mafuuu P2, Chopper P3 Yes
CRF-13 P2 Open docs/admin/integrations/oauth2-provider.md:598 Troubleshooting entry never tells the reader the secret they sent is disclosed and must be rotated R3 Leorio Yes
CRF-14 P3 Open coderd/oauth2.go:197 /oauth2/revoke swagger @Failure 400 enumerates invalid_request causes; the new one is missing R3 Netero P3, Mafu-san P3, Chopper P3, Mafuuu Nit, Knov Nit Yes
CRF-15 P3 Open coderd/oauth2provider/tokens.go:282 Query().Has refuses a valueless ?client_secret=; measured revocation regression 200 to 400, against RFC 6749 3.2 and the package's own precedent R3 Ryosuke P3, Netero Note, Bisky Note, Chopper Note, Kite Note, Meruem Note, Hisoka Note Yes
CRF-16 P3 Open coderd/oauth2provider/authorize.go:306 /oauth2/authorize still accepts client_secret in the query and answers 200/302; unmentioned in scope or ticket R3 Knov P3, Hisoka Note Yes
CRF-17 P3 Open coderd/oauth2provider/tokens.go:188 client_id, the other half of the cited 2.4.1 MUST NOT, is dropped with no ticket, and the stated blocker cites a different route R3 Razor P3, Kite P3, Zoro P3, Pariston Note Yes
CRF-18 P3 Open coderd/oauth2provider/tokens.go:280 Code comment and docs still give the log-exposure rationale the author withdrew, repeated in five places across three layers R3 Chopper P3, Gon P2 Yes
CRF-19 P3 Open docs/admin/integrations/oauth2-provider.md:153 Page gives a general reason for a one-parameter rule and never discloses that code, refresh_token, and token are still read from the query R3 Leorio Yes
CRF-20 P3 Open coderd/oauth2_test.go:1466 Test comment asserts an ordering constraint that does not exist; the real constraint is which map is read R3 Leorio P3, Gon P2 Yes
CRF-21 P3 Open coderd/oauth2provider/tokens.go:174 Credential parameters are still sourced from the merged r.Form; the allowlist fix was measured green across the suite R3 Ryosuke P3, Pariston P3 Yes
CRF-22 P4 Open coderd/oauth2provider/revoke.go:100 Revocation echoes err.Error() into error_description unsanitized and uncapped, emitting a quote RFC 6749 5.2 excludes R3 Ryosuke Yes
CRF-23 P4 Open coderd/oauth2provider/tokens.go:373 A parameter sent twice is reported as missing; still open for code and client_id R3 Mafuuu Yes
CRF-24 Nit Open coderd/oauth2provider/tokens_test.go:808 Tests assign r.URL.RawQuery instead of merging, silently dropping any query parameter an earlier option set R3 Komugi Yes
CRF-25 Nit Open coderd/oauth2provider/tokens_test.go:814 Survive-and-redeem tail duplicates requireRefused; comment restates the two lines under it R3 Robin Nit, Gon P2 Yes
CRF-26 Nit Open coderd/oauth2_test.go:1482 Revocation 400 assertion tail is spelled out inline three times while its 401 twin is a named helper R3 Robin Yes
CRF-27 Nit Open coderd/oauth2provider/tokens.go:32 Message says "not in the URL" while the check and the docs say query string; it names the remedy but not the failed condition R3 Gon Yes
CRF-28 Nit Open coderd/oauth2provider/tokens.go:29 Const comment's trailing clause restates "shared" R3 Gon Yes
CRF-29 Note Open coderd/oauth2provider/revoke.go:81 The 405-ordering rationale for the guard's placement rests on a branch the router never reaches R3 Meruem, Zoro, Bisky, Chopper Yes

Contested and acknowledged

CRF-1 (P2, coderd/oauth2provider/tokens.go:192) - refresh_token and code readable from the URL query

  • Finding: vals := r.Form is the merged query-plus-body map, so refresh_token and code are accepted from the URL query exactly as client_secret was. Proposed fix: guard the credential set, not the single name.
  • Author defense (R3, comment IC_kwDOGkVX1s8AAAABVdUHFw): Concedes the mechanical claim and reproduces it (tokens: form refresh_token="RT_FROM_URL" code="CODE_FROM_URL"). Disputes equivalence on three grounds: (1) OAuth 2.1 §2.4.1's "MUST NOT be included in the request URI" covers the client authentication parameters only; §3.2.2 states a positive requirement for the other parameters and never a MUST NOT, so different normative weight; (2) no privilege is conceded, since a caller who puts a refresh token in a URL already holds that refresh token, whereas client_secret in a forbidden location actually authenticated; (3) the guard is not a disclosure mitigation, because every proxy on the path logged the URL before the handler ran. Also argues the blast-radius framing proves too much, citing coderd/httpmw/apikey.go:966, which accepts a live Coder session token from ?access_token= on every authenticated route. Concedes the round 1 PR description over-reached by resting on log exposure and rewrote it to rest on §2.4.1, naming the remaining parameters as out of scope with the ticket link. Separately corrected the client_id carve-out rationale: not "it is not a secret" (§2.4.1 covers client_id too) but that ExtractOAuth2ProviderAppWithOAuth2Errors reads client_id from the query to look up the app without parsing the body, and site/src/api/api.ts:2045 depends on it.
  • Status: contested, deferred to PLAT-660 (https://linear.app/codercom/issue/PLAT-660/oauth2-token-and-revocation-endpoints-accept-request-parameters-from). No panel decision yet.

CRF-2 (P2, coderd/oauth2provider/revoke.go:40) - revocation token readable from the URL query

  • Finding: extractRevocationRequest reads r.Form.Get("token"), so the token being revoked is accepted from the URL query. Round 1 argued this is worse than the token-endpoint case because a 401 leaves the logged token live and replayable.
  • Author defense (R3, same comment): Same three grounds as CRF-1, with a direct rebuttal of the 401 argument: "It is still live and replayable either way. Rejecting the query parameter does not un-log it." Reproduced the behavior (revoke: err=<nil> token="FROM_URL"). Same PLAT-660 link.
  • Status: contested, deferred to PLAT-660. No panel decision yet.

CRF-1 and CRF-2 panel closure (R3, 11/11)

  • Panel closure: Eleven panel reviewers evaluated the contest independently (Netero, Hisoka, Mafu-san, Mafuuu, Pariston, Komugi, Leorio, Kurapika, Chopper, Ryosuke, Razor, Knov, Kite, Meruem, Zoro all addressed it; none re-raised). Accepted on grounds 1 and 2. Verified facts behind the closure: OAuth 2.1 2.4.1's MUST NOT is scoped to the client authentication parameters while 3.2.2 is a positive requirement only (checked by Netero, Mafu-san, Kurapika); the cited precedent is real, APITokenFromRequest accepts a live session token from ?coder_session_token= and ?access_token= on every authenticated route (coderd/httpmw/apikey.go:947,966, read by Netero, Mafu-san, Pariston, Kurapika, Knov, Meruem); the presented credential is single-use, refreshTokenGrant deletes the presented row before issuing the replacement and logs a warning on second use (tokens.go:811, Knov, Mafuuu); Coder's own request logger records r.URL.Path only and never RawQuery (httpmw/loggermw/logger.go:90, Kurapika, Razor, Mafuuu), so the disclosure lives in third-party infrastructure.
  • Ground 3 rejected: "The guard is not a disclosure mitigation, every proxy logged the URL before the handler ran" applies verbatim to client_secret, which this PR does reject. Six reviewers reached this independently (Mafu-san, Mafuuu, Hisoka, Chopper, Meruem, Kite, Knov). What a refusal buys is that the client stops sending the shape; it never un-logs anything. Ground 3 must not be the rationale of record in PLAT-660, and it is currently the rationale in the code comment and the docs (CRF-18).
  • Additions PLAT-660 should carry: for a public client, refreshTokenGrant skips authenticateClient (tokens.go:711), so a logged refresh token is a complete standalone credential (Hisoka, Chopper, Kite); code_verifier is in the same class and code plus code_verifier in one URL is a replayable grant (Ryosuke); ?access_token= at httpmw/apikey.go:966 is a sibling in the same class, not a precedent that settles it (Pariston, Knov); the structural fix (read credential parameters from r.PostForm) was measured green across the package and TestOAuth2* (Pariston, Ryosuke), so the deferral's implied cost argument does not hold.

Round log

Round 1

Netero-only first pass (LOC 91 additions, Law not triggered). 1 P1, 2 P2, 1 P3, 2 Nit, 2 Note.
Reviewed against c47eb47..2d2a156.

Orchestrator verification:

  • CRF-1/CRF-2: confirmed by reading extractTokenRequest (vals := r.Form, the merged query-plus-body map, feeds Code and RefreshToken) and extractRevocationRequest (r.Form.Get("token")).
  • CRF-3: reproduced. go test ./coderd/oauth2provider/ -run 'TestOAuth2RefreshClientAuthentication/SecretInQueryString' fails with failed to init driver with path .: duplicate migration file: 000596_template_usage_stats_session_usage.down.sql. git ls-tree origin/main shows both 000596_aibridge_workspace_context.* and 000596_template_usage_stats_session_usage.* present on main at c47eb47, so the defect is upstream, not introduced here.
  • CRF-4: confirmed. Line 142 scopes the section to "the token endpoint (/oauth2/tokens)"; the new sentence at line 150 sits inside it, while revoke.go:89 now refuses the same way.

Drop/downgrade gate: no finding dropped or downgraded this round.

Panel has not reviewed this PR. Netero-only round, COMMENT event.

Round 2 update

BLOCKED. CRF-1 and CRF-2 (both P2) silent: no code change and no author response. No review.
Reviewed against eec4915..a2daa20.

Churn guard: 6 addressed, 2 silent, 0 acknowledged, 0 contested, 0 deferred.

Orchestrator verification of the silence:

  • git diff eec491530d..a2daa20ed4 --stat shows 5 files, none adding a guard beyond client_secret.
  • tokens.go:190,192 still read Code and RefreshToken from vals := r.Form; revoke.go:40 still reads r.Form.Get("token"). The new clientSecretInQuery (tokens.go:281) checks one name.
  • CRF-1 and CRF-2 were folded into the round 1 review body by the post tool, so no inline thread existed for the author to reply to. The author replied to all five threads that did exist (CRF-4 through CRF-8). The PR description at this head is unchanged and does not scope the stronger credentials out.

CRF-3 was fixed upstream by #29568, not by the author. The three new integration tests can now run.

No panel spawned. No Netero. COMMENT event.

Round 3

Churn guard: PROCEED. 2 contested (deferral with ticket PLAT-660), 0 silent. No new code commits; git log a2daa20ed4..bc45163128 is main merges only, base moved to ceed0df. The author's engagement is the PR description rewrite plus comment IC_kwDOGkVX1s8AAAABVdUHFw.
Reviewed against ceed0df..bc45163.

First panel round for this PR.

Panel: 18 reviewers (Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Komugi, Gon, Leorio, Kurapika, Ging-Go, Chopper, Ryosuke, Razor, Knov, Kite, Robin + wildcards Meruem, Zoro), plus Netero. Law not triggered (123 effective additions). Netero's round 3 pass was P3 and below, so the panel was not gated.

New findings: CRF-9 through CRF-29. 5 P2, 8 P3, 2 P4, 5 Nit, 1 Note. No new findings dropped.

Cross-check notes:

  • CRF-9 is the round's convergence: 11 reviewers independently mutated the guard and found SecretInQueryStringAndBody green. Severity spread P2/P3; higher wins, and the specific arguments (Bisky, Chopper, Pariston, Gon) name the exact pre-existing path (parseSingle at coderd/httpapi/queryparams.go:442) and the exact impostor description.
  • CRF-11 spread ran Nit to P2. Mafu-san's P2 carries the sharpest evidence, a measured 413 where the docs promise 400, plus the fact that the contradicting behavior was already written down in revoke.go:92-95, a file the author edited. Higher severity taken.
  • CRF-15: Ryosuke P3 against six Notes. Kept at P3 because Ryosuke measured an actual regression (revocation 200 to 400 for a body-authenticated client that emits an empty query parameter) and cited both RFC 6749 3.2 and the package's own precedent at authorize.go:418. The Note-writers judged the strict reading defensible without testing the regression.
  • CRF-16: Knov P3 against Hisoka's Note. Knov verified 200 and 302 responses with a secret in the authorize query. Hisoka's objection is to a 400 there, not to the gap, so the finding is posted with the RFC 6749 3.1 tension named and the ask reduced to a log plus an explicit scope statement.
  • CRF-18: Gon rated the rationale duplication P2 and Leorio independently praised the same doc comment as earned. Merged with Chopper's P3, which is the stronger frame: the stated reason is the one the author withdrew when defending CRF-1 and CRF-2, so it does not distinguish the shipped scope.
  • Gon's other comment-quality findings were rated P2 by their reviewer and posted as Nits (CRF-25, CRF-27, CRF-28). Keep-argument written before downgrading: the round 1 CRF-8 fix removed a duplicated literal and introduced a duplicated rationale, so this is the same class recurring. It stays below P3 because the consequence is reader cost with no behavior or contract at risk, and a writing specialist (Leorio) read the same comments and judged the load-bearing ones earned. The one Gon comment finding kept at P3 is CRF-20, where Leorio showed the comment is not merely long but false.
  • Convergent verification worth recording: Kurapika, Komugi, Chopper, Razor, Hisoka, Kite and Mafuuu each independently established that r.URL.Query() and r.ParseForm use the same parser on the same bytes, so the guard has no parse-divergence or encoding bypass. Komugi found no determinism exposure and drove the reject-before-body-read path at 1 KiB, 256 KiB, 1 MiB and 3 MiB without a transport error. Ging-Go and Zoro each verified the //nolint:gosec is load-bearing by removing it and running gosec.
  • No migrations or schema changes in this diff, so there are no implicit database side effects to enumerate.

CRF-1 and CRF-2 closed by panel. Event: COMMENT (no P0 or P1).

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First-pass review only. These are mechanical findings from a single reviewer; the full review panel has not looked at this PR yet and will review after these are addressed.

The change is small, correct for what it claims, and the tests are real: each refusal asserts the status, the error code, and the description, then proves the refused request consumed nothing (the refresh row survives, the session survives, the body-only retry succeeds). The guard runs before ParseForm, so nothing is read off the wire before the refusal. The docs sentence says what a client should do instead. All six hand-built requests that reach the new code path were updated, none missed.

1 P1, 2 P2, 1 P3, 2 Nits, 2 Notes.

The substantive concern is scope of the fix rather than its correctness. vals := r.Form is the merged query-plus-body map, so client_secret was never the only credential reachable from the URL: refresh_token, code, and the revocation token all still are. The PR ships a breaking change for the weakest of those four and leaves the live bearer credentials accepted. Reviewer verdict, verbatim: "a client can put a live refresh token, which mints access tokens for the length of the refresh lifetime, into a URL and every proxy and access log on the path records it. That is the harm the PR description gives as its reason for the client_secret refusal, at equal or greater blast radius." Naming the set instead of the one parameter costs one line.

One blocker is not yours, detailed below: a duplicate migration number on main stops this package's tests from running at all.

Process note: the head commit is a merge of main with no changes of its own, so the diff reviewed is the feature work only.


coderd/oauth2provider/tokens.go:172

P2 [CRF-1] The guard covers client_secret only; the token endpoint still accepts refresh_token and code from the URL query, which is the same root cause and the same leak. (Netero)

vals := r.Form is the merged query-plus-body map, so every credential the endpoint reads is reachable from the URL, not just client_secret. rejectClientSecretInQuery closes one name out of three.

Verified. I called extractTokenRequest with POST /oauth2/tokens?refresh_token=secret_from_url and a body of grant_type=refresh_token&client_id=...&client_secret=...:

err=<nil> validationErrs=[] refresh_token="secret_from_url" client_secret="coder_secret" grant=refresh_token

No duplicate-parameter rejection fires, because the token appears once. Code: p.String(vals, "", "code") on line 188 reads through the same map, so an authorization code in the URL is accepted the same way.

Fix: name the set, not the one parameter. rejectCredentialsInQuery(r, "client_secret", "refresh_token", "code"), same sentinel, same response.

Confirmed by reading the function: vals := r.Form at line 172 feeds both Code and RefreshToken. A refresh token in a URL is a live credential that mints access tokens for the length of the refresh lifetime and needs no second factor; that is a worse leak than the one this PR fixes, and it is left open by the same function that closes the weaker case. The stated justification for the breaking change ("URLs are recorded by proxies and access logs") applies unchanged to these parameters. Either extend the guard to the credential set, or say in the PR description why the stronger credentials are out of scope.

🤖

coderd/oauth2provider/revoke.go:43

P2 [CRF-2] The revocation endpoint still accepts the token it is about to revoke from the URL query. (Netero)

Same root cause as client_secret: r.Form.Get("token") reads the merged map.

Verified. extractRevocationRequest with POST /oauth2/revoke?token=secret_from_url and a body carrying only client_id and client_secret:

err=<nil> token="secret_from_url"

Confirmed at line 43. The presented token is a bearer credential. Worse than the token-endpoint case: when the revocation fails client authentication (401, nothing revoked), the token that just landed in every access log on the path is still live and replayable. Add token to the rejected set.

🤖

coderd/database/migrations/000596_template_usage_stats_session_usage.down.sql:1

P1 [CRF-3] Two migrations share number 000596, so every Postgres-backed test in coderd/oauth2provider fails before it runs, including the three tests this PR adds. (Netero) Not caused by this diff; raised because it blocks verification. Reproduced at the PR head: go test ./coderd/oauth2provider/ -run 'TestOAuth2RefreshClientAuthentication/SecretInQueryString' dies with failed to init driver with path .: duplicate migration file: 000596_template_usage_stats_session_usage.down.sql before reaching the test body. git ls-tree origin/main coderd/database/migrations/ shows both 000596_aibridge_workspace_context.* and 000596_template_usage_stats_session_usage.* at c47eb47, which is both this PR's merge base and current main, so the collision is upstream. Renumbering one pair to 000597 is the fix. Until it lands, SecretInQueryString, SecretInQueryStringAndBody, and the revocation test have never executed, so the PR's central claim is unverified by its own tests. The DB-free internal tests in the package do pass.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread docs/admin/integrations/oauth2-provider.md Outdated
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/oauth2provider/tokens.go
Comment thread coderd/oauth2_test.go
Comment thread coderd/oauth2provider/revoke.go Outdated
Move the check out of extractTokenRequest and extractRevocationRequest and
into the Tokens and RevokeToken handlers. The check reads the URL, not the
form, so placing it in the extract functions added a non-nil r.URL
precondition to functions whose callers supplied only Form. That cost six
hand-built test requests an unused URL field and would have made the next one
panic with a nil dereference instead of failing with a named cause. Moving it
removes the errClientSecretInQuery sentinel, both errors.Is branches, and the
six test edits, and it drops the reject path that built a query parser it
never used.

RevokeToken keeps the check after the method test so a GET still answers 405
as RFC 7009 requires.

Share the two error descriptions the endpoints answer identically, and cover
the revocation URL-plus-body case, where extractRevocationRequest reads
r.Form.Get and does not reject a repeated parameter.

Document the refusal for both endpoints. The reference sentence sat under a
heading scoped to the token endpoint, leaving the same breaking change at
/oauth2/revoke unstated, so name both there and add a troubleshooting entry
beside the neighbouring invalid_client case.
@BobbyHo

BobbyHo commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2. Six of eight round 1 findings are resolved: the docs now name both endpoints and gained a troubleshooting entry, the guard moved into Tokens and RevokeToken (which deleted the sentinel, both errors.Is branches, and all six URL: &url.URL{} test edits), the revocation URL-plus-body case is covered, and both duplicated error literals became consts. Keeping the method check ahead of the guard in RevokeToken so a GET still answers 405 is the right call, and mutation-testing the new revoke cases by stubbing the guard out is the kind of verification most fixes skip. The P1 migration collision is gone: this head sits on eec4915, where #29568 renumbered the duplicate, so the three integration tests can finally run.

Further review is blocked. Two P2 findings from round 1 got neither a code change nor a response:

  • CRF-1, coderd/oauth2provider/tokens.go:192: refresh_token and code are still read from vals := r.Form, the merged query-plus-body map, so both are still accepted from the URL query. clientSecretInQuery checks one name out of the set.
  • CRF-2, coderd/oauth2provider/revoke.go:40: extractRevocationRequest still reads the token it is about to revoke via r.Form.Get("token"), so that token is still accepted from the URL query.

This is not an accusation of ignoring them: the post tool folded both into the round 1 review body instead of creating inline threads, so there was no thread to reply to. You replied to all five threads that existed. Recording it here so the request is unambiguous.

What is needed for review to continue, either one:

  1. Extend the guard to the credential set (client_secret, refresh_token, code, and the revocation token), or
  2. State why the stronger credentials are out of scope for this PR, in a reply here or in the PR description, and file an issue for them.

The reason this blocks rather than trails behind: the PR's own justification for a breaking change is that URLs are recorded by proxies and access logs. A refresh token in a URL is a live credential that mints access tokens for the whole refresh lifetime with no second factor, and a presented revocation token stays replayable when the revocation itself 401s. Shipping the refusal for the weakest of the four names, while the same function leaves the strongest accepted, is the part of this change a reviewer cannot sign off on silently. If the answer is "separate PR," that is a legitimate answer; it needs to be written down, because nobody comes back to this file with the context you have right now.

No panel review this round. The panel has still not reviewed this PR, and will not until these two are answered.

🤖 This review was automatically generated with Coder Agents.

@BobbyHo

BobbyHo commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Answering CRF-1 and CRF-2 directly: both are out of scope for this PR, deliberately, and PLAT-660 tracks them: https://linear.app/codercom/issue/PLAT-660/oauth2-token-and-revocation-endpoints-accept-request-parameters-from

First, the mechanical claim is correct and I am not disputing it. vals := r.Form is the merged query-plus-body map, and I reproduced both cases:

revoke: err=<nil> token="FROM_URL"
tokens: form refresh_token="RT_FROM_URL" code="CODE_FROM_URL"

No duplicate-parameter check fires, because each parameter appears once. So refresh_token, code, and the revocation token are all still readable from the URL query. That behavior is real and it should change.

What I disagree with is the equivalence. This PR fixes an authentication defect, and the remaining parameters are not one.

The normative basis is not the same. OAuth 2.1 section 2.4.1 says, verbatim:

The parameters can only be transmitted in the request content and MUST NOT be included in the request URI.

That is what this PR implements, and it is why the change is a compliance fix rather than a judgment call. There is no equivalent prohibition for the other parameters. Section 3.2 requires POST and section 3.2.2 requires parameters "in the HTTP request content," which a query-borne refresh_token violates in spirit, but the spec states that as a positive requirement and never as a MUST NOT. Different normative weight, so it belongs in a separate change with its own justification rather than riding on this one's citation.

No privilege is gained by the parameters that remain. The client_secret case was a defect because a location the spec forbids for client authentication nonetheless authenticated the client. refresh_token, code, and token are the subject of the request, not proof of identity. A caller who can put a refresh token in a URL already holds that refresh token, so refusing it grants nothing and withholds nothing.

The refusal does not prevent the leak it is named for. By the time either handler runs, every proxy and access log on the path has already recorded the URL. That is why I do not think this line holds:

when the revocation fails client authentication (401, nothing revoked), the token that just landed in every access log on the path is still live and replayable

It is still live and replayable either way. Rejecting the query parameter does not un-log it. The server-side guard is a conformance measure that makes a misbehaving client fail loudly, not a mitigation for the disclosure.

The blast-radius argument proves more than intended. coderd/httpmw/apikey.go:966 accepts a live Coder session token from ?access_token= on every authenticated route:

// Check access_token query parameter
accessToken := r.URL.Query().Get("access_token")

If "a live bearer credential reachable from a URL" is the blocking standard, that is a much larger surface than the OAuth2 token endpoint, it is deliberate, and retiring it is a product decision rather than something this PR should settle by implication.

You are right that the PR description over-reached, though, and that is the part I am fixing here. It gave "URLs end up in proxy and access logs" as the reason, and that argument does cover refresh_token and code, so the prose argued for more than the diff delivers. I have rewritten the description to rest on section 2.4.1 and to name the remaining parameters as out of scope with the issue link.

I also corrected a second thing in the description that no one flagged. It said client_id in the query is still accepted because "it is not a secret." Secrecy is not the basis of the rule: section 2.4.1's MUST NOT covers the parameters of that authentication method, client_id included. The actual reason for the carve-out is that ExtractOAuth2ProviderAppWithOAuth2Errors reads client_id from the query to look up the app without touching the body, and site/src/api/api.ts:2045 depends on it. Changing that is a larger change than this one.

@BobbyHo

BobbyHo commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First panel round. Eighteen reviewers plus the mechanical pass. Round 1's eight findings are closed: six fixed at the root rather than patched, one fixed upstream, and the two contested ones settled below.

The guard itself survived every attack the panel brought. Seven reviewers independently established that r.URL.Query() and r.ParseForm run the same parser over the same bytes, so there is no key the guard misses that the authenticator can still read: percent-encoded spellings (client%5Fsecret) trip it, semicolon smuggling and bad escapes drop the segment from both sides and then fail the request anyway, and case variants are invisible to both. Both request-side readers of client_secret in the tree are behind it, no first-party caller sends the secret in a URL, the refusal precedes every mint and every delete, and the error body is a constant that echoes nothing. Determinism: no clock, no goroutine, no shared state, and the reject-before-reading-the-body path was driven at 1 KiB through 3 MiB without a transport error. The //nolint:gosec two reviewers went looking to delete is load-bearing, and its comment is accurate.

5 P2, 8 P3, 2 P4, 5 Nits, 1 Note.

The round's convergence is one test. Eleven reviewers independently mutated the guard and found that SecretInQueryStringAndBody at the token endpoint stays green without it, because a client_secret in both places was already a 400 through parseSingle's repeated-parameter rejection. Bisky put it best: "I mutated the guard to return false and ran all four: three went red, one stayed green. That one is glass." The revocation twin does discriminate, so the gap is one-sided and one assertion closes it. That matters more than a test nit because it sits exactly where a future refactor would land.

CRF-1 and CRF-2: contested, and the panel closes them in your favour, 11 of 11, on grounds 1 and 2. The normative asymmetry checks out, the ?access_token= precedent at coderd/httpmw/apikey.go:966 is real, the presented credentials are single-use with replay detection at tokens.go:811, and Coder's own request logger records r.URL.Path and never the raw query, so the disclosure lives in third-party infrastructure. PLAT-660 makes it a deferral rather than a drop.

Ground 3 does not survive, and six reviewers reached that independently: "every proxy already logged the URL" is equally true of client_secret, which this PR rejects. A refusal never un-logs anything; what it buys is that the client stops sending the shape. That argument, if sound, would have blocked this PR. Please keep it out of PLAT-660's rationale, because a future reader could close the ticket with it. Four facts worth adding to that ticket: for a public client refreshTokenGrant skips authenticateClient, so a logged refresh token is a complete standalone credential; code_verifier belongs in the set, and code plus code_verifier in one URL is a replayable grant; ?access_token= is a sibling in the class rather than a precedent that settles it; and two reviewers measured the structural fix (source the credential parameters from r.PostForm) green across the package and TestOAuth2*, so the deferral does not rest on cost.

Process: no code commits this round, so this review is against the same diff round 2 saw, with the description rewrite and your CRF-1/CRF-2 comment as the change. That comment is the best artifact in this PR: it reproduces the behavior it concedes, separates the normative from the practical, corrects two claims nobody asked about, and files a ticket. Two of the P2s below exist because the prose around the guard did not get the same treatment.


coderd/oauth2.go:197

P3 [CRF-14] /oauth2/revoke enumerates its invalid_request causes in swagger and the new one is missing. (Netero P3, Mafu-san P3, Chopper P3, Mafuuu Nit, Knov Nit)

The annotation reads invalid_request: a missing client_id or token, credentials in both the Authorization header and the body, or a malformed token. This PR adds a fourth cause and leaves the list alone, so the published reference (docs/reference/api/enterprise.md:5402, coderd/apidoc/swagger.json:15195) now describes an incomplete set. An enumerated list that stops being exhaustive misleads a client author writing error handling from the reference. (Netero)

Five reviewers found it; the consequence they converge on is that the prose page and the API reference now disagree about when /oauth2/revoke returns 400. AGENTS.md requires swagger annotations for public HTTP endpoints, and the two prose locations were updated while the machine-generated one was not.

/oauth2/tokens declares only 413 (coderd/oauth2.go:169), so it has no list to fall out of date; Mafu-san and Chopper both suggest adding a @Failure 400 there for symmetry, which is a judgment call rather than a correctness one. Extend line 197 and run make gen; the generated files are currently consistent, so they regenerate together.

🤖

coderd/oauth2provider/authorize.go:306

P3 [CRF-16] /oauth2/authorize still accepts client_secret in the query and answers 200, and it is the endpoint whose URL is most exposed. (Knov P3, Hisoka Note)

Both reviewers found it independently, both by probing rather than reading. Knov:

extractAuthorizeParams reads r.URL.Query() and drops anything it did not parse through ignoredParams (authorize.go:360), which logs parameter names at debug level and nothing else. A throwaway test against a seeded app sent the well-formed authorize query plus client_secret=coder_deadbeef_cafebabe:

GET  /oauth2/authorize ... &client_secret=...  -> 200 (consent page)
POST /oauth2/authorize ... &client_secret=...  -> 302 https://example.com/callback?code=coder_...

The justification this PR gives, a URL is recorded by proxies and access logs, is strictly stronger at the authorization endpoint, because that URL also lands in the end user's address bar, browser history, and the Referer the user agent sends to the callback host. A confidential client that appends client_secret to its authorization URL is the misconfiguration this PR exists to catch, and it is the one case Coder answers 200 to, so the client never learns.

The two reviewers disagree about the remedy, and I am not asking for a 400. Hisoka:

Do not "complete" the fix there. The comment at authorize.go:357 cites the rule that unrecognized authorization-request parameters MUST be ignored, and client_secret is not a parameter of the authorization request. Rejecting it would trade one compliance claim for another.

Knov reaches the same tension and names the defensible answer: a warn-level log with the client_id and the parameter name, instead of silence at debug level, plus a sentence putting the authorization endpoint out of scope and why. The PR description scopes out client_id, refresh_token, code, and the revocation token; it does not mention /oauth2/authorize at all. That is what makes this an unnoticed sibling rather than a deferral, and it is the part that is not defensible.

Root cause is placement: this rule is enforced per handler while the three OAuth2 protocol routes share a middleware that already owns the cross-endpoint body limit for exactly this reason. Kurapika and Meruem raise the same structural point for a future third client-authenticating endpoint (introspection, device code) that would have to remember.

🤖

coderd/oauth2provider/tokens.go:188

P3 [CRF-17] client_id is the other half of the §2.4.1 MUST NOT this PR rests on, and it is dropped with no ticket, on a stated blocker that points at a different route. (Razor P3, Kite P3, Zoro P3, Pariston Note)

Four reviewers, none of whom saw each other's work, independently checked the two reasons the description gives and found both weaker than stated. This is not CRF-1 or CRF-2: those rest on §3.2.2 carrying no MUST NOT, and client_id sits inside the one sentence of §2.4.1 that the entire PR rests on. The description concedes it ("Section 2.4.1's prohibition covers it too") while PLAT-660 is named only for the other three parameters.

"site/src/api/api.ts:2045 relies on it." That line is this.axios.delete('/oauth2/tokens?client_id=${appId}'), the DELETE route, Coder's own revoke-access call, registered separately behind apiKeyMiddleware at coderd/coderd.go:1284. §2.4.1 governs the token request, not that route. The only other frontend query client_id is GET /oauth2/authorize, where §2.4.1 does not apply. (Zoro, confirmed independently by Razor, Kite and Pariston)

Razor then measured what a handler-level guard would actually cost:

I added the two-line equivalent to Tokens and RevokeToken and ran the suite: everything passed except TestFormEndpointsBodyTooLarge (both subtests, 413 became 400), and that test puts client_id in the query deliberately so the middleware does not read the oversized body. So the cost is not middleware surgery; it is a second breaking change for confidential clients that put client_id in the URL.

That cost is real and it is a legitimate reason to keep it out of this PR. Kite and Zoro converge on the same minimum ask, and it is the whole finding: add client_id to PLAT-660, or state in the ticket that it is out of scope and why. As it stands, D6-04 will read as closed while half of the cited MUST NOT is unenforced and untracked, and a paragraph in a PR description is not a tracked item.

🤖

coderd/oauth2provider/tokens.go:174

P3 [CRF-21] The guard is a one-name denylist in front of a read that admits the whole query, so the enforcement lives one layer above the line that chooses the source. (Ryosuke P3, Pariston P3)

Two reviewers arrived here separately, and both measured the alternative instead of asserting it. This is not a re-raise of CRF-1 or CRF-2, whose disposition both accept; it is about the shape of what ships.

I replaced vals := r.Form with vals := r.PostForm (keeping a merged read for client_id alone, for the middleware's sake) and r.Form.Get with r.PostForm.Get for token, token_type_hint and client_secret in extractRevocationRequest. Probe, refresh grant with refresh_token only in the query: HEAD answers 200 with a minted access token, the variant answers 400. go test ./coderd/oauth2provider/ -count=1 passes, including all four new tests and the Form-literal internal tests. go test ./coderd/ -count=1 -run TestOAuth2 passes, 39 top-level tests. (Pariston)

Ryosuke ran the same experiment independently with the same result, and both reverted cleanly. Two consequences follow.

The next client authentication parameter is client_assertion (RFC 7523 private_key_jwt, already ignored by name at tokens.go:246), and §2.4.1's MUST NOT, the one the author accepts, covers it. Under the merged read it will be accepted from the query on the day it is implemented, and clientSecretInQuery will not notice, because it checks one string. (Pariston)

And in fairness to the guard, from the same reviewer: r.PostForm alone is not a replacement, because with the secret in both places ignoring the query copy would mint a token for a request that leaked its secret in the URL, where the guard refuses. The two are complementary. The ask is not to rewrite this PR; it is that PLAT-660 should carry the allowlist shape rather than a longer name list, since the measurement shows the structural fix is smaller than the deferral implies.

🤖

coderd/oauth2provider/revoke.go:100

P4 [CRF-22] Revocation's generic branch writes err.Error() straight into error_description, so client-controlled bytes come back unsanitized and uncapped. (Ryosuke)

Verified live. A temporary subtest posting to /oauth2/revoke?client_id=<uuid>&%zz=1 with the secret in the body returns:

status=400 err="invalid_request" desc="invalid form data: invalid URL escape \"%zz\""

writeTokenError (tokens.go:318) exists precisely so descriptions are sanitized and capped "at the endpoint rather than at each call site", and RFC 6749 §5.2's NQSCHAR excludes the double quote that response just emitted. The token endpoint's matching branch answers a fixed string and never echoes.

Pre-existing, and P4 because the echoed bytes are the caller's own malformed query in a JSON error body. It is on the ladder this PR edits (the constant at line 88 replaced the duplicate literal CRF-8 named), so it is a sibling of what you already fixed rather than a separate subsystem. writeRevocationError mirroring writeTokenError would close it.

🤖

coderd/oauth2provider/tokens.go:373

P4 [CRF-23] A parameter sent twice is reported as missing. (Mafuuu)

Observed while checking CRF-9: with the guard disabled, a request carrying client_secret in both the query and the body answers Missing required parameter: client_secret. The parameter was sent twice, not zero times. parseSingle writes an accurate detail ("provided more than once, found 2 times"), and the loop at tokens.go:369 discards it, keying only on the field name.

The guard closes this for client_secret specifically. It stays open for code and client_id, which the same loop covers: a client that repeats code, for instance through a retry that appends rather than replaces, is told the code is missing and will keep sending the same request.

Pre-existing and outside the diff. Raised because it is the reason CRF-9's impostor message is convincing, and because nothing else will surface it: the accurate detail already exists and is thrown away.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/oauth2provider/tokens_test.go Outdated
Comment thread coderd/oauth2provider/tokens_test.go
Comment thread docs/admin/integrations/oauth2-provider.md Outdated
Comment thread coderd/oauth2provider/tokens.go
Comment thread docs/admin/integrations/oauth2-provider.md
Comment thread coderd/oauth2provider/tokens_test.go Outdated
Comment thread coderd/oauth2_test.go Outdated
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/oauth2provider/revoke.go
…al by description

SecretInQueryStringAndBody asserted status and code only. A repeated
client_secret in r.Form already fails parseSingle with the same 400
invalid_request, so the subtest passed with the guard deleted. Assert the
guard's own description in both query-string subtests.
… refusals

requireRefused hardcoded the 401 invalid_client assertion, so the two
query-string subtests could not reuse its tail and SecretInQueryStringAndBody
omitted the redeem step entirely. Split the tail into requireNothingConsumed
and call it from all five refusal cases.
Query().Has reported a bare ?client_secret= as present, so a client that sent
its secret correctly in the body could no longer revoke a token. RFC 6749
section 3.2 says a parameter sent without a value counts as omitted, which is
what authorize.go already does for client_id. Every value is checked so a
repeated parameter cannot hide a real secret behind an empty one.
The comment, the docs, and the troubleshooting entry all justified the guard
by saying URLs are recorded by proxies and access logs. That reason applies
just as well to refresh_token, code, and the revocation token, which stay
accepted, so it does not explain why this rule covers client_secret alone.
Use the section 2.4.1 prohibition instead, say on the page which parameters
are still read from the query string, and have the error name what went wrong
as well as the fix.
The docs, the test comment, and the PR description all said the refusal happens
before the body is read. The middleware parses the form when client_id is absent
from the query string, so a client that sends client_id in the body gets 413 for
an oversized body rather than the documented 400. What holds is that the body's
contents never change the answer.

The constraint that does exist is which map the guard reads, so it now sits on
the handler rather than in a test comment.
The refusal reached only the client that sent the secret. Both handlers now log
it with the app id and never the value, matching the neighbouring credential
refusals, so an operator can find the affected integrations.

The error message and the troubleshooting entry now say to rotate the secret. A
URL is recorded outside Coder, and Coder does not log query strings, so finding
nothing in the Coder logs is not evidence that the secret stayed private.
The subtests assigned RawQuery, which would drop any query parameter an earlier
request option had set. Nothing sets one on this path today, so merge the way
the SDK does rather than depend on that. Add requireInvalidRequest beside its
401 twin for the four inline 400 tails, and trim the const comment.
@BobbyHo

BobbyHo commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review re-review the P2 and P3 reported in the last review. Only report if there are more P0 to P3.

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