fix: deliver the rest of the invalid_request class to the client - #28736
Merged
Conversation
Add ScopesCover, which reports whether every permission a requested scope grants is also granted by at least one of a set of allowed scopes. It expands both sides and compares the resulting permissions, so coder:workspaces.access covers workspace:read even though it never names it, and coder:all covers everything. The comparison is deliberately asymmetric. Positive permissions on the allowed side that it does not model are dropped, which can only make the answer stricter. Anything unmodelled on the requested side is an error instead, because ignoring it would answer "covered" about authority that was never compared. Negative permissions are the exception and fail closed on both sides, since dropping an anti-grant from the ceiling would widen it rather than narrow it. Add CanonicalScopeName, which maps the backward-compatibility aliases IsExternalScope accepts onto the names the api_key_scope enum stores. IsExternalScope answers whether a name may be requested, not how that name is spelled once persisted, so a caller that stores what it validated has to canonicalize in between. Both functions are added without production callers. The OAuth2 authorize endpoint uses them to negotiate a requested scope against an app's configured allowlist, which follows in a separate change.
State the rule the guards enforce, site-level grants only, instead of describing the asymmetry abstractly. The allow-list case is now covered alongside negative permissions, which the previous wording omitted even though the code treats them identically. Co-Authored-By: Claude Opus 5 <[email protected]>
ScopesCover checked the requested scope for org and user grants but not the allowed scopes, whose User and ByOrgID permissions were discarded unread. A scope granting workspace:* at site level while negating workspace:delete for the user would have covered a request for workspace:delete, because the negative that carves the action back out lives in the half coverage never examined. No catalog scope populates those fields today, so nothing was miscompared in practice. The gap mattered because these guards exist to keep the comparison fail-closed, and this one failed open. Both sides now run the same checkCoverable helper, which refuses a scope carrying org or user grants, a negative permission, or a resource allow list. The helper names the side, so an error reports which half of the comparison was undecidable. The doc comment claimed an unmodeled grant on the allowed side is dropped; nothing is dropped now, so it is gone. ScopesCover builds every Scope it reads from ExpandScope, which cannot produce these shapes, so the guards are unreachable through the public API. scopes_internal_test.go drives synthetic Scope values through checkCoverable instead. Co-Authored-By: Claude Opus 5 <[email protected]>
permissionCovered skipped negative permissions, but checkCoverable now refuses a scope carrying one on either side, so the branch was dead. It was never defense in depth. Had a negative reached it, skipping the anti-grant would leave any wildcard beside it free to match, and a scope granting workspace:* while negating workspace:delete would report workspace:delete as covered. The skip widened the ceiling while looking like it narrowed it. The precondition moves to the doc comment, which names checkCoverable as what enforces it and says why subsumption cannot answer the question an anti-grant poses. No behavior change: the branch was unreachable. permissionCovered goes from 88.9% to 100% statement coverage.
Five review findings on the coverage tests, all in scopes_test.go. CanonicalScopeName had both alias arms at zero coverage. Its only caller in the tests loops over ExternalScopeNames, which yields canonical names only, so the canonicalizing call returned its input unchanged on every iteration and read as coverage without being any. Swapping the arms, so that `all` persisted application_connect and the reverse, kept the suite green. TestCanonicalScopeName now pins the mapping and the loop appends the aliases, taking the function from 50% to 100%. The appended aliases raise branch coverage and assert a requestable name is comparable once canonicalized, but they cannot detect a swapped mapping, since both aliases resolve to scopes that cover themselves. The comment says so rather than implying the loop guards more than it does. CompositeDoesNotCoverNonMember and CompositeDoesNotCoverWiderActionOnCoveredResource both asked for an ungranted action on a resource coder:workspaces.access does grant, so they tested one branch twice and left "resource not granted at all" untested. They are now split along that line, with names that describe which failure each one is. The three wantErr rows shared a bare require.Error, so any error passed any row and a bug failing every input on the requested side would have left the allowed-side row green. wantErrContains replaces the bool and names the side. Rewording the allowed-side message as the requested-side one now fails three rows that previously all passed. Alias rejection was tested for one alias on one side. Both aliases are now tested on both sides. The allowed-side rows are the ones that earn their place: they are what would catch someone canonicalizing inside the allowed loop and widening the contract without a caller asking.
ScopesCover expanded and compared in a single pass, so the invariant guards only ever ran on scopes ExpandScope had produced. Every such scope satisfies them, which left the guards unverified in the position that matters: the existing test called checkCoverable directly and could not tell whether ScopesCover consulted it on both sides, or at all. Split the comparison into scopesCoverExpanded, which takes already expanded scopes paired with the names they came from. Tests drive synthetic Scope values through it, so dropping the guard from either side now fails, as does an allowed scope that grants every workspace action except delete answering a request for delete. Expanding every allowed scope before any guard runs reorders two error paths against each other: a requested scope that fails a guard alongside an unknown allowed name now reports the expansion failure rather than the guard failure. Both return (false, error), and no ScopeName reaches that combination today.
…ontract The knowledge of which spellings are backward-compatibility aliases lived in two switches, one in IsExternalScope and one in CanonicalScopeName, kept in step by discipline. Drift between them is asymmetric: a name the first accepts and the second does not rewrite is declared public and then fails to expand on every request naming it. Both now read one table, so they agree by construction, and an internal test walks that table asserting each alias is public, resolves to a public name, and resolves to one ExpandScope accepts. A third alias is covered the day it is added. ScopesCover stated "names must be canonical" in prose only, which is wrong for exactly the two inputs IsExternalScope accepts and ExpandScope does not. The parameters are now canonicalAllowed and canonicalRequested, so the requirement shows up in editor hints at every call site rather than only in a doc comment the caller may not have opened. Naming the parameters was chosen over canonicalizing inside ScopesCover. The single downstream caller already canonicalizes both sides in bulk before comparing, so absorbing the step would remove nothing from it while dissolving the distinction between a public spelling and a stored one at the layer that should hold it.
…roken The site-only, wildcard-allow-list, no-negatives invariant was described on ScopesCover and enforced by its guards, but ExpandScope, which is what produces those values, had no doc comment at all. Someone adding a scope reads ExpandScope and its neighbors; nothing there warned that populating User or adding a negative makes the scope uncomparable. State it there, along with the canonicalization requirement, and name the consequence rather than just the rule. Also note on ScopesCover that a wildcard request needs a wildcard grant. Enumerating today's concrete actions genuinely is narrower than `workspace:*`, so the rejection is intended. The OneActionDoesNotCoverResourceWildcard row already pins the behavior; the note stops the next reader of an authorize endpoint from taking it for a bug and closing the gap. Comments only. Checked that the documented invariant actually holds for all three builtin scopes and all seven composites.
TestScopesCoverAllowedNegativeDoesNotWiden drove the same scope shape as the NegativeUserPermission row of TestScopesCoverGuards, but asserted only that some error came back. The row asserts the message, the side it names, and that the comparison reports no coverage, and it runs the shape on both sides rather than one. The weaker copy could pass on a regression that returned the wrong error or stopped naming the side. Fold the scenario it documented into the row's comment and drop the copy. Rename the shared permission fixtures after the value they hold. The site prefix read as "belongs in Role.Site", while two of the three are placed in Role.User to build the shapes the guards refuse, and the No suffix gave no hint that it means Negate. Co-Authored-By: Claude Opus 5 <[email protected]>
The allowed-side wrap printed the scope name and then wrapped an error that prints it again, so the two sides of one comparison read differently: expand allowed scope "foo": no scope named "foo" expand requested scope: no scope named "foo" Drop the redundant verb and let the inner error carry the name on both sides. Co-Authored-By: Claude Opus 5 <[email protected]>
The docstring said the list includes the `all` and `application_connect` special scopes. It appends ScopeAll and ScopeApplicationConnect, which are the `coder:` spellings, so the bare aliases are absent. Two callers already compensate by appending them by hand, one of them with a comment stating the mismatch. Describe what the function returns and name the helper that bridges the gap. Co-Authored-By: Claude Opus 5 <[email protected]>
The invariant that expansion populates Site only was stated in full on ExpandScope, checkCoverable and ScopesCover, and the "everything except delete" example appeared on checkCoverable and again on permissionCovered twenty lines below. State it once on ScopesCover, which is the function whose behaviour depends on it, and cross-reference from the other two. Drop framing that ranked implementation choices nobody proposed, and cut the two test comments down to the facts the assertions do not already carry. Kept in full: what each guard in checkCoverable defends, since no other comment says it, and the wildcard rule on ScopesCover. Co-Authored-By: Claude Opus 5 <[email protected]>
checkCoverable said a negative site permission would be skipped, naming a branch permissionCovered no longer has. A negative reaching it matches on resource type and action like any other grant, so the anti-grant would read as a grant. Name that instead, so the cross-reference lands on a doc that matches the code.
The docstring listed the aliases and the low-level scopes, omitting the curated composites the function also accepts. A caller consulting it to decide whether coder:workspaces.access is public read no from the doc and yes from the code.
ExternalScopeNames promises it offers each scope under one canonical spelling, and no test held it to that. TestScopesCoverEveryExternalScope appended the two aliases, but canonicalized them back into names the list already carries, so it re-ran assertions the list iteration had made and left the promise itself unpinned. Assert on the alias table instead: the list omits the alias and offers its canonical target. Every offered name is already proven coverable, so the aliases inherit coverage, and a third alias inherits both invariants the day it is added rather than needing a third hardcoded pair here.
The authorize endpoint parsed the scope parameter and discarded it, so an app's configured allowlist never restricted anything and a client asking for more than it should get was never told no. Phase 1 added the columns that carry a negotiated scope from a code to the token it becomes, but nothing wrote one, so every code was stamped unrestricted. Negotiate the scope at authorization time and persist the result: - Requested names must be in the external scope catalog, and the app's stored allowlist is filtered through that same catalog. Filtering only ever narrows what can be granted. - The allowlist bounds authority, not spelling. A request is granted when every permission it grants is also granted by the allowlist, whether or not the allowlist names it, so an app allowed coder:workspaces.access can approve a client asking only for workspace:ssh. - Omitting scope grants the filtered allowlist, per RFC 6749 section 3.3. - Both handlers negotiate, so a request that cannot succeed fails before the consent page renders rather than after the user clicks Allow. Each reports the failure the way it already reports its own errors: a static error page on the GET side, an OAuth2 error body on the POST side. - Two paths produce an empty result and are deliberately distinct. No allowlist and no request keeps the previous unrestricted grant, written as an explicit sentinel because the column is NOT NULL with a non-empty CHECK. An allowlist that filters to nothing is rejected, since falling back would grant strictly more than the allowlist ever permitted. Dynamic client registration performs no catalog validation, so apps registered with scopes such as openid or admin hold allowlists this server cannot grant from. They now fail authorization in both directions. Grandfathering unknown names would seed the enforcement path with values it cannot evaluate, trading a visible negotiation-time error for a silent enforcement-time hole. The failure names the registered scopes and the remedy. Issued tokens are still unrestricted: the exchange copies the negotiated scope onto the token record, but the API key it mints carries no scope. This changes which authorization requests succeed, not what a token can do.
The consent page told every user the app was getting full access to their account, which stopped being true once the authorize endpoint began negotiating a narrower scope. A user approving a request has no other place to learn what they are handing over, so the page has to follow the grant rather than a fixed sentence. List the negotiated permissions when the grant is bounded, and keep the original full-access wording when it is not. An unrestricted grant is reported as full access rather than as "coder:all", since the scope name tells a user less than the sentence does. The list collapses to the full-access wording whenever the unrestricted scope is present, not only when it stands alone: an allowlist registered as `coder:all coder:workspaces.access` grants everything, and naming the narrower entry beside it would describe the grant as bounded. role="list" and role="listitem" are explicit because WebKit drops the implicit list semantics from a list styled with list-style: none, which would otherwise leave VoiceOver announcing the permissions as loose text. Also narrow the fragment the tests match for one rejection branch. The GET side renders its description into HTML, which escapes the apostrophe in "this app's allowed scope list", so the fragment stops before it.
…back A rejected authorization request answered on Coder, which reaches only the user's screen. The client's error handling never ran, and the state it sent was dropped, so it could not correlate the failure with the request that caused it. RFC 6749 section 4.1.2.1 requires the error be delivered to the client's redirect URI once the client is known. Redirect to the app's registered callback with error, error_description, and the state exactly as it arrived. Both handlers use this, replacing the static error page on the GET side and the OAuth2 error body on the POST side. This is safe here specifically because of ordering: extractAuthorizeParams exact-matches the redirect URI against the app's registered callback, and it runs before the scope check, so the destination is the app's own no matter what the request carried. Only errors raised after that point may use this helper, which its precondition states. Errors from extractAuthorizeParams itself must not, since the URI is unvalidated there. MismatchedRedirectURINotRedirected pins the ordering: an unregistered redirect_uri fails on Coder with no Location header on either verb, even when the same request also carries a scope the app cannot be granted. The other error paths in this file are unchanged, since several of them are where redirect URI validation fails.
permissionCovered could drop its action comparison and the suite stayed
green: no ScopeName expands to {*, <specific action>}, since the wildcard
entry in policy.RBACPermissions carries no actions and coder:all is the
only wildcard resource the catalog spells. Reach the shape through
scopesCoverExpanded instead, with a positive control so the case fails on
the action rather than on resource matching, and a mirror pinning that a
single-resource grant does not cover a request for every resource.
Every allowed-side error row named the bad scope as the only entry, so an
implementation that answers as soon as one entry covers the request never
reached it. Add a row where the bad name sits behind coder:all, the only
row that fails when ScopesCover expands inside the comparison loop rather
than up front.
…otiateScope The function does not check a requested scope and hand back a verdict. It decides what scope the code will carry, which for an omitted request is the app's allowlist and for an app with no allowlist is coder:all. Neither is a value the caller asked for, so the name promised the wrong thing.
… sentinels The black-box tests assert on the description that reaches the client, and they did so through hand-copied fragments of the sentinel messages. A reworded sentinel would leave every case asserting on text no branch produces, and each case would still pass through whichever branch happened to match next. The sentinels live in package oauth2provider and the tests live in oauth2provider_test, so they are bound through exported values declared in the package's internal test file, which compiles into the same binary.
…turning it rbac.ScopesCover reports an error when it cannot expand one of the names it was handed. That is a deployment-side condition: the app's stored allowlist holds something RBAC will not resolve, and no client can fix it by asking differently. Folding it into errScopeNotAllowed both told the client it had asked for too much, which is not what happened, and rendered RBAC internals into error_description. The failure now goes to the log with the app that provoked it, and the client receives a sentinel of its own. negotiateScope takes the whole app rather than its scope alone so the log line can name it.
… is grantable The rejection named the filter's input, rejoined from fields. For a whitespace-only allowlist that input is empty, so the app owner was shown "" as the value they had to change: the one configuration where the message is the only clue anything is set at all. It now names the stored value verbatim.
…asons Two reasons said things the code does not do. "scope is not in this app's allowed scope list" described membership, but the check is permission coverage: a scope the allowlist never names is granted when a listed composite already confers it. A client reading the old text would go looking for its scope in a list it was never matched against. "re-register the app with supported scopes" prescribed the one remedy a DCR client has. An admin-created app is edited, not re-registered, and a DCR client can update itself in place through RFC 7592. The new text carries an apostrophe on the path the GET handler renders through an HTML template, so the helper that reads those responses now unescapes before matching.
The swagger annotation said a requested scope must be within the app's configured allowlist, which is wrong twice over. The allowlist is checked by permission coverage, not name membership, and it is not the only gate: every requested name must also be in this deployment's scope catalog, including for an app that has no allowlist at all. The omitted-scope default was likewise stated only for apps that have one. Two code comments went stale the same way. The branch table called the omitted-scope default the whole allowlist when it is the catalog-filtered one, and the comment over the persisted scope said the token minted from the code will carry it, which is the next phase's work, not this one's.
…-invalid-request # Conflicts: # coderd/oauth2provider/authorize.go
… into plat479-4-consolidate-redirects
blamesClient asked whether any parser error named client_id, so two shapes that leave the identity settled stayed on Coder and the client never learned its request failed: a client_id repeated in the query, which parseSingle collapses to "" after logging the duplicate, and a POST carrying client_id in the form body, which httpmw resolves and this parser never reads. clientIDInDoubt reads the raw values and compares parsed UUIDs against the app httpmw resolved. A repeated client_id still stays here, since the callback was matched against one of several candidates. TestCarveOutDelivery pins both directions; deleting or inverting the carve-out now fails.
… the callback newAuthorizeResponse decided the redirect URI had failed by counting errors across a span, and the span included the state read. A repeated state therefore nilled the callback, so a request whose redirect_uri exact-matched the registration still answered 400 here and the app waited on an authorization that never arrived. The condition now names the field it means.
RFC 6749 §3.1 and OAuth 2.1 §3.1 both say the authorization server MUST ignore unrecognized request parameters. ErrorExcessParams rejected them instead, and since this PR delivers rejections to the callback, an OIDC client sending nonce or prompt received a spec-shaped invalid_request asserting its request was malformed when it was not. The names are logged at debug instead, so a misspelled parameter is still visible to an operator. Repeats of the parameters this endpoint does read are still rejected, by parseSingle.
…error code response_type was parsed through the SDK enum, so a value with a Go constant (token) answered unsupported_response_type while id_token or a typo answered invalid_request. Read it as plain text instead: the client made one mistake and now gets one code for it.
…ource The token endpoint already answers invalid_target (RFC 8707 §2) for a resource the same validator rejects; authorize flattened it into invalid_request, which a client cannot retry. Carry an error code on the failure and use invalid_target when resource is the only field that failed.
The description joined Go struct dumps with commas, and details contain commas, so the client could not split it back into per-field diagnostics. Join "field: detail" with "; " instead. Its length was also the client's to choose, and it reaches both a Location header and an Info log. Cap it at redirectAuthorizeError, which covers the invalid_scope path too.
The reference listed a parameter set that fails: code_challenge is required for every response_type=code request but was undocumented, and now that failures redirect the client would see nothing on Coder. state was marked required and is not (OAuth 2.1 §4.1.1 makes it OPTIONAL). Also document code_challenge_method, whose S256 default inverts the spec default, and resource.
… endpoint The invalid_request section listed two causes that no longer produce it: an unparseable response_type now answers unsupported_response_type, and an unrecognized parameter is ignored. A malformed resource gets its own invalid_target section. The client_id bullet describes the identity check that is actually run.
…t' into plat479-5-deliver-invalid-request # Conflicts: # coderd/oauth2provider/tokens_internal_test.go
The three answers an authorization failure can get were spelled as independent fields, so both handlers re-derived the precedence by hand and either could be reordered without the suite noticing. kind() states it once and both handlers switch on it. Fold the registered callback's url.Parse into newAuthorizeResponse so a callback that does not parse joins the class the type already claims to represent. That removes the two pre-parse branches, which had no coverage: GET rendered the raw Go parse error, carrying the stored URL, into the browser, and POST answered "Failed to validate query parameters" for a failure that read no query parameter at all. Both now say the callback is not usable, which is true of either cause.
…it returns String made authorizeResponse an implicit fmt.Stringer, and it dereferences the callback unconditionally. The zero value is now routine on failure paths, so a %v on one prints %!v(PANIC=...) instead of the state, at exactly the moment someone is debugging why a request did not redirect.
message said it joins the parser's errors "for the response body", but both delivery paths put it in the error_description query parameter. Rename it to description, which also reads as a pair with the code field next to it. The type doc called the value "a request that did not parse", which its own corruptCallback field contradicts. Drop canRedirect's comment, which restated its one-line body.
…tion says Both callers passed invalid_request and asserted nothing else, so a missing code_challenge and a malformed one were indistinguishable: collapsing the whole parse stage into one blanket code would have left both green. Take the description too. Rename with it. "ExpectingError" described a status code; the helper requires a redirect.
Both verbs answer 400 for the RFC 6749 4.1.2.1 carve-outs and 500 for an unusable registered callback, and the reference listed neither. A redirect_uri mismatch is the most common integration mistake there is, and the admin guide already covers it, so the two artifacts disagreed about whether the status exists. POST declares Produce json so its error body renders as the JSON it is, which also documents codersdk.OAuth2Error for the first time.
The consent page section named only the blocked-scheme cause. The same page, and server_error on POST /oauth2/authorize, now also answer a stored callback that does not parse.
BobbyHo
marked this pull request as ready for review
September 2, 2026 20:10
dylanhuff-at-coder
approved these changes
Sep 3, 2026
dylanhuff-at-coder
left a comment
Contributor
There was a problem hiding this comment.
nice overall lgtm, one small edge case that could be covered with a test that I noted
…t_id RFC 6749 §3.1 says a parameter sent without a value MUST be treated as omitted, but clientIDInDoubt switched on len(vals["client_id"]), so ?client_id= counted as a candidate the callback had to be matched against. httpmw reads the query through Query().Get and falls through to the form body or Basic auth on an empty string, so it resolved the same client whichever spelling arrived; only the query differed. The client got the identical validation error kept on this server as a 400 rather than sent to its callback, where the absent spelling already delivered it. Drop valueless entries before counting. That covers a repeat of them too: with every value empty httpmw had one candidate, not several, so the len(named) > 1 rationale never applied there.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Fifth of the stack, split out of #28045. #28450 delivered the errors raised after the callback is trusted and left the parameter failures behind. This PR delivers those, and replaces the implicit ordering that decided where an answer went with an explicit classification.
codes.scopeandtokens.scope, so a negotiated scope has somewhere to live.ScopesCovercompares an allowlist against a request by permission coverage rather than by name.invalid_scopereaches the client's callback.Contract change
A rejected parameter now arrives at the app's registered callback with
error=invalid_request, a description naming every failing field, and the request'sstate. Both verbs.WriteOAuth2Error, 400An integrator watching for either now reads the error from its own callback instead.
Two failures still answer on Coder, because in neither case is the callback trustworthy yet:
redirect_urithat does not parse, or does not exactly match the registration. Redirecting to it would defeat the check that just rejected it.client_idsent more than once, or naming something other than the app the callback was matched against. Coder cannot tell whose registration it is about to redirect to.An absent query
client_idis not in that group.httpmwalso reads the POST form body and the §2.3.1 Basic credential, so an absent query parameter still names a client and its failure is deliverable.Precedence change. An app whose registered callback does not parse, or uses a rejected scheme, now answers 500 even when the request also carries parameter errors; that used to answer 400. Decided before any parameter is read, so the others are never detected. Only reachable for an app row that bypassed registration.
DangerousCallbackSchemeOutranksParseFailurepins it.Also in this PR
ErrorExcessParams. An OIDCnonceor a vendor extension no longer fails the request. Repeats of parameters the endpoint does read are still rejected.response_typegets one code. Read as text rather than through the SDK enum, so a value with no Go constant behind it answersunsupported_response_typeliketokendoes, instead of splitting on whether the SDK names it.resourceanswersinvalid_target(RFC 8707 §2), but only when nothing else failed. A client retrying oninvalid_targetwould otherwise resend a request still broken in a field it never heard about.error_descriptionis bounded and readable. Capped at 2048 characters and marked(truncated)before the log field or theLocationheader is written. Entries readfield: reasonjoined with;, not the parser's debug shape, whose details contain commas and cannot be split apart again.stateis charged to the client, not the callback. Readingstateshares a function with theredirect_urimatch, so it used to fall into that carve-out and answer on Coder.Where in the OAuth Flow
Diagram: the three-way dispatch, and what licenses a redirect
flowchart TD NEW["newAuthorizeResponse<br/>parses the registered callback, checks its scheme,<br/>exact-matches any redirect_uri, reads state"] NEW -->|"registration unusable"| C500["500 on Coder<br/>server_error, value logged not echoed"] NEW --> PARSE["extractAuthorizeParams<br/>reads every parameter, collects all failures"] PARSE -->|"no failure"| OK["GET: consent page<br/>POST: authorization code"] PARSE -->|"failure"| KIND{"authorizeFailure.kind()"} KIND -->|"corrupt registration"| C500 KIND -->|"redirect_uri or client_id at fault"| CODER["400 on Coder<br/>RFC 6749 4.1.2.1 carve-out"] KIND -->|"anything else"| CLIENT["302 to the callback<br/>invalid_request or invalid_target, with state"]ErrorExcessParamsstill guards the token endpoint.validatedCallbackURLbecomesauthorizeResponse, built only bynewAuthorizeResponse, which runs the scheme check and the exact match in that order. Holding one is what licenses a redirect, so the ordering is structural rather than a convention each call site remembers.p.RedirectURLreturns the client's URI on a mismatch and checking that would blame the app for a request it never made.statemoves onto the type, out of the parameter lists ofwithQuery,errorURL,codeURL, andredirectAuthorizeError, so no call site can emit a response the client cannot correlate.extractAuthorizeParamsreturns oneauthorizeFailureinstead of two trailing values, and both handlers dispatch onkind()rather than re-deriving precedence from field checks.What it satisfies
error_descriptionis confined to the permitted set, on the decoded value.resourcethat is not an absolute URI without a fragment answersinvalid_target.code_challengeis rejected at the authorization request rather than deferred to token exchange, where the error would point at thecode_verifier.server_errorsites) and CRF-13 (fragment delivery), both as in fix: deliver four more authorize errors to the client #28450.Docs and Swagger.
docs/admin/integrations/oauth2-provider.mdgains entries for the redirected parameter errors,invalid_target, and the two failures that stay on Coder. Both authorize verbs document their 400 and 500 responses and stop advertisingresponse_type=token, which meant declaring the parameter as astring, sinceEnumsappends to what swaggo derives from the type.Split out of #28045 (PLAT-479). Fifth of the stack, stacked on #28450.
Manual Tests
Verified by hand against a local dev deployment (
v2.37.0-devel+1145d35a47, dev Postgres), in addition to the automated suite. The build string was checked first, becausedevelop.shbuilds from whatever the tree held when it started and every result below would otherwise be describing the wrong binary.Two scenario groups need state the API cannot produce. The scope allowlist is reachable only through dynamic client registration, which was enabled for the run and disabled again at the end. A registered callback that does not parse, or that uses a blocked scheme, is refused at registration, so those rows were planted directly with
psqlon a purpose-built client and restored afterwards.28 scenarios, all passing. No correctness or security defect was found in the
code this PR changes. Seven observations came out of the run: six are consistency,
documentation or diagnosability points, and one is a small defect that predates
this PR.
Scenario summary, all 28
POSTGET, and the consent page does not render first;redirect_uriis answered on Coder with noLocationon either verbredirect_urilikewise, and the echoed value is HTML escapedclient_idis answered on Coder although the callback was validclient_idcannot disagree with the resolved app over HTTPclient_idsupplied only in thePOSTform body is delivered, not withheld{UPPERCASE}client_idis delivered, and also succeeds end to endstateis delivered rather than charged to the redirect carve-outresponse_typegets one code; an empty one isinvalid_requestresponse_typeis not recast as a missingcode_challengeREDIRECT_URIredirect_urlis ignored and cannot smuggle a destinationresourceanswersinvalid_targetalone andinvalid_requestin companyresourceis rejected(truncated), on both verbsaccess_deniedand issues no codeObservations, all 7
invalid_requestis returned in three description shapes: the parser'sInvalid query params: field: reason; ...aggregate, the PKCE validator's single message, andscopeFailureResponse's. A client cannot parse all three with one rule.field: x detail: ymessage shape, which this PR replaces. That doc needs a one-line update.clientIDInDoubt'sdefaultbranch is unreachable over HTTP, becausehttpmwderivesapp.IDfrom the same query value the parser reads. Defensive rather than dead, but the comment does not say so.POSTsupplyingclient_idonly in the form body always fails withclient_id ... is required and cannot be empty, naming a parameter it did supply.httpmwreads the body,RequiredNotEmptyreads the query. Pre-existing.ignoring unrecognized authorization parametersline islogger.Debugso it is not emitted.resourcesent twice answersinvalid_targetrather thaninvalid_request. RFC 8707 §2 framesinvalid_targetas being about the resource value; a duplicated parameter is a malformed request under RFC 6749 §3.1.resource=https://api.example.com/#(trailing#, empty fragment) is accepted and stored with the#intact, so the persisted audience is not textually equal to the fragment-free form.url.Parsemaps empty and absent fragments both toFragment == "". Pre-existing, intokens.go.One note for anyone re-running this. Every request below depends on
$CHALLENGEfrom the most recentnew_pkcecall. Forgetting to call it sends an emptycode_challenge, which fails with the fixed "is required and cannot be empty" message at 98 characters and looks exactly like a cap or a validator failing to fire. That cost one wrong measurement during this run before the numbers below were taken.Shell helpers used throughout
Fixtures, all registered through DCR:
1. Baseline: a well-formed request still succeeds
GETcarries noLocationat all, so the consent page really rendered rather than redirecting. Thestateechoed is the one sent. The code persisted ascoder:all, this app having no allowlist, withredirect_urirecorded because the request supplied one.Also driven through a real browser rather than curl, so the consent form's
nosurftoken is the one submitted rather than the session-token header alone. Clicking Allow deliveredcodeandstateto the callback. Both request shapes agree on the success case.Not to be misread:
oauth2_provider_app_codesholds one row after both POSTs, not two.ProcessAuthorizeopens its transaction withDeleteOAuth2ProviderAppCodesByAppAndUserID, so a row count is not a count of successful authorizations. That predates this PR.2 to 4. A rejected parameter now reaches the client's callback
Identical on both verbs. Decoded:
The
GETbody confirms no page was rendered, against the baseline:code_challenge263 bytes is Go's redirect stub. There is no consent form in it, so there is no Allow button to press for a request the server has already refused.
Multiple failures are reported together (scenario 4):
The separator is
;, and no entry uses the olderfield: x detail: yshape. The code isinvalid_requestrather thaninvalid_targeteven thoughresourceis one of the two failures, which is the scenario 22 guard firing.The same request also carried
code_challenge_method=plain, which is invalid and is not mentioned, because the method is validated afterextractAuthorizeParamsreturns and a parse failure short-circuits first. Sent alone it is rejected ascode_challenge_method 'plain' is not supported; use 'S256', with noInvalid query params:prefix. That difference is observation 1.5 and 6. The redirect_uri carve-out keeps the answer here
The absence in that output is the assertion: no
Locationon either verb, so nothing redirects toevil.example. Stronger,evil.exampleappears zero times in either full response, headers and body together, so the attacker-supplied host is not reflected into the page either.{"error":"invalid_request","error_description":"Invalid query params: redirect_uri: Query param \"redirect_uri\" must exactly match http://localhost:9876/callback"}The
GETpage names the registered callback as the value the parameter had to match, which is the app's own configuration and safe to display.An unparseable
redirect_urireaches the same outcome by a different route, having never parsed at all:{"error":"invalid_request","error_description":"Invalid query params: redirect_uri: Query param \"redirect_uri\" must be a valid url: parse \"http://%zz\": invalid URL escape \"%zz\""}That message echoes the caller's value, unlike the mismatch case, so the
GETpath was probed with markup in it:Escaped correctly by the template layer. Recorded as a negative result so a future change to the error page has something to regress against.
One note for the docs: this detail contains colons, so a client splitting
field: reasonentries must split each on its first colon only.7 to 10. The client_id carve-out, and its negatives
A repeated
client_idin an otherwise perfect request:This is the one carve-out not implied by the response's shape. The
redirect_urimatched the registration, socanRedirect()was true and there was somewhere to send the answer; it was withheld becauseextractAuthorizeParamsassignsfailure.redirectonly whenclientIDInDoubtis false.The
defaultbranch of that function is unreachable over HTTP (observation 3).httpmwresolves the app fromr.URL.Query().Get("client_id"), soapp.IDand the parsed value derive from the same string and cannot disagree. Three experiments:The negatives matter as much as the positives. An identifier absent from the query but present in the
POSTform body is delivered, correctly:That is observation 4: the description says
client_idis required for a request that supplied it. With everything else valid the same shape still fails, while the identical request carryingclient_idin the query succeeds with a code.RequiredNotEmptyreads the query;httpmwreads the body. Pre-existing, and arguably the parser is the layer in the right, since RFC 6749 §4.1.1 puts authorization parameters in the query string and §3.2.1 is the endpoint that uses a form body.A braced upper-case UUID is delivered, and succeeds end to end:
uuid.Parseaccepts that spelling andhttpmwresolved through it, so a string comparison here would withhold an answer the client is entitled to, for no reason it could diagnose.11. A repeated state is delivered, not withheld
Reading
statehappens insidenewAuthorizeResponse, the same function that runs theredirect_urimatch. A count of errors across those two lines would charge this to the redirect carve-out and answer 400. It answered 302, so the field-specific test is doing the work.The asymmetry with scenario 7 is deliberate: a repeated
client_idleaves the server unsure whose callback it holds, a repeatedstateleaves the callback fully settled.The response carries no
state, sinceparseSinglecollapsed the repeat to the empty string andwithQueryonly sets it when non-empty. Unavoidable rather than wrong: picking one of two values could satisfy a CSRF check the client meant to be strict.12 to 16. A corrupt registration outranks everything
DCR refuses both planted values, which is why
psqlis needed and why 500 is the right answer: a stored one is not something a client did.{"error":"invalid_client_metadata","error_description":"invalid redirect_uris: redirect URI at index 0: redirect URI uses dangerous scheme javascript which is not allowed"} {"error":"invalid_client_metadata","error_description":"invalid redirect_uris: redirect URI at index 0 is not a valid URL: parse \"http://%zz\": invalid URL escape \"%zz\""}The unparseable callback is indistinguishable to the caller: same description, and a
GETpage of exactly 3346 bytes, matching byte for byte. Two different defects in stored state, one answer. Correct, since neither is actionable by the client.Note the request carried no
redirect_uriof its own and still failed, because the scheme is checked on the registered URL before the client's value is consulted.Precedence was tested against all three competitors, not one:
code_challenge=shortclient_idrepeatedredirect_urimismatchedReading the source, this is stronger than precedence.
newAuthorizeResponseruns atauthorize.go:250, before any ofclient_id,code_challenge,scopeorresourceis read, and the early return discards whateverp.Errorsalready held. In rows two and three those failures are never detected at all, sokind()rankingfailureCorruptRegistrationfirst is belt and braces for a failure constructed some other way.The value is logged and never echoed. A distinctive marker was planted and five requests fired:
Five requests, five lines, in request order. Occurrences of the marker in the POST body, the rendered GET page, and the response headers: 0, 0, 0.
The
request_idjoins that line to the request log entry whoseresponse_bodyfield holds the vague message, so the server's own record confirms what it sent for the same request rather than a replay. The log is also where the two causes diverge, namingvalidateSchemeand its source line, which the response cannot do.Restoring the row restores normal behaviour immediately, with no restart:
That is a control rather than housekeeping: it rules out the app having been poisoned lastingly, and rules out a cached parse outliving the
UPDATE.17 and 18. Every unsupported response_type gets one code
response_typesenterrorerror_descriptiontokenunsupported_response_typebananaunsupported_response_typecode tokenunsupported_response_typeCODEunsupported_response_typecode_extraunsupported_response_typeinvalid_requesttokenhas a Go constant behind it andbananadoes not, so their agreement is what reading the value as text bought. Three rows are more interesting thanbanana:code tokenis a legal RFC 6749 §3.1.1 space-delimited list used by OIDC's hybrid flow,CODEconfirms the comparison is case sensitive, andcode_extraconfirms it is equality rather than a prefix match.The empty string is correctly the exception. A valueless parameter is missing, not unsupported, and RFC 6749 §3.1 requires it be treated as omitted.
The error is in the query, not the fragment.
response_type=tokenis the implicit grant's own value, so a fragment would be arguable, but this deployment advertises"response_types_supported":["code"]alone, and a fragment is never sent to the server, so it would be unreadable to the client's backend.PKCE is not recast, which the controls show:
response_type, nocode_challengesenterrortokenunsupported_response_type, zero mentions ofcode_challengebananaunsupported_response_type, zero mentions ofcode_challengecodeinvalid_request, namingcode_challengeWithout the
if params.responseType == responseTypeCodeguard, a client sendingtokenwould be told itscode_challengewas missing, add one, resend, and be told the same thing again.19 to 21. Unrecognized parameters ignored, repeats still rejected
Eight unknown parameters, each on an otherwise valid request, every one issuing a code:
nonce,prompt,login_hint,acr_values,max_age,ui_localescode_challenge_methods=S256REDIRECT_URI=http://evil.example/stealFive of those are OpenID Connect Core parameters, so an OIDC client pointed here degrades to plain OAuth2 rather than failing. The ignored parameters are dropped, not forwarded: the callback receives only
codeandstate.The last two rows are the ones with teeth. Query parameter names are case sensitive, so
REDIRECT_URImust be ignored, and a misspelledredirect_urllikewise:The consent page renders for the typo case too, and even its cancel link targets the registered callback. A parser matching names case-insensitively would have redirected to
evil.examplewith a valid code attached.Observation 5. The
ignoring unrecognized authorization parametersline was never emitted. Three requests carrying a distinctive marker produced no new server output at all, because the call islogger.Debug(authorize.go:307) and the deployment runs withverbose: null. The behaviour is correct and required by RFC 6749 §3.1, but at default verbosity the misspelling is invisible from every angle: the client sees a 302 with a code, its redirect is silently replaced, and nothing is logged. The comment claiming the typo "surfaces here" is optimistic.Repeats of known parameters are still rejected, which the replacement of
ErrorExcessParamshad to leave alone:errorcode_challengeinvalid_request, two entries one fieldcode_challenge_methodinvalid_requestresponse_typeinvalid_requestredirect_uriscopeinvalid_requestresourceinvalid_targetredirect_uribeing withheld confirms the carve-out keys on the field name rather than the kind of mistake.code_challengecollects two entries for one mistake, becauseparseSinglecollapses the value to empty and the PKCE block then reports it missing. Theresourcerow is observation 6.22 and 23. resource, its own code, and the fragment rule
Both sides of the rule, since a check enforced too broadly would reject a valid URN:
resourcesenterrorresource_urinot a uriinvalid_target/apirelativeinvalid_targethttps://api.example.comhttps://api.example.com/v1?q=1urn:example:resourceNULL, same as omittedinvalid_targetapplies only whenresourceis the sole failure. Add any second failure in any other field and the code becomesinvalid_requestnaming both, which is the retry-loop guard:Fragments are rejected in all three placements, with or without a path:
The last row is observation 7, and it predates this PR. Go cannot represent the distinction:
url.Parsecollapses "no fragment" and "empty fragment", soif u.Fragment != ""invalidateResourceParameter(coderd/oauth2provider/tokens.go:591) cannot see a trailing#. RFC 3986 §3.5 permits a zero-length fragment, which RFC 8707 §2's "MUST NOT include a fragment component" reads as forbidding. Validation parses the value but persistence stores the raw string, so the#reachesresource_uriand the stored audience is not textually equal to the fragment-free form. A client appending a harmless-looking#would get a token bound to an audience nothing matches.The authorize-side call to
validateResourceParameteris context in this diff rather than an added line; this PR added theinvalid_targetcode for the failure.24 and 25. The description cap and character set
The obvious probe does not exercise the cap: a 4000-character
code_challengeproduces a 116-character description, because that message is fixed text and never quotes the offending value. The descriptions that echo caller input come from a validator's own error.errorLocationlengthcode_challenge_method= 4000 charsinvalid_requestscope= 4000 charsinvalid_scopecode_challenge_method= 2000 charsinvalid_requestcode_challenge= 4000 charsinvalid_request2060 is exactly
maxErrorDescriptionplus" (truncated)". Row three is the boundary control, so the cap fires on length rather than on the presence of echoed input. Measured from theLocationheader rather than a log line, since the stated reason for the cap is that the header must survive intermediary proxies; the longest observed was 2176 bytes.Both verbs agree, the cap living in the shared
redirectAuthorizeError:The character set is enforced independently, later, in
errorURL:"becomes',\is dropped so it cannot escape the rewritten quote, and anything below 0x20 or above 0x7E becomes a space. Checked programmatically rather than by eye. The RFC sets no length limit, so the 2048 cap is policy; the character set is conformance.26. The registered callback query, retained except reserved names
plat479-reservedregisters a callback carrying all four reserved names plus two ordinary ones, and DCR stores it unchanged:Success response, no
redirect_urisent:Failure response, same app:
Stale registered values in either response:
FAKECODE0,stale0,oldmsg0,oldstate0.Without the deletion step a registered
error=stalewould ride out on the success response, and a client readingerrorbeforecode, the conventional order, would discard a valid authorization code.FAKECODEis the mirror image on a failure response.The cancel link obeys the same rule, being built by the same
withQuerypath:RFC 6749 §3.1.2 requires the registered query be retained when adding parameters, which is the
tenantandkeephalf. It does not say what to do when the registered query collides with the response parameters §4.1.2 adds, so dropping the registered copies is the resolution that keeps the client's read unambiguous.27 and 28. Scope rejections, and declining consent
plat479-cicarries the allowlistcoder:workspaces.access.workspace:sshis granted although the allowlist names the composite rather than that scope, which is the coverage-not-spelling rule from #28045. This run only checks that the changes here left it delivering the same answers.The consent page for a narrow scope reads differently from the unrestricted one: "to access your admin account with these permissions?", the scope as
<li role="listitem">workspace:ssh, and the caution "These are technical permission names. Grant them only to an application you trust."The scope description shape differs from the parser's, coming from
scopeFailureResponserather than the field join, which is the third producer behind observation 1.Declining consent, clicked in a real browser:
The page carries exactly one
hrefand it is the cancel link. Thestatematches, there is nocode, and the code count for the app is unchanged, the link being a plainGETto the client's callback that never reachesProcessAuthorize. The description is RFC 6749 §4.1.2.1's own definition ofaccess_denied, word for word.That shared construction is what makes the
#nosec G203annotation onCancelURIsound: the URL is injected as a trustedhtmltemplate.URL, safe only becausenewAuthorizeResponsevalidated the registered scheme before the response object could exist. Scenario 12 is the other half, an app with a rejected scheme never reaching this page at all.Where the consent page renders, across every GET case
Only
GETcan render anything, and body size identifies which of four renderers ran, so this is the table to check when a status code alone looks right.code_challengeredirect_uriredirect_uriclient_idclient_idin form body onlyhttpmwJSON, unstyled{UPPERCASE}client_idstateresponse_type=tokenbanana/code token/CODEresponse_typetoken, no PKCEcode, no PKCEredirect_urltypo, valid requestresourcemalformedresourcevalid absolute URIresource=.../#empty fragmentscopeoutside allowlistscopecovered by allowlistThe invariant: the page renders if and only if
extractAuthorizeParamsreturned no failure, so no input asks the owner to approve a request the server has already refused. Theevil.examplerows are worth reading carefully rather than alarming: RFC 6749 §3.1 requires a case variant and a typo to be ignored, so from the server's view nothing was wrong with either request, and both cancel links target the registered callback.About 4930 bytes is the consent page, about 3800 the 400 page, 3346 the 500 page, 77 the unstyled
httpmwJSON, and low hundreds is Go's redirect stub, whose length tracks theLocationit embeds. Everyunsupported_response_typeanswer is exactly 187 bytes because they share one description. Small variations within a renderer are expected: the consent page moves 4928 to 4940 with a fresh CSRF token and the app name.As a regression signal, body size is more sensitive than the status line. A change that rendered a page alongside a redirect would still report 302, but the stub would come back at thousands of bytes rather than hundreds.
Cleanup
All planted callback rows were restored before deletion, so no fixture was left holding a value DCR would refuse.