fix: deliver three more authorize errors to the client - #28450
Conversation
…e client RFC 6749 §4.1.2.1 delivers an authorization failure to the client's registered callback once the client is known. Three sites still answered on Coder: unsupported_response_type on both verbs, and the PKCE 'plain' rejection on POST. A client hitting one saw a page its own error handling never runs against, without the state that would tell it which request failed. Extract the §4.1.2.1 URL construction into one builder and route those three through it, along with the consent page's cancel link, which built its error URL by hand and aliased params.redirectURL where the helper copied it. Carry the redirect URI in a validatedCallbackURL, produced only by extractAuthorizeParams once the URI has been exact-matched against the app's registration. That match is the precondition licensing every redirect here, and the type makes it something a caller holds rather than something a comment asks it to remember. It is a guard rather than a proof: inside the package a composite literal can still forge one. The sites where the callback is not yet trustworthy are unchanged and keep answering on Coder: both extractAuthorizeParams failures, and the invalid registered scheme.
…lidate-redirects # Conflicts: # coderd/oauth2provider/authorize.go # coderd/oauth2provider/authorize_test.go
…e' into plat479-4-consolidate-redirects # Conflicts: # coderd/oauth2provider/authorize.go # coderd/oauth2provider/authorize_test.go
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.9.0 | Round 1 | Last posted: Round 1, 29 findings (5 P2, 12 P3, 10 Nit, 2 Note), COMMENT. Review Finding inventoryFinding inventory - PR #28450Findings
Cross-check notes
Law analysisNot run (effective additions 219 < 1000). Round logRound 1Netero first pass (P3-and-below), then 16-reviewer panel (Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Kurapika, Meruem, Chopper, Ging-go, Ryosuke, Gon, Leorio, Komugi, Knov + wildcards Kite, Razor). Komugi: no findings. 5 P2, 13 P3, 10 Nit, 2 Note. No P0/P1, so COMMENT. Reviewed against c7d3d53..67a0556. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
Keep only what the code cannot show: the RFC references, the open-redirect invariant validatedCallbackURL carries, the copy and Set-not-Add rationale, why the cancel link is read back out of rendered HTML, and why the no-store case needs a response_type that does not parse. The test preambles and helper docs go entirely, since the names and the assertions already say what is covered.
67a0556 to
8a5004a
Compare
There was a problem hiding this comment.
This is a well-executed change. It moves three §4.1.2.1 failures onto the client's own callback, collapses two hand-rolled URL builders (one of which aliased and mutated in place) into a single copy-on-build type, and turns a precondition that used to live in a comment into a value callers hold. The deliberate non-redirects each ship with a named regression guard (MismatchedRedirectURINotRedirected, DangerousCallbackSchemeNotRedirected, and the new UnparseableResponseTypeNotRedirected), the no-store header still lands on the new 302s, and Komugi found no determinism or flake risk after 30x -race runs. The PR description is unusually honest, to the point that several findings below exist only because facts that good stayed in the description instead of the code.
Counts: 5 P2, 13 P3, 10 Nit, 2 Note. No P0/P1, so this is a COMMENT rather than a request for changes, but the P2 cluster is worth a careful look before merge.
The review keeps returning to two structures. First, the type: its doc promises an open-redirect guarantee it cannot enforce in-package (CRF-10), it carries only half the real precondition since the scheme check lives outside it (CRF-11), its state parameter is free (CRF-17), and its zero value panics (CRF-27). A single constructor that runs the exact match and the scheme check, is the only producer, and carries state would close all four at once. Second, GET and POST run two hand-aligned pre-consent sequences that have already drifted: code_challenge_method is checked on POST only, so GET renders consent for a request that can never succeed (CRF-12). Ryosuke put it best: "Two cars running the same corner on two different lines. Give them one racing line and they cannot take it differently."
Two P2s are pre-existing but land here because this PR stakes new claims on them. CRF-9 (a malformed redirect_uri nil-derefs and 500s, unauthenticated on /oauth2/tokens) is the call the type's whole invariant rests on. CRF-7 (client-supplied text reflected into error_description, violating the §4.1.2.1 charset) has a live sibling in the invalid_scope path; this PR builds the one chokepoint that could close the class. Both need a human decision: fix here, or file a ticket and say so. Neither the panel nor the author can accept a known gap as permanent on its own.
CRF-6 is the one to weigh hardest: the PR's stated reason for leaving the rest of the invalid_request class on Coder ("the URI is only whatever the request supplied") is false whenever redirect_uri matched or was omitted and some other field failed. Three reviewers reproduced malformed code_challenge, bad resource, and excess params all terminating on Coder with a fully trusted callback, which is the exact complaint this PR exists to fix. Either narrow the justification to the real reason (the parser bundles the redirect_uri verdict with everything else) or split it with ValidationError.Field and deliver those too.
Process note: four consecutive docs(...) commits removed roughly 90 comment lines, and the last steps crossed from concision into deleting facts the code cannot state (the token-accepting enum note, the type's forgeability caveat). The PR description's "this PR's diff is only the top commit" is now stale; the range holds five commits.
coderd/oauth2provider/authorize.go:236
P2 [CRF-6] extractAuthorizeParams discards an already-validated callback, so the bulk of the invalid_request class still terminates on Coder, and the stated justification for it is false. (Hisoka P2, Meruem P2, Pariston P2)
The PR's stated reason for leaving these sites alone is that "the URI is only whatever the request supplied". That is true for exactly one of the errors the parser can collect, the redirect_uri mismatch.
When redirect_uri is omitted, p.RedirectURL returns base (the app's registered callback); when it matches, it returns a string-identical copy. In both cases a request failing on some other field (malformed code_challenge, bad resource, excess param) has a fully trusted callback, and line 236 throws it away with authorizeParams{}. Reviewers reproduced all three answering 400/HTML on Coder with no state, on both verbs, which is the exact defect the PR title claims to fix. codersdk.ValidationError carries Field, and the client is already resolved, so "no error names redirect_uri" licenses the redirect. Fix at the classification point, or narrow the description to the real reason (the parser bundles the redirect_uri verdict with everything else) and file a ticket for the rest. This needs a human decision on scope, not silent deferral.
🤖
docs/admin/integrations/oauth2-provider.md:353
P3 [CRF-16] Three error codes change delivery channel with no docs entry, in a page that already documents this exact behavior for the fourth. (Kite P3, Mafuuu Nit)
The page has "invalid_scope returned to your callback", written when that error moved to the redirect.
unsupported_response_type and invalid_request now arrive the same way, and the GET side loses its static "Unsupported Response Type" page. The Limitations bullet at line 421 still says the implicit grant "returns unsupported_response_type" without saying where, which was unambiguous when the answer was a Coder page. The PR body already has the table; it belongs in the doc where an integrator looks.
🤖
🤖 This review was automatically generated with Coder Agents.
RFC 6749 Appendix A restricts error_description to NQSCHAR (%x20-21 / %x23-5B / %x5D-7E), excluding the double quote and the backslash, and applies the rule to the decoded value, so percent-encoding on the wire does not satisfy it. Every invalid_scope description names the offending scope with %q, which puts a quote on both ends and escapes any quote inside, so each one emitted today falls outside the permitted set. The PKCE method rejection is the same shape, rendering the raw query value. Sanitizing at errorURL rather than at each caller also covers the branches this PR did not touch, since errorURL is the single point every 4.1.2.1 description passes through. Quotes become apostrophes rather than disappearing, because they delimit the offending value and a reader needs to see where it starts and ends. The backslash goes with the quote it escaped. Anything outside printable ASCII becomes a space. The shared redirect assertion in authorize_test.go now checks the charset, so every branch reaching it is covered rather than only the cases with a test of their own.
url.Parse returns a nil *url.URL alongside its error. RedirectURL recorded the validation error and then fell through to the exact-match comparison, calling String() on that nil. A redirect_uri of %00, %7F, or :// reaches it on both authorize verbs and in the POST /oauth2/tokens form body. POST /oauth2/tokens takes no API key, so an unauthenticated caller who knows a public client ID can make the server capture a stack trace and write it to the log on demand. httpmw.Recover keeps the trace out of the response, so the cost is log volume and wasted work rather than disclosure. Returning base is safe for both callers: p.Errors is non-empty by that point, so extractAuthorizeParams returns before reading the value and tokens.go discards it.
…callback withQuery seeded its query from the registered callback's own, so an app registered with code, error, error_description, or state in its callback URL received that value back alongside the one the response set. Registration validates the scheme and rejects fragments but says nothing about the query, so such a callback is accepted. The success path was the damaging one: with error= registered, a code redirect carried both, and a client following 4.1.2.1 checks error first, discards a valid code, and can never complete the flow. The four reserved parameters are now cleared before set runs. The rest of the registered query stays, which 3.1.2 requires. The doc comment claimed the copy existed because one request builds several destinations from the same callback. No path calls withQuery twice; the reason is inherited from the aliasing bug this branch removed. What the copy actually protects is String, which ProcessAuthorize records on the code row from the same pointer codeURL would otherwise mutate. Set, not Add, moves from errorURL to withQuery, where the state it justifies is actually set.
The method was validated on POST only, so a request carrying code_challenge_method=plain rendered the consent page, and the user learned the request could never succeed only after clicking Allow. That contradicts the invariant the handler's own comment states, and it is the one post-extraction check left without parity after response_type was brought to both verbs. GET validates without defaulting an omitted method: only POST records the method on the code row, and the validator accepts an empty value, so both verbs accept the same set.
Delivering an authorization error to the client's callback puts the error code and description in a Location header, which loggermw does not record. A failed authorization therefore logged status_code=302, byte identical to the successful one, and the diagnosis existed only in the app's logs rather than Coder's. An operator handed "our login is broken" lost the signal a 400 used to give them. redirectAuthorizeError now logs once, which covers the invalid_scope redirects that were already silent as well as the four this branch adds. Info, not Warn: these are client errors, and one line per failed authorization is in proportion to the request logging already emitted. The app comes from the request context, which the route's middleware guarantees, so the call sites pass only the logger they already hold.
… make Five comments and one test message state things the code does not do. validatedCallbackURL's doc says requiring the type is what keeps the error redirects from becoming open redirects. Every consumer is in this package, where a composite literal still compiles, so the caveat removed in bf4c089 goes back: the guarantee holds across packages and not within one. redirectAuthorizeError said §4.1.2.1 requires informing the user "there", meaning neither of the two places the sentence had just named, and gave the RFC as the reason extractAuthorizeParams failures answer on Coder. That reason is false for most of them: only a mismatched or unparsable redirect_uri is untrusted, while a bad code_challenge, a bad resource, or an excess parameter leaves a callback that was exact-matched. The real reason is that the parser reports one verdict for every field at once. UnparseableResponseTypeNotRedirected asserted the same false rationale, and its own request omits redirect_uri, so the callback it describes as untrusted is the registered one. Both scheme-check comments enumerated the redirects that consume the URL, a list this branch already outgrew. They now count nothing. The POST response_type branch explained less than its GET twin, and the GET branch did not record why an unsupported_response_type error goes in the query when §4.2.2.1 puts implicit-grant errors in the fragment.
…evels One value carried three names: the params field was redirectURL, the type wraps a field named callback, and use sites read params.redirectURL.callback, where redirectURL no longer holds a URL. The field becomes callback and the wrapped URL becomes url, so the use sites read params.callback.url and each name says what it is at its own level. url as a field name is unambiguous alongside the imported package, since field access is always qualified.
AuthorizeOAuth2AppExpectingRedirectError asserted the same six facts as requireAuthorizeErrorRedirect, more weakly, and had drifted on arrival. It compared Host and Path but not Scheme, which is the field ValidateRedirectURIScheme and DangerousCallbackSchemeNotRedirected exist to defend, and asserted only that error_description was non-empty, so its one caller proved that some invalid_request came back rather than that the PKCE branch produced it. The exported helper and TestOAuth2PKCEPlainMethodRejected go away. What that test covered beyond the in-package case is an explicit redirect_uri rather than an omitted one, which is a real axis through the parser, so it becomes two more sub-cases of InvalidPKCEMethodRedirected.
Three unrelated snags in the same file. Two cases deferred a body close inside a loop, so the closes ran at subtest exit rather than per iteration. A subtest per method closes per iteration and puts the method in the failure name rather than in every message. The second seedApp closure shadowed the first by name while taking a different arity and capturing a different db. It becomes seedAppInCatalog, which is what it does. cancelLinkFromConsentPage indexed and sliced twice and hand-checked the -1 sentinel. strings.Cut returns the remainder and an ok bool in one call.
GET can now answer a §4.1.2.1 failure with a 302, and the POST 302 may carry an error instead of a code, so the generated reference told an integrator to read every 302 as success and pull an empty `code` with no error branch. Adds the 302 case to GET and rewords the POST one. The advertised `response_type` enum still lists `token`. It is shared with `response_types_supported`, so correcting it is a separate change.
…llback `unsupported_response_type` and `invalid_request` for a bad `code_challenge_method` reach the registered callback rather than terminating on Coder, in the shape the invalid_scope section already established. The Limitations bullet said requests "return" `unsupported_response_type`, which was unambiguous only while the answer was a Coder page.
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. |
The comments added for the redirect consolidation explained what the RFC requires rather than what the code does with it. Cite the section and keep only what a reader cannot recover from the code: the validatedCallbackURL guard, why the callback is copied, why quotes become apostrophes, why extractAuthorizeParams failures still answer on this server, and why the GET side re-checks PKCE. Comment-only apart from rewrapping.
CRF-15 asked for this alongside the 302 responses #28450 documented, and it was the one part left undone. Both authorize endpoints reject token, but the reference listed it as an accepted value, so a client reading the table sends a request the endpoint refuses. Enums on the typed parameter appends to the list swaggo derives from the type rather than replacing it, yielding code, token, code. Declaring the parameter as a string is what narrows it. The generated JSON already rendered it as a string with an inline enum, so only the enum array changes. codersdk.OAuth2ProviderResponseType keeps both constants: it also feeds ResponseTypesSupported, which already advertises code alone.
Stacked on #28179. Review that first.
TL;DR
What RFC 6749 §4.1.2.1 expects. Once Coder knows which app is asking and has verified the callback URL against the one that app registered, an authorization failure is a response to the app, not a page for the user. It goes back as a redirect to that callback carrying
error,error_description, and thestatethe app sent. Coder shows its own error page only when the callback cannot be trusted yet.The problem. Three failure checks in
authorize.goran after the callback was verified but still answered on Coder, one as a static error page and two as a 400 JSON body.Who hits it. Any client that asks for a response type other than
code, or that sendscode_challenge_method=plain. Both are things a real client library can send on its first attempt, and both are refused.What went wrong for them.
statecame back, so even a user reporting "I saw an error page" gave the app nothing to match against the pending request.What they get now. A redirect to
https://app.example.com/callback?error=unsupported_response_type&error_description=...&state=abc123, which runs their existing error handling and identifies the request.The redirect is safe because the URI has already been exact-matched against the app's registration. Failures raised before that match still answer on Coder.
Contract change. Four failures now arrive at the app's registered callback rather than terminating on Coder, so integrators with error handling on their callback will start seeing codes they previously never received:
response_type != codeunsupported_response_typeresponse_type != codeunsupported_response_typeWriteOAuth2Error, 400code_challenge_method=plaininvalid_requestWriteOAuth2Error, 400code_challenge_method=plaininvalid_requestThe GET side loses its static error page as a result. Nothing linked to it. The last row is the one the review found: the method was checked on POST only, so the user was asked to approve a request that could never succeed and learned otherwise after clicking Allow.
Three smaller behavior changes come with it:
code,error,error_description, orstatein its own query no longer receives that value back. Registration never checked the query, and a registerederror=rode out on the success redirect, where a client that readserrorfirst discards a valid code. The rest of the registered query is retained, as §3.1.2 requires.error_descriptionis confined to the charset Appendix A permits. Descriptions name the offending value with%q, so everyinvalid_scopeerror Coder emits today carries quotes and a backslash, all of which are excluded. Quotes now render as apostrophes.redirect_urithat will not parse returns 400 instead of 500. It reached a nil dereference in the shared query parser, whichPOST /oauth2/tokensalso uses, and that endpoint takes no API key.Also in this PR.
Locationheader, which the request logger does not record, so a failed authorization used to look exactly like a successful one in Coder's own logs.validatedCallbackURL, produced only where the exact match against the app's registration happens. Holding one is what permits a redirect, so the precondition is something a caller carries rather than something a comment asks it to remember. It is a guard, not a proof: no other package can fabricate one, but code inside this package still can.TestOAuth2PKCEPlainMethodRejectedasserted the old 400 and is gone.authorize_test.gonow covers the method on both verbs, through the same redirect contract as every other error.docs/admin/integrations/oauth2-provider.mdgains a Common Issues entry per newly redirected code: what the callback receives, and what Coder used to do instead. The implicit-grant limitation now says where the error arrives.make genregeneratedcoderd/apidocanddocs/reference/api/enterprise.md.Left alone on purpose.
MismatchedRedirectURINotRedirectedandDangerousCallbackSchemeNotRedirected.code_challenge, a badresource, or an excess parameter still answers on Coder, because the parser reports one verdict for every field at once and the return type cannot say which field failed. §4.1.2.1 wants those delivered to the client, so a follow-up will classify them; it needs the scheme check moved ahead of the failure branch, which two of the tests above constrain.server_errorsites (CRF-26 in the first review). Redirecting them is correct per §4.1.2.1, but sending an internal fault outbound with a Coder-written description is a different risk and wants its own review.Refs PLAT-479.