feat: reject secret creation and fix reporting for public clients by BobbyHo · Pull Request #28097 · coder/coder · GitHub
Skip to content

feat: reject secret creation and fix reporting for public clients - #28097

Merged
BobbyHo merged 77 commits into
mainfrom
oauth2-public-clients-guards
Sep 1, 2026
Merged

feat: reject secret creation and fix reporting for public clients#28097
BobbyHo merged 77 commits into
mainfrom
oauth2-public-clients-guards

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

TL;DR

On top of #28047, closing the last two gaps in the stack split out of #27873. A public client can now register (#28046) and exchange a code (#28047), but the admin secrets API will still issue it a secret that authenticates nothing, and the registration responses still report an auth method the token endpoint will not accept.

PR What it does
#27712 Schema: app_secret_id becomes nullable and tokens gain an always-populated app_id, so ownership checks work without a secret row.
#28041 Accepts bare custom-scheme redirect URIs (vscode://, cursor://) that native apps actually register.
#28043 Derives and stores client_type (public vs confidential) from the requested token_endpoint_auth_method, and pins it across updates.
#28046 Registration issues no secret for a public client and returns no client_secret.
#28047 The token endpoint accepts the exchange without a client_secret. Discovery advertises none.
#28097 (this) The admin secrets API refuses to create a secret for a public client, and registration, read, and update report the auth method the token endpoint actually enforces.

A public client can only come into existence through dynamic client registration, which is off by default, so neither guard is reachable until DCR is enabled.

Where in the flow

  • POST /oauth2-provider/apps/{app}/secrets, the admin secrets API. The rest of the secret lifecycle is untouched: GET and DELETE are unchanged, and a confidential app behaves exactly as before.
  • The three RFC 7591 and 7592 responses that carry token_endpoint_auth_method: registration (POST), read (GET), and update (PUT). Only what is reported changes; the stored column is left as the client sent it.
  • Authorize, token exchange, refresh, and revocation are untouched.

What it satisfies

  • RFC 7591 §2: none means the client is public and "does not have a client secret", so the secrets API should not create one for it.
  • RFC 7591 §3.2.1: the server MUST return all registered metadata and MAY replace a requested value with a suitable one. Reporting the enforced method in place of a stored one that contradicts client_type is that substitution.
  • RFC 7592 §2.2: an update MUST include all metadata as returned by a previous registration, read, or update. Because clients echo back what Coder reports, reporting the enforced method is what lets a legacy row heal on the client's next PUT.
  • RFC 7591 §2 default: an app with no stored method reports client_secret_basic, which is both the RFC default and what ApplyDefaults substitutes on update.
  • OAuth 2.1 §3.2.1: the token endpoint enforces on client_type, so a reported method that disagrees with it tells a client to authenticate in a way the server will reject.

  • CreateAppSecret returns 400 for a public client before generating anything. The secret would authenticate nothing, since the token endpoint no longer checks one for a public client. Deleting it would be worse: oauth2_provider_app_tokens.app_secret_id is ON DELETE CASCADE, so removing a confidential app's secret takes its tokens with it, but a public client's tokens carry a NULL app_secret_id (feat(coderd): support public OAuth2 client tokens at the schema layer #27712) and survive. An admin who deleted the secret would think they had cut off the client when they had not.
  • reportedAuthMethod() replaces the raw stored value in all three registration responses. It returns the stored method when it agrees with client_type, none for a public app, and client_secret_basic otherwise.
  • The rows that can disagree are clients registered before feat: derive OAuth2 client type from token_endpoint_auth_method #28043 derived the type from the method: the method was stored as sent while the type was always confidential. Such a client holds a secret its exchange still requires, while GET reported none and told it to drop that secret.
  • The stored column is deliberately not rewritten, so the response can still differ from the row. That divergence is what a read-modify-write client resolves on its next update, and the test asserts the row keeps what the client sent.
  • Coverage: the secrets guard asserts the 400 and that no partial secret row was left behind. A table test walks GET then PUT over six stored-method and client-type pairs: the legacy mismatch in both resend shapes, the reverse mismatch that registration cannot produce but the function still handles, an empty stored method, and the single pair that already agrees and must be reported as stored.
  • Docs: the client type is fixed at registration and a type-changing update is rejected with invalid_client_metadata (behavior from feat: derive OAuth2 client type from token_endpoint_auth_method #28043 that was never written down), plus what a legacy none client now sees.

Refs https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client

Manual Tests

Verified by hand against a local dev deployment (v2.36.3-devel+c85e4621ef, dev Postgres), in addition to the automated suite.

Both guards are reachable only through dynamic client registration, which was confirmed off by default on this deployment and enabled for the run. Two of the scenarios need a row where client_type and token_endpoint_auth_method disagree, which registration cannot produce since #28043 derives one from the other, so those rows were seeded by updating the method column directly on a purpose-built client.

# Scenario Result
1 Secrets API refuses a public client, with the guard's own message Pass
2 No partial secret row is left behind by the rejection Pass
3 A confidential app's secret create, list, and delete are unchanged Pass
4 Admin-created apps are always confidential, so the guard is unreachable there Pass
5 When stored method and client type agree, the stored value is reported unchanged Pass
6 A legacy confidential row storing none reports client_secret_basic Pass
7 The reported method is the one the token endpoint enforces, in both directions Pass
8 An update resending the stored value is accepted and leaves the row alone Pass
9 An update resending the reported value heals the row Pass
10 The reverse mismatch, a public row storing a secret method, reports none Pass
11 An empty or unrecognized stored method falls back to client_secret_basic Pass
12 Type-changing updates are rejected both ways, client_secret_basic to client_secret_post is allowed Pass
13 Regression sweep of the merged stack, discovery through revocation Pass
14 Every claim in the new docs paragraphs matches observed behavior Pass

No correctness defects found. Commands and captured output for each scenario below.

Two notes for anyone re-running this.

The previous runbook for this stack asserts the opposite of scenario 12: it flips a confidential client to public with a PUT and records 200. That was correct before #28043 pinned the client type. The 400 here is the fix, not a regression.

Generate the PKCE verifier with enough entropy that stripping =+/ still leaves 43 characters. The openssl rand -base64 32 | tr -d "=+/" | cut -c -43 recipe yields 38 to 43, so most runs are rejected by the token endpoint under RFC 7636 section 4.1 with an error that looks unrelated. Same trap noted in #28045.

Shell helpers used throughout
export BASE_URL=http://localhost:3000
export AUTH_HEADER="Coder-Session-Token: $(cat ./.coderv2/session)"
export PGURL="postgres://coder@localhost:$(cat ./.coderv2/postgres/port)/coder?sslmode=disable&password=$(cat ./.coderv2/postgres/password)"

# Enable DCR; both guards are unreachable without it.
curl -s -X PUT "$BASE_URL/api/v2/oauth2-provider/settings" -H "$AUTH_HEADER" \
  -H "Content-Type: application/json" \
  -d '{"dynamic_client_registration_enabled": true}'

# Print "HTTP <code>" then the body.
show() {
  local out; out=$(curl -s -w '\n%{http_code}' "$@")
  echo "HTTP $(printf '%s\n' "$out" | tail -n1)"
  printf '%s\n' "$out" | sed '$d' | jq . 2>/dev/null || printf '%s\n' "$out" | sed '$d'
}

# $1=name, $2=auth method ("" to omit the field).
register() {
  local body
  if [ -z "$2" ]; then
    body=$(jq -nc --arg n "$1" '{client_name:$n,redirect_uris:["http://localhost:9876/callback"]}')
  else
    body=$(jq -nc --arg n "$1" --arg m "$2" '{client_name:$n,redirect_uris:["http://localhost:9876/callback"],token_endpoint_auth_method:$m}')
  fi
  curl -s -X POST "$BASE_URL/oauth2/register" -H "Content-Type: application/json" -d "$body"
}

# The stored row, as opposed to what the API reports.
stored() {
  psql "$PGURL" -At -c "select client_type || ' | ' || coalesce(token_endpoint_auth_method,'<NULL>')
    from oauth2_provider_apps where id = '$1';"
}

# 43 characters from the unreserved set, always. See the note above.
gen_verifier() { openssl rand -base64 96 | tr -d "=+/\n" | cut -c -43; }
challenge_for() { printf '%s' "$1" | openssl dgst -sha256 -binary | base64 | tr -d "=" | tr '+/' '-_'; }

# Full PKCE authorize plus exchange. $1=client_id, $2=client_secret ("" for none).
exchange() {
  local verifier challenge redirect code args
  verifier=$(gen_verifier); challenge=$(challenge_for "$verifier")
  redirect=$(curl -s -X POST \
    "$BASE_URL/oauth2/authorize?client_id=$1&response_type=code&redirect_uri=http://localhost:9876/callback&state=$(openssl rand -hex 16)&code_challenge=$challenge&code_challenge_method=S256" \
    -H "$AUTH_HEADER" -w '\n%{redirect_url}' -o /dev/null)
  code=$(printf '%s' "$redirect" | grep -oE 'code=[^&]+' | sed 's/code=//')
  args=(-d "grant_type=authorization_code" -d "code=$code" -d "client_id=$1"
        -d "redirect_uri=http://localhost:9876/callback" -d "code_verifier=$verifier")
  [ -n "$2" ] && args+=(-d "client_secret=$2")
  curl -s -X POST "$BASE_URL/oauth2/tokens" -H "Content-Type: application/x-www-form-urlencoded" "${args[@]}"
}
1. Secrets API refuses a public client

A bare non-201 would also be produced by a request that failed on authentication, routing, or a malformed UUID before reaching the guard, so the status and the message are both asserted.

PUB=$(register manual-28097-public none)
PUB_ID=$(echo "$PUB" | jq -r '.client_id')
stored "$PUB_ID"
show -X POST "$BASE_URL/api/v2/oauth2-provider/apps/$PUB_ID/secrets" -H "$AUTH_HEADER"
public | none
HTTP 400
{
  "message": "Cannot create a client secret for a public OAuth2 app.",
  "detail": "Public clients authenticate with PKCE and have no client secret. The client type is fixed at registration, so register a new confidential client instead."
}

The message is the guard's own, so execution reached CreateAppSecret and returned at the IsPublic() check. The detail points at registering a new confidential client, which scenario 12 confirms is the only remedy.

2. No partial secret row is left behind

The guard returns before GenerateSecret(), so nothing should exist at either layer. The count was already 0 at registration, and a confidential app is queried the same way as a control, so 0 is not merely what this query always returns.

curl -s "$BASE_URL/api/v2/oauth2-provider/apps/$PUB_ID/secrets" -H "$AUTH_HEADER" | jq -c .
psql "$PGURL" -At -c "select count(*) from oauth2_provider_app_secrets where app_id = '$PUB_ID';"
psql "$PGURL" -At -c "select count(*) from oauth2_provider_app_secrets where app_id = '$CONF_ID';"
[]
0
1
3. A confidential app's secret lifecycle is unchanged
CONF=$(register manual-28097-confidential "")
CONF_ID=$(echo "$CONF" | jq -r '.client_id')
show -X POST "$BASE_URL/api/v2/oauth2-provider/apps/$CONF_ID/secrets" -H "$AUTH_HEADER"
curl -s "$BASE_URL/api/v2/oauth2-provider/apps/$CONF_ID/secrets" -H "$AUTH_HEADER" | jq -c '[.[].id]'
curl -s -X DELETE "$BASE_URL/api/v2/oauth2-provider/apps/$CONF_ID/secrets/$SECRET_ID" \
  -H "$AUTH_HEADER" -o /dev/null -w "delete: HTTP %{http_code}\n"
HTTP 201
{
  "id": "da7a1029-47c4-435e-988a-557ffa4aeacf",
  "client_secret_full": "coder_POUtoElODM_<redacted>"
}
["b2fc55b6-...","da7a1029-...","24f8f8c2-..."]
delete: HTTP 204
["b2fc55b6-...","da7a1029-..."]

Create, list, and delete all behave as before. The guard keys on client_type and nothing else on this path changed.

Unrelated observation, not a finding against this PR: the listing returns two different client_secret_truncated formats. Secrets issued at registration are asterisk-padded (***...TzqEOt, from createDisplaySecret), while secrets issued through the admin API show the bare last six characters (Rixs60). Both write the same column and surface through the same field. Cosmetic and pre-existing, but a UI listing both kinds together would render them inconsistently.

4. Admin-created apps are always confidential

postOAuth2ProviderApp hardcodes the client type, so the guard is unreachable through the admin create path.

ADMIN_APP=$(curl -s -X POST "$BASE_URL/api/v2/oauth2-provider/apps" -H "$AUTH_HEADER" \
  -H "Content-Type: application/json" \
  -d '{"name":"manual-28097-admin","callback_url":"http://localhost:9876/callback"}')
ADMIN_ID=$(echo "$ADMIN_APP" | jq -r '.id')
stored "$ADMIN_ID"
show -X POST "$BASE_URL/api/v2/oauth2-provider/apps/$ADMIN_ID/secrets" -H "$AUTH_HEADER"
confidential | client_secret_post
HTTP 201
{
  "id": "a77ab276-ea74-4711-ba8b-955a8cb97951",
  "client_secret_full": "coder_eGlt04LhYg_<redacted>"
}

Together with scenario 1 this brackets the guard: it fires for a dynamically registered public client and for nothing else. Note the admin API stores client_secret_post, not the RFC 7591 section 2 default, so every admin-created app is an agreement case for scenario 5. Such apps carry no registration access token, so they cannot reach the RFC 7592 endpoints where the reporting change applies at all.

5. Agreement cases report the stored value unchanged

The client_secret_post case is the one that matters. An implementation that substituted the type default on every call would still pass the public case, since none is the public default, but would rewrite this client to client_secret_basic and tell it to send its secret in the wrong place.

curl -s "$BASE_URL/oauth2/clients/$PUB_ID" -H "Authorization: Bearer $PUB_RAT" \
  | jq -c '{reported: .token_endpoint_auth_method}'

POST_REG=$(register manual-28097-post client_secret_post)
POST_ID=$(echo "$POST_REG" | jq -r '.client_id')
echo "$POST_REG" | jq -c '{reported_on_register: .token_endpoint_auth_method}'
curl -s "$BASE_URL/oauth2/clients/$POST_ID" -H "Authorization: Bearer $POST_RAT" \
  | jq -c '.token_endpoint_auth_method'
stored "$POST_ID"
{"reported":"none"}
{"reported_on_register":"client_secret_post"}
"client_secret_post"
confidential | client_secret_post

Registration and read both report the stored value. The update site is covered by scenarios 8 and 9.

6. A legacy confidential row storing `none` reports `client_secret_basic`

Seeded from a real confidential client so it genuinely holds the secret its exchange requires, with only the method column rewritten. This is the pre-#28043 shape and cannot be produced through the API any more.

LEGACY=$(register manual-28097-legacy "")
LEGACY_ID=$(echo "$LEGACY" | jq -r '.client_id')
LEGACY_SECRET=$(echo "$LEGACY" | jq -r '.client_secret')
stored "$LEGACY_ID"

psql "$PGURL" -c "update oauth2_provider_apps set token_endpoint_auth_method = 'none'
  where id = '$LEGACY_ID';"
stored "$LEGACY_ID"
psql "$PGURL" -At -c "select count(*) from oauth2_provider_app_secrets where app_id = '$LEGACY_ID';"

curl -s "$BASE_URL/oauth2/clients/$LEGACY_ID" -H "Authorization: Bearer $LEGACY_RAT" \
  | jq -c '{reported: .token_endpoint_auth_method}'
stored "$LEGACY_ID"
confidential | client_secret_basic
UPDATE 1
confidential | none
1
{"reported":"client_secret_basic"}
confidential | none

The response substitutes and the row is deliberately not rewritten. A read has no side effects.

7. The reported method is the one actually enforced

The token endpoint enforces on client_type, which is still confidential, so the secret is required regardless of what the method column says.

exchange "$LEGACY_ID" "$LEGACY_SECRET" | jq -c '{has_access: has("access_token"), error, error_description}'
exchange "$LEGACY_ID" ""               | jq -c '{has_access: has("access_token"), error, error_description}'
{"has_access":true,"error":null,"error_description":null}
{"has_access":false,"error":"invalid_request","error_description":"Missing required parameter: client_secret"}

The second line is the pre-fix breakage reproduced. A client that read "token_endpoint_auth_method": "none" from GET and dropped its secret accordingly, which is what RFC 7592 tells it to do, would have hit exactly this on its next exchange. The old response was not internally inconsistent, it was actionable and wrong.

Scenario 10 shows the same property in the opposite direction.

8. An update resending the stored value is accepted and changes nothing
show -X PUT "$BASE_URL/oauth2/clients/$LEGACY_ID" \
  -H "Authorization: Bearer $LEGACY_RAT" -H "Content-Type: application/json" \
  -d '{"client_name":"manual-28097-legacy","redirect_uris":["http://localhost:9876/callback"],"token_endpoint_auth_method":"none"}'
stored "$LEGACY_ID"
HTTP 200
  "token_endpoint_auth_method": "client_secret_basic",
confidential | none

Three things at once. The update is accepted, which is why the type-change guard requires the auth method to actually change: the requested none implies a public client while the row is confidential, so a guard keyed on the type mismatch alone would reject this and lock a legacy client out of managing its own registration. The PUT reports the same substituted value as the GET, covering the third call site. The row keeps what the client sent, so the divergence persists until scenario 9.

9. An update resending the reported value heals the row
show -X PUT "$BASE_URL/oauth2/clients/$LEGACY_ID" \
  -H "Authorization: Bearer $LEGACY_RAT" -H "Content-Type: application/json" \
  -d '{"client_name":"manual-28097-legacy","redirect_uris":["http://localhost:9876/callback"],"token_endpoint_auth_method":"client_secret_basic"}'
stored "$LEGACY_ID"
curl -s "$BASE_URL/oauth2/clients/$LEGACY_ID" -H "Authorization: Bearer $LEGACY_RAT" \
  | jq -c '{reported: .token_endpoint_auth_method}'
exchange "$LEGACY_ID" "$LEGACY_SECRET" | jq -c '{has_access: has("access_token")}'
HTTP 200
  "token_endpoint_auth_method": "client_secret_basic",
confidential | client_secret_basic
{"reported":"client_secret_basic"}
{"has_access":true}

The row healed through an ordinary read-modify-write cycle, with no migration, backfill, or admin action. That works only because scenario 8 keeps the door open and this response gives the client a correct value to echo back; remove either and the row stays inconsistent indefinitely.

10. The reverse mismatch reports `none`

Registration cannot produce this direction either, but reportedAuthMethod branches on the general disagreement rather than the one shape known to exist, so it is covered.

REVERSE=$(register manual-28097-reverse none)
REVERSE_ID=$(echo "$REVERSE" | jq -r '.client_id')
psql "$PGURL" -c "update oauth2_provider_apps set token_endpoint_auth_method = 'client_secret_basic'
  where id = '$REVERSE_ID';"
stored "$REVERSE_ID"
curl -s "$BASE_URL/oauth2/clients/$REVERSE_ID" -H "Authorization: Bearer $REVERSE_RAT" \
  | jq -c '{reported: .token_endpoint_auth_method}'
exchange "$REVERSE_ID" "" | jq -c '{has_access: has("access_token"), error}'
public | client_secret_basic
{"reported":"none"}
{"has_access":true,"error":null}

This client has no secret row at all and the token endpoint does not ask for one, so none is what the response should say. Reporting the stored client_secret_basic would have instructed it to send a secret that does not exist.

Both resend shapes behave symmetrically with scenarios 8 and 9:

resend stored (client_secret_basic) -> reported "none", row unchanged
resend reported (none)              -> reported "none", row now public | none
11. Empty or unrecognized stored methods fall back to the default

NULL and '' are indistinguishable once read into sql.NullString. A third case was added because the function gates on validity rather than emptiness.

for v in "null" "''" "'private_key_jwt'"; do
  psql "$PGURL" -At -c "update oauth2_provider_apps set token_endpoint_auth_method = $v where id = '$CONF_ID';"
  stored "$CONF_ID"
  curl -s "$BASE_URL/oauth2/clients/$CONF_ID" -H "Authorization: Bearer $CONF_RAT" \
    | jq -c '.token_endpoint_auth_method'
done
confidential | <NULL>
"client_secret_basic"
confidential |
"client_secret_basic"
confidential | private_key_jwt
"client_secret_basic"

private_key_jwt is a real RFC 7591 method that Coder does not implement. Reporting it back would advertise an authentication scheme the token endpoint cannot honor, which is the same class of problem as the legacy none report in scenario 7.

12. Type-changing updates are rejected both ways
show -X PUT "$BASE_URL/oauth2/clients/$CONF_ID" \
  -H "Authorization: Bearer $CONF_RAT" -H "Content-Type: application/json" \
  -d '{"client_name":"manual-28097-confidential","redirect_uris":["http://localhost:9876/callback"],"token_endpoint_auth_method":"none"}'
stored "$CONF_ID"

show -X PUT "$BASE_URL/oauth2/clients/$PUB_ID" \
  -H "Authorization: Bearer $PUB_RAT" -H "Content-Type: application/json" \
  -d '{"client_name":"manual-28097-public","redirect_uris":["http://localhost:9876/callback"],"token_endpoint_auth_method":"client_secret_basic"}'
stored "$PUB_ID"

show -X PUT "$BASE_URL/oauth2/clients/$CONF_ID" \
  -H "Authorization: Bearer $CONF_RAT" -H "Content-Type: application/json" \
  -d '{"client_name":"manual-28097-confidential","redirect_uris":["http://localhost:9876/callback"],"token_endpoint_auth_method":"client_secret_post"}'
stored "$CONF_ID"
HTTP 400
{
  "error": "invalid_client_metadata",
  "error_description": "token_endpoint_auth_method cannot move an existing client between public and confidential (stored \"client_secret_basic\", requested \"none\"); the client type is fixed at registration, so register a new client instead"
}
confidential | client_secret_basic

HTTP 400
{
  "error": "invalid_client_metadata",
  "error_description": "token_endpoint_auth_method cannot move an existing client between public and confidential (stored \"none\", requested \"client_secret_basic\"); the client type is fixed at registration, so register a new client instead"
}
public | none

HTTP 200
  "token_endpoint_auth_method": "client_secret_post",
confidential | client_secret_post

Both rejections leave the row and its secrets untouched. A partial application would be the dangerous outcome here, a client left confidential while its caller believes it went public.

The third case is the control. Two 400s alone would be equally consistent with a guard that rejects every token_endpoint_auth_method change, a considerably more disruptive rule than the documented one. The 200 pins the guard to the derived client type rather than the method string.

The second case is also what backs scenario 1. If public to confidential were permitted, the workaround to a refused secret request would be to flip the type and ask again. The two guards close that loop, which is why the same remedy appears in both error messages.

13. Regression sweep of the merged stack

Nothing here is new in this PR, but the guards sit on top of it.

discovery token_endpoint_auth_methods_supported : ["client_secret_basic","client_secret_post","none"]
public registration, client_secret key present  : false
confidential registration, secret issued        : true, method defaulted to client_secret_basic
PKCE-only exchange, no client_secret sent       : access + refresh issued
access token against /api/v2/users/me           : 200
token row app_id / app_secret_id                : populated / NULL
RFC 7009 revoke of own refresh token            : 200, access token then 401
authorized-apps listing while a token is live   : [{"name":"manual-28097-public"}]
bulk DELETE /oauth2/tokens                      : 204, listing then []

The NULL app_secret_id is what makes the secrets guard necessary. That column is ON DELETE CASCADE, so deleting a confidential app's secret takes its tokens with it, while a public client's tokens would survive. An admin who deleted such a secret would believe access was cut off when it was not.

PKCE failure modes were checked separately, since the token endpoint distinguishes two the previous runbook treated as one:

verifier omitted                     : invalid_request, RFC 7636 section 4.1 bounds
well-formed 43 chars but wrong       : invalid_grant, "The PKCE code verifier is invalid"
correct verifier, replaying that code: invalid_grant, "invalid or expired"

The third confirms the failed check destroys the code, so a leaked code cannot absorb repeated verifier guesses.

14. The new docs paragraphs match observed behavior

pnpm run lint-docs reports 0 errors across 501 files, pnpm run format-docs rewrites nothing, and Vale reports 0 errors with 2 warnings, both on pre-existing gerund headings that the same file on main also produces. The new prose is one sentence per line.

Documented claim Verified by
A client's type is fixed when it registers 12
A type-changing update is rejected with invalid_client_metadata 12, both directions
The client either holds a secret that would stop being required or has none and no way to be issued one 12 (2 secrets), 1 and 12 (0 secrets, issuance refused)
Switching between client_secret_basic and client_secret_post is allowed 12
To change type, register a new client matches the remedy in both errors and in scenario 1's detail
Legacy none clients are stored as confidential and still require their client_secret 6, 7
Coder reports client_secret_basic for those clients 6
So that what it reports matches what it enforces 7, both directions
The mismatch clears itself the next time the client updates its registration 9

One wording note. Scenario 8 shows the mismatch does not clear on an update that resends the stored value, so strictly it clears on the next update carrying the reported value. A read-modify-write client, which is what RFC 7592 section 2.2 prescribes, always carries the reported value, so the sentence holds for the client it describes. A client that hardcodes its own metadata would not self-heal, but it would also not be following RFC 7592.

Cleanup
for id in "$PUB_ID" "$CONF_ID" "$POST_ID" "$LEGACY_ID" "$REVERSE_ID" "$ADMIN_ID"; do
  curl -s -X DELETE "$BASE_URL/api/v2/oauth2-provider/apps/$id" -H "$AUTH_HEADER" \
    -o /dev/null -w "%{http_code}\n"
done
curl -s -X PUT "$BASE_URL/api/v2/oauth2-provider/settings" -H "$AUTH_HEADER" \
  -H "Content-Type: application/json" -d '{"dynamic_client_registration_enabled": false}'
show -X POST "$BASE_URL/oauth2/register" -H "Content-Type: application/json" \
  -d '{"client_name":"should-fail","redirect_uris":["http://localhost:9876/callback"],"token_endpoint_auth_method":"none"}'
204 (x6)
{"dynamic_client_registration_enabled":false}
HTTP 403
{"error":"invalid_request","error_description":"Dynamic client registration is disabled on this deployment"}

All six test apps deleted with no orphaned secret or token rows. Both seeded rows had already healed to a consistent state before deletion. DCR is back off, confirmed by a registration attempt rather than by reading the flag back.

BobbyHo and others added 16 commits August 10, 2026 12:51
…length floor

The token endpoint accepted any non-empty code_verifier, so a client
could authenticate with a one-character verifier. RFC 7636 §4.1 sets a
43 to 128 character floor over the unreserved character set. The
challenge travels in the authorization request URL and the code
travels in the redirect, both of which land in browser history,
referrer headers, and proxy logs, so an attacker holding those
brute-forces the verifier offline at whatever entropy the client
chose, with no server-side rate limit. A one-character verifier is a
one-character password, and the server should refuse it rather than
accept whatever the client picked.

ValidPKCEVerifier enforces the length and charset bounds before the
existing S256 comparison runs. The existing TestOAuth2InvalidPKCE test
already exercises a 14-character verifier end to end and continues to
pass, now rejected on length rather than on hash mismatch.
…ngth

tr -d "=+/" deleted every '+' and '/' character that happened to appear
in the base64 output instead of translating them to the URL-safe
alphabet, so cut -c -43 truncated a string that was often already
short. Roughly 70% of runs produced a verifier below the 43-character
floor coderd/oauth2provider now enforces (#28003), so the manual and
scripted OAuth2 flows these scripts drive failed token exchange
intermittently.

Use tr '+/' '-_' | tr -d '=' instead: translating first and then
stripping the single padding character is deterministic, since 32
random bytes always base64-encode to a fixed length. This always
yields exactly 43 characters, so the cut is no longer needed.
extractAuthorizeParams only checked code_challenge for non-emptiness, so
a malformed value (wrong length, disallowed characters, an arbitrarily
large blob) was persisted verbatim and only surfaced as a failure at
token exchange, with an error that misleadingly names code_verifier
instead of the parameter that was actually invalid.

RFC 7636 gives code_verifier and code_challenge the same ABNF, so reuse
the existing bounds check rather than adding a second one: rename
ValidPKCEVerifier to ValidPKCEFormat and validate code_challenge against
it in extractAuthorizeParams, rejecting a malformed value with
invalid_request at the authorization request per RFC 7636 §4.4.1.

TestExtractAuthorizeParams_Scopes used a 14-character placeholder
code_challenge that the new check now correctly rejects; lengthened it
to a valid value since that test only exercises scope parsing.
…_verifier

A malformed code_verifier (wrong length or disallowed characters) and a
well-formed verifier that simply fails the PKCE hash comparison both
returned the same error: invalid_grant, "The PKCE code verifier is
invalid." A client that sent a too-short verifier had no way to tell
that apart from a genuine hash mismatch, would re-check its SHA-256
computation, find nothing wrong, and retry the same bad verifier
indefinitely since invalid_grant conventionally signals "retry."

RFC 6749 §5.2 assigns a malformed parameter to invalid_request; RFC 7636
§4.6 reserves invalid_grant for the comparison failure specifically. Move
the code_verifier format check out of authorizationCodeGrant and into
extractTokenRequest, which already owns syntax validation for this grant
type, so the two failure modes return distinct, spec-accurate errors.

Several existing tests sent an empty or placeholder code_verifier
incidental to what they were actually testing (client_secret
requirements, scope parsing, malformed-code handling); updated them to
use a valid-length value so they still reach the behavior under test.
…f PKCE hash mismatch

InvalidCodeVerifier ("wrong-verifier", 14 chars) was rejected on length
before VerifyPKCE ever ran, so no test exercised the token endpoint's
hash-comparison branch end to end; TestVerifyPKCE unit-tests the
function, but nothing proved the endpoint still calls it.

Lengthen InvalidCodeVerifier to a well-formed but wrong 43-character
value so it again reaches the hash comparison. Add MalformedCodeVerifier
and a new test asserting the length-rejection path returns
invalid_request, now that the previous commit gives it a distinct error
from the hash-mismatch invalid_grant case.
The code was deleted only inside the success-path transaction, so every
PKCE rejection (errInvalidPKCE) left it live in the database. RFC 6749
§10.5 requires authorization codes to be single-use; without that, an
attacker holding a leaked code (the exact threat PKCE defends against,
since codes and challenges land in browser history, referrer headers, and
proxy logs) could retry the token endpoint with different code_verifier
guesses for the entire 10-minute code lifetime, unthrottled. The
43-character length floor bounds guess format, not entropy.

Add revokeOAuth2CodeOnPKCEFailure, called from both PKCE rejection paths
in authorizationCodeGrant. It deletes the code using the same system
authz context already used for reads in this function; a deletion
failure is noted on the request's log line rather than changing the
response, since surfacing it as a different error would let a caller
distinguish delete success from failure, itself a new oracle.

Added TestOAuth2PKCEFailureConsumesCode to verify the code is
unredeemable, even with the correct verifier, once a PKCE mismatch has
occurred.
Tighten the ValidPKCEFormat doc comment and correct a false claim
(CRF-8, CRF-10). The rationale restated the same threat model across
three separate rhetorical framings, and claimed PKCE is the only
client authentication some clients have, which is false today since
authorizationCodeGrant validates a client secret before PKCE ever
runs; that claim only becomes true once #27873 adds public clients.
Trim the paragraph to a single concrete why and note the caveat.

Delete four boundary-case comments in pkce_test.go (CRF-9). Each one
restated the case name and the strings.Repeat literal beside it; the
RFC provenance already lives on ValidPKCEFormat's doc comment and the
pkceVerifierMinLength/pkceVerifierMaxLength constants, so the comments
carried no information and would drift if either constant changed.

Replace an em-dash with a comma in a comment inside the block this
PR's PKCE-failure handling touches (CRF-2), per the repo's no-emdash
rule. It survived lint because the check scans only changed lines by
default, and this comment was pre-existing context rather than a line
this PR added.

Fix the PKCE example in docs/admin/integrations/oauth2-provider.md
(CRF-7). tr -d "=+/" deleted reserved base64 characters instead of
translating them to the URL-safe alphabet, so the example computed a
code_challenge that failed to verify roughly 74% of the time. Also
strip the newline openssl base64 inserts at its default 64-column
wrap, which the 96-byte verifier example crosses; the prior
cut -c1-128 never merged the wrapped lines back together either.
The PKCE Flow section showed how to generate a code_verifier and
code_challenge but never stated the bound now enforced server-side:
43 to 128 characters from the unreserved set [A-Za-z0-9-._~] (RFC
7636 §4.1). A value outside these bounds returns invalid_request,
at the token endpoint for code_verifier and at the authorization
endpoint for code_challenge.
isValidCustomScheme required a literal "." in the scheme for a public
client's redirect URI, so vscode://, jetbrains://, and cursor:// all
400'd while the identical schemes passed for a confidential client
through the separate, more permissive validateScheme. Native and CLI
apps, the population public clients exist for, register those exact
schemes with their OS.

Removed the extra restriction: validateScheme already blocks the
schemes that are actually dangerous in a redirect context, and RFC
8252 section 7.1 only recommends reverse-domain notation rather than
requiring it. PKCE, not the scheme's spelling, is what secures a
public client's redirect.

That removal also stopped rejecting mailto, tel, and sms for public
clients specifically, since validateScheme's dangerous-scheme
blocklist never covered them either. Those three hand off to a mail
client, dialer, or SMS app rather than returning control to the
client, so unlike vscode:// or jetbrains://, none of them can deliver
an authorization code. A public client's redirect URI scheme is its
only mechanism for regaining control, so they are rejected again here,
scoped specifically to public clients rather than folded into
validateScheme's blocklist, since they are harmless for a confidential
client's redirect.
…/tel/sms scope, not an invented one

The previous comment claimed mailto, tel, and sms are harmless for a
confidential client's redirect specifically. That is not true: the
client_secret only matters at token exchange, not at redirect
delivery, so nothing about being confidential changes what happens
when the browser is sent to one of these schemes. The actual reason
they are checked only in the isPublicClient branch is that
custom-scheme validation was already scoped there before this PR;
confidential clients were never subject to any scheme-shape check
here, independent of any judgment about these three schemes.
Split out of #27873 to make that PR smaller to review. Second in the
stack; adds the vocabulary the rest of the public-client work is built
on, with no behavioral change beyond what it stores.

RFC 7591 §2 / OAuth 2.1 §2.1 define two client types: a confidential
client authenticates with a secret, a public client authenticates with
PKCE alone. DetermineClientType() previously hardcoded "confidential"
regardless of the requested token_endpoint_auth_method. It now derives
the type via the new ClientTypeFor() mapping, which is the single owner
of the auth-method-to-client-type relationship: registration derives
the stored client_type from it, and redirect URI validation uses it to
pick which RFC 8252 rules apply, so the two cannot disagree about what
"public" means.

OAuth2ProviderApp.IsPublic() is the reader for the stored client_type
column, added alongside matching database constants so the value
registration writes and the value IsPublic reads back cannot drift.
An unset or unrecognized client type reads as confidential, so an app
can never skip client authentication by accident.

AllOAuth2TokenEndpointAuthMethods() is the single source Valid() reads
from, so what registration accepts is defined in one place. Discovery
metadata does not yet derive from it and still hardcodes its own list
without "none"; a follow-up PR wires the token endpoint to honor
"none", and only then should discovery advertise it too.

registration.go and app registration itself do not yet skip secret
issuance for a public client; that follows in the next PR in the
stack.
Split out of #27873 to make that PR smaller to review. First in the
stack; the rest of the public-client work builds on this.

`isValidCustomScheme` required a literal `.` in the scheme for a public
client's redirect URI, so `vscode://`, `jetbrains://`, and `cursor://`
all 400'd while the identical schemes passed for a confidential client
through the separate, more permissive `validateScheme`. Native and CLI
apps, the population public clients exist for, register those exact
schemes with their OS.

Removed the extra restriction: `validateScheme` already blocks the
schemes that are actually dangerous in a redirect context, and RFC 8252
section 7.1 only recommends reverse-domain notation rather than
requiring it. PKCE, not the scheme's spelling, is what secures a public
client's redirect.

That removal also stopped rejecting `mailto`, `tel`, and `sms` for
public clients specifically, since `validateScheme`'s dangerous-scheme
blocklist never covered them either. Those three hand off to a mail
client, dialer, or SMS app rather than returning control to the
application that started the flow, so a public client registered with
one of them could never actually complete authorization. They are
rejected again here, scoped to public clients only because that is how
custom-scheme validation was already scoped before this change, not
because they are known to be safe for a confidential client's redirect;
confidential clients were never subject to any scheme-shape check beyond
`validateScheme` and remain so here.

Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
UpdateClientConfiguration wrote ClientType: string(req.DetermineClientType())
on every PUT, recomputed from the request instead of read from storage.
ApplyDefaults() fills an omitted token_endpoint_auth_method with
client_secret_basic, so a public client's PUT that only touched an
unrelated field (e.g. redirect_uris) silently converted it to
confidential, since DetermineClientType() can now return "public" where
it previously always returned "confidential".

A client's type is fixed at registration; RFC 7592 §2.2 permits
rejecting metadata the server will not accept. UpdateClientConfiguration
now rejects a PUT that would move a client between public and
confidential with 400 invalid_client_metadata, and carries the stored
client_type through verbatim rather than re-deriving it. A legacy row
whose stored client_type and token_endpoint_auth_method already
disagree can still manage itself, as long as the update does not also
ask to change the auth method.

ClientTypeFor(), extracted as its own function in the previous commit,
had exactly one caller and no second one materialized, so it is
inlined back into DetermineClientType().
…-vocabulary

Resolves a conflict in coderd/oauth2provider/tokens.go: #28003 hardened
revokeOAuth2CodeOnPKCEFailure on main (detaches the delete from the
request context, treats sql.ErrNoRows as non-error) after this branch's
own copy of that function predated the hardening. Took main's version
in full; this branch made no independent edits to it.
Split out of #27873 to make that PR smaller to review. Third in the
stack; this is the point where dynamic client registration actually
produces a public client.

An RFC 7591 registration requesting token_endpoint_auth_method: "none"
now skips secret generation entirely: no secret is minted, and the app
is persisted with the client_type the previous PR in the stack derives
from that auth method.

Discovery does not yet advertise "none" as a supported method.
AdvertisedOAuth2TokenEndpointAuthMethods() excludes it until the token
endpoint actually accepts a public client's exchange, in the next PR in
the stack; advertising it earlier would tell a conforming client the
server accepts an exchange it will reject.

Registration now writes the app and its secret in one transaction. They
were two independently committed inserts, so a failure of the second
left a permanently committed app that can never authenticate while
still holding a registration access token. Pre-existing, but making a
public client's "no secret row" a legitimate state removes the ability
to spot the orphaned confidential case by inspection later, so it is
fixed here alongside the rest of this change.

The registration_client_uri now uses url.JoinPath instead of
fmt.Sprintf, fixing a latent bug where an access URL configured with a
trailing slash would mint "//oauth2/clients/{id}" as the client's
management endpoint. Pinned with a regression test against a
trailing-slash access URL.

The public-client redirect URI documentation is corrected to match
validateRedirectURIs: https is allowed for both client types, the
loopback list was incomplete, and the confidential-client restriction
was misstated.

RegisterPublicClient, a test helper for registering a public client end
to end, is exercised in this PR instead of landing unexercised for a
later PR to discover a bug in.

The token endpoint does not yet accept a public client's PKCE-only
exchange; that follows in the next PR in the stack, so a client
registered here cannot yet obtain a token.
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

BobbyHo and others added 5 commits August 13, 2026 07:53
Split out of #27873 to make that PR smaller to review. Fourth in the
stack; this is the half that makes the public client registered by the
previous PR in the stack actually able to obtain a token.

The token endpoint no longer requires a client_secret for a public
client: extractTokenRequest skips the client_secret presence check, and
authorizationCodeGrant skips secret validation entirely for a public
client, since it has none. PKCE was already mandatory for every
authorization_code flow, so public clients inherit it with no new
validation code. That makes the code ownership check (dbCode.AppID !=
app.ID) the only binding between the exchange and the app named by
client_id for a public client, where it was defense in depth for
confidential ones. It is retained and now covered with a public client
on both sides.

Issued tokens for a public client carry a NULL app_secret_id rather
than referencing a secret row that does not exist. The refresh and
revocation paths already verify ownership directly via app_id rather
than joining through app_secret_id, so they need no code change, only
updated comments and coverage confirming they handle a NULL
app_secret_id correctly.

Refs https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
RFC 6749 §2.3 defines client authentication as proving client identity,
and §3.2.1 says a public client is not authenticated. RFC 7636 §1 casts
PKCE as a mitigation against authorization code interception, binding
the token request to the entity that started authorization. Calling it
"client authentication" pointed readers at the opposite of what the RFCs
say, so the swagger annotation, the admin docs page, and the comments in
tokens.go now call it proof of possession.

The docs page also dropped "shorter values are rejected", which named
one of the three RFC 7636 §4.1 failure modes and left a caller who sent
a 200-character verifier expecting it to pass.

extractTokenRequest's godoc claimed IsPublic was the only reader of
ClientType; registration.go and apps.go read it too. It is the only
decision-making reader, which is what keeps the confidential/public
branch in one place.
…tests

TestOAuth2ProviderTokenExchangePublicClientCodeBelongsToDifferentApp ran
the PKCE probes its name did not advertise, so it is now
TestOAuth2ProviderPublicClientTokenExchange, covering both checks that
stand in for a client secret: code ownership and PKCE. The empty
code_verifier case is gone; it hits the same length branch as the
one-character case, which strictly dominates it, and pkce_test.go tables
both at the unit level.

TestOAuth2PublicClientTokenLifecycle ran a full register, authorize,
exchange, refresh, and cross-app revoke for each of its two table rows,
which differ only in which string reaches RevokeOAuth2Token. That setup
moves to refreshedPublicClientSession, leaving each row with the revoke
and the session check that actually diverge.

Comments no longer anchor on "this PR" or "currently", and the comment
above assertSecretlessToken no longer implies the raw-DB read proves the
token authenticates; the session probe is what proves that. Literal 43s
use pkceVerifierMinLength, and the public-client-with-secret case cites
RFC 7591 §2 and OAuth 2.1 §2.1 rather than RFC 6749 §2.3.1, which does
not authorize public clients to send credentials.
…s it

extractTokenRequest derived isPublic at the top of the function but read
it only inside the authorization_code branch, so a reader hitting the
derivation had to carry it past the form parsing, the Basic auth merge,
and the required-parameter checks before learning what it decides.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-token-exchange branch from b6d1a46 to 240db54 Compare August 15, 2026 16:40
… the branch that reads it

The confidential branch now records appSecretID directly instead of leaving
the fetched secret in function scope for a second !isPublic branch inside the
insert transaction to read.
…o the trap

Both guard sites keep a one-line warning against joining through
app_secret_id. The rationale itself lives on the app_id column comment.
Name the referenced test instead of anchoring by position, drop the PR-context
narration and the ordinal claim, and rename the lifecycle test into the
TestOAuth2Provider* bucket its sibling already uses.
The token endpoint accepts a PKCE-only exchange from a public client as of
this branch, but token_endpoint_auth_methods_supported still omitted "none",
so a client doing RFC 8414 discovery could not learn that secretless
registration was available.

AdvertisedOAuth2TokenEndpointAuthMethods now returns everything registration
accepts. It stays separate from AllOAuth2TokenEndpointAuthMethods because the
two bound different things: what Valid() enforces on a registration request,
versus what the token endpoint honors at exchange time.

The existing ElementsMatch assertion compares the handler against the
function, so it holds for whatever the function returns. Added a Contains
assertion against the literal value, which is what fails if "none" stops
being advertised.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-guards branch from dcac72d to ee449cb Compare August 17, 2026 03:52
Only the first entry in redirect_uris is enforced. Registration stores
the full list and echoes it back, but every enforcement path parses
app.CallbackURL, which registration sets to redirect_uris[0]. Telling
readers to register every URI the client will use describes a remedy
the server does not honor.

Exact matching is already stated under Standards Compliance, so the
accurate half of the sentence is covered there.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-guards branch from ed0dc66 to 9627868 Compare August 22, 2026 17:08
@BobbyHo

BobbyHo commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 (panel). Both R1 findings addressed. CRF-1: TestClientConfiguration_ReportedAuthMethod seeds five legacy-shaped rows and asserts the reported method from GET and PUT response bodies plus the persisted row; removing the normalization fails at least three subtests. CRF-2: the redirect-URI paragraph is gone, and the commit body correctly notes the sentence was also factually off, since only redirect_uris[0] is enforced downstream. Both fixes are at the right layer.

Mafuuu, Kurapika, Razor, Ryosuke, Ging-go, Komugi, Robin, Hisoka, Zoro: no findings. Load-bearing invariant (token endpoint reads client_type, reporting must match that) holds end to end; no new attack surface, no dead code, no concurrency issues.

Severity: 2 P3, 5 Nit, 4 Note. No P0-P2.

CRF-3 is the sharpest new finding: TestClientConfiguration_ReportedAuthMethod's table never seeds client_secret_post, so short-circuiting the helper's "return stored" branch to always false still passes every subtest, and a regression that collapsed the helper to a two-value switch would silently rewrite every confidential client_secret_post row to client_secret_basic in the reported response. One extra row closes it.

CRF-4 is a small operator-experience gap that the same PR already solved once: the sibling error at registration.go:359 ends with "register a new client instead," and the new rejection at app_secrets.go:66 stops one clause short.

CRF-6 is a mechanical PR-body slip: an em dash sits between "(RFC 7591 §2)" and "deleting" in the description bullet. AGENTS.md bans emdashes/endashes/spaced double hyphens; the rest of the change (code, comments, docs, commits) is clean, so the PR body is the last outstanding place. Replace with a period.

The four Notes are structural observations, not fix-me items: CRF-10 (self-healing doc claim is stronger than the tests can prove), CRF-11 (a write-side normalization would collapse "stored" and "reported" back to one truth, out of scope but worth naming), CRF-12 (the "public rows carry no secrets" invariant is enforced by handler guards, not the schema; a CHECK or partial index would make it unbypassable), and CRF-13 (the admin UI still shows the "Generate secret" button for public clients since codersdk.OAuth2ProviderApp has no client_type field, so the API guard turns any operator click into a guaranteed 400 toast).

On Luffy: "OI THIS IS GREAT! Two real bugs, both about 'what does the operator see vs. what does the server do.' No new config, no new notifications, no new abstraction anyone has to learn."

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/oauth2provider/registration_test.go
Comment thread coderd/oauth2provider/app_secrets.go Outdated
Comment thread coderd/oauth2_test.go Outdated
Comment thread coderd/oauth2provider/app_secrets.go Outdated
Comment thread coderd/oauth2_test.go Outdated
Comment thread coderd/oauth2provider/registration_test.go Outdated
Comment thread coderd/oauth2provider/registration.go Outdated
Comment thread coderd/oauth2provider/registration.go
Comment thread coderd/oauth2provider/app_secrets.go
Comment thread coderd/oauth2provider/app_secrets.go Outdated
BobbyHo added 11 commits August 23, 2026 16:12
TestClientConfiguration_ReportedAuthMethod seeded no confidential row
storing client_secret_post, the only stored value whose reported method
differs from the client type default. Every case passed whether
reportedAuthMethod returned the stored method or switched on client type
alone, so collapsing it to a two-value switch would have silently
reported client_secret_basic for those rows and shipped green.

Verified by short-circuiting the "return stored" branch: the whole
coderd/oauth2provider package passed before this case and fails on it
after.
The rejection stated the constraint but not the next step, while the
sibling client type error in registration.go already ends with "register
a new client instead". Match it.

RejectsPublicClient asserted only require.Error, which the client returns
for any non-201, so a routing or middleware failure that never reached
the guard passed too. Pin the status code and the reason instead.
The handler comment, its test comment, and the test godoc each restated
the rationale that belongs on the code they cover. Keep the reasoning at
one site and let the others say what they verify.

Also soften the self-healing claim on reportedAuthMethod: the row heals
only when a client echoes back the reported method, which is what the
resending-reported cases show, not a guarantee for a client that resends
the stored value.
Remove the function headers from the public-client token exchange and
client-secret requirement tests, whose names and subtest names already say
what they cover, and cut the inline explanations to the facts that are not
in the assertion messages.

Comments only, no behavior change.
The two call sites explained the same code_verifier length floor at
different lengths.

Comments only, no behavior change.
…tests

Remove the headers from the client type and reported auth method tests,
whose names and table case names already say what they cover, and cut the
case comments to what those names cannot carry. The reason the fixtures are
seeded through dbgen moves to the call it explains.

Comments only, no behavior change.
Base automatically changed from oauth2-public-clients-token-exchange to main August 31, 2026 16:56
Take main's wording for the token_endpoint_auth_methods_supported
paragraph. Both sides added it: the branch carries the version from
the token-exchange branch, and main carries the reviewed version from
that PR's squash merge (#28047).
@BobbyHo
BobbyHo marked this pull request as ready for review August 31, 2026 21:36

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

Two small non blocking comments

Comment thread docs/admin/integrations/oauth2-provider.md Outdated
Comment thread coderd/oauth2provider/app_secrets.go
…smatch clears

A legacy client stored as confidential with token_endpoint_auth_method
"none" only repairs its row when it sends back the method GET reports.
Resending the stored "none" is accepted and leaves the mismatch in
place, so the update alone is not enough to clear it.
Creating a secret for a public client is rejected, but the generated
reference listed only the 200 response, so integrators had no documented
explanation for the failure. Annotate the endpoint and regenerate.
@BobbyHo
BobbyHo merged commit 20a41c0 into main Sep 1, 2026
31 checks passed
@BobbyHo
BobbyHo deleted the oauth2-public-clients-guards branch September 1, 2026 15:58
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 1, 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