Skip to content

fix: deliver three more authorize errors to the client - #28450

Draft
BobbyHo wants to merge 18 commits into
plat479-3-report-negotiated-scopefrom
plat479-4-consolidate-redirects
Draft

fix: deliver three more authorize errors to the client#28450
BobbyHo wants to merge 18 commits into
plat479-3-report-negotiated-scopefrom
plat479-4-consolidate-redirects

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

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 the state the app sent. Coder shows its own error page only when the callback cannot be trusted yet.

The problem. Three failure checks in authorize.go ran 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 sends code_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.

  • The browser stopped on a Coder page and never reached the app's callback route.
  • That route is where every client keeps its error handling, so none of it ran. The app never learned the request had failed and sat waiting on an authorization that would never arrive.
  • No state came 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:

Verb Condition Error code Was
GET response_type != code unsupported_response_type static "Unsupported Response Type" page, 400
POST response_type != code unsupported_response_type WriteOAuth2Error, 400
POST code_challenge_method=plain invalid_request WriteOAuth2Error, 400
GET code_challenge_method=plain invalid_request consent page, 200, then the POST refused it

The 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:

  • A callback registered with code, error, error_description, or state in its own query no longer receives that value back. Registration never checked the query, and a registered error= rode out on the success redirect, where a client that reads error first discards a valid code. The rest of the registered query is retained, as §3.1.2 requires.
  • error_description is confined to the charset Appendix A permits. Descriptions name the offending value with %q, so every invalid_scope error Coder emits today carries quotes and a backslash, all of which are excluded. Quotes now render as apostrophes.
  • A redirect_uri that will not parse returns 400 instead of 500. It reached a nil dereference in the shared query parser, which POST /oauth2/tokens also uses, and that endpoint takes no API key.

Also in this PR.

  • One URL builder. The §4.1.2.1 error URL was built in two places: the shared helper, and the consent page's cancel link, which rolled its own. The helper copied the URL before editing it; the cancel link edited the shared one in place. The divergence was latent, since nothing read the mutated value afterwards, but it would have become real the moment a line was added below it. Both now go through one builder that copies.
  • A log line for each rejection. The error leaves in a Location header, which the request logger does not record, so a failed authorization used to look exactly like a successful one in Coder's own logs.
  • A type instead of a comment. The verified callback is carried in a 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.
  • One place asserting plain PKCE. TestOAuth2PKCEPlainMethodRejected asserted the old 400 and is gone. authorize_test.go now covers the method on both verbs, through the same redirect contract as every other error.
  • Docs. docs/admin/integrations/oauth2-provider.md gains 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.
  • Swagger. Both authorize verbs document the 302, the GET side for the first time. make gen regenerated coderd/apidoc and docs/reference/api/enterprise.md.

Left alone on purpose.

  • A redirect URI that failed the match, and a registered URI with a rejected scheme. Redirecting either would defeat the check that just rejected it. Guarded by MismatchedRedirectURINotRedirected and DangerousCallbackSchemeNotRedirected.
  • The rest of the parameter failures, which do have a trusted callback in hand. A bad code_challenge, a bad resource, 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.
  • The two server_error sites (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.

…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.
@linear-code

linear-code Bot commented Aug 23, 2026

Copy link
Copy Markdown

PLAT-479

…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
@BobbyHo

BobbyHo commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-08-25 16:30 UTC by @BobbyHo

Review history
  • R1 (2026-08-25): 16 reviewers, 10 Nit, 2 Note, 5 P2, 12 P3, COMMENT. Review

deep-review v0.9.0 | Round 1 | c7d3d53..67a0556

Last posted: Round 1, 29 findings (5 P2, 12 P3, 10 Nit, 2 Note), COMMENT. Review

Finding inventory

Finding inventory - PR #28450

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Open oauth2providertest/helpers.go:405 AuthorizeOAuth2AppExpectingRedirectError duplicates requireAuthorizeErrorRedirect's contract R1 Netero, Bisky Yes
CRF-2 Nit Open oauth2providertest/helpers.go:387 New exported helper missing doc comment R1 Netero Yes
CRF-3 Nit Open authorize.go:439 Scheme-check comment enumerates consumers, omits the two new redirect sites (GET sibling at 355) R1 Netero, Gon Yes
CRF-4 Nit Open authorize_test.go:534 defer resp.Body.Close() inside a for loop R1 Netero, Ging-go, Chopper Yes
CRF-5 Note Open authorize_test.go:577 Description's "disagreed in a way that mattered" claim unproven; divergence was latent R1 Netero, Mafu-san, Razor Yes
CRF-6 P2 Open authorize.go:236 extractAuthorizeParams discards an already-matched callback, so the invalid_request class still terminates on Coder; the stated justification is false R1 Hisoka P2, Meruem P2, Pariston P2, Razor P3 Yes
CRF-7 P2 Open authorize.go:465 error_description reflects client-supplied input; violates RFC 6749 §4.1.2.1 charset; Coder becomes a reflector; class incl. invalid_scope R1 Mafuuu P2, Meruem P2, Knov P2, Kite P2, Razor P2, Hisoka P3; Kurapika dissent Yes
CRF-8 P2 Open authorize.go:258 withQuery seeds from the registered callback's own query, so a callback carrying code/error/state gets contradictory params on success and error redirects R1 Ryosuke Yes
CRF-9 P2 Open authorize.go:192 (root queryparams.go:232) Malformed redirect_uri nil-derefs and 500s; unauthenticated at tokens.go:116 (log amplification) R1 Kurapika P2, Knov P2, Razor P3, Pariston P4, Kite P4 Yes
CRF-10 P2 Open authorize.go:241 Type doc claims the guard prevents open redirects; an in-package composite literal can still forge one (caveat was deleted) R1 Mafu-san P2, Gon P2, Leorio P3, Bisky P4 Yes
CRF-11 P3 Open authorize.go:289 "Holding a validatedCallbackURL licenses the redirect" is false: the scheme check is a separate, order-dependent precondition; the type carries half the invariant R1 Meruem P3, Ryosuke P3, Leorio P3 Yes
CRF-12 P3 Open authorize.go:463 code_challenge_method validated on POST only; GET renders consent for a doomed request, then bounces after Allow R1 Ryosuke P2, Mafuuu P3, Meruem P3, Knov P3, Razor P3, Hisoka P4 Yes
CRF-13 P3 Open authorize.go:379 response_type=token is the sole reachable input; its error belongs in the fragment (§4.2.2.1), not the query the client won't read R1 Mafuuu P3, Pariston P3, Kite Note, Knov Note Yes
CRF-14 P3 Open oauth2providertest/helpers.go:400 Exported helper is weaker than its in-package twin: no description assert, host/path not scheme, compares a constant not params.RedirectURI R1 Bisky P3, Mafu-san P3, Chopper P3, Kurapika P3, Razor Nit Yes
CRF-15 P3 Open authorize.go:452 (swagger coderd/oauth2.go:125,139) @success annotations misdescribe both authorize endpoints; 302 error cases undocumented, token advertised R1 Chopper Yes
CRF-16 P3 Open docs/admin/integrations/oauth2-provider.md:353 Three codes now delivered to the callback, no docs entry; the fourth (invalid_scope) already has one; Limitations bullet stale R1 Kite P3, Mafuuu Nit Yes
CRF-17 P3 Open authorize.go:292 state is a free parameter on every builder though all callsites pass params.state; fold it into the type R1 Knov Yes
CRF-18 P3 Open authorize.go:256 withQuery's copy rationale describes a request that cannot happen; the real reason is String()/redirect_uri recording R1 Leorio Yes
CRF-19 P3 Open authorize.go:271 "Set, not Add" rationale sits on errorURL but explains state (withQuery) and code (codeURL) R1 Gon P3, Leorio Nit, Knov Nit Yes
CRF-20 Nit Open authorize.go:291 "informing the user there" has no antecedent R1 Gon Yes
CRF-21 Nit Open authorize.go:449 POST response_type comment left behind while the GET twin was rewritten R1 Gon, Leorio Yes
CRF-22 Nit Open authorize.go:257 withQuery doc enumerates its call sites (same staleness class as CRF-3) R1 Gon Yes
CRF-23 Nit Open authorize.go:173 One value, three names: field redirectURL, param callback, inner field callback R1 Gon Yes
CRF-24 Nit Open authorize_test.go:514 Second seedApp in the file, same name, different arity R1 Gon Yes
CRF-25 Nit Open authorize_test.go:604 strings.Index+manual slicing where strings.Cut is the idiom R1 Ging-go Yes
CRF-26 Nit Open authorize_test.go:603 cancelLinkFromConsentPage couples to HTML attribute order; x/net/html is available R1 Bisky Yes
CRF-27 Note Open authorize.go:244 Zero-value validatedCallbackURL nil-derefs; latent (no live caller), but the fix for CRF-6 puts one on the error return R1 Hisoka, Mafuuu, Meruem, Knov, Bisky Yes
CRF-28 P3 Open authorize.go:292 302 redirects are indistinguishable from success in Coder's own logs; redirectAuthorizeError logs nothing R1 Chopper Yes
CRF-29 P3 Open authorize_test.go:556 UnparseableResponseTypeNotRedirected asserts a false rationale ("fails before the callback is trusted"); it cements CRF-6's gap as intended behavior R1 Pariston P3, Hisoka P3, Meruem Nit, Gon Note Yes

Cross-check notes

  • CRF-6 root cause and CRF-29 are one story: the parser bundles "was redirect_uri the failure?" with every other error, so extractAuthorizeParams returns authorizeParams{} on any error and throws away a callback that was already exact-matched (or defaulted from registration when redirect_uri was omitted). CRF-29's test message ("fails before the callback is trusted") makes the gap read as intended. Fix at the classification point: use codersdk.ValidationError.Field to redirect invalid_request when no error names redirect_uri.
  • CRF-7 is a class, not an instance. invalid_scope at base already ships %q-quoted client input (authorize.go:91/120/150) to the callback and is reachable on GET with no consent click. The PR builds the chokepoint (errorURL) that can close the whole class; filter to the §4.1.2.1 charset there. Kurapika rated it an acceptable Note on the XSS axis only; the charset-conformance violation (the RFC-compliance PR shipping non-conforming values) is unrebutted, so higher severity holds.
  • CRF-9 is pre-existing and upstream (queryparams.go, tokens.go), outside this diff, but it is the call the new type's doc stakes its whole invariant on, and the unauthenticated /oauth2/tokens site (Kurapika) sets the severity. One-line fix at queryparams.go:229/232 covers both sites. Needs a human decision: fix here or file a ticket.
  • CRF-10, CRF-11, CRF-17, CRF-27 converge on the type. The structural fix several reviewers proposed (Ryosuke, Meruem, Knov): a single constructor newValidatedCallback that runs the exact match and ValidateRedirectURIScheme, is the only producer, carries state, and makes the zero value the only unvalidated state. That closes CRF-8, CRF-10, CRF-11, CRF-17, CRF-27 at once.
  • CRF-12, CRF-13, CRF-29, CRF-6 all trace to GET and POST running two hand-aligned pre-consent sequences that drift. Ryosuke's fold (one validateAuthorizeRequest called by both handlers) makes the PKCE drift structurally impossible.
  • CRF-8 (withQuery seeds registered query) is single-reviewer but reproduced against the worktree and affects the success redirect, not just errors; kept at P2 with the precondition (an app registering a callback that carries code/error/state) stated.

Law analysis

Not run (effective additions 219 < 1000).

Round log

Round 1

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

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.
@BobbyHo
BobbyHo force-pushed the plat479-4-consolidate-redirects branch from 67a0556 to 8a5004a Compare August 25, 2026 17:00

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

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.

Comment thread coderd/oauth2provider/authorize.go
Comment thread coderd/oauth2provider/authorize.go
Comment thread coderd/oauth2provider/authorize.go Outdated
Comment thread coderd/oauth2provider/authorize.go Outdated
Comment thread coderd/oauth2provider/authorize.go Outdated
Comment thread coderd/oauth2provider/authorize_test.go Outdated
Comment thread coderd/oauth2provider/authorize.go
Comment thread coderd/oauth2provider/authorize.go Outdated
Comment thread coderd/oauth2provider/authorize_test.go Outdated
Comment thread coderd/oauth2provider/authorize_test.go
BobbyHo added 12 commits August 27, 2026 00:36
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.
@github-actions

github-actions Bot commented Aug 27, 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 changed the title fix(coderd/oauth2provider): deliver three more authorize errors to the client fix: deliver three more authorize errors to the client Aug 27, 2026
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.
BobbyHo added a commit that referenced this pull request Aug 27, 2026
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.
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