Skip to content

fix: deliver the rest of the invalid_request class to the client - #28736

Merged
BobbyHo merged 121 commits into
mainfrom
plat479-5-deliver-invalid-request
Sep 3, 2026
Merged

fix: deliver the rest of the invalid_request class to the client#28736
BobbyHo merged 121 commits into
mainfrom
plat479-5-deliver-invalid-request

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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.

PR What it does
#28007 Schema: codes.scope and tokens.scope, so a negotiated scope has somewhere to live.
#28167 ScopesCover compares an allowlist against a request by permission coverage rather than by name.
#28178 The authorize endpoint negotiates the scope against the app's allowlist and persists it on the code.
#28179 The consent page lists the negotiated permissions, and invalid_scope reaches the client's callback.
#28450 The same delivery for every other error raised once the callback is trusted.
#28736 (this) The parameter failures #28450 left behind, and the classification that decides where each answer goes.

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's state. Both verbs.

Verb Was
GET static "Invalid Query Parameters" page, 400
POST WriteOAuth2Error, 400

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

  • A redirect_uri that does not parse, or does not exactly match the registration. Redirecting to it would defeat the check that just rejected it.
  • A client_id sent 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_id is not in that group. httpmw also 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. DangerousCallbackSchemeOutranksParseFailure pins it.

Also in this PR

  • Unrecognized parameters are ignored, as §3.1 requires, rather than rejected by ErrorExcessParams. An OIDC nonce or a vendor extension no longer fails the request. Repeats of parameters the endpoint does read are still rejected.
  • Every unsupported response_type gets one code. Read as text rather than through the SDK enum, so a value with no Go constant behind it answers unsupported_response_type like token does, instead of splitting on whether the SDK names it.
  • A malformed resource answers invalid_target (RFC 8707 §2), but only when nothing else failed. A client retrying on invalid_target would otherwise resend a request still broken in a field it never heard about.
  • error_description is bounded and readable. Capped at 2048 characters and marked (truncated) before the log field or the Location header is written. Entries read field: reason joined with ; , not the parser's debug shape, whose details contain commas and cannot be split apart again.
  • A repeated state is charged to the client, not the callback. Reading state shares a function with the redirect_uri match, 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"]
Loading
  • Authorize endpoint only, both verbs. Token exchange, refresh, and revocation are untouched, and ErrorExcessParams still guards the token endpoint.
  • validatedCallbackURL becomes authorizeResponse, built only by newAuthorizeResponse, 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.
  • The scheme is checked on the registered URL, since p.RedirectURL returns the client's URI on a mismatch and checking that would blame the app for a request it never made.
  • state moves onto the type, out of the parameter lists of withQuery, errorURL, codeURL, and redirectAuthorizeError, so no call site can emit a response the client cannot correlate.
  • extractAuthorizeParams returns one authorizeFailure instead of two trailing values, and both handlers dispatch on kind() rather than re-deriving precedence from field checks.

What it satisfies

  • RFC 6749 §4.1.2.1: a failure that is not a bad redirection URI or client identifier is reported through the redirection URI. That is the whole PR. Its two exceptions "MUST NOT" be redirected to, hence the carve-outs.
  • RFC 6749 §3.1: unrecognized parameters MUST be ignored, and no parameter may appear more than once. Both now hold here.
  • RFC 6749 Appendix A: error_description is confined to the permitted set, on the decoded value.
  • RFC 8707 §2: a resource that is not an absolute URI without a fragment answers invalid_target.
  • RFC 7636 §4.4.1: a malformed code_challenge is rejected at the authorization request rather than deferred to token exchange, where the error would point at the code_verifier.
  • Not yet: CRF-26 (the server_error sites) 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.md gains 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 advertising response_type=token, which meant declaring the parameter as a string, since Enums appends 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, because develop.sh builds 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 psql on 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
# Scenario Result
1 A well-formed request still succeeds on both verbs, and the consent page renders Pass
2 A rejected parameter now reaches the client's callback on POST Pass
3 The same on GET, and the consent page does not render first Pass
4 The description names every failing field, joined with ; Pass
5 A mismatched redirect_uri is answered on Coder with no Location on either verb Pass
6 An unparseable redirect_uri likewise, and the echoed value is HTML escaped Pass
7 A repeated client_id is answered on Coder although the callback was valid Pass
8 A single query client_id cannot disagree with the resolved app over HTTP Pass
9 A client_id supplied only in the POST form body is delivered, not withheld Pass
10 A {UPPERCASE} client_id is delivered, and also succeeds end to end Pass
11 A repeated state is delivered rather than charged to the redirect carve-out Pass
12 A registered callback with a blocked scheme answers 500 on both verbs Pass
13 An unparseable registered callback answers 500, indistinguishably to the caller Pass
14 Corrupt registration outranks a deliverable failure and both carve-outs Pass
15 The corrupt value is logged with the app ID and never appears in a response Pass
16 Restoring the row restores normal behaviour with no restart Pass
17 Every unsupported response_type gets one code; an empty one is invalid_request Pass
18 An unsupported response_type is not recast as a missing code_challenge Pass
19 Unrecognized parameters are ignored, including a case-variant REDIRECT_URI Pass
20 A misspelled redirect_url is ignored and cannot smuggle a destination Pass
21 Repeated known parameters are still rejected Pass
22 resource answers invalid_target alone and invalid_request in company Pass
23 A fragment in resource is rejected Pass, with one gap
24 A long description is capped at 2048 and marked (truncated), on both verbs Pass
25 The description is sanitized to the RFC 6749 §4.1.2.1 character set Pass
26 The registered callback query is retained except the reserved names Pass
27 Scope rejections still reach the callback, and no consent page renders Pass
28 Declining consent carries access_denied and issues no code Pass
Observations, all 7
# Observation Severity
1 invalid_request is returned in three description shapes: the parser's Invalid query params: field: reason; ... aggregate, the PKCE validator's single message, and scopeFailureResponse's. A client cannot parse all three with one rule. Consistency, reviewer call
2 The phase 2 runbook for #28045 asserts the old field: x detail: y message shape, which this PR replaces. That doc needs a one-line update. Docs follow-up
3 clientIDInDoubt's default branch is unreachable over HTTP, because httpmw derives app.ID from the same query value the parser reads. Defensive rather than dead, but the comment does not say so. Comment clarity
4 A POST supplying client_id only in the form body always fails with client_id ... is required and cannot be empty, naming a parameter it did supply. httpmw reads the body, RequiredNotEmpty reads the query. Pre-existing. Misleading diagnostic
5 A misspelled parameter is invisible from every angle at default verbosity. The client gets a 302 with a code, its redirect is silently replaced by the registered one, and the ignoring unrecognized authorization parameters line is logger.Debug so it is not emitted. Diagnosability
6 resource sent twice answers invalid_target rather than invalid_request. RFC 8707 §2 frames invalid_target as being about the resource value; a duplicated parameter is a malformed request under RFC 6749 §3.1. Low, error code choice
7 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.Parse maps empty and absent fragments both to Fragment == "". Pre-existing, in tokens.go. Defect, low severity

One note for anyone re-running this. Every request below depends on $CHALLENGE from the most recent new_pkce call. Forgetting to call it sends an empty code_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
export BASE_URL=http://localhost:3000
export AUTH_HEADER="Coder-Session-Token: $(cat ./.coderv2/session)"
export PGPASSWORD=$(cat ./.coderv2/postgres/password)
export PGPORT=$(cat ./.coderv2/postgres/port)

pg() { psql -h localhost -p "$PGPORT" -U coder -d coder -tAc "$1"; }
plant_callback() { pg "UPDATE oauth2_provider_apps SET callback_url = '$2' WHERE id = '$1';"; }
urlenc() { jq -rn --arg v "$1" '$v|@uri'; }

# 43 unreserved characters every time. Base64url of the raw 32 bytes.
new_pkce() {
  VERIFIER=$(openssl rand 32 | base64 | tr -d '\n=' | tr '+/' '-_')
  CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d "=" | tr '+/' '-_')
  STATE=$(openssl rand -hex 16)
}

authz_url() {
  local url="$BASE_URL/oauth2/authorize?client_id=$1&response_type=code"
  url="$url&redirect_uri=$(urlenc "$2")&state=$STATE"
  url="$url&code_challenge=$CHALLENGE&code_challenge_method=S256"
  printf '%s' "$url"
}

# The core assertion of the whole run: what answer, delivered where.
answer() { curl -s -o /dev/null -D - -X "$1" "$2" -H "$AUTH_HEADER" | grep -iE '^HTTP/|^location:'; }

show_callback() {
  python3 - "$1" <<'PY'
import sys, urllib.parse
q = urllib.parse.urlparse(sys.argv[1]).query
for k, v in urllib.parse.parse_qsl(q, keep_blank_values=True):
    print(f"{k} = {v}")
PY
}

Fixtures, all registered through DCR:

plat479-plain    http://localhost:9876/callback
plat479-query    http://localhost:9876/callback?tenant=acme&error=stale
plat479-corrupt  http://localhost:9876/callback   (overwritten by psql in 12 to 16)
plat479-reserved http://localhost:9876/callback?tenant=acme&code=FAKECODE&error=stale&error_description=oldmsg&state=oldstate&keep=yes
plat479-ci       http://localhost:9876/callback   (scope allowlist coder:workspaces.access)
1. Baseline: a well-formed request still succeeds
new_pkce; answer GET  "$(authz_url "$APP_ID" http://localhost:9876/callback)"
new_pkce; answer POST "$(authz_url "$APP_ID" http://localhost:9876/callback)"
=== GET ===
HTTP/1.1 200 OK

=== POST ===
HTTP/1.1 302 Found
Location: http://localhost:9876/callback?code=coder_dzvaCPf9W1_...&state=e321bd5d5b594a1fae8a2d5da654b84a

GET carries no Location at all, so the consent page really rendered rather than redirecting. The state echoed is the one sent. The code persisted as coder:all, this app having no allowlist, with redirect_uri recorded because the request supplied one.

Also driven through a real browser rather than curl, so the consent form's nosurf token is the one submitted rather than the session-token header alone. Clicking Allow delivered code and state to the callback. Both request shapes agree on the success case.

Not to be misread: oauth2_provider_app_codes holds one row after both POSTs, not two. ProcessAuthorize opens its transaction with DeleteOAuth2ProviderAppCodesByAppAndUserID, 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
new_pkce
BAD="$BASE_URL/oauth2/authorize?client_id=$APP_ID&response_type=code"
BAD="$BAD&redirect_uri=$(urlenc http://localhost:9876/callback)&state=$STATE"
BAD="$BAD&code_challenge=short&code_challenge_method=S256"
answer POST "$BAD"
answer GET  "$BAD"
HTTP/1.1 302 Found
Location: http://localhost:9876/callback?error=invalid_request&error_description=Invalid+query+params%3A+code_challenge%3A+must+be+43+to+128+characters+from+the+unreserved+character+set+%5BA-Za-z0-9-._~%5D&state=e89f4d98d4c8c347e5ae25175748b731

Identical on both verbs. Decoded:

error = invalid_request
error_description = Invalid query params: code_challenge: must be 43 to 128 characters from the unreserved character set [A-Za-z0-9-._~]
state = e89f4d98d4c8c347e5ae25175748b731

The GET body confirms no page was rendered, against the baseline:

Request Body size Occurrences of "Allow"
Malformed code_challenge 263 bytes 0
Well formed (scenario 1) 4928 bytes 2

263 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):

error_description = Invalid query params: code_challenge: must be 43 to 128 characters from the unreserved character set [A-Za-z0-9-._~]; resource: must be an absolute URI without fragment

The separator is ; , and no entry uses the older field: x detail: y shape. The code is invalid_request rather than invalid_target even though resource is 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 after extractAuthorizeParams returns and a parse failure short-circuits first. Sent alone it is rejected as code_challenge_method 'plain' is not supported; use 'S256', with no Invalid query params: prefix. That difference is observation 1.

5 and 6. The redirect_uri carve-out keeps the answer here
new_pkce
EVIL="$BASE_URL/oauth2/authorize?client_id=$APP_ID&response_type=code"
EVIL="$EVIL&redirect_uri=$(urlenc http://evil.example/steal)&state=$STATE"
EVIL="$EVIL&code_challenge=$CHALLENGE&code_challenge_method=S256"
answer POST "$EVIL"; answer GET "$EVIL"
HTTP/1.1 400 Bad Request
HTTP/1.1 400 Bad Request

The absence in that output is the assertion: no Location on either verb, so nothing redirects to evil.example. Stronger, evil.example appears 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 GET page 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_uri reaches 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 GET path was probed with markup in it:

input:  http://%zz"><img src=x onerror=BAD>
page:   ...must be a valid url: parse &#34;http://%zz\&#34;&gt;&lt;img src=x onerror=BAD&gt;&#34;...
literal `<img` in body: 0     entity encoded `&lt;img`: 1

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: reason entries must split each on its first colon only.

7 to 10. The client_id carve-out, and its negatives

A repeated client_id in an otherwise perfect request:

HTTP/1.1 400 Bad Request   (both verbs, no Location)
{"error":"invalid_request","error_description":"Invalid query params: client_id: Query param \"client_id\" provided more than once, found 2 times. Only provide 1 instance of this query param."}

This is the one carve-out not implied by the response's shape. The redirect_uri matched the registration, so canRedirect() was true and there was somewhere to send the answer; it was withheld because extractAuthorizeParams assigns failure.redirect only when clientIDInDoubt is false.

The default branch of that function is unreachable over HTTP (observation 3). httpmw resolves the app from r.URL.Query().Get("client_id"), so app.ID and the parsed value derive from the same string and cannot disagree. Three experiments:

one query client_id, bad challenge          -> 302 delivered
two DIFFERENT client_id values              -> 400, caught by the repeated branch
query names app A, form body names app B    -> 302 to app A; the body is ignored

The negatives matter as much as the positives. An identifier absent from the query but present in the POST form body is delivered, correctly:

HTTP/1.1 302 Found
Location: ...error=invalid_request&error_description=Invalid+query+params%3A+client_id%3A+...is+required+and+cannot+be+empty%3B+code_challenge%3A+...

That is observation 4: the description says client_id is required for a request that supplied it. With everything else valid the same shape still fails, while the identical request carrying client_id in the query succeeds with a code. RequiredNotEmpty reads the query; httpmw reads 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:

{UPPERCASE} client_id, bad challenge -> 302, description names ONLY code_challenge
{UPPERCASE} client_id, all valid     -> 302 with a real code
GET, all valid                       -> 200, consent page, heading resolves to the app's real name

uuid.Parse accepts that spelling and httpmw resolved 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
HTTP/1.1 302 Found   (both verbs)
error = invalid_request
error_description = Invalid query params: state: Query param 'state' provided more than once, found 2 times. Only provide 1 instance of this query param.

Reading state happens inside newAuthorizeResponse, the same function that runs the redirect_uri match. 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_id leaves the server unsure whose callback it holds, a repeated state leaves the callback fully settled.

The response carries no state, since parseSingle collapsed the repeat to the empty string and withQuery only 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 psql is 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\""}
plant_callback "$APP_CORRUPT_ID" 'javascript:alert(1)'
new_pkce
CORRUPT="$BASE_URL/oauth2/authorize?client_id=$APP_CORRUPT_ID&response_type=code&state=$STATE"
CORRUPT="$CORRUPT&code_challenge=$CHALLENGE&code_challenge_method=S256"
answer POST "$CORRUPT"; answer GET "$CORRUPT"
HTTP/1.1 500 Internal Server Error
{"error":"server_error","error_description":"The application's registered callback URL is not usable"}
GET: HTTP/1.1 500, page reads "500 - Invalid Callback URL", 3346 bytes, 0 Allow buttons

The unparseable callback is indistinguishable to the caller: same description, and a GET page 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_uri of 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:

Also wrong in the request Would answer alone Actually answered
code_challenge=short 302 to the callback 500
client_id repeated 400 kept here 500
redirect_uri mismatched 400 kept here 500

Reading the source, this is stronger than precedence. newAuthorizeResponse runs at authorize.go:250, before any of client_id, code_challenge, scope or resource is read, and the early return discards whatever p.Errors already held. In rows two and three those failures are never detected at all, so kind() ranking failureCorruptRegistration first 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:

[erro] coderd: oauth2 app has an unusable registered callback URL
  request_id=01ca4796-c743-4923-a06b-afc1f3decc95
  app_id=12f9ca3b-7a2c-4c54-8ed7-723698c62bd1
  callback_url="javascript:alert(\"plat479-6d-marker\")"
  error= redirect URI uses dangerous scheme javascript which is not allowed:
         codersdk/oauth2_validation.go:116

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_id joins that line to the request log entry whose response_body field 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, naming validateScheme and its source line, which the response cannot do.

Restoring the row restores normal behaviour immediately, with no restart:

POST -> 302 with a code      GET -> 200 consent page

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_type sent error error_description
token unsupported_response_type Only response_type=code is supported
banana unsupported_response_type Only response_type=code is supported
code token unsupported_response_type Only response_type=code is supported
CODE unsupported_response_type Only response_type=code is supported
code_extra unsupported_response_type Only response_type=code is supported
empty string invalid_request Invalid query params: response_type: Query param 'response_type' is required and cannot be empty

token has a Go constant behind it and banana does not, so their agreement is what reading the value as text bought. Three rows are more interesting than banana: code token is a legal RFC 6749 §3.1.1 space-delimited list used by OIDC's hybrid flow, CODE confirms the comparison is case sensitive, and code_extra confirms 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=token is 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, no code_challenge sent error
token unsupported_response_type, zero mentions of code_challenge
banana unsupported_response_type, zero mentions of code_challenge
code invalid_request, naming code_challenge

Without the if params.responseType == responseTypeCode guard, a client sending token would be told its code_challenge was 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:

Unknown parameter Result
nonce, prompt, login_hint, acr_values, max_age, ui_locales code issued
code_challenge_methods=S256 code issued
REDIRECT_URI=http://evil.example/steal code issued, to the registered callback

Five 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 code and state.

The last two rows are the ones with teeth. Query parameter names are case sensitive, so REDIRECT_URI must be ignored, and a misspelled redirect_url likewise:

case variant alongside a valid redirect_uri : Location host localhost:9876
case variant as the only redirect parameter : Location host localhost:9876
redirect_url typo, no valid redirect_uri    : Location host localhost:9876, code issued
evil.example occurrences in any Location    : 0

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.example with a valid code attached.

Observation 5. The ignoring unrecognized authorization parameters line was never emitted. Three requests carrying a distinctive marker produced no new server output at all, because the call is logger.Debug (authorize.go:307) and the deployment runs with verbose: 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 ErrorExcessParams had to leave alone:

Repeated parameter HTTP error Answered at
code_challenge 302 invalid_request, two entries one field client callback
code_challenge_method 302 invalid_request client callback
response_type 302 invalid_request client callback
redirect_uri 400 withheld Coder
scope 302 invalid_request client callback
resource 302 invalid_target client callback

redirect_uri being withheld confirms the carve-out keys on the field name rather than the kind of mistake. code_challenge collects two entries for one mistake, because parseSingle collapses the value to empty and the PKCE block then reports it missing. The resource row 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:

resource sent error Stored resource_uri
not a uri invalid_target not stored
/api relative invalid_target not stored
https://api.example.com none, code issued as sent
https://api.example.com/v1?q=1 none, code issued as sent, query is not a fragment
urn:example:resource none, code issued as sent
empty string none, code issued NULL, same as omitted

invalid_target applies only when resource is the sole failure. Add any second failure in any other field and the code becomes invalid_request naming both, which is the retry-loop guard:

resource + bad code_challenge -> invalid_request, both named
resource + repeated scope     -> invalid_request, both named
resource + repeated state     -> invalid_request, both named
resource alone (control)      -> invalid_target

Fragments are rejected in all three placements, with or without a path:

https://api.example.com/#x         -> invalid_target
https://api.example.com#x          -> invalid_target
https://api.example.com/v1#frag    -> invalid_target
https://api.example.com/#          -> code issued, stored as https://api.example.com/#

The last row is observation 7, and it predates this PR. Go cannot represent the distinction:

https://a.example.com/    Fragment=""  String()="https://a.example.com/"
https://a.example.com/#   Fragment=""  String()="https://a.example.com/"
https://a.example.com/#x  Fragment="x" String()="https://a.example.com/#x"

url.Parse collapses "no fragment" and "empty fragment", so if u.Fragment != "" in validateResourceParameter (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 # reaches resource_uri and 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 validateResourceParameter is context in this diff rather than an added line; this PR added the invalid_target code for the failure.

24 and 25. The description cap and character set

The obvious probe does not exercise the cap: a 4000-character code_challenge produces 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.

Request error Description length Truncated Location length
code_challenge_method = 4000 chars invalid_request 2060 yes 2176
scope = 4000 chars invalid_scope 2060 yes 2174
code_challenge_method = 2000 chars invalid_request 2035 no 2147
code_challenge = 4000 chars invalid_request 116 no 234

2060 is exactly maxErrorDescription plus " (truncated)". Row three is the boundary control, so the cap fires on length rather than on the presence of echoed input. Measured from the Location header 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:

GET, long method : desc_len=2060 truncated=True
GET, long scope  : desc_len=2060 truncated=True

The character set is enforced independently, later, in errorURL:

input:       pl"ain\back<newline>tab<tab>end~unicode
description: unsupported code_challenge_method: pl'ainback tab end~ nicode
chars outside the RFC 6749 §4.1.2.1 set: []

" 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-reserved registers a callback carrying all four reserved names plus two ordinary ones, and DCR stores it unchanged:

http://localhost:9876/callback?tenant=acme&code=FAKECODE&error=stale&error_description=oldmsg&state=oldstate&keep=yes

Success response, no redirect_uri sent:

code  = coder_WXwBpvYR5u_...
keep  = yes
state = 516abeea9976119eb33ddc10f0ca04ed
tenant = acme

Failure response, same app:

error = invalid_request
error_description = Invalid query params: code_challenge: must be 43 to 128 characters...
keep  = yes
state = 3a17bdd75dfcf740bda36124cd161fb7
tenant = acme

Stale registered values in either response: FAKECODE 0, stale 0, oldmsg 0, oldstate 0.

Without the deletion step a registered error=stale would ride out on the success response, and a client reading error before code, the conventional order, would discard a valid authorization code. FAKECODE is the mirror image on a failure response.

The cancel link obeys the same rule, being built by the same withQuery path:

http://localhost:9876/callback?error=access_denied&error_description=The+resource+owner...&keep=yes&state=e15b5f0f...&tenant=acme

RFC 6749 §3.1.2 requires the registered query be retained when adding parameters, which is the tenant and keep half. 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-ci carries the allowlist coder:workspaces.access.

scope=template:update (outside)  -> error = invalid_scope
                                    'template:update': scope requests permissions beyond this app's allowed scopes
GET, same request                -> 302, 225 bytes, 0 Allow buttons, no consent page
scope=workspace:ssh (covered)    -> code issued, persisted scope workspace:ssh
GET, same request                -> 200, 5182 bytes, consent page naming the scope

workspace:ssh is 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 scopeFailureResponse rather than the field join, which is the third producer behind observation 1.

Declining consent, clicked in a real browser:

error             = access_denied
error_description = The resource owner or authorization server denied the request
state             = a8bd538b365b61f54586892c73968b80

The page carries exactly one href and it is the cancel link. The state matches, there is no code, and the code count for the app is unchanged, the link being a plain GET to the client's callback that never reaches ProcessAuthorize. The description is RFC 6749 §4.1.2.1's own definition of access_denied, word for word.

That shared construction is what makes the #nosec G203 annotation on CancelURI sound: the URL is injected as a trusted htmltemplate.URL, safe only because newAuthorizeResponse validated 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 GET can 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.

Case Status Body bytes Allow buttons Rendered
well formed 200 4928 1 consent page
bad code_challenge 302 263 0 redirect stub
two bad fields 302 319 0 redirect stub
mismatched redirect_uri 400 3814 0 400 error page
unparseable redirect_uri 400 3846 0 400 error page
repeated client_id 400 3846 0 400 error page
client_id in form body only 400 77 0 httpmw JSON, unstyled
{UPPERCASE} client_id 200 4940 1 consent page
repeated state 302 239 0 redirect stub
corrupt callback, blocked scheme 500 3346 0 500 error page
corrupt callback, unparseable 500 3346 0 500 error page
response_type=token 302 187 0 redirect stub
banana / code token / CODE 302 187 0 redirect stub
empty response_type 302 243 0 redirect stub
token, no PKCE 302 187 0 redirect stub
code, no PKCE 302 245 0 redirect stub
unknown params, valid request 200 4932 1 consent page
redirect_url typo, valid request 200 4932 1 consent page
resource malformed 302 214 0 redirect stub
resource valid absolute URI 200 4932 1 consent page
resource=.../# empty fragment 200 4932 1 consent page, observation 7
reserved params in registered callback 200 4973 1 consent page
scope outside allowlist 302 225 0 redirect stub
scope covered by allowlist 200 5182 1 consent page

The invariant: the page renders if and only if extractAuthorizeParams returned no failure, so no input asks the owner to approve a request the server has already refused. The evil.example rows 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 httpmw JSON, and low hundreds is Go's redirect stub, whose length tracks the Location it embeds. Every unsupported_response_type answer 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
for id in $(pg "SELECT id FROM oauth2_provider_apps WHERE name LIKE 'plat479%';"); do
  curl -s -X DELETE "$BASE_URL/api/v2/oauth2-provider/apps/$id" -H "$AUTH_HEADER" \
    -o /dev/null -w "$id -> %{http_code}\n"
done
pg "SELECT count(*) FROM oauth2_provider_apps WHERE name LIKE 'plat479%';"
./scripts/coder-dev.sh oauth2-provider dcr disable

All planted callback rows were restored before deletion, so no fixture was left holding a value DCR would refuse.

BobbyHo and others added 30 commits August 14, 2026 17:02
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
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.
Base automatically changed from plat479-4-consolidate-redirects to main September 2, 2026 19:43
@BobbyHo
BobbyHo marked this pull request as ready for review September 2, 2026 20:10

@dylanhuff-at-coder dylanhuff-at-coder 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.

nice overall lgtm, one small edge case that could be covered with a test that I noted

Comment thread coderd/oauth2provider/authorize.go
…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.
@BobbyHo
BobbyHo merged commit 68ca033 into main Sep 3, 2026
31 checks passed
@BobbyHo
BobbyHo deleted the plat479-5-deliver-invalid-request branch September 3, 2026 17:54
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 3, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants