feat: accept PKCE-only token exchange for public clients by BobbyHo · Pull Request #28047 · coder/coder · GitHub
Skip to content

feat: accept PKCE-only token exchange for public clients - #28047

Merged
BobbyHo merged 66 commits into
mainfrom
oauth2-public-clients-token-exchange
Aug 31, 2026
Merged

feat: accept PKCE-only token exchange for public clients#28047
BobbyHo merged 66 commits into
mainfrom
oauth2-public-clients-token-exchange

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Last of the stack, split out of #27873, that makes Coder usable by public OAuth2 clients (CLIs, IDE plugins, MCP clients), which cannot hold a secret and authenticate with PKCE alone.

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 (this) The token endpoint accepts the exchange without a client_secret, so a public client can finally get a token. Discovery now advertises none.

Each of the earlier PRs is inert on its own: until this one, a registered public client still could not complete a flow. Dynamic client registration is off by default, so nothing here is user-visible until it is enabled.

Where in the flow

  • Token endpoint only — the authorization_code exchange. Authorize, consent, and code issuance are untouched.
  • Refresh and revocation come along for free: both already bind by app_id, so a secretless token row works as-is.
  • Discovery starts advertising none.

What it satisfies

  • OAuth 2.1 §3.2.1 — only confidential clients must authenticate at the token endpoint. A public client is no longer rejected for a missing secret.
  • OAuth 2.1 §4.1.3 — for a public client the server must instead ensure the code was issued to the request's client_id. That check already existed; here it becomes the sole binding.
  • OAuth 2.1 §4.1.3 — client_id is required when the client does not authenticate, and a code yields a token at most once: a failed PKCE comparison consumes the code, so a leaked one cannot be brute-forced.
  • OAuth 2.1 §2.1 + RFC 7591 §2 — none means public client with no secret. The type is derived at registration (feat: derive OAuth2 client type from token_endpoint_auth_method #28043) and read here.
  • RFC 7636 §4.1 / §4.6 — a malformed verifier is invalid_request, a wrong one invalid_grant. PKCE was already mandatory for every code flow, so public clients inherit it.
  • RFC 8414 §2 — advertised auth methods now match what the token endpoint actually accepts.
  • RFC 7009 — a cross-app revoke of a public client's token still returns 200 without revoking.

  • The token endpoint stops requiring client_secret for a public client, in both the presence check and secret validation. PKCE was already mandatory for every authorization_code flow, so public clients inherit it with no new validation code.
  • The code ownership check (dbCode.AppID != app.ID) becomes 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. Retained, now covered with a public client on both sides.
  • Public client tokens carry a NULL app_secret_id rather than referencing a secret row that does not exist. Refresh and revocation already verify ownership via app_id, so they change only comments and coverage.
  • Discovery advertises none now that the token endpoint honors it.
  • Valid() derives from the single canonical auth method list, so what registration accepts and what discovery advertises cannot disagree.
  • Swagger marks client_secret confidential-only.

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

…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.
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Docs preview

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

@BobbyHo BobbyHo changed the title feat(coderd/oauth2provider): accept PKCE-only token exchange for public clients feat: accept PKCE-only token exchange for public clients Aug 12, 2026
…/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.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-registration branch from 7c8d3e5 to 800fda7 Compare August 12, 2026 01:33
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-token-exchange branch from be1eaf3 to b4ab1ba Compare August 12, 2026 01:33
BobbyHo and others added 4 commits August 11, 2026 21:54
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().
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-registration branch 2 times, most recently from ff8d29f to 4bd1e82 Compare August 12, 2026 19:58
…-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.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-registration branch from 4bd1e82 to cbdf0bc Compare August 12, 2026 22:06
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.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-registration branch from cbdf0bc to 7c124e7 Compare August 12, 2026 22:09
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-token-exchange branch from b4ab1ba to e19d7ea Compare August 13, 2026 00:08
@BobbyHo

BobbyHo commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-08-21 23:38 UTC by @BobbyHo

Review history
  • R1 (2026-08-13): 19 reviewers, 8 Nit, 5 Note, 1 P2, COMMENT. Review
  • R2 (2026-08-15): 11 reviewers, 12 Nit, 5 Note, 5 P2, 1 P3, COMMENT. Review
  • R3 (2026-08-21): 12 reviewers, 16 Nit, 5 Note, 6 P2, 1 P3, COMMENT. Review
  • R4 (2026-08-22): 9 reviewers, 17 Nit, 5 Note, 6 P2, 1 P3, COMMENT. Review

deep-review v0.9.0 | Round 4 | 610f321..8255649

Last posted: Round 4, 29 findings (6 P2, 1 P3, 17 Nit, 5 Note), COMMENT. Review

Finding inventory

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Author fixed (aca591d) coderd/oauth2_test.go:1148 Godoc anchors on "this PR", stale after merge R1 Gon Yes
CRF-2 Nit Author fixed (aca591d) coderd/oauth2_test.go:675 "currently sits outside the if !isPublic block" bakes today's layout into the comment R1 Gon Yes
CRF-3 Nit Author fixed (aca591d) coderd/oauth2_test.go:1218 "Confirm the precondition this test exists for" narrates author reasoning R1 Gon Yes
CRF-4 Nit Author fixed (d93704d) coderd/oauth2provider/tokens.go:44 extractTokenRequest doc: "only reader" over-claim + revive-lint rationale R1 Gon, Leorio Yes
CRF-5 Nit Author fixed (aca591d) coderd/oauth2provider/tokens_internal_test.go:525 Magic 43 in strings.Repeat; use pkceVerifierMinLength (same package) R1 Gon Yes
CRF-6 Nit Author fixed (aca591d) coderd/oauth2_test.go:620 Test name promises code-ownership but also runs the PKCE lifecycle R1 Bisky Yes
CRF-7 Nit Author fixed (aca591d) coderd/oauth2_test.go:686 Empty and 1-char verifier probes exercise the same length branch R1 Bisky Yes
CRF-8 Note Author fixed (aca591d) coderd/oauth2_test.go:1161 Table runs the full setup twice; only the revoke target differs R1 Bisky Yes
CRF-9 Note Author fixed (aca591d, 577538b) coderd/oauth2provider/tokens.go:103 Public client that sends client_secret (form or Basic) is silently accepted R1 Kite, Knov Yes
CRF-10 Nit Author fixed (d93704d) docs/admin/integrations/oauth2-provider.md:243 "shorter values are rejected" names one of three RFC 7636 §4.1 failure modes R1 Leorio Yes
CRF-11 Nit Author fixed (d93704d) docs/admin/integrations/oauth2-provider.md:242 "client authentication" for PKCE mixes RFC 6749 §2.3 (identity) with RFC 7636 §1 (interception) R1 Knov Yes
CRF-12 Note Author fixed (d93704d) coderd/oauth2provider/tokens.go:332 dbCode.AppID != app.ID is now load-bearing for public clients; add a one-line comment to deter symmetry-driven refactors R1 Kite Yes
CRF-13 Note Author fixed (aca591d) coderd/oauth2_test.go:1221 assertSecretlessToken discards keySecret; direct-DB assertion is weaker than it looks R1 Kite Yes
CRF-14 Note Author fixed (240db54) coderd/oauth2provider/tokens.go:48 isPublic derived at parser top but only used inside the authorization_code branch R1 Ryosuke Yes
CRF-15 Nit Author fixed (577538b) commit message / PR title Commit scope coderd/oauth2provider does not contain every changed file R1 Razor Yes (in body)
CRF-16 Nit Author fixed (b5457af) coderd/oauth2_test.go:620 Positional anchor "the public-client counterpart to the test above" R2 Gon, Leorio Yes
CRF-17 Nit Author fixed (b5457af) coderd/oauth2_test.go:1144 "the first with a NULL app_secret_id" bakes ordinal state into godoc R2 Gon, Leorio Yes
CRF-18 Nit Author fixed (b5457af) coderd/oauth2_test.go:1186 "the claim app_id was promoted for" implies a promotion that didn't happen in this PR R2 Gon Yes
CRF-19 P2 Author fixed (b5457af) coderd/oauth2_test.go:1143 Lifecycle godoc paragraph 2 re-narrates confidential-client mechanism as PR-context R2 Gon Yes
CRF-20 P2 Author fixed (b5457af) coderd/oauth2_test.go:674 PKCE preamble in test reproduces tokens.go rationale and doubles maintenance surface R2 Gon Yes
CRF-21 P2 Author fixed (29ec017) coderd/oauth2provider/revoke.go:142 Ownership comment bloat + rationale duplicated at sibling site (AGENTS.md fact-in-one-place) R2 Gon Yes
CRF-22 P2 Author fixed (29ec017) coderd/oauth2provider/revoke.go:199 Same bloat pattern as revoke.go:142, rationale duplicated a second time R2 Gon Yes
CRF-23 P3 Author fixed (12e1cab) coderd/oauth2provider/tokens.go:281 authorizationCodeGrant hoists dbSecret + isPublic to outer scope; two !isPublic branches ~170 lines apart coupled only through dbSecret courier R2 Ryosuke P3, Zoro P3, Meruem Nit Yes
CRF-24 Nit Author fixed (b5457af) coderd/oauth2_test.go:1152 TestOAuth2PublicClientTokenLifecycle breaks the TestOAuth2Provider* naming pattern R2 Zoro Yes
CRF-25 P3 Author fixed (02a7495) codersdk/oauth2.go:310 OAuth2ClientType godoc still promises "A follow-up PR wires the token endpoint to read it", but this PR is the follow-up R3 Leorio P3, Gon Nit, Mafuuu Nit, Pariston Nit Yes (in body, outside diff)
CRF-26 P2 Author fixed (02a7495, 05c0580) codersdk/oauth2.go:287 AdvertisedOAuth2TokenEndpointAuthMethods godoc restates the same rationale twice; underlying Advertised ⊆ HonoredByTokenEndpoint invariant is prose-only, not enforced R3 Gon P2, Meruem Note Yes
CRF-27 Nit Author fixed (aada1ca) coderd/oauth2provider/tokens.go:278 Positional anchors "below" and "further down" in the new client-secret block comment; sibling instance of CRF-2/CRF-16 class R3 Gon Yes
CRF-28 Nit Author fixed (aada1ca) coderd/oauth2provider/tokens.go:333 Positional anchor "the confidential-client branch above" in the code-ownership check comment R3 Gon Yes
CRF-29 Nit Author fixed (09a9cbe) coderd/oauth2.go:152 Swagger code_verifier description "This is a public client's only proof of possession" reads as scope restriction in an OpenAPI parameter description R3 Gon Yes
CRF-30 Nit Author fixed (aada1ca) coderd/oauth2provider/tokens.go:44 extractTokenRequest godoc claims "keeping the confidential/public branch in one place" but the diff adds two branches at two call sites R3 Zoro Yes
CRF-31 Nit Open coderd/oauth2_test.go:690 Three positional "above"/"below" anchors in the new public-client tests (lines 690, 697, 1241); sibling class of CRF-16/CRF-27/CRF-28 R4 Gon Yes
CRF-32 Nit Open PR description Bullet six promises swagger "documents code_verifier as a public client's only proof of possession", but 09a9cbe (CRF-29 fix) deleted exactly that phrasing R4 Leorio (raised P3, downgraded to Nit for process-artifact surface per gate) Yes (in body, no source line)

Contested and acknowledged

(No entries yet.)

Round log

Round 1

Panel. Netero-only pre-pass returned "No findings," so the panel ran the same round. 19 panel reviewers: Bisky, Chopper, Ging-Go, Gon, Hisoka, Kite, Knov, Knuckle, Komugi, Kurapika, Leorio, Mafu-san, Mafuuu, Meruem, Pariston, Razor, Ryosuke, Zoro (wildcard), Luffy (wildcard). Chopper, Ging-Go, Hisoka, Knuckle, Komugi, Kurapika, Mafu-san, Mafuuu, Meruem, Pariston, Zoro, Luffy returned no findings. 1 P2, 8 Nit, 6 Note. Reviewed against 7c124e7..e19d7ea.

Round 2

Churn guard: PROCEED. All 15 R1 findings marked Author fixed across commits aca591d, d93704d, 240db54, 577538b. Reviewed against 7c124e7..240db54.

Panel round. Netero returned "No findings." 11 panel reviewers: Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Gon, Leorio, Meruem, Kurapika, Ryosuke, Zoro (wildcard). Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Kurapika returned no findings. 1 P3, 4 P2, 4 Nit new. Convergent structural finding (CRF-23) from Ryosuke P3, Zoro P3, Meruem Nit; higher wins per tiebreaker.

Round 3

Churn guard: PROCEED. All 9 R2 findings marked Author fixed across commits 12e1cab (CRF-23), 29ec017 (CRF-21, CRF-22), b5457af (CRF-16 through CRF-20, CRF-24). Author raised framing points on CRF-23 severity (argues the app_secret_id FK constraint already turns the "silent dangling FK" case into a loud insert failure; coupling/readability was the real ground for the fix) and on CRF-22 alternative (rejected cross-reference in favor of one-line duplication because trap warnings should fire at the mistake site; noted canonical home for the trap is the app_id column comment from #27712). Both fixes still landed. Base moved from 7c124e7 to 903d7b7 (rebase + merged in the two prior stack PRs). Reviewed against 903d7b7..c5b4461. New production surface beyond R2 fixes: codersdk/oauth2.go (Valid() derives from canonical auth-method list), coderd/oauth2provider/metadata_test.go (discovery advertises none), several docs edits, and a refactor(coderd/oauth2provider): name the client type change conjuncts commit. Panel must review this net-new surface.

Panel round. Netero returned "No findings." 12 panel reviewers: Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Gon, Leorio, Meruem, Melody, Knov, Chopper, Zoro (wildcard). Bisky, Hisoka, Mafu-san, Melody, Knov, Chopper returned no findings. 1 P3, 2 P2, 4 Nit new. Convergent CRF-25 from Leorio P3, Gon Nit, Mafuuu Nit, Pariston Nit; higher wins per tiebreaker (Leorio's public-SDK-impact evidence sets the floor). CRF-26 combines Gon P2 (doc redundancy) with Meruem Note (unenforced invariant); posted at P2 with both concerns in body.

Round 4

Churn guard: PROCEED. All 6 R3 findings marked Author fixed across commits 02a7495 (CRF-25 godoc, CRF-26 docstring), 05c0580 (CRF-26 test literal), aada1ca (CRF-27, CRF-28, CRF-30), 09a9cbe (CRF-29 swagger + regenerated files). Author raised substantive framing on three findings: CRF-26 alternative test (argued Meruem's subset test would be vacuous today since Advertised = All; instead pinned the advertised set to a literal {ClientSecretBasic, ClientSecretPost, None}, which catches the drift direction that matters); CRF-30 wording (dropped "IsPublic the only reader of ClientType" superlative because codersdk/oauth2_validation.go:152 and registration.go:76 also branch on client type, so the R1 CRF-4 replacement claim was still checkably false); CRF-25 wording (rejected OAuth2ProviderApp.IsPublic() name because it is a coderd/database symbol unreachable from an SDK docstring). All fixes still landed. Base moved from 903d7b7 to 610f321. Reviewed against 610f321..8255649.

Panel round. Netero returned "No findings." 9 panel reviewers: Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Gon, Leorio, Meruem, Zoro (wildcard). Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Meruem, Zoro returned no findings; Hisoka, Mafu-san, Pariston with positive attestations. 0 P3, 0 P2, 2 Nit new. Leorio raised the PR-body drift at P3; downgraded to Nit by the orchestrator (PR body is process artifact, not shipped code; calibrates with R1 CRF-15 Nit for the commit-metadata class).

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

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

The PR does exactly what it claims and no more. The three production edits (skip client_secret presence, skip the secret block, mint NULL app_secret_id) each have a test that would fail without that edit, and the refresh/revoke paths that the PR asserts "already verify ownership via app_id" are now exercised end-to-end against a token row with AppSecretID.Valid == false. RFC citations sit at the exact lines they enforce (RFC 7591 §2, OAuth 2.1 §2.1, RFC 6749 §5.2 and §10.5, RFC 7636 §4.1 and §4.6, RFC 7009). The load-bearing shift, dbCode.AppID != app.ID moving from defense in depth to the sole binding between the exchange and the app named by client_id, is pinned with two public apps sharing a redirect URI, which is the exact shape the check has to hold in.

One fun quote from the panel, on the error handling: "Every new error message in this repo should read like this. This is the bar." (Leorio)

Findings: 1 P2, 8 Nit, 6 Note. Nothing structural, nothing blocking; the P2 is a godoc anchor and the notes are mostly about comment precision and one signal-hiding hazard around public clients that mis-send client_secret.

Process note (no inline anchor for a commit-metadata concern): the commit and PR scope coderd/oauth2provider does not contain coderd/oauth2.go, coderd/apidoc/*, docs/admin/integrations/oauth2-provider.md, or docs/reference/api/enterprise.md. AGENTS.md requires the scope to be a real filesystem path containing every changed file, or omitted for cross-cutting changes. Drop the scope or broaden it (coderd,docs) on the next amend.

Nothing here needs a re-review round; the notes and nits can travel with the merge or the next fix-up.


coderd/oauth2provider/tokens.go:332

Note [CRF-12] Consider a one-line comment on this check so a future reader doesn't refactor it into the !isPublic block by symmetry. (Kite)

For confidential clients, dbSecret.AppID != app.ID above pre-binds client_id to a known secret before this line runs; for a public client, client_id reaches authorizationCodeGrant completely unauthenticated (parsed from the form) and this single line is the entire binding between the exchange and the app named by client_id. The rationale comment at tokens.go:278-281 names this shift, but the load-bearing line itself carries only "same reason as the secret check above," which reads like defense in depth even when it is the last line of defense for public clients. A one-liner such as // For public clients this is the only binding between client_id and the code; do not move into the !isPublic block above. would prevent a plausible "cleanup" that quietly deletes the sole public-client bind. TestOAuth2ProviderTokenExchangePublicClientCodeBelongsToDifferentApp defends this regression; the comment makes the defense visible in the code.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/oauth2_test.go Outdated
Comment thread coderd/oauth2_test.go Outdated
Comment thread coderd/oauth2_test.go Outdated
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/oauth2provider/tokens_internal_test.go Outdated
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread docs/admin/integrations/oauth2-provider.md Outdated
Comment thread docs/admin/integrations/oauth2-provider.md Outdated
Comment thread coderd/oauth2_test.go Outdated
Comment thread coderd/oauth2provider/tokens.go Outdated

BobbyHo commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

BobbyHo added a commit that referenced this pull request Aug 22, 2026
Fourth in the stack splitting up #27873 (public OAuth2 clients), on top
of #28047. Closes two remaining gaps beyond registration and the token
endpoint: the admin/API surface for managing client secrets, and what
auth method Coder reports back to a client whose stored method and
client_type disagree.

CreateAppSecret now rejects minting a secret for a public client
(RFC 7591 §2, OAuth 2.1 §2.1: a public client authenticates with PKCE
alone). Without this, an operator could create a secret the token
endpoint never validates, and deleting it would look like a kill
switch while revoking nothing, since a public client's tokens carry a
NULL app_secret_id.

reportedAuthMethod() normalizes what token_endpoint_auth_method
CreateDynamicClientRegistration, GetClientConfiguration, and
UpdateClientConfiguration report back for a client whose stored method
and client_type disagree. This only arises for clients registered
before client_type was derived from the method: such a row is stored
confidential with a method of "none", and reporting "none" verbatim
would tell the client to drop a secret its exchange still requires.
Reporting the enforced behavior instead means the client's next PUT
repairs the mismatch on its own.
@BobbyHo
BobbyHo marked this pull request as ready for review August 22, 2026 17:10
@jdomeracki-coder
jdomeracki-coder self-requested a review August 24, 2026 18:24
aslilac pushed a commit that referenced this pull request Aug 24, 2026
Third in the stack, split out of #27873. This is where dynamic client
registration first produces a public client.

- A registration requesting token_endpoint_auth_method `none` issues no
secret and persists the client_type derived in #28043. The response
omits client_secret entirely, per RFC 7591 §3.2.1.
- Discovery still advertises only the two secret-based methods. `none`
is withheld until the token endpoint honors it, so a conforming client
is not told to attempt an exchange that would be rejected.
- The app and its secret are written in one transaction. They were two
independent inserts, so a failed second one left a committed app that
could never authenticate while still holding a registration access
token. Pre-existing, but a public client's missing secret row makes the
orphaned case unspottable by inspection.
- registration_client_uri uses url.JoinPath, fixing a trailing-slash
access URL producing //oauth2/clients/{id}.
- Docs state both limits above, so the page does not describe a flow
that returns 400 until #28047 lands.

A client registered here cannot obtain a token yet; #28047 adds that.
Dynamic client registration is off by default, so none of this is
user-visible until then.

Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
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.
The field is not gated on Dynamic Client Registration, so advertising
none does not mean a client can register a public client. Point at
registration_endpoint, which is gated, for that.
@BobbyHo
BobbyHo requested review from a team and Emyrk August 26, 2026 22:58
@hwang251
hwang251 requested review from a team and snagles and removed request for a team and Emyrk August 26, 2026 23:39
@BobbyHo
BobbyHo requested a review from Emyrk August 31, 2026 01:16
@BobbyHo
BobbyHo merged commit 9be61a0 into main Aug 31, 2026
31 checks passed
@BobbyHo
BobbyHo deleted the oauth2-public-clients-token-exchange branch August 31, 2026 16:56
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 31, 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