Conversation
- 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.
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. |
|
/coder-agents-review |
|
/coder-agents-review |
|
Chat: Review in progress | View chat deep-review v0.9.0 | Round 3 | Last posted: Round 3, 29 findings (1 P1, 7 P2, 9 P3, 2 P4, 7 Nit, 3 Note), COMMENT. Review Finding inventoryFinding inventory, PR #29508Findings
Contested and acknowledgedCRF-1 (P2, coderd/oauth2provider/tokens.go:192) - refresh_token and code readable from the URL query
CRF-2 (P2, coderd/oauth2provider/revoke.go:40) - revocation token readable from the URL query
CRF-1 and CRF-2 panel closure (R3, 11/11)
Round logRound 1Netero-only first pass (LOC 91 additions, Law not triggered). 1 P1, 2 P2, 1 P3, 2 Nit, 2 Note. Orchestrator verification:
Drop/downgrade gate: no finding dropped or downgraded this round. Panel has not reviewed this PR. Netero-only round, COMMENT event. Round 2 updateBLOCKED. CRF-1 and CRF-2 (both P2) silent: no code change and no author response. No review. Churn guard: 6 addressed, 2 silent, 0 acknowledged, 0 contested, 0 deferred. Orchestrator verification of the silence:
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 3Churn guard: PROCEED. 2 contested (deferral with ticket PLAT-660), 0 silent. No new code commits; 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-1 and CRF-2 closed by panel. Event: COMMENT (no P0 or P1). 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 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.Formis the merged query-plus-body map, so every credential the endpoint reads is reachable from the URL, not justclient_secret.rejectClientSecretInQuerycloses one name out of three.Verified. I called
extractTokenRequestwithPOST /oauth2/tokens?refresh_token=secret_from_urland a body ofgrant_type=refresh_token&client_id=...&client_secret=...:err=<nil> validationErrs=[] refresh_token="secret_from_url" client_secret="coder_secret" grant=refresh_tokenNo 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.
extractRevocationRequestwithPOST /oauth2/revoke?token=secret_from_urland a body carrying onlyclient_idandclient_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.
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.
|
/coder-agents-review |
There was a problem hiding this comment.
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_tokenandcodeare still read fromvals := r.Form, the merged query-plus-body map, so both are still accepted from the URL query.clientSecretInQuerychecks one name out of the set. - CRF-2,
coderd/oauth2provider/revoke.go:40:extractRevocationRequeststill reads the token it is about to revoke viar.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:
- Extend the guard to the credential set (
client_secret,refresh_token,code, and the revocationtoken), or - 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.
|
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. No duplicate-parameter check fires, because each parameter appears once. So 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:
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 No privilege is gained by the parameters that remain. The 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:
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. // 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 I also corrected a second thing in the description that no one flagged. It said |
|
/coder-agents-review |
There was a problem hiding this comment.
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:
extractAuthorizeParamsreadsr.URL.Query()and drops anything it did not parse throughignoredParams(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 plusclient_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
Refererthe user agent sends to the callback host. A confidential client that appendsclient_secretto 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:357cites the rule that unrecognized authorization-request parameters MUST be ignored, andclient_secretis 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:2045relies on it." That line isthis.axios.delete('/oauth2/tokens?client_id=${appId}'), theDELETEroute, Coder's own revoke-access call, registered separately behindapiKeyMiddlewareatcoderd/coderd.go:1284. §2.4.1 governs the token request, not that route. The only other frontend queryclient_idisGET /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
TokensandRevokeTokenand ran the suite: everything passed exceptTestFormEndpointsBodyTooLarge(both subtests, 413 became 400), and that test putsclient_idin 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 putclient_idin 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.Formwithvals := r.PostForm(keeping a merged read forclient_idalone, for the middleware's sake) andr.Form.Getwithr.PostForm.Getfortoken,token_type_hintandclient_secretinextractRevocationRequest. Probe, refresh grant withrefresh_tokenonly in the query: HEAD answers 200 with a minted access token, the variant answers 400.go test ./coderd/oauth2provider/ -count=1passes, including all four new tests and theForm-literal internal tests.go test ./coderd/ -count=1 -run TestOAuth2passes, 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 7523private_key_jwt, already ignored by name attokens.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, andclientSecretInQuerywill 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=1with 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_secretin both the query and the body answersMissing required parameter: client_secret. The parameter was sent twice, not zero times.parseSinglewrites an accurate detail ("provided more than once, found 2 times"), and the loop attokens.go:369discards it, keying only on the field name.The guard closes this for
client_secretspecifically. It stays open forcodeandclient_id, which the same loop covers: a client that repeatscode, 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.
…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.
|
/coder-agents-review re-review the P2 and P3 reported in the last review. Only report if there are more P0 to P3. |
TL;DR
The token and revocation endpoints read client credentials from the merged form, so a
client_secretin 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.invalid_requestwhen the query string namesclient_secret, whatever the body holds.Scope
client_idin the query string is still accepted. Section 2.4.1's prohibition covers it too, butExtractOAuth2ProviderAppWithOAuth2Errorsreadsclient_idfrom the query to look up the app without parsing the body, andsite/src/api/api.ts:2045relies on it, so moving it is a larger change than this one.refresh_token,code, and the revocationtokenalso remain readable from the query, becauser.Formis 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
client_secret. The token endpoint and the revocation endpoint both call it. It reads the URL query rather thanr.Form, which cannot tell a body value from a query value.invalid_clientcase.Tests