Skip to content

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

Draft
BobbyHo wants to merge 7 commits into
oauth2-public-clients-registrationfrom
oauth2-public-clients-token-exchange
Draft

feat: accept PKCE-only token exchange for public clients#28047
BobbyHo wants to merge 7 commits into
oauth2-public-clients-registrationfrom
oauth2-public-clients-token-exchange

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Split out of #27873 to make that PR smaller to review. Fourth in the stack (on top of #28046); 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

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

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
@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
BobbyHo force-pushed the oauth2-public-clients-registration branch 4 times, most recently 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-15 16:52 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

deep-review v0.9.0 | Round 2 | 7c124e7..240db54

Last posted: Round 2, 23 findings (5 P2, 1 P3, 12 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 Open coderd/oauth2_test.go:620 Positional anchor "the public-client counterpart to the test above" R2 Gon, Leorio Yes
CRF-17 Nit Open 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 Open 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 Open coderd/oauth2_test.go:1143 Lifecycle godoc paragraph 2 re-narrates confidential-client mechanism as PR-context R2 Gon Yes
CRF-20 P2 Open coderd/oauth2_test.go:674 PKCE preamble in test reproduces tokens.go rationale and doubles maintenance surface R2 Gon Yes
CRF-21 P2 Open 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 Open coderd/oauth2provider/revoke.go:199 Same bloat pattern as revoke.go:142, rationale duplicated a second time R2 Gon Yes
CRF-23 P3 Open 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 Open coderd/oauth2_test.go:1152 TestOAuth2PublicClientTokenLifecycle breaks the TestOAuth2Provider* naming pattern R2 Zoro Yes

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.

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

BobbyHo commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Round 1 responses. All 15 findings are addressed across four commits; the branch was force-pushed to correct one commit message, so the SHAs below are the current ones.

# Resolution Commit
CRF-1 Comment no longer anchors on "this PR". aca591d
CRF-2 "currently sits outside the if !isPublic block" rewritten so it states the requirement rather than today's layout. aca591d
CRF-3 Author-reasoning narration dropped; the helper name carries it. aca591d
CRF-4 Godoc no longer claims IsPublic is the only reader of ClientType (registration.go and apps.go read it too) and no longer cites the revive rule. It now says sole decision-making reader. d93704d
CRF-5 strings.Repeat uses pkceVerifierMinLength. aca591d
CRF-6 Renamed to TestOAuth2ProviderPublicClientTokenExchange, which covers both stand-ins for a client secret: code ownership and PKCE. aca591d
CRF-7 Empty-verifier probe removed; the one-character case dominates it, and pkce_test.go tables both at the unit level. aca591d
CRF-8 Shared setup extracted to refreshedPublicClientSession; each row keeps only the revoke and the session check that diverge. aca591d
CRF-9 Working as intended, now stated rather than implied. A public client has no secret to check (RFC 7591 §2, OAuth 2.1 §2.1), so one sent anyway is ignored, not rejected; PKCE and the code's own app_id still bind the exchange. Covered by PublicClientWithSecretIsAccepted. 577538b, comment in aca591d
CRF-10 "shorter values are rejected" dropped; it named one of three RFC 7636 §4.1 failure modes and misled a caller sending a 200-character verifier. d93704d
CRF-11 Swagger annotation, admin docs, and tokens.go comments now call PKCE proof of possession rather than client authentication. d93704d
CRF-12 dbCode.AppID != app.ID carries a comment stating it is the only client_id binding for a public client and must stay outside the !isPublic block. d93704d
CRF-13 Comment above assertSecretlessToken no longer implies the raw-DB read proves the token authenticates; the session probe is what proves that. aca591d
CRF-14 isPublic derivation removed; the authorization_code branch reads app.IsPublic() at the point of use. 240db54
CRF-15 feat(coderd/oauth2provider): was wrong for a commit that also touches coderd/oauth2.go, coderd/apidoc/, and docs/. Reworded to unscoped feat: and force-pushed. 577538b

BobbyHo commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

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

R2 verifies R1 cleanly. All 15 R1 findings landed at the root cause across aca591d, d93704d, 240db54, and 577538b; the churn guard classified every finding as Author fixed and spot checks confirm. Kurapika, Bisky, Hisoka, Mafuuu, Mafu-san, and Pariston returned no findings. The commit graph is now honestly scoped (refactor(coderd/oauth2provider):, test(coderd):, unscoped feat: and docs: for the cross-cutting commits); CRF-15 was internalized, not just applied once.

New findings: 1 P3, 4 P2, 4 Nit. Two patterns worth naming.

  1. The R1 CRF-14 sweep landed in extractTokenRequest but skipped the sibling function. authorizationCodeGrant still hoists isPublic and dbSecret to outer scope only so a second if !isPublic block ~170 lines away inside db.InTx can read dbSecret.ID for the token row's AppSecretID. Three reviewers converge on this from different angles: Meruem calls the outer dbSecret a courier, Ryosuke names the concrete failure (a future edit flipping one branch mints AppSecretID = {UUID: uuid.Nil, Valid: true}, a dangling FK pointer), Zoro proposes the same colocation. Fix collapses the two branches to one and eliminates the paired-branch invariant.

  2. The R1 CRF-1 / CRF-2 sweep, which was about stale-after-merge anchors in comments, closed the specific instances but left multiple siblings on the same file. Two positional / temporal anchors survived in oauth2_test.go ("the test above", "the first with a NULL app_secret_id", "the claim app_id was promoted for"), and the round's rewrites in revoke.go and oauth2_test.go expanded four comment blocks by re-narrating rationale that lives on the production code or on sibling comments. Gon flags all seven; Leorio echoes on two.

One fun quote from the panel: "coupled only through a function-level dbSecret used as a courier." (Meruem)

Still COMMENT, not REQUEST_CHANGES: the P3 is a class-of-bug elimination worth doing but the code works today, and the P2s are AGENTS.md fact-in-one-place violations that trim rather than change behavior. Reasonable to fold into the same amend as CRF-23.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/oauth2provider/revoke.go Outdated
Comment thread coderd/oauth2provider/revoke.go Outdated
Comment thread coderd/oauth2_test.go Outdated
Comment thread coderd/oauth2_test.go Outdated
Comment thread coderd/oauth2_test.go
Comment thread coderd/oauth2_test.go Outdated
Comment thread coderd/oauth2_test.go Outdated
Comment thread coderd/oauth2_test.go Outdated
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant