Skip to content

feat: support public (secretless, PKCE-only) OAuth2 clients at the handler layer - #27873

Draft
BobbyHo wants to merge 1 commit into
oauth2-client-type-constraintfrom
oauth2-public-clients-handler-layer
Draft

feat: support public (secretless, PKCE-only) OAuth2 clients at the handler layer#27873
BobbyHo wants to merge 1 commit into
oauth2-client-type-constraintfrom
oauth2-public-clients-handler-layer

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Layer 2 of the #27195 split. Depends on #27712, which made the schema able to store a secretless client's tokens and moved revocation ownership onto app_id, but deliberately added no new capability. This turns the capability on.

An RFC 7591 registration requesting token_endpoint_auth_method: "none" now produces a public client: no secret is minted, client_type is persisted as public, and the token endpoint accepts that client's authorization_code exchange with PKCE alone. Discovery advertises "none" as a supported auth method.

PKCE was already mandatory for every authorization_code flow, so public clients inherit it with no new code. That also makes the code ownership check (dbCode.AppID != app.ID, added in #27712) 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.

Clients already registered with token_endpoint_auth_method: "none" are stored as confidential with a secret and are not reclassified, so their token exchange is unaffected. Only new registrations get a different client type.

Those clients need one extra accommodation, caught in review. Registration always persisted the requested auth method verbatim while hardcoding client_type to confidential, and "none" has always passed validation, so an app can be stored as confidential with an auth method of "none". Comparing only the derived client type would reject such a client from RFC 7592 forever, including when it resends the exact metadata GET reports. The guard therefore rejects only an update that actually changes token_endpoint_auth_method, and the update carries the stored client_type through rather than re-deriving it, so a legacy client can never be converted to public while it still holds a secret.

Two changes beyond the original PR

Both were found while implementing this layer, and both are in registration.go because that is the function public clients already restructure.

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 isPublic makes "app with no secret row" a legitimate state, which removes the ability to spot the orphaned case by inspection later.

An RFC 7592 update can no longer move a registered client between public and confidential. UpdateClientConfiguration wrote client_type straight from the request body with no comparison to the existing app, which was inert only while DetermineClientType() was hardcoded. Public to confidential is the damaging direction: the client is marked confidential with no secret row, the token endpoint then demands a client_secret it was never issued, and OAuth2ClientConfiguration has no field to deliver a newly minted one. That client is permanently unable to obtain a token, recoverable only by re-registering. RFC 7592 §2.2 permits rejecting metadata the server will not accept, so this returns 400 invalid_client_metadata. The guard compares the derived client type rather than the raw auth method, so client_secret_basic and client_secret_post stay interchangeable.

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

Base automatically changed from oauth2-public-clients-db to main August 5, 2026 17:41
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-handler-layer branch from 6c82b8f to 7cab879 Compare August 5, 2026 18:00
@BobbyHo

BobbyHo commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

Review history
  • R1 (2026-08-05), 3 Note, 1 P2, 3 P3, COMMENT. Review
  • R2 (2026-08-05): 23 reviewers, 9 Nit, 11 Note, 5 P2, 20 P3, 3 P4, COMMENT. Review

deep-review v0.9.0 | Round 2 | 97c4031..2b89218

Last posted: Round 2, 48 findings (5 P2, 20 P3, 3 P4, 9 Nit, 11 Note), COMMENT. Review

Finding inventory

Finding inventory, PR #27873

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Author fixed (332a48f) registration.go:342 Immutability guard permanently 400s RFC 7592 updates for pre-existing token_endpoint_auth_method: none clients R1 Netero Yes
CRF-2 P3 Author fixed (332a48f) docs/admin/integrations/oauth2-provider.md:117 Docs list only the two secret-based auth methods while discovery now advertises none R1 Netero Yes
CRF-3 P3 Author fixed (332a48f) modelmethods.go:693 "public"/"confidential" are bare literals across four packages and now gate secret validation R1 Netero Yes
CRF-4 P3 Author fixed (332a48f) tokens.go:391 No test refreshes or revokes a token with app_secret_id = NULL R1 Netero Yes
CRF-5 Note Author accepted R2 (RFC 6749 §2.3.1 leniency; pinned by PublicClientWithSecretIsAccepted) tokens.go:98 Public client sending a client_secret is accepted and the secret is never validated R1 Netero Yes
CRF-6 Note Author accepted R2 (gating on dcrEnabled would make discovery lie to still-working public clients) metadata.go:39 none advertised unconditionally, including when DCR is disabled; defensible as-is R1 Netero Yes
CRF-7 Note Author accepted R2 (checker is changed-lines-scoped; sweep would churn untouched lines) tokens.go:299 Pre-existing em-dash cluster in touched files on untouched lines R1 Netero Yes

Contested and acknowledged

CRF-5 (Note, tokens.go:98) - public client sending a client_secret is accepted

  • Finding: A public client that sends a client_secret is accepted and the secret is never looked at. The reverse also holds: an admin-minted secret on a public app is never required or validated, because authorizationCodeGrant branches on client_type rather than on whether a secret row exists.
  • Author defense (R2): Leniency follows RFC 6749 §2.3.1: a client with no secret authenticates by client_id, and rejecting a stray field would only add a failure mode for clients that harmlessly send an empty string. PublicClientWithSecretIsAccepted pins it so it stays a decision rather than an accident. On the second half: branching on client_type rather than secret presence is the intended direction, since client_type is the server's declaration of how the client authenticates and a secret's presence is not.
  • Author accepted: The finding recommended no change and recorded the observation for future readers. The author engaged with both halves and stated the intent explicitly, which is the outcome the Note asked for.

CRF-6 (Note, metadata.go:39) - none advertised unconditionally

  • Finding: none is advertised in discovery even on deployments where DCR is disabled and no public client can be created, while RegistrationEndpoint in the same function is gated on dcrEnabled.
  • Author defense (R2): Disabling DCR stops new public clients being created but does not stop existing ones exchanging tokens, so gating the advertised auth method on dcrEnabled would make discovery lie to clients that still work. RegistrationEndpoint is gated because that endpoint genuinely stops existing.
  • Author accepted: Matches the finding's own recommendation ("No change wanted"). Recorded so a later reviewer does not re-derive it.

CRF-7 (Note, tokens.go:299) - pre-existing em-dashes in touched files

  • Finding: The files this PR edits carry pre-existing em-dashes on lines it does not touch. CI is green because scripts/check_emdash.sh defaults to changed-lines mode.
  • Author defense (R2): No change. The changed-lines scope is exactly why the two relocated nolint lines had to be rewritten while untouched ones stay. Sweeping the rest would add churn to lines this change has no reason to touch, in a diff whose security-relevant parts benefit from close reading.
  • Author accepted: The finding explicitly said it was not this PR's job. No follow-up was promised, so nothing is deferred.

Law analysis

  • R2: effective +1061 (215 production, 846 test, 14 generated), head 2b89218698. Verdict: Don't split. Enforcement: Advisory. One clean cut exists (the registration transaction fix, ~45 production lines) and Law recommends landing it first as advisory only. The remaining concerns are provably inert without the feature: the immutability guard cannot fire before public clients exist (migration 000344 defaults and backfills client_type to confidential, and the base DetermineClientType returned that constant, so both sides of the comparison were always equal), and the client-type constants have no caller on their own.

Round log

Round 1

Netero-only first pass (effective +778, below the 1000 Law threshold, so Law did not run). 1 P2, 3 P3, 3 Note. Netero decision gate fired on the P2: panel not yet spawned. Orchestrator verified CRF-1 independently (base commit hardcoded DetermineClientType to confidential while Valid() accepted none, and oauth2_security_test.go:258 registers with none today, so legacy rows where the two columns disagree are reachable), CRF-2 (docs enumerate only client_secret_basic/client_secret_post), and CRF-3 (grep confirms the literals in modelmethods.go:693, registration.go:76, apps.go:95, dbgen.go:1735, codersdk/oauth2.go:536). Reviewed against db68c6c..7cab879.

Round 2

Churn guard: PROCEED. 4 addressed (CRF-1 to CRF-4, all in 332a48f), 3 acknowledged (CRF-5 to CRF-7), 0 contested, 0 deferred, 0 silent. "Author fixed" records the author's claim; the panel verifies when it reaches the code. Effective additions grew 778 to 1061, crossing the Law threshold for the first time, so Law ran alongside Netero. Branch was rebased: base moved db68c6c to 97c4031. Reviewed against 97c4031..2b89218.

Netero R2: no findings. All four round 1 code fixes verified against the tree, both halves of the CRF-1 fix mutation-checked independently (reverting either the guard condition or the ClientType: existingApp.ClientType write fails TestUpdateClientConfiguration_LegacyAuthMethodMismatch). Mechanical floor clean, so the panel proceeds. Law advisory, panel proceeds.

Round 2 panel findings

# Sev Status Location Summary Round Reviewer Posted
CRF-8 P2 Open registration_test.go:253 Transaction test passes with the secret insert moved back outside the transaction R2 Bisky P2, Ryosuke P3, Meruem Note, Kite Note Yes
CRF-9 P2 Open registration.go:353 Server reports token_endpoint_auth_method: none for legacy rows whose exchange still requires a secret, and discovery now makes that report actionable R2 Meruem P2, Chopper P3, Kite P3, Ryosuke P3, Melody P3 Yes
CRF-10 P3 Open docs/admin/integrations/oauth2-provider.md:121 The new none bullet is false for clients registered with none before this change R2 Mafuuu P3, Leorio P3, Kite Note, Razor Note Yes
CRF-11 P2 Open codersdk/oauth2_validation.go:171 Registering with none rejects vscode://-style schemes, so the native clients the docs point at none cannot register their own redirect URI R2 Pariston P3, orchestrator raised Yes
CRF-12 P2 Open app_secrets.go:64 An admin can mint a secret on a public app; it is never validated, and deleting it revokes nothing R2 Pen Botter P2, Kite P3, Mafuuu P3, Luffy P3, Melody P3, Mafu-san Note Yes
CRF-13 P3 Open modelmethods.go:693 client_type now gates client authentication but is nullable free text with no CHECK R2 Knuckle P3, Kurapika P3, Knov P3, Kite Note Yes
CRF-14 P3 Open registration.go:354 The guard compares client_type by raw string while IsPublic() is the canonical reader, so a non-canonical row is locked out of RFC 7592 forever R2 Zoro P3 Yes
CRF-15 P3 Open tokens.go:414 The refresh grant authenticates no client at all, contrary to RFC 6749 §6 for confidential clients R2 Kurapika P3, Ryosuke P3, Chopper P4, Pariston Note Yes
CRF-16 P3 Open tokens.go:295 No code_verifier length or charset validation, and PKCE is now a public client's only authentication R2 Hisoka P3 Yes
CRF-17 P3 Open codersdk/oauth2_validation.go:148 validateRedirectURIs re-derives publicness instead of calling DetermineClientType R2 Razor P3, Ryosuke P3, Robin P3, Pariston P4, Melody Nit Yes
CRF-18 P3 Open registration.go:137 registration_client_uri built with Sprintf doubles the slash when the access URL ends in one R2 Ging-Go P3 Yes
CRF-19 P3 Open registration.go:357 The immutability 400 names a field the client never sent, offers no next step, and no test covers the omitted-field path R2 Chopper P3, Pen Botter P3, Bisky P3, Leorio P3, Hisoka Note, Knov Note, Gon Nit Yes
CRF-20 P3 Open registration_test.go:184 require.Empty(resp.ClientSecret) cannot distinguish an omitted key from an empty one, so the documented wire shape is unpinned R2 Komugi P3, Mafu-san P3 Yes
CRF-21 P3 Open oauth2_test.go:621 No public-client test exercises a bad or missing code_verifier R2 Chopper P3, Kite P3 Yes
CRF-22 P3 Open constants.go:19 The rationale comment credits aliasing with preventing a failure aliasing cannot prevent, and waves off the tests that do R2 Mafu-san P3 Yes
CRF-23 P3 Open coderd/oauth2.go:150 Swagger annotation still says client_secret is required for authorization_code; code_verifier is undocumented R2 Hisoka P3 Yes
CRF-24 P3 Open docs/admin/integrations/oauth2-provider.md:210 Five token-exchange and refresh examples, none runnable by a public client R2 Leorio P3 Yes
CRF-25 P3 Open docs/admin/integrations/oauth2-provider.md:121 none is reachable only through DCR, which the same page says is disabled by default, while line 43 sends native app authors to the web UI R2 Pen Botter P3 Yes
CRF-26 P3 Open oauth2_test.go:1161 registerPublic duplicates registerPublicClient 530 lines earlier; neither lives in the shared helper package R2 Robin P3, Zoro Nit, Gon Nit Yes
CRF-27 P3 Open registration_test.go:456 Both rejection cases assert only invalid_client_metadata, which the guard shares with request validation R2 Chopper P3 Yes
CRF-28 P4 Open codersdk/oauth2.go:591 client_secret_expires_at is never emitted, though RFC 7591 §3.2.1 requires it when a secret is issued R2 Mafuuu P4, Chopper P4 Yes
CRF-29 P4 Open coderd/database/dump.sql:2626 oauth2_provider_app_tokens has no secondary indexes, and this PR grows that table R2 Knuckle P4 Yes
CRF-30 P4 Open tokens.go:391 A public client's refresh token is a bearer credential with rotation but no reuse detection or signal R2 Knov P4 Yes
CRF-31 P3 Open codersdk/oauth2.go:275 Client-type constants are untyped strings while all seven sibling enums are defined types; the loose shape becomes codersdk contract on release R2 Robin P3, Ryosuke Nit, Meruem Nit, Mafuuu Nit, Knov Nit, Ging-Go Nit, Zoro Nit, Gon Nit, Luffy Nit Yes
CRF-32 Nit Open codersdk/oauth2.go:539 DetermineClientType doc comment states an unreachable hazard and omits the real ordering dependency R2 Knov Nit, Ryosuke Nit Yes
CRF-33 Nit Open registration.go:334 Comment bloat cluster: 18 lines on a 6-line guard, legacy rationale written three times R2 Gon P2 x6, orchestrator consolidated Yes
CRF-34 Nit Open registration_test.go:298 New test code adds 18 bare public/confidential literals in the PR that added constants to stop them R2 Gon Nit Yes
CRF-35 Nit Open registration.go:352 clientType does not say whose type it is on the one line where that is the question R2 Gon Nit Yes
CRF-36 Nit Open metadata.go:39 Advertised auth-method list is a second hand-maintained enumeration of Valid() R2 Robin Nit Yes
CRF-37 Nit Open registration_test.go:169 The marshal/request/recorder block is now written five times in one file R2 Robin Nit Yes
CRF-38 Nit Open registration_test.go:435 Hand-wired chi route context where the codebase drives this endpoint through the real router R2 Zoro Nit Yes
CRF-39 Nit Open tokens.go:42 extractTokenRequest takes the whole database.OAuth2ProviderApp to read one bool R2 Zoro Nit Yes
CRF-40 Note Open tokens.go:43 Dual client_id resolution between middleware and parser; orchestrator proved the duplicate-param case is rejected, so it is unreachable today R2 Knov P3 (downgraded), Kurapika Note, Hisoka Note, Meruem Note Yes
CRF-41 Note Open registration.go:140 The app-insert error branch inside the new InTx closure has zero coverage R2 Komugi Note Yes
CRF-42 Note Open oauth2_test.go:1206 AsSystemRestricted is inert on the raw store handle; the nolint describes a layer not in the path R2 Komugi Note Yes
CRF-44 Note Open registration.go:376 apps.go:153 already carries client_type through; two update paths, same rule, no cross-reference R2 Robin Note Yes
CRF-45 Note Open tokens.go:98 A public client_id lets an unauthenticated caller reach a second DB query, on a route tree with no rate limiter R2 Killua Note Yes
CRF-46 Note Open registration.go:353 Client type is permanent; the only recovery is re-registration, which re-prompts every consent R2 Luffy Note Yes
CRF-47 Note Open docs/admin/integrations/oauth2-provider.md:121 Only RedirectURIs[0] is used for matching, by exact string compare, so no RFC 8252 loopback port flexibility R2 Razor Note Yes
CRF-48 Nit Open docs/admin/integrations/oauth2-provider.md:123 "Coder supports both secret-based methods" sits under a three-item list R2 Pen Botter Nit Yes
CRF-49 Note Open registration.go:363 The update-path nolint keeps its em-dash while the two create-path ones were rewritten, so one directive reads two ways in one file R2 Gon Note Yes

CRF-43 was merged into CRF-12 (Mafu-san's cascade observation is CRF-12's consequence, not a separate finding).

Round 2 cross-check decisions

  • CRF-11 raised P3 to P2. Keep-at-P3 argument tested first: the restriction predates the PR, loopback redirects work, and nothing existing regresses. It loses. Verified by reading codersdk/oauth2_validation.go:167-175 myself: isValidCustomScheme requires a literal . and is applied only on the isPublicClient branch, so vscode://, jetbrains://, and cursor:// are accepted for confidential clients and rejected for public ones. This PR is what makes none the only route to the advertised capability and adds the docs line pointing native, mobile, and CLI apps at it, so an inert restriction became a live registration failure for the feature's stated target population. Pattern inheritance applies: the preconditions that made the restriction harmless died when none started meaning something.
  • CRF-12 held at P2 over three P3s. Pen Botter's P2 plus Mafuuu's cascade evidence outweigh the P3 framings, which stopped at "the secret is inert". The combination is worse than either part: an operator mints a credential the token endpoint ignores, and the deletion that is a real kill switch for a confidential app (app_secret_id ON DELETE CASCADE) revokes nothing for a public one, so an incident-response action silently does not contain anything.
  • CRF-40 downgraded P3 to Note on empirical grounds. Keep-at-P3 argument written first: the app that decides isPublic is resolved by a different rule than the client_id the same function parses, the safety rests on an invariant in another package, and Knov's three-line reconciliation cannot break a conforming client. Then I tested the premise. A POST /oauth2/tokens?client_id=QUERYID with client_id=BODYID in the body returns two validation errors from parseSingle (Query param "client_id" provided more than once), so the divergence is rejected before any decision reads it. Kurapika found the reason; the empirical result wins over the three reviewers who verified only Go's form precedence. The recommendation survives in the Note.
  • CRF-33 consolidated Gon's six P2s into one Nit. Keep-at-P2 argument written first: AGENTS.md requires substantive, concise comments, and the legacy rationale written three times in substantially the same words will rot, with one stale copy misleading a future reader about a security-relevant guard. It does not carry P2: no behavior is wrong, and the deep-review vocabulary puts project-standard violations where the code works at Nit. Gon's own report says the pattern "belongs alongside the other process observations for this round, not as inflated severity on any one finding", so the cluster is one Nit plus a body note.
  • CRF-31 held at P3 over eight Nits. Robin's specificity plus Ryosuke's point that exported codersdk constants become contract on release (a one-way door) sets the floor above Nit.
  • CRF-15, CRF-29 need a human decision. Both are pre-existing, both are named as this PR's neighbours rather than its defects, and neither can be accepted as permanent by an agent.
  • Schema-level side effects enumerated. No migration in this diff. oauth2_provider_app_tokens.app_secret_id FK is ON DELETE CASCADE and is now NULL for public-client tokens, which removes the secret-deletion cascade as a revocation path (CRF-12). app_id FK is also ON DELETE CASCADE, so deleting a public app still removes its tokens. client_type has a column default of confidential and no CHECK (CRF-13). No trigger touches these tables.
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.

First-pass review only. These are mechanical findings from a single first-pass reviewer; the full review panel has not yet reviewed this PR and will do so once these are addressed.

The change is well built. PKCE was already mandatory, so public clients genuinely inherit it with no new code, and the PR says so rather than claiming new hardening. Test density is 79% (615 test lines to 163 production), and the tests assert persisted state and call topology rather than mock return values: TestUpdateClientConfiguration_ClientTypeIsImmutable reads rows back and checks the whole update is unapplied on rejection, and TestCreateDynamicClientRegistration_Transaction injects a mid-transaction failure. The two out-of-scope fixes are both real bugs the feature exposes, and both are explained in the description instead of smuggled in. On the rename: "DetermineClientType now actually determines the type instead of returning a constant, so the name became true rather than false."

Severity count: 1 P2, 3 P3, 3 Note.

The P2 is a regression on the claim in the description that clients already registered with token_endpoint_auth_method: "none" "keep working". Their token exchange does. Their RFC 7592 management endpoint becomes a permanent 400, verified against a real database.

CRF-2 is on docs/admin/integrations/oauth2-provider.md:117, outside the diff: the documented list of supported token endpoint auth methods names only client_secret_basic and client_secret_post, while metadata.go:39 now advertises none at /.well-known/oauth-authorization-server. Server and docs contradict each other on a capability this PR ships, so an integrator reading the docs concludes public clients are unsupported. One bullet plus one sentence in the DCR paragraph.

CRF-7 is a Note on coderd/oauth2provider/tokens.go:299, outside the diff: the files this PR edits carry pre-existing em-dashes on lines it does not touch (tokens.go:260,299,420,457, registration.go:225,311,351,434,458,510). scripts/check_emdash.sh defaults to changed-lines mode so CI is green, and this PR correctly replaced the em-dashes on the two nolint lines it moved. Not this PR's job to sweep the rest; recorded because a --all run would flag them.


docs/admin/integrations/oauth2-provider.md:117

P3 [CRF-2] The documented list of supported token endpoint auth methods still names only the two secret-based ones, while discovery now advertises none. (Netero)

The page states "Coder supports the following OAuth2 client authentication methods at the token endpoint" and enumerates client_secret_basic and client_secret_post, then explains how to request client_secret_post via DCR. After metadata.go:39, /.well-known/oauth-authorization-server advertises none, so the server and the docs contradict each other on a user-facing capability shipped in this PR. An integrator reading the docs concludes public clients are unsupported.

🤖

coderd/oauth2provider/tokens.go:299

Note [CRF-7] The files this PR edits contain pre-existing em-dashes on lines it does not touch: tokens.go:260,299,420,457 and registration.go:225,311,351,434,458,510. (Netero)

scripts/check_emdash.sh defaults to changed-lines mode, so these do not fail CI, and the PR correctly replaced the em-dashes on the two nolint lines it did move. Not this PR's job to sweep the rest; noting the cluster since a --all run would flag them.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/oauth2provider/registration.go Outdated
Comment thread coderd/database/modelmethods.go Outdated
Comment thread coderd/oauth2provider/tokens.go
Comment thread coderd/oauth2provider/tokens.go
Comment thread coderd/oauth2provider/metadata.go Outdated
BobbyHo added a commit that referenced this pull request Aug 5, 2026
Addresses the first-pass review on #27873.

Registration has always persisted `token_endpoint_auth_method` verbatim
while hardcoding `client_type` to `confidential`, and `"none"` has always
passed validation, so apps stored as confidential with an auth method of
`"none"` exist wherever a native or MCP client self-registered. Comparing
only the derived client type rejected those clients from RFC 7592 forever,
including when they resent the exact metadata `GET` reports, leaving
re-registration as the only recovery.

Reject only an update that actually changes `token_endpoint_auth_method`,
and carry the stored `client_type` through the update instead of
re-deriving it. The second half matters on its own: relaxing the guard
without it converts such a client to public while it still holds a secret,
which stops the token endpoint from requiring that secret.

Add public-client coverage for refresh and revocation. These are the first
tokens with a NULL `app_secret_id`, and every existing test minted one with
a real secret, so the refresh path that carries `AppSecretID` forward and
both ownership checks in `revoke.go` only ever ran against a non-NULL
value. This PR's premise is that ownership moved onto `app_id` so a
secretless token stays revocable, so that claim now has a test.

Replace the bare `"public"`/`"confidential"` literals with constants.
`IsPublic` decides whether a client secret is validated at all, so the
database layer aliases the codersdk values rather than redeclaring them,
making a drift between the two a compile error rather than a silent change
in authentication behavior.

Document `none` as a supported token endpoint auth method. Discovery
advertises it, so the page contradicted the server on a capability this
change ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 5, 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 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All seven findings addressed in 332a48f; per-finding replies are inline. Summary, plus the two that were reported in the review body rather than on the diff:

CRF-2 (P3), docs contradict discovery. Fixed. docs/admin/integrations/oauth2-provider.md now lists none alongside the two secret-based methods, explains that registering with it yields no client_secret, and documents that a client's type is fixed at registration so the invalid_client_metadata rejection is discoverable before someone hits it. I left the PKCE sentence out on purpose: the dedicated PKCE Flow (Required) section already states it applies to public and confidential clients alike, and repeating it would duplicate rather than clarify.

CRF-7 (Note), pre-existing em-dashes. No change, agreed. scripts/check_emdash.sh is changed-lines-scoped, which is exactly why the two nolint lines this PR relocated had to be rewritten while the untouched ones stay. Sweeping the rest would add churn to lines this change has no reason to touch, in a diff whose security-relevant parts benefit from being read closely. Worth a separate pass if a --all run is ever made the default.

The P2 is real, and its suggested one-line fix is not safe on its own. Detail inline, but briefly: relaxing the condition without also stopping the update from re-deriving client_type converts a legacy client to public while it still holds its secret, so the token endpoint stops requiring that secret. Verified against a real database before implementing. Whoever picks up a similar finding elsewhere should take both halves.

Every fix here is mutation-checked rather than just asserted green:

Change Mutation applied Result
Preserve stored client_type restore the derived write expected: "confidential", actual: "public"
Public refresh/revoke coverage reintroduce an app_secret_id join in the ownership check both subtests fail on public client must be able to revoke its own token

Also corrected the PR description, which claimed legacy none clients "keep working" without qualification. Their token exchange does; their management endpoint did not, which is precisely CRF-1.

Verification on the updated branch: go build ./... clean, make gen no drift beyond the two generated TS constants noted in the CRF-3 reply, make lint clean, and ./coderd -run TestOAuth2, ./coderd/oauth2provider/..., ./codersdk, ./coderd/database/dbauthz/... all passing.

@BobbyHo

BobbyHo commented Aug 5, 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.

Panel round. 23 reviewers, first full panel on this PR.

The round 1 fixes hold up under verification rather than inspection. Both halves of the registration.go fix were mutation-checked independently: reverting either the guard condition or ClientType: existingApp.ClientType fails TestUpdateClientConfiguration_LegacyAuthMethodMismatch. TestOAuth2PublicClientTokenLifecycle asserts AppSecretID.Valid == false before it tests revocation and again after the refresh, so it proves its own precondition instead of assuming the feature produced it, and it keeps a negative control (cross-app revoke returns 200 and the session survives) next to the positive one. Four reviewers independently walked all four RFC 7592 transitions plus the legacy row and none could reach "public while holding a secret". Pushing back on the round 1 suggested fix with a database probe, rather than applying it, is the behavior this panel exists to reward.

On the change itself: "A CLI or a desktop editor can't hide a secret, so making it pretend to have one was always a lie. This PR deletes the lie."

Severity count: 4 P2, 17 P3, 3 P4, 9 Nit, 8 Note. Round 1's seven findings are all closed (four fixed, three accepted).

The four P2s, in the order I would fix them:

  1. registration_test.go:253, the transaction test passes with the secret insert moved back outside the transaction, proven by mutation by two reviewers independently. It is the only test standing between this handler and the project's documented outer-store-inside-InTx failure mode.
  2. app_secrets.go:64, an admin can mint a secret on a public app that the token endpoint never validates, and deleting it revokes nothing because public-client tokens carry app_secret_id = NULL. The confidential kill switch silently does not exist for public apps.
  3. codersdk/oauth2_validation.go:171, the docs line this PR adds points native, mobile, and CLI apps at none, and none is the branch that rejects vscode://, jetbrains://, and cursor://. I verified the asymmetry in the code: those schemes register fine as confidential and 400 as public.
  4. registration.go:353, the server reports token_endpoint_auth_method: none for legacy rows whose token exchange still requires a secret, and this PR is what makes clients likely to act on that report.

Two findings are pre-existing and need a human decision rather than an agent's: the refresh grant authenticates no client at all (CRF-15, RFC 6749 §6 for confidential clients), and oauth2_provider_app_tokens has no secondary indexes while GetOAuth2ProviderAppTokenByAPIKeyID runs on every authenticated request made with an OAuth2 app token (CRF-29). Fix here, file a ticket, or state the acceptance explicitly. "It will get an index eventually" is not a plan.

One process observation. Comment bloat is systemic in this diff rather than local: six added comments narrate what the line below them does or restate the PR description, and the legacy-row rationale is written three times in substantially the same words (registration.go:334, registration.go:371, registration_test.go:469). Filed as one Nit (CRF-33) rather than six findings, because the pattern is the point. Set against that, tokens.go:221, modelmethods.go:688, and DetermineClientType's precondition are the best kind of comment: they tell the next reader why a check they might delete as redundant is what holds the door shut.

One finding was downgraded on evidence rather than judgment. Three reviewers flagged that the token endpoint resolves client_id twice by different rules (middleware reads query-first, the parser reads the merged form where body wins), and rated it up to P3. Kurapika argued it is unreachable. I tested it: a request carrying client_id in both places returns Query param "client_id" provided more than once from parseSingle before any decision reads either value. Recorded as a Note (CRF-40) with the recommendation intact.

CRF-12 is posted as a reply on the CRF-5 thread, since five reviewers framed it as new evidence on that finding's territory; coderd/oauth2provider/app_secrets.go:64 is where the fix goes. Six other findings land on files or lines outside the diff and are folded in below with their path:line.

Last, on decomposition: Law analyzed the diff and concluded don't split. One clean independent cut exists, the registration transaction fix (~45 production lines), and landing it first would shrink the feature diff. Advisory only, not worth a rebase if the feature is otherwise ready.


codersdk/oauth2_validation.go:171

P2 [CRF-11] Registering with token_endpoint_auth_method: "none" rejects the private-use URI schemes that real native apps use, so the client class this PR exists for cannot register the redirect URI it actually owns. (Pariston P3, orchestrator raised to P2)

The docs line this PR adds says to use none for "native, mobile, and CLI applications that cannot keep a secret confidential". validateRedirectURIs disagrees. When the requested auth method is none it takes the isPublicClient branch and runs every custom scheme through isValidCustomScheme, which requires a literal . in the scheme. Pariston ran the matrix:

vscode://coder.authenticate    none                 -> 400 "custom scheme vscode should use reverse domain notation"
vscode://coder.authenticate    client_secret_basic  -> ok
jetbrains://cb                 none                 -> 400
jetbrains://cb                 client_secret_basic  -> ok
cursor://cb                    none                 -> 400
cursor://cb                    client_secret_basic  -> ok
com.example.app://cb           none                 -> ok
http://127.0.0.1:9999/cb       none                 -> ok

I re-read the branch to confirm the asymmetry: isValidCustomScheme is called only under if isPublicClient, and the confidential path waves custom schemes through. The file's own doc comment at line 83 names these as legitimate: "Legitimate custom schemes for native apps (e.g. vscode://, jetbrains://) are allowed". They are, but only if you register as confidential and take a secret you cannot protect, which is the exact trade this PR was built to remove.

Raised from P3. I tested the keep-at-P3 case first: the restriction predates this PR, loopback redirects work, and nothing existing regresses. It loses to what changed. none bought a client nothing before, so nobody had reason to choose it and eat the stricter redirect rules; this PR makes none the only path to the advertised capability and then points native apps at it. An inert restriction became a live registration failure for the feature's stated target population, and no test covers the gap: the existing custom-scheme cases at oauth2_security_test.go:254-269 pair none only with com.example.* and the OOB URN.

Two ways out, and the choice is yours. Relax isValidCustomScheme for public clients to match the doc comment above it, on the grounds that RFC 8252 §7.1 recommends reverse-domain notation rather than requiring it and the scheme is not the security boundary (PKCE is). Or keep the restriction and say so in the docs paragraph this PR already edits: public clients must use a loopback redirect or a reverse-domain scheme, and vscode://-style schemes are confidential-only. What should not ship is a docs line recommending none to native apps next to a validator that 400s the schemes those apps register with the OS.

🤖

coderd/oauth2provider/tokens.go:414

P3 [CRF-15] The refresh grant authenticates no client at all, on either side of the axis this PR just drew. (Kurapika P3, Ryosuke P3, Chopper P4, Pariston Note)

extractTokenRequest requires client_secret only inside the authorization_code branch, and refreshTokenGrant never reads req.ClientSecret. A confidential client refreshes with client_id and a refresh token, no secret. RFC 6749 §6 requires the server to authenticate a confidential client on refresh.

Proof rather than inference: TestRefreshTokenGrant_Scopes (tokens_internal_test.go:503) calls extractTokenRequest with a zero-value app, which IsPublic() reads as confidential, and a form carrying no client_id and no client_secret, then asserts require.Empty(t, validationErrs). The confidential path accepts a refresh exchange with no client credential, and the test pins it.

a refresh token belonging to a confidential client is redeemable by anyone who holds it plus the client_id. client_id is not a secret. It is returned in the registration response, echoed by RFC 7592 GET, and travels in every authorize URL.

Not a regression: base 97c4031526 has the identical structure, and the only change is the !isPublic && conjunct. Four reviewers raised it anyway for the same reason. This PR makes "does this client present a secret" a first-class property and routes exactly one of the two grants through it, so the new comment at tokens.go:221 framing the code-ownership check as load-bearing for public clients will read to the next person as if the refresh path has a secret check the auth-code path is relaxing. It does not, for anyone. /oauth2/revoke is the third instance: extractRevocationRequest parses client_id and client_secret and reads neither.

The fix is not a one-liner and it breaks any confidential client that refreshes without sending its secret today, which is why it may belong in its own change. It does not belong in nobody's: fix it here, file a ticket, or state that the gap is accepted, and if it stays, say so in a comment at this line. Silence is the one outcome that leaves the next reader to rediscover it.

🤖

coderd/oauth2.go:150

P3 [CRF-23] The published API reference still tells clients a client_secret is required for authorization_code, which this PR made false. (Hisoka)

// @Param client_secret formData string false "Client secret, required if grant_type=authorization_code" renders into docs/reference/api/enterprise.md:5103 and coderd/apidoc/docs.go:14827. The admin guide got the public-client treatment in CRF-2's fix; this annotation is its sibling and was missed. A developer reading the API reference for the endpoint they are about to call learns the opposite of what the endpoint now does.

Same annotation block, same root cause: code_verifier is not listed as a parameter at all, though it has been mandatory for every exchange and is now the sole client authentication for public clients. The token endpoint's parameter docs describe a world with only confidential clients in it.

Amend the client_secret line to say confidential clients, add a code_verifier param, run make gen.

🤖

codersdk/oauth2.go:591

P4 [CRF-28] client_secret_expires_at is never emitted, including for confidential clients that were issued a secret, where RFC 7591 §3.2.1 makes it REQUIRED. (Mafuuu P4, Chopper P4)

Both reviewers verified by dumping the raw response rather than reading the tag. ClientSecretExpiresAt: 0 combined with json:"client_secret_expires_at,omitempty" drops the field, so a client_secret_basic registration returns client_secret with no expiry field at all. Per §3.2.1, 0 is the value that means the secret never expires, so the intent is right and only the serialization loses it. A strict RFC 7591 client that requires the field on a secret-issuing response has no value to read.

coderd/oauth2_test.go:1767 asserts int64(0) after decoding, which is indistinguishable from absent, so no test constrains the wire shape. Same class as CRF-20.

Pre-existing and untouched, and the omission is now correct for public clients, which is why it is P4. It sits on a line this PR rewrote and there is no follow-up, so it is a human's call: drop omitempty and keep sending 0 when a secret is issued, or accept it explicitly.

🤖

coderd/database/dump.sql:2626

P4 [CRF-29] oauth2_provider_app_tokens has no secondary indexes, and it sits on the authenticated-request hot path. (Knuckle)

Predates this PR and changes nothing in this diff. Raised because this PR is the one whose purpose is to grow that table, and public clients are for native and MCP clients, which is precisely the population that turns this from a rounding error into a bill.

The table has exactly two indexes, pkey (id) and UNIQUE (hash_prefix). Nothing on api_key_id, app_id, user_id, or app_secret_id; Postgres does not index the referencing side of a foreign key for you. Three consequences:

  1. httpmw/apikey.go:707 calls GetOAuth2ProviderAppTokenByAPIKeyID on every authenticated request made with an OAuth2 provider app token, for RFC 8707 audience validation. WHERE api_key_id = $1 with no index is a sequential scan, once per request.
  2. dbpurge/dbpurge.go:227 deletes up to 10,000 expired api_keys rows every 10 minutes, and the FK is ON DELETE CASCADE, so Postgres fires a per-row referential-integrity lookup for each deleted key. Unindexed, that is up to 10,000 sequential scans inside one purge transaction. Cost is rows-deleted times table-size, and both factors grow together.
  3. DeleteOAuth2ProviderAppTokensByAppAndUserID filters app_id AND user_id and GetOAuth2ProviderAppsByUserID joins on user_id. Both scan. These are the two queries #27712 moved onto app_id specifically so public clients would work, so the new access pattern arrived without the index that supports it.

Unverified at scale, and stated as such: the scans are predicted from the schema, not measured. Cheap to confirm on any deployment with real traffic with EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM oauth2_provider_app_tokens WHERE api_key_id = '<id>';

CREATE INDEX idx_oauth2_provider_app_tokens_api_key_id ON oauth2_provider_app_tokens (api_key_id);
CREATE INDEX idx_oauth2_provider_app_tokens_app_id_user_id ON oauth2_provider_app_tokens (app_id, user_id);
CREATE INDEX idx_oauth2_provider_app_tokens_user_id ON oauth2_provider_app_tokens (user_id);

CREATE INDEX CONCURRENTLY in its own migration if you would rather not gamble on the deployment's table size. Leave app_secret_id alone: it is NULL for exactly the rows this PR creates. If this belongs in its own PR, it needs a ticket attached before this merges. "The tokens table will get an index eventually" is not a plan, it is the interest payment deferred.

🤖

coderd/oauth2provider/tokens.go:295

P3 [CRF-16] PKCE is now the only lock on a public client's exchange, and the server never checks the key is longer than one character. (Hisoka)

authorizationCodeGrant requires req.CodeVerifier != "" and hands it straight to VerifyPKCE. Nothing in the path enforces RFC 7636 §4.1's 43-to-128-character verifier. I grepped the tree myself to confirm: no length or charset check exists anywhere in coderd/ or codersdk/.

Reproduced at the verification boundary: a one-character verifier "a" verifies against its own 43-character S256 challenge.

For a confidential client this was the second lock. Your own comment at tokens.go:221-224 says that for a public client it is the only one. The challenge travels in the authorization request URL, which lives in browser history, referrer headers, and proxy logs; the code travels in the redirect. An attacker holding both brute-forces the verifier offline, at whatever entropy the client chose, and rate limiting cannot help because the attack never touches your server.

The gap is the client's to create and the server's to refuse. Reject a verifier outside 43-128 characters of [A-Za-z0-9-._~] next to the emptiness check that is already there. Five lines, and the class of buggy native client this feature exists to serve can no longer hand you a one-character password.

Distinct from CRF-5, which is about a stray client_secret on a field nobody reads. This is the field that is now the entire authentication.

🤖

codersdk/oauth2_validation.go:148

P3 [CRF-17] validateRedirectURIs re-derives publicness inline instead of calling DetermineClientType, so one fact now has two definitions in the same package. (Razor P3, Ryosuke P3, Robin P3, Pariston P4, Melody Nit)

Five reviewers arrived here independently. isPublicClient := tokenEndpointAuthMethod == OAuth2TokenEndpointAuthMethodNone decides which RFC 8252 rules apply to a redirect URI; DetermineClientType decides what goes in client_type and whether a secret is minted. Same field, same comparison, 400 lines apart.

Before this PR the duplication was inert, because DetermineClientType was hardcoded and had no policy in it. This PR gives it one. They agree today only because the derivation is a single comparison, and the comment you deleted named the exact way they diverge:

The day one of those lands in DetermineClientType, a client registered as application_type: native with client_secret_basic is stored public, gets no secret, and is validated against the confidential redirect rules, which permit http:// to any non-loopback host the registrant names. A secretless client with a plaintext redirect to a host it does not control is the exact failure RFC 8252 §7.3 exists to prevent.

Not a re-raise of CRF-3: that was duplicated literals, which the constants fixed. This is a duplicated derivation, which the constants leave untouched.

Fix: one owner. validateRedirectURIs(req.RedirectURIs, req.DetermineClientType()), or an exported ClientTypeFor(method) that both call. Validate() has the whole request in hand. constants.go:17 argues that two spellings of "public" should be held equal by the compiler rather than by a test after the fact; this is the same argument and the one instance left unaligned.

🤖

docs/admin/integrations/oauth2-provider.md:210

P3 [CRF-24] The page now tells people to register public clients and then shows them five token-exchange examples, every one of which needs a client secret. (Leorio)

The section header at line 184 says it out loud: "Both public and confidential clients must include PKCE parameters." Then step 3 hands the reader -u "$CLIENT_ID:$CLIENT_SECRET". Same at lines 149, 165, 238, 253. Five curl blocks, zero of them runnable by the client this PR exists for.

The CLI developer at line 121 has just read "Use this for native, mobile, and CLI applications that cannot keep a secret confidential", registered with none, and received a response with no client_secret in it. Nothing on the page tells them client_id goes in the form body and client_secret is simply absent, and the failure mode of guessing wrong is 401 The client credentials are invalid. The knowledge exists in the test file and not in the docs.

A third option under PKCE step 3:

# Public client (token_endpoint_auth_method: none), no secret
curl -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$AUTH_CODE" \
  -d "client_id=$CLIENT_ID" \
  -d "code_verifier=$CODE_VERIFIER" \
  -d "redirect_uri=https://yourapp.example.com/callback" \
  "$CODER_URL/oauth2/tokens"

Same omission in Refresh Tokens (lines 234 to 255): both options pass a secret, and a public client refreshes with client_id plus refresh_token. Same fix, same pass.

🤖

🤖 This review was automatically generated with Coder Agents.


- `client_secret_basic` (recommended): HTTP Basic authentication (RFC 6749 §2.3.1). The username is `client_id` and the password is `client_secret`.
- `client_secret_post`: Form-based authentication where `client_id` and `client_secret` are sent in the request body.
- `none`: No client secret. The client is a public client and authenticates with PKCE alone (RFC 7591 §2, OAuth 2.1 §2.1). Use this for native, mobile, and CLI applications that cannot keep a secret confidential.

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.

Note [CRF-47] The docs point native, mobile, and CLI apps at public clients, but redirect URI matching is exact against a single stored URI. (Razor)

extractAuthorizeParams and extractTokenRequest both validate redirect_uri against callbackURL, which is url.Parse(app.CallbackURL), and app.CallbackURL is req.RedirectURIs[0]. QueryParamParser.RedirectURL compares full strings. So app.RedirectUris is stored and echoed back but never consulted for matching, registering more than one redirect URI has no effect, and RFC 8252 §7.3 loopback port flexibility is unavailable: http://127.0.0.1:8080/cb will not match a client registered with http://127.0.0.1:9000/cb.

A CLI can work around this by registering a fresh client through DCR after it binds its port, which is the flow this feature is aimed at, so it is not a blocker. Both behaviors predate this PR and neither is this PR's to fix. Flagged because the new docs sentence is the first thing that sends native apps down this path, and a reader will assume the multi-URI redirect_uris array they just registered does something.

🤖

// (the mock itself, standing in for `tx`) so the two inserts
// below are recorded as happening inside one shared
// transaction, not as two independently committed statements.
mDB.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(

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.

P2 [CRF-8] The transaction test passes with the secret insert moved back outside the transaction, which is the exact regression it was written to catch. (Bisky P2, Ryosuke P3, Meruem Note, Kite Note)

InTx is stubbed as func(f func(database.Store) error, _ *database.TxOptions) error { return f(mDB) }. The closure receives the same mock the handler holds, so a call recorded "inside" the transaction and a call made after InTx returns are indistinguishable to gomock. Times(1) on InTx and gomock.InOrder(appCall, secretCall) both stay satisfied.

Two reviewers proved it by mutation, independently and by different routes. Bisky rewrote the handler into the pre-PR two-commit shape with InTx still called once: both subtests pass, including SecretInsertFailureFailsTheWholeRegistration, because the out-of-transaction path also returns 500. Ryosuke swapped one tx.InsertOAuth2ProviderAppSecret for db.InsertOAuth2ProviderAppSecret inside the closure: still green.

That second mutation is not hypothetical. .claude/docs/DATABASE.md:183-192 names outer-store use inside InTx as a specific project failure with a specific consequence, pool starvation and idle in transaction. This test is the only thing standing between that rule and this handler, and the header comment at line 214 claims the opposite of what the test can see.

Fix, verified green on current code and FAIL on the mutated code:

mTx := dbmock.NewMockStore(ctrl)
mDB.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(
    func(f func(database.Store) error, _ *database.TxOptions) error { return f(mTx) },
).Times(1)
appCall := mTx.EXPECT().InsertOAuth2ProviderApp(...).Times(1)
secretCall := mTx.EXPECT().InsertOAuth2ProviderAppSecret(...).Times(1)
gomock.InOrder(appCall, secretCall)

An insert issued outside the transaction then lands on mDB, which has no expectation for it, and gomock fails on the unexpected call. The same change applies to PublicClientSkipsSecretInsert at line 308, whose "absence of an expectation is the assertion" only holds once the two handles are distinct.

🤖

Comment thread coderd/oauth2provider/registration.go Outdated
// client_secret_basic and client_secret_post interchangeable, since both
// are confidential.
clientType := req.DetermineClientType()
if req.TokenEndpointAuthMethod != codersdk.OAuth2TokenEndpointAuthMethod(existingApp.TokenEndpointAuthMethod.String) &&

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.

P2 [CRF-9] The server reports token_endpoint_auth_method: "none" for legacy rows whose token exchange still requires a secret, and this PR is what makes a client likely to act on that report. (Meruem P2, Chopper P3, Kite P3, Ryosuke P3, Melody P3)

Not a re-raise of CRF-1. Five reviewers verified that fix is correct and complete for what it claimed. This is the state the guard deliberately preserves.

The server enforces on one column and reports the other. Nothing in coderd/oauth2provider reads token_endpoint_auth_method to decide behavior; IsPublic() reads client_type. But token_endpoint_auth_method is what GET /oauth2/clients/{id} reports (registration.go:262) and what the PUT response echoes (:418).

Before this PR that was inert, because "none" meant nothing anywhere. This PR changes two things at once. DetermineClientType now makes "none" mean "no secret" for new registrations, and metadata.go:39 now advertises "none" in token_endpoint_auth_methods_supported, which tells every client that Coder honors it.

Chopper traced it from the client's side: an MCP client registered months ago with "none", kept its secret because it had to, re-reads discovery, sees none advertised, reads its own metadata, sees "none", drops the secret, and gets 400 Missing required parameter: client_secret or 401 invalid_client with nothing naming the cause and no server-side log of the mismatch. The recovery, a PUT declaring client_secret_basic, is the one thing nobody is told about, and it is already covered by TestUpdateClientConfiguration_LegacyAuthMethodMismatch/MovingToSecretBasedMethodIsAccepted.

The population is not empty: coderd/oauth2_security_test.go:258 registers with none today.

Two fixes, and three reviewers converged on the first as the structural one. Report the auth method derived from client_type in both response builders, so what Coder tells a client always matches what Coder enforces; it round-trips through the guard (both derived types are confidential, so the update is accepted) and the row repairs itself on the client's next PUT. Or backfill once:

UPDATE oauth2_provider_apps a
SET token_endpoint_auth_method = 'client_secret_basic'
WHERE a.client_type = 'confidential'
  AND a.token_endpoint_auth_method = 'none'
  AND EXISTS (SELECT 1 FROM oauth2_provider_app_secrets s WHERE s.app_id = a.id);

After either, derived(token_endpoint_auth_method) == client_type becomes a true invariant of the table, the guard's first conjunct stops being load-bearing, and the comment block explaining why the two columns may disagree goes away with it. Held at P2 because the client behavior that triggers it is spec-conformant but inferred, not observed.

🤖


- `client_secret_basic` (recommended): HTTP Basic authentication (RFC 6749 §2.3.1). The username is `client_id` and the password is `client_secret`.
- `client_secret_post`: Form-based authentication where `client_id` and `client_secret` are sent in the request body.
- `none`: No client secret. The client is a public client and authenticates with PKCE alone (RFC 7591 §2, OAuth 2.1 §2.1). Use this for native, mobile, and CLI applications that cannot keep a secret confidential.

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.

P3 [CRF-10] The new none bullet states as unconditional fact something that is false for every client registered with none before this change. (Mafuuu P3, Leorio P3, Kite Note, Razor Note)

This is the line CRF-2's fix added, and the fix is incomplete rather than undone: the added text is accurate for new registrations and wrong for pre-existing none clients, a case your PR description documents and the page does not.

The line reads "none: No client secret. The client is a public client and authenticates with PKCE alone". [...] The docs page is the one place a human goes to reconcile what GET /oauth2/clients/{id} reports with what /oauth2/tokens demands, and for those clients it reports none while demanding a secret. client_type is exposed by no API surface, so the docs are the only available explanation and they contradict the behavior.

Leorio traced the reader: they do the RFC 7592 GET, see "none", read this bullet, drop the secret, get 401 invalid_client: The client credentials are invalid, and spend the evening re-reading their PKCE code, because the page told them PKCE is what authenticates them.

One sentence after the bullet closes it, and it is also the only place a reader can learn what to do instead, since line 127 tells them the RFC 7592 update will be rejected but not the way out: clients that registered with none before this feature shipped were issued a secret and remain confidential, they must keep sending client_secret, and re-registering is how they become public.

If CRF-9 is fixed by deriving the reported auth method from client_type, this sentence becomes unnecessary. Do one or the other, not neither.

🤖

Comment thread coderd/database/modelmethods.go Outdated
// An unset or unrecognized client type reads as confidential, so an app can
// never skip client authentication by accident.
func (a OAuth2ProviderApp) IsPublic() bool {
return a.ClientType.String == OAuth2ProviderAppClientTypePublic

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.

P3 [CRF-13] client_type now decides whether client authentication runs at all, and the column accepts any text. (Knuckle P3, Kurapika P3, Knov P3, Kite Note)

CRF-3's fix stops at the driver boundary. It makes the two Go spellings compiler-equal, which is real, but dump.sql:2655 declares client_type text DEFAULT 'confidential'::text: nullable, no CHECK, no enum. Three reviewers grepped independently and found no check constraint on any oauth2_provider_* table.

No current Go path can write a bad value, and IsPublic() fails closed on anything unrecognized, which ZeroValueClientTypeDefaultsToRequiringSecret and UnrecognizedClientTypeDefaultsToRequiringSecret pin. So the read side is safe today. The point is which failure the schema still permits:

the one string that matters is the one a future migration is most likely to write correctly by accident, and writing 'public' onto a row that holds a secret turns off client authentication for that app with nothing to catch it: no constraint, no log, no audit entry, no test.

This PR is the one that promotes the column from decoration to the gate. Layer 1 (#27712) owned the schema and deliberately added no capability, so the constraint belongs with the capability, and there is no third layer to put it in. The repo already has the pattern (mcp_server_configs_auth_type_check, and a real enum for the unrelated chats.client_type).

UPDATE oauth2_provider_apps SET client_type = 'confidential' WHERE client_type IS NULL;
ALTER TABLE oauth2_provider_apps
    ADD CONSTRAINT oauth2_provider_apps_client_type_check
    CHECK (client_type IN ('confidential', 'public'));
ALTER TABLE oauth2_provider_apps ALTER COLUMN client_type SET NOT NULL;

The UPDATE should touch zero rows (migration 000344 already backfilled with COALESCE). Both ALTERs take ACCESS EXCLUSIVE and scan a table holding one row per registered client, so the lock is milliseconds. token_endpoint_auth_method is the sibling instance, also unconstrained nullable text and also compared against codersdk constants.

🤖

// differ for a legacy row whose stored type and auth method
// disagree, silently converting it to public while it still holds a
// secret.
ClientType: existingApp.ClientType,

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.

Note [CRF-44] The carry-the-stored-client_type-through decision already had precedent one file over. (Robin)

apps.go:153 writes ClientType: app.ClientType, // Keep existing value, in a block where eleven fields do the same. UpdateClientConfiguration has now reached the same conclusion independently, by a different route, with a nine-line comment. The code is right; two update paths now hold the same rule with no reference between them, so a future change to the rule has two places to find.

🤖

if req.ClientSecret == "" {
// Public clients have no secret; PKCE is their client
// authentication (RFC 7591 §2, OAuth 2.1 §2.1).
if !isPublic && req.ClientSecret == "" {

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.

Note [CRF-45] A public client_id lets an unauthenticated request reach a second DB query where a confidential one stopped at one. (Killua)

Measured, not guessed. Before this change a garbage authorization_code POST died in extractTokenRequest on the empty client_secret, after exactly one query: the app lookup in the middleware. For a public client, validation passes with no secret, so the request reaches GetOAuth2ProviderAppCodeByPrefix and fails there instead.

The /oauth2 route tree carries no apiRateLimiter (coderd/coderd.go:1229), unlike /api/v2 and /api/experimental. That gap is pre-existing and dominates: an attacker was already free to hammer the endpoint for one query per request. This doubles the constant, it does not change the order.

Nothing after the code lookup is expensive either. Not worth changing here. Worth knowing that public clients are the first client type where the token endpoint does real lookup work for a caller that proved nothing.

🤖

Comment thread coderd/oauth2provider/registration.go Outdated
// they resend the exact metadata GET reports, leaving re-registration as
// the only recovery.
//
// Comparing derived types rather than raw auth methods keeps

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.

Note [CRF-46] A client's type is now permanent, and the only way out is a new client_id. (Luffy, Razor)

The guard is correct and neither reviewer asks for a change. Naming the cost: a developer who registers with the default, which is client_secret_basic and therefore confidential, and then realizes they are shipping a CLI cannot fix it through RFC 7592. They re-register, get a new client_id, and every user who already consented consents again.

The damaging direction is public to confidential, which the PR description explains well. Confidential to public isn't damaging, it's a downgrade the client is explicitly asking for with a registration access token in hand. Both are rejected because that's simpler, and simpler is probably right for a v1 that mostly serves clients who auto-register on first run and never edit.

Recorded so the next person reading a bug report titled "can't convert my client" knows it is deliberate and knows the answer is re-register.

Separately, on the comment at this line: Razor notes it says the guard compares derived types "rather than raw auth methods", while the condition it documents compares both. The behavioral conclusion is still true, since the derived conjunct is what keeps client_secret_basic and client_secret_post interchangeable. Only the description of the mechanism contradicts the paragraph above it. Worth fixing while doing CRF-33.

🤖

- `none`: No client secret. The client is a public client and authenticates with PKCE alone (RFC 7591 §2, OAuth 2.1 §2.1). Use this for native, mobile, and CLI applications that cannot keep a secret confidential.

Coder supports both methods for compatibility; existing integrations using `client_secret_post` do not need to change.
Coder supports both secret-based methods for compatibility; existing integrations using `client_secret_post` do not need to change.

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.

Nit [CRF-48] "Coder supports both secret-based methods" sits directly under a three-item list. (Pen Botter)

The qualifier "secret-based" is doing real work and a scanning reader will go back up to check whether "both" means two of three. "Coder supports client_secret_basic and client_secret_post for compatibility" removes the count.

🤖

Comment thread coderd/oauth2provider/registration.go Outdated

// Update app in database
now := dbtime.Now()
//nolint:gocritic // OAuth2 system context — RFC 7592 client configuration endpoint

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.

Note [CRF-49] The update-path //nolint still carries an em-dash while the two on the create path were rewritten to commas. (Gon)

CRF-7 covers this and your position stands: scripts/check_emdash.sh is changed-lines-scoped, so untouched lines stay. Recorded only because the same directive now reads two different ways within one file, which a later reader will notice before they notice the checker's scope.

🤖

BobbyHo added a commit that referenced this pull request Aug 6, 2026
Addresses the first full panel review on #27873. Twenty findings; the
substantive ones:

The transaction test could not detect the regression it was written for.
Stubbing InTx to call the closure with the same mock made a call on tx and
a call on the outer store indistinguishable, so moving the secret insert
back outside the transaction kept it green. The closure now receives a
second mock, and an insert issued on the outer handle fails as an
unexpected call.

The server reported `token_endpoint_auth_method` from the stored column
while enforcing on `client_type`. Clients registered with "none" before it
was honored are stored confidential and still need their secret, so
reporting "none" told them to drop it. Report the method implied by the
enforced type instead, which also lets the row repair itself on the
client's next update.

An admin could mint a client secret for a public app. The token endpoint
never validates it, and deleting it revokes nothing, because a public
client's tokens carry a NULL `app_secret_id` rather than cascading from the
secret. The confidential kill switch silently did not exist for public
apps, so secret creation is now rejected for them.

The client type is a defined type with a single owner for the mapping from
auth method, and `client_type` is constrained at the schema level. It
decides whether client authentication runs at all, and the column accepted
any text. Redirect URI validation now derives publicness from the same
place registration does rather than re-deriving it.

PKCE verifiers are checked against RFC 7636 §4.1's 43 to 128 character
bound. For a public client the verifier is the only client authentication,
and a one-character verifier hashes to a well-formed challenge, so the
comparison alone could not tell a secret from a guess.

The rest: RFC 7592 rejections name the values compared and log, a public
client's exchange is tested with missing, wrong, and too-short verifiers,
`client_secret` absence is asserted against the raw body rather than a
decoded struct, `registration_client_uri` uses JoinPath so a trailing slash
in the access URL cannot double, and the public-client fixture is shared
instead of copied.

Docs and the token endpoint's swagger annotations described a
confidential-only world: both now cover public clients, including the
redirect URI schemes they cannot use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An RFC 7591 registration requesting `token_endpoint_auth_method: "none"`
now produces a public client: no secret is minted, `client_type` is
persisted as `public`, and the token endpoint accepts that client's
authorization_code exchange with PKCE alone. Discovery advertises `"none"`
as a supported auth method.

PKCE was already mandatory for every authorization_code flow, so public
clients inherit it unchanged. That makes the code ownership check
(`dbCode.AppID != app.ID`) the sole 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 covered with a public client, along
with the refresh and revocation paths, which are the first to see a NULL
`app_secret_id`. Verifier length is now checked against RFC 7636 §4.1, since
for a public client PKCE is the only client authentication and a
one-character verifier hashes to a well-formed challenge.

Registration writes the app and its secret in one transaction. Two
independently committed inserts could leave a permanently committed app that
can never authenticate while still holding a registration access token.

An RFC 7592 update can no longer move a client between public and
confidential, and secret creation is rejected for a public app: the token
endpoint would never validate that secret, and deleting it revokes nothing
because a public client's tokens carry a NULL `app_secret_id` instead of
cascading from the secret.

Clients registered with `"none"` before it was honored are stored
confidential and still require their secret, so an update that resends their
own metadata is accepted rather than rejected, and the auth method reported
back is the one the server actually enforces.

Depends on #27931 for the client_type constraint and the schema-level
alignment of the two columns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-handler-layer branch from bf25ead to c5c3320 Compare August 6, 2026 20:09
@BobbyHo
BobbyHo changed the base branch from main to oauth2-client-type-constraint August 6, 2026 20:09
@BobbyHo

BobbyHo commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto #27931; schema changes moved out

This PR had grown to include database-layer work, which is the thing the layered split exists to avoid. The schema changes have been extracted into #27931, and this branch now sits on top of it.

Nothing has been reverted. All 20 findings from the panel round are still addressed here; only their location changed.

What moved to #27931

migrations/000565 CHECK (client_type IN (...)) and NOT NULL
migrations/000566 backfill for rows whose token_endpoint_auth_method contradicted client_type
dump.sql, models.go, queries.sql.go, check_constraint.go generated from those
3 write sites in apps.go, registration.go, dbgen.go mechanical: SET NOT NULL changes the generated field from sql.NullString to string

That last row is why the two PRs cannot be reviewed independently: this branch reads app.ClientType as a plain string, which only holds once #27931's migration has changed the generated type. #27931 merges first.

The migrations were also renumbered from 000563/000564, which main has since taken for template_agents_allowed and delete_agents_template_allowlist. If you looked at this PR earlier, it was carrying colliding migration numbers.

What stayed here

The capability and its tests: conditional secret minting, the registration transaction, the RFC 7592 client-type guard, IsPublic, the typed OAuth2ClientType, PKCE verifier bounds, the public-client exchange/refresh/revoke coverage, the secret-creation rejection for public apps, and the docs and swagger updates. 25 files, no migrations.

Note on commit SHAs in the review replies

Rebuilding onto #27931 collapsed this branch's three commits into one, c5c332051a. The replies on the review threads cite the previous SHAs, and those links still resolve, but the commits are no longer in this branch's history:

Cited in replies Now part of
332a48fca2 (round 1 fixes) c5c332051a
be8944a582 (panel round fixes) c5c332051a
bf25ead690 (auth method backfill) #27931, as 000566

I chose a file-level rebuild over hand-resolving four commits of conflicts across registration.go, tokens.go, and oauth2_test.go, since every intermediate state would have needed re-verification on the security-relevant paths. Since this repo squash-merges, the boundaries would not have reached main either way. Happy to reconstruct them if it would help review.

Verification on the current tip

go build ./... clean, golangci-lint exit 0, no make gen drift, and passing: ./coderd -run TestOAuth2, ./coderd/oauth2provider/..., ./codersdk, ./coderd/database/migrations/..., and database.TestOAuth2ProviderAppIsPublic.

Still open

Three findings from the panel round are deliberate deferrals awaiting a human decision rather than an agent's, and are unaddressed on purpose: CRF-11 (private-use URI schemes such as vscode:// are rejected for public clients but accepted for confidential ones; relax the validator or keep the restriction, currently documented), CRF-15 (the refresh grant authenticates no client at all, confidential included, contrary to RFC 6749 §6; pre-existing and identical on main), and CRF-29 (oauth2_provider_app_tokens has no secondary indexes while GetOAuth2ProviderAppTokenByAPIKeyID runs on every authenticated request made with an app token).

BobbyHo added a commit that referenced this pull request Aug 7, 2026
… this tree

Addresses part of CRF-6 on #27931. The comment named IsPublic, which is
added in #27873 and greps to nothing here, so a reader during the window
between the two PRs goes looking for a function that does not exist. Ten
panel reviewers flagged it independently.

States the property directly instead, which is true at this commit and stays
true afterwards.

The finding's other four instances describe enforcement that arrives with
#27873 and are deliberately left: unlike a dangling symbol they are accurate
statements about where the column is headed, and each becomes true when that
PR lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BobbyHo added a commit that referenced this pull request Aug 7, 2026
… assertions

Addresses CRF-7 and CRF-5 on #27931.

CRF-7: the parallel subtests queried through the parent's context. Its
deadline starts when it is created, but a parallel subtest does not run until
a -parallel slot frees, so most of the budget can be spent queued behind other
tests in the package before the first query runs. The reported failure would
be "context deadline exceeded" on a sub-20ms single-row read, naming no code
and worsening as the package grows. paralleltestctx does not catch it because
the context arrives through a helper's return value rather than a direct call
in the subtest. Each subtest now creates its own.

CRF-5, first half: no public + NULL row was seeded, so the second UPDATE's
IS NULL arm matched nothing and removing it left the test green. That shape is
now seeded and asserted.

CRF-5, second half: the closing invariant used <>, which is NULL-blind. With a
NULL declaration the comparison is NULL, WHERE drops the row, and an
unrepaired NULL counts as consistent. Now IS DISTINCT FROM. This matters past
this test: the predicate is the obvious candidate for the permanent
cross-column CHECK after #27873, and a CHECK is more forgiving still, since
NULL reads as not-violated.

Mutation-checked. Removing the second UPDATE's IS NULL arm now fails both the
publicNull case and the invariant count; with the old <> form only the former
fires, which is what made the blind spot invisible. Full package passes at
-parallel=1, the worst case for queue wait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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