feat: support public (secretless, PKCE-only) OAuth2 clients at the handler layer - #27873
feat: support public (secretless, PKCE-only) OAuth2 clients at the handler layer#27873BobbyHo wants to merge 11 commits into
Conversation
6c82b8f to
7cab879
Compare
|
/coder-agents-review |
|
Chat: Review posted | View chat Review historydeep-review v0.9.0 | Round 2 | Last posted: Round 2, 48 findings (5 P2, 20 P3, 3 P4, 9 Nit, 11 Note), COMMENT. Review Finding inventoryFinding inventory, PR #27873Findings
Contested and acknowledgedCRF-5 (Note, tokens.go:98) - public client sending a client_secret is accepted
CRF-6 (Note, metadata.go:39) -
|
| # | 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-175myself:isValidCustomSchemerequires a literal.and is applied only on theisPublicClientbranch, sovscode://,jetbrains://, andcursor://are accepted for confidential clients and rejected for public ones. This PR is what makesnonethe 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 whennonestarted 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
isPublicis resolved by a different rule than theclient_idthe 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. APOST /oauth2/tokens?client_id=QUERYIDwithclient_id=BODYIDin the body returns two validation errors fromparseSingle(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_idFK isON DELETE CASCADEand is now NULL for public-client tokens, which removes the secret-deletion cascade as a revocation path (CRF-12).app_idFK is alsoON DELETE CASCADE, so deleting a public app still removes its tokens.client_typehas a column default ofconfidentialand 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.
There was a problem hiding this comment.
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.
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]>
Docs previewCheck 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. |
|
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. CRF-7 (Note), pre-existing em-dashes. No change, agreed. 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 Every fix here is mutation-checked rather than just asserted green:
Also corrected the PR description, which claimed legacy Verification on the updated branch: |
|
/coder-agents-review |
There was a problem hiding this comment.
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:
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.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 carryapp_secret_id = NULL. The confidential kill switch silently does not exist for public apps.codersdk/oauth2_validation.go:171, the docs line this PR adds points native, mobile, and CLI apps atnone, andnoneis the branch that rejectsvscode://,jetbrains://, andcursor://. I verified the asymmetry in the code: those schemes register fine as confidential and 400 as public.registration.go:353, the server reportstoken_endpoint_auth_method: nonefor 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_idis not a secret. It is returned in the registration response, echoed by RFC 7592GET, 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:
httpmw/apikey.go:707callsGetOAuth2ProviderAppTokenByAPIKeyIDon every authenticated request made with an OAuth2 provider app token, for RFC 8707 audience validation.WHERE api_key_id = $1with no index is a sequential scan, once per request.dbpurge/dbpurge.go:227deletes up to 10,000 expiredapi_keysrows every 10 minutes, and the FK isON 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.DeleteOAuth2ProviderAppTokensByAppAndUserIDfiltersapp_id AND user_idandGetOAuth2ProviderAppsByUserIDjoins onuser_id. Both scan. These are the two queries #27712 moved ontoapp_idspecifically 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-224says 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 asapplication_type: nativewithclient_secret_basicis storedpublic, gets no secret, and is validated against the confidential redirect rules, which permithttp://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. |
There was a problem hiding this comment.
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.
🤖
| // 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, |
There was a problem hiding this comment.
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 == "" { |
There was a problem hiding this comment.
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
/oauth2route tree carries noapiRateLimiter(coderd/coderd.go:1229), unlike/api/v2and/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.
🤖
| - `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. |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
🤖
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]>
bf25ead to
c5c3320
Compare
Rebased onto #27931; schema changes moved outThis 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
That last row is why the two PRs cannot be reviewed independently: this branch reads The migrations were also renumbered from What stayed hereThe capability and its tests: conditional secret minting, the registration transaction, the RFC 7592 client-type guard, Note on commit SHAs in the review repliesRebuilding onto #27931 collapsed this branch's three commits into one,
I chose a file-level rebuild over hand-resolving four commits of conflicts across Verification on the current tip
Still openThree 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 |
… 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]>
… 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]>
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]>
c5c3320 to
0bc20fd
Compare
…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.
0bc20fd to
9795ef7
Compare
…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.
9795ef7 to
9eca480
Compare
…_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.
…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.
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.
…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
…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.
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.
…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
Split out of #27873 to make that PR smaller to review. Second in the stack; adds the vocabulary the rest of the public-client work is built on, with no behavioral change beyond what it stores. RFC 7591 §2 / OAuth 2.1 §2.1 define two client types: a confidential client authenticates with a secret, a public client authenticates with PKCE alone. DetermineClientType() previously hardcoded "confidential" regardless of the requested token_endpoint_auth_method. It now derives the type via the new ClientTypeFor() mapping, which is the single owner of the auth-method-to-client-type relationship: registration derives the stored client_type from it, and redirect URI validation uses it to pick which RFC 8252 rules apply, so the two cannot disagree about what "public" means. OAuth2ProviderApp.IsPublic() is the reader for the stored client_type column, added alongside matching database constants so the value registration writes and the value IsPublic reads back cannot drift. An unset or unrecognized client type reads as confidential, so an app can never skip client authentication by accident. AllOAuth2TokenEndpointAuthMethods() is the single source Valid() reads from, so what registration accepts is defined in one place. Discovery metadata does not yet derive from it and still hardcodes its own list without "none"; a follow-up PR wires the token endpoint to honor "none", and only then should discovery advertise it too. registration.go and app registration itself do not yet skip secret issuance for a public client; that follows in the next PR in the stack.
Split out of #27873 to make that PR smaller to review. First in the stack; the rest of the public-client work builds on this. `isValidCustomScheme` required a literal `.` in the scheme for a public client's redirect URI, so `vscode://`, `jetbrains://`, and `cursor://` all 400'd while the identical schemes passed for a confidential client through the separate, more permissive `validateScheme`. Native and CLI apps, the population public clients exist for, register those exact schemes with their OS. Removed the extra restriction: `validateScheme` already blocks the schemes that are actually dangerous in a redirect context, and RFC 8252 section 7.1 only recommends reverse-domain notation rather than requiring it. PKCE, not the scheme's spelling, is what secures a public client's redirect. That removal also stopped rejecting `mailto`, `tel`, and `sms` for public clients specifically, since `validateScheme`'s dangerous-scheme blocklist never covered them either. Those three hand off to a mail client, dialer, or SMS app rather than returning control to the application that started the flow, so a public client registered with one of them could never actually complete authorization. They are rejected again here, scoped to public clients only because that is how custom-scheme validation was already scoped before this change, not because they are known to be safe for a confidential client's redirect; confidential clients were never subject to any scheme-shape check beyond `validateScheme` and remain so here. Refs https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
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.
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.
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>
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.
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.
…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
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
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.
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
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
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.
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
**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
…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>
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_typeis persisted aspublic, and the token endpoint accepts that client'sauthorization_codeexchange 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 byclient_idfor 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_typetoconfidential, 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 metadataGETreports. The guard therefore rejects only an update that actually changestoken_endpoint_auth_method, and the update carries the storedclient_typethrough 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.gobecause 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
isPublicmakes "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.
UpdateClientConfigurationwroteclient_typestraight from the request body with no comparison to the existing app, which was inert only whileDetermineClientType()was hardcoded. Public to confidential is the damaging direction: the client is marked confidential with no secret row, the token endpoint then demands aclient_secretit was never issued, andOAuth2ClientConfigurationhas 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 returns400 invalid_client_metadata. The guard compares the derived client type rather than the raw auth method, soclient_secret_basicandclient_secret_poststay interchangeable.Refs https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client