Skip to content

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

Closed
BobbyHo wants to merge 11 commits into
mainfrom
oauth2-public-clients-handler-layer
Closed

feat: support public (secretless, PKCE-only) OAuth2 clients at the handler layer#27873
BobbyHo wants to merge 11 commits into
mainfrom
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) <[email protected]>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Docs preview

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

@BobbyHo

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

🤖

Comment thread coderd/oauth2provider/registration_test.go
Comment thread coderd/oauth2provider/registration.go Outdated
Comment thread docs/admin/integrations/oauth2-provider.md Outdated
Comment thread coderd/database/modelmethods.go Outdated
// 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
- `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.

🤖


// 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) <[email protected]>
@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) <[email protected]>
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) <[email protected]>
BobbyHo added a commit that referenced this pull request Aug 10, 2026
Extracted from #27873 so the schema change can be reviewed for migration
safety on its own. #27873 will rebase onto this.

`client_type` decides whether the token endpoint validates a client
secret at all, and the column accepts any text: nullable, no `CHECK`, no
enum. No Go path can write a bad value today, and `IsPublic` fails
closed on anything unrecognized, so the read side is safe. What the
schema still permits is the problem: a future migration 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.

`000565` adds `CHECK (client_type IN ('confidential', 'public'))` and
`NOT NULL`. The `UPDATE` ahead of it should touch zero rows, since
migration `000344` added the column with a default of `'confidential'`
and backfilled with `COALESCE`; it is there so `SET NOT NULL` cannot
fail on an unexpected row. Both `ALTER`s take `ACCESS EXCLUSIVE` and
scan a table holding one row per registered OAuth2 client, so the lock
is brief.

## The second migration, and why it aligns the way it does

Two columns describe the same fact and can currently contradict each
other.

`token_endpoint_auth_method` is the client's own declaration: registered
client metadata under RFC 7591 §2, where `"none"` is defined to mean the
client is public and has no secret. `client_type` is Coder's derived
copy, and it is what the token endpoint enforces on. RFC 7591 defines no
`client_type` metadata field; the column exists only as a
denormalization.

Registration used to persist the declaration verbatim while hardcoding
`client_type` to `'confidential'`, so rows exist declaring `"none"` on a
client stored confidential that was issued, and still requires, a real
secret. A client that reads its own metadata and believes it is public
will drop that secret and stop being able to exchange codes.

`000566` aligns the declaration to what is enforced, not the reverse.
Deriving enforcement from the declaration would reclassify every such
client as public and stop requiring the secret it holds, which is a
silent authentication downgrade. The down migration is deliberately
empty: the previous values are not recorded, and restoring them would
only reinstate metadata that tells a client to authenticate in a way the
server rejects.

## Application changes

`SET NOT NULL` changes the generated field from `sql.NullString` to
`string`, so the three write sites are updated to match. That is the
entire application diff and no behavior depends on it.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Base automatically changed from oauth2-client-type-constraint to main August 10, 2026 17:49
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-handler-layer branch from c5c3320 to 0bc20fd Compare August 10, 2026 18:38
…length floor

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

ValidPKCEVerifier enforces the length and charset bounds before the
existing S256 comparison runs. The existing TestOAuth2InvalidPKCE test
already exercises a 14-character verifier end to end and continues to
pass, now rejected on length rather than on hash mismatch.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-handler-layer branch from 0bc20fd to 9795ef7 Compare August 10, 2026 20:26
@BobbyHo
BobbyHo changed the base branch from main to oauth2-pkce-verifier-length August 10, 2026 20:27
…ngth

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

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

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

TestExtractAuthorizeParams_Scopes used a 14-character placeholder
code_challenge that the new check now correctly rejects; lengthened it
to a valid value since that test only exercises scope parsing.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-handler-layer branch from 9795ef7 to 9eca480 Compare August 10, 2026 22:28
…_verifier

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

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

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

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

Lengthen InvalidCodeVerifier to a well-formed but wrong 43-character
value so it again reaches the hash comparison. Add MalformedCodeVerifier
and a new test asserting the length-rejection path returns
invalid_request, now that the previous commit gives it a distinct error
from the hash-mismatch invalid_grant case.
BobbyHo added a commit that referenced this pull request Aug 12, 2026
…om auth method

Split out of #27873 to make that PR smaller to review. Second in the
stack; adds the vocabulary the rest of the public-client work is built
on, with no behavioral change beyond what it stores.

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

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

AllOAuth2TokenEndpointAuthMethods() is the single source both Valid()
and (in a later change) discovery metadata read from, so what
registration accepts and what /.well-known advertises cannot drift
apart either.

registration.go and app registration itself do not yet skip secret
issuance for a public client; that follows in the next PR in the
stack.
BobbyHo added a commit that referenced this pull request Aug 12, 2026
Split out of #27873 to make that PR smaller to review. Third in the
stack; this is the point where dynamic client registration actually
produces a public client.

An RFC 7591 registration requesting token_endpoint_auth_method: "none"
now skips secret generation entirely: no secret is minted, and the app
is persisted with the client_type the previous PR in the stack derives
from that auth method. Discovery advertises "none" as a supported
method so a client can find out Coder will accept it.

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

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

The token endpoint does not yet accept a public client's PKCE-only
exchange; that follows in the next PR in the stack, so a client
registered here cannot yet obtain a token.
BobbyHo added a commit that referenced this pull request Aug 12, 2026
…ic clients

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
BobbyHo added a commit that referenced this pull request Aug 12, 2026
…om auth method

Split out of #27873 to make that PR smaller to review. Second in the
stack; adds the vocabulary the rest of the public-client work is built
on, with no behavioral change beyond what it stores.

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

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

AllOAuth2TokenEndpointAuthMethods() is the single source both Valid()
and (in a later change) discovery metadata read from, so what
registration accepts and what /.well-known advertises cannot drift
apart either.

registration.go and app registration itself do not yet skip secret
issuance for a public client; that follows in the next PR in the
stack.
BobbyHo added a commit that referenced this pull request Aug 12, 2026
Split out of #27873 to make that PR smaller to review. Third in the
stack; this is the point where dynamic client registration actually
produces a public client.

An RFC 7591 registration requesting token_endpoint_auth_method: "none"
now skips secret generation entirely: no secret is minted, and the app
is persisted with the client_type the previous PR in the stack derives
from that auth method. Discovery advertises "none" as a supported
method so a client can find out Coder will accept it.

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

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

The token endpoint does not yet accept a public client's PKCE-only
exchange; that follows in the next PR in the stack, so a client
registered here cannot yet obtain a token.
BobbyHo added a commit that referenced this pull request Aug 12, 2026
…ic clients

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
BobbyHo added a commit that referenced this pull request Aug 12, 2026
Split out of #27873 to make that PR smaller to review. Second in the
stack; adds the vocabulary the rest of the public-client work is built
on, with no behavioral change beyond what it stores.

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

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

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

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

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

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

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

Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
BobbyHo added a commit that referenced this pull request Aug 12, 2026
Split out of #27873 to make that PR smaller to review. Third in the
stack; this is the point where dynamic client registration actually
produces a public client.

An RFC 7591 registration requesting token_endpoint_auth_method: "none"
now skips secret generation entirely: no secret is minted, and the app
is persisted with the client_type the previous PR in the stack derives
from that auth method. Discovery advertises "none" as a supported
method so a client can find out Coder will accept it.

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

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

The token endpoint does not yet accept a public client's PKCE-only
exchange; that follows in the next PR in the stack, so a client
registered here cannot yet obtain a token.
BobbyHo added a commit that referenced this pull request Aug 12, 2026
Split out of #27873 to make that PR smaller to review. Third in the
stack; this is the point where dynamic client registration actually
produces a public client.

An RFC 7591 registration requesting token_endpoint_auth_method: "none"
now skips secret generation entirely: no secret is minted, and the app
is persisted with the client_type the previous PR in the stack derives
from that auth method. Discovery advertises "none" as a supported
method so a client can find out Coder will accept it.

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

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

The token endpoint does not yet accept a public client's PKCE-only
exchange; that follows in the next PR in the stack, so a client
registered here cannot yet obtain a token.
BobbyHo added a commit that referenced this pull request Aug 12, 2026
The token endpoint accepted any non-empty `code_verifier`, so a
one-character verifier was enough to authenticate. RFC 7636 §4.1
requires 43 to 128 characters from the unreserved set.

That fix plus the related gaps review surfaced in the same path:

- Enforce the length and charset floor on the verifier before the S256
comparison runs.
- Validate the challenge at the authorize endpoint too. It was only
checked for non-emptiness, so a malformed challenge was stored and then
failed late at token exchange, blaming the wrong parameter.
- A malformed verifier now returns `invalid_request` (RFC 6749 §5.2); a
well-formed but wrong one still returns `invalid_grant` (RFC 7636 §4.6).
Both looked identical before, so a client had no way to tell a syntax
error from a hash mismatch and would retry the same bad verifier
forever.
- Revoke the authorization code when a PKCE check fails. Without that, a
leaked code could be replayed with unlimited verifier guesses for its
remaining lifetime, and RFC 6749 §10.5 requires codes to be single use.
- Fix verifier generation in `scripts/oauth2/*.sh` and the docs example.
They deleted reserved base64 characters instead of translating them to
the URL-safe alphabet, so most runs produced verifiers under the new
floor.

Also carries #28041, which merged into this branch: public clients may
register bare custom schemes such as `vscode://` again, with `mailto`,
`tel`, and `sms` rejected.

Split out of #27873 (public OAuth2 client support). PKCE is already
mandatory for every client, so this stands on its own.

<details>
<summary>Manual verification</summary>

Ran against a local dev server on this branch, using a session token and
a throwaway app from `scripts/oauth2/setup-test-app.sh`.

1. Happy path unchanged: HTTP 200, verifier length 43.
2. `code_verifier=short`, and a 43-character verifier ending in `!`:
both HTTP 400 `invalid_request`, so charset is enforced and not just
length.
3. `code_challenge=tooshort` at authorize: HTTP 400 `invalid_request`,
no code issued. An empty challenge still hits the older "required and
cannot be empty" message.
4. Well-formed but wrong verifier: HTTP 400 `invalid_grant`, distinct
from the cases above.
5. Retrying that same code with the correct verifier: HTTP 400, code
already revoked by the failed check.
6. `generate-pkce.sh` produces a 43-character verifier (20 out of 20
runs); the docs example produces 128.
7. `scripts/oauth2/test-mcp-oauth2.sh` passes end to end. The two
bearer-token failures in its output are a pre-existing script bug
(`09c50559f3`, July 2025) that reuses a resource-scoped token against
the real API, not a regression here.

</details>
Base automatically changed from oauth2-pkce-verifier-length to main August 12, 2026 20:36
BobbyHo added a commit that referenced this pull request Aug 12, 2026
Split out of #27873 to make that PR smaller to review. Third in the
stack; this is the point where dynamic client registration actually
produces a public client.

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

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

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

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

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

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

The token endpoint does not yet accept a public client's PKCE-only
exchange; that follows in the next PR in the stack, so a client
registered here cannot yet obtain a token.
BobbyHo added a commit that referenced this pull request Aug 12, 2026
Split out of #27873 to make that PR smaller to review. Third in the
stack; this is the point where dynamic client registration actually
produces a public client.

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

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

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

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

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

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

The token endpoint does not yet accept a public client's PKCE-only
exchange; that follows in the next PR in the stack, so a client
registered here cannot yet obtain a token.
BobbyHo added a commit that referenced this pull request Aug 13, 2026
…ic clients

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

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

reportedAuthMethod() normalizes what token_endpoint_auth_method
CreateDynamicClientRegistration, GetClientConfiguration, and
UpdateClientConfiguration report back for a client whose stored method
and client_type disagree. This only arises for clients registered
before client_type was derived from the method: such a row is stored
confidential with a method of "none", and reporting "none" verbatim
would tell the client to drop a secret its exchange still requires.
Reporting the enforced behavior instead means the client's next PUT
repairs the mismatch on its own.
BobbyHo added a commit that referenced this pull request Aug 19, 2026
Adds an OAuth2 client type (public vs confidential, RFC 7591 §2) derived
from the requested auth method instead of hardcoded confidential. The
type is stored and guarded here, but no endpoint enforces on it yet;
public behavior at the token endpoint follows in the next PR in the
stack.

- Client type is derived once and reused by both registration and
redirect URI validation, so they can't disagree
- IsPublic() fails closed: an unrecognized or missing value reads as
confidential
- RFC 7592 update (PUT) now rejects moving a client between public and
confidential (400) instead of silently flipping it when the auth method
is omitted
- Discovery still doesn't advertise "none"; follows once the token
endpoint honors it

### Behavior by client shape

`client_type` is derived from `token_endpoint_auth_method` at POST and
pinned at PUT. RFC 7592 GET/PUT authenticate with the registration
access token, not the client secret, so neither endpoint reads a secret.

| Registered with | Stored `client_type` / method | GET reports | PUT
that flips the method |

|------------------------------------|---------------------------------------|-----------------------|-----------------------------------------|
| omitted, or `client_secret_basic` | `confidential` /
`client_secret_basic` | `client_secret_basic` | `none` → 400
`invalid_client_metadata` |
| `none` (new) | `public` / `none` | `none` | `client_secret_*` → 400
`invalid_client_metadata` |
| `none` (before this PR) | `confidential` / `none` | `none` | either →
200, type stays `confidential` |

- PUT still replaces every other RFC 7591 field. `client_type` is the
only pinned one; the method may move within a type
(`client_secret_basic` ↔ `client_secret_post`).
- Row 3 is the only shape where the two columns disagree. The guard
fires only on a method change that crosses the type line, so those
clients keep managing themselves instead of being locked out of their
own configuration endpoint.
- The token endpoint does not consult `client_type` yet, so every client
still authenticates with a secret and registration still issues one.

Split out of #27873, second in the stack (on top of #28041).
Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
BobbyHo added a commit that referenced this pull request Aug 20, 2026
Third in the stack, split out of #27873. This is where dynamic client
registration first produces a public client.

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

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

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

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

reportedAuthMethod() normalizes what token_endpoint_auth_method
CreateDynamicClientRegistration, GetClientConfiguration, and
UpdateClientConfiguration report back for a client whose stored method
and client_type disagree. This only arises for clients registered
before client_type was derived from the method: such a row is stored
confidential with a method of "none", and reporting "none" verbatim
would tell the client to drop a secret its exchange still requires.
Reporting the enforced behavior instead means the client's next PUT
repairs the mismatch on its own.
@github-actions github-actions Bot added the stale This issue is like stale bread. label Aug 24, 2026
aslilac pushed a commit that referenced this pull request Aug 24, 2026
Third in the stack, split out of #27873. This is where dynamic client
registration first produces a public client.

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

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

Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
@github-actions github-actions Bot closed this Aug 27, 2026
BobbyHo added a commit that referenced this pull request Aug 31, 2026
**TL;DR**

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

| PR | What it does |
|---|---|
| #27712 | Schema: `app_secret_id` becomes nullable and tokens gain an
always-populated `app_id`, so ownership checks work without a secret
row. |
| #28041 | Accepts bare custom-scheme redirect URIs (`vscode://`,
`cursor://`) that native apps actually register. |
| #28043 | Derives and stores `client_type` (public vs confidential)
from the requested `token_endpoint_auth_method`, and pins it across
updates. |
| #28046 | Registration issues no secret for a public client and returns
no `client_secret`. |
| **#28047 (this)** | The token endpoint accepts the exchange without a
`client_secret`, so a public client can finally get a token. Discovery
now advertises `none`. |

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

**Where in the flow**

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

**What it satisfies**

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

---

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

Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
BobbyHo added a commit that referenced this pull request Sep 1, 2026
…8097)

**TL;DR**

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

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

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

**Where in the flow**

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

**What it satisfies**

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

---

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

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



## Manual Tests

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

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

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

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

**Two notes for anyone re-running this.**

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

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

<details>
<summary>Shell helpers used throughout</summary>

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

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

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

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

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

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

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

</details>

<details>
<summary>1. Secrets API refuses a public client</summary>

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

```bash
PUB=$(register manual-28097-public none)
PUB_ID=$(echo "$PUB" | jq -r '.client_id')
stored "$PUB_ID"
show -X POST "$BASE_URL/api/v2/oauth2-provider/apps/$PUB_ID/secrets" -H "$AUTH_HEADER"
```

```text
public | none
HTTP 400
{
  "message": "Cannot create a client secret for a public OAuth2 app.",
  "detail": "Public clients authenticate with PKCE and have no client secret. The client type is fixed at registration, so register a new confidential client instead."
}
```

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

</details>

<details>
<summary>2. No partial secret row is left behind</summary>

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

```bash
curl -s "$BASE_URL/api/v2/oauth2-provider/apps/$PUB_ID/secrets" -H "$AUTH_HEADER" | jq -c .
psql "$PGURL" -At -c "select count(*) from oauth2_provider_app_secrets where app_id = '$PUB_ID';"
psql "$PGURL" -At -c "select count(*) from oauth2_provider_app_secrets where app_id = '$CONF_ID';"
```

```text
[]
0
1
```

</details>

<details>
<summary>3. A confidential app's secret lifecycle is unchanged</summary>

```bash
CONF=$(register manual-28097-confidential "")
CONF_ID=$(echo "$CONF" | jq -r '.client_id')
show -X POST "$BASE_URL/api/v2/oauth2-provider/apps/$CONF_ID/secrets" -H "$AUTH_HEADER"
curl -s "$BASE_URL/api/v2/oauth2-provider/apps/$CONF_ID/secrets" -H "$AUTH_HEADER" | jq -c '[.[].id]'
curl -s -X DELETE "$BASE_URL/api/v2/oauth2-provider/apps/$CONF_ID/secrets/$SECRET_ID" \
  -H "$AUTH_HEADER" -o /dev/null -w "delete: HTTP %{http_code}\n"
```

```text
HTTP 201
{
  "id": "da7a1029-47c4-435e-988a-557ffa4aeacf",
  "client_secret_full": "coder_POUtoElODM_<redacted>"
}
["b2fc55b6-...","da7a1029-...","24f8f8c2-..."]
delete: HTTP 204
["b2fc55b6-...","da7a1029-..."]
```

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

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

</details>

<details>
<summary>4. Admin-created apps are always confidential</summary>

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

```bash
ADMIN_APP=$(curl -s -X POST "$BASE_URL/api/v2/oauth2-provider/apps" -H "$AUTH_HEADER" \
  -H "Content-Type: application/json" \
  -d '{"name":"manual-28097-admin","callback_url":"http://localhost:9876/callback"}')
ADMIN_ID=$(echo "$ADMIN_APP" | jq -r '.id')
stored "$ADMIN_ID"
show -X POST "$BASE_URL/api/v2/oauth2-provider/apps/$ADMIN_ID/secrets" -H "$AUTH_HEADER"
```

```text
confidential | client_secret_post
HTTP 201
{
  "id": "a77ab276-ea74-4711-ba8b-955a8cb97951",
  "client_secret_full": "coder_eGlt04LhYg_<redacted>"
}
```

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

</details>

<details>
<summary>5. Agreement cases report the stored value unchanged</summary>

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

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

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

```text
{"reported":"none"}
{"reported_on_register":"client_secret_post"}
"client_secret_post"
confidential | client_secret_post
```

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

</details>

<details>
<summary>6. A legacy confidential row storing `none` reports
`client_secret_basic`</summary>

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

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

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

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

```text
confidential | client_secret_basic
UPDATE 1
confidential | none
1
{"reported":"client_secret_basic"}
confidential | none
```

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

</details>

<details>
<summary>7. The reported method is the one actually enforced</summary>

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

```bash
exchange "$LEGACY_ID" "$LEGACY_SECRET" | jq -c '{has_access: has("access_token"), error, error_description}'
exchange "$LEGACY_ID" ""               | jq -c '{has_access: has("access_token"), error, error_description}'
```

```text
{"has_access":true,"error":null,"error_description":null}
{"has_access":false,"error":"invalid_request","error_description":"Missing required parameter: client_secret"}
```

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

Scenario 10 shows the same property in the opposite direction.

</details>

<details>
<summary>8. An update resending the stored value is accepted and changes
nothing</summary>

```bash
show -X PUT "$BASE_URL/oauth2/clients/$LEGACY_ID" \
  -H "Authorization: Bearer $LEGACY_RAT" -H "Content-Type: application/json" \
  -d '{"client_name":"manual-28097-legacy","redirect_uris":["http://localhost:9876/callback"],"token_endpoint_auth_method":"none"}'
stored "$LEGACY_ID"
```

```text
HTTP 200
  "token_endpoint_auth_method": "client_secret_basic",
confidential | none
```

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

</details>

<details>
<summary>9. An update resending the reported value heals the
row</summary>

```bash
show -X PUT "$BASE_URL/oauth2/clients/$LEGACY_ID" \
  -H "Authorization: Bearer $LEGACY_RAT" -H "Content-Type: application/json" \
  -d '{"client_name":"manual-28097-legacy","redirect_uris":["http://localhost:9876/callback"],"token_endpoint_auth_method":"client_secret_basic"}'
stored "$LEGACY_ID"
curl -s "$BASE_URL/oauth2/clients/$LEGACY_ID" -H "Authorization: Bearer $LEGACY_RAT" \
  | jq -c '{reported: .token_endpoint_auth_method}'
exchange "$LEGACY_ID" "$LEGACY_SECRET" | jq -c '{has_access: has("access_token")}'
```

```text
HTTP 200
  "token_endpoint_auth_method": "client_secret_basic",
confidential | client_secret_basic
{"reported":"client_secret_basic"}
{"has_access":true}
```

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

</details>

<details>
<summary>10. The reverse mismatch reports `none`</summary>

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

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

```text
public | client_secret_basic
{"reported":"none"}
{"has_access":true,"error":null}
```

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

Both resend shapes behave symmetrically with scenarios 8 and 9:

```text
resend stored (client_secret_basic) -> reported "none", row unchanged
resend reported (none)              -> reported "none", row now public | none
```

</details>

<details>
<summary>11. Empty or unrecognized stored methods fall back to the
default</summary>

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

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

```text
confidential | <NULL>
"client_secret_basic"
confidential |
"client_secret_basic"
confidential | private_key_jwt
"client_secret_basic"
```

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

</details>

<details>
<summary>12. Type-changing updates are rejected both ways</summary>

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

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

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

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

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

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

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

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

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

</details>

<details>
<summary>13. Regression sweep of the merged stack</summary>

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

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

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

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

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

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

</details>

<details>
<summary>14. The new docs paragraphs match observed behavior</summary>

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

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

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

</details>

<details>
<summary>Cleanup</summary>

```bash
for id in "$PUB_ID" "$CONF_ID" "$POST_ID" "$LEGACY_ID" "$REVERSE_ID" "$ADMIN_ID"; do
  curl -s -X DELETE "$BASE_URL/api/v2/oauth2-provider/apps/$id" -H "$AUTH_HEADER" \
    -o /dev/null -w "%{http_code}\n"
done
curl -s -X PUT "$BASE_URL/api/v2/oauth2-provider/settings" -H "$AUTH_HEADER" \
  -H "Content-Type: application/json" -d '{"dynamic_client_registration_enabled": false}'
show -X POST "$BASE_URL/oauth2/register" -H "Content-Type: application/json" \
  -d '{"client_name":"should-fail","redirect_uris":["http://localhost:9876/callback"],"token_endpoint_auth_method":"none"}'
```

```text
204 (x6)
{"dynamic_client_registration_enabled":false}
HTTP 403
{"error":"invalid_request","error_description":"Dynamic client registration is disabled on this deployment"}
```

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

</details>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale This issue is like stale bread.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant