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 1 commit 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) <noreply@anthropic.com>
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.
🤖
| // (the mock itself, standing in for `tx`) so the two inserts | ||
| // below are recorded as happening inside one shared | ||
| // transaction, not as two independently committed statements. | ||
| mDB.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn( |
There was a problem hiding this comment.
P2 [CRF-8] The transaction test passes with the secret insert moved back outside the transaction, which is the exact regression it was written to catch. (Bisky P2, Ryosuke P3, Meruem Note, Kite Note)
InTxis stubbed asfunc(f func(database.Store) error, _ *database.TxOptions) error { return f(mDB) }. The closure receives the same mock the handler holds, so a call recorded "inside" the transaction and a call made afterInTxreturns are indistinguishable to gomock.Times(1)onInTxandgomock.InOrder(appCall, secretCall)both stay satisfied.
Two reviewers proved it by mutation, independently and by different routes. Bisky rewrote the handler into the pre-PR two-commit shape with InTx still called once: both subtests pass, including SecretInsertFailureFailsTheWholeRegistration, because the out-of-transaction path also returns 500. Ryosuke swapped one tx.InsertOAuth2ProviderAppSecret for db.InsertOAuth2ProviderAppSecret inside the closure: still green.
That second mutation is not hypothetical. .claude/docs/DATABASE.md:183-192 names outer-store use inside InTx as a specific project failure with a specific consequence, pool starvation and idle in transaction. This test is the only thing standing between that rule and this handler, and the header comment at line 214 claims the opposite of what the test can see.
Fix, verified green on current code and FAIL on the mutated code:
mTx := dbmock.NewMockStore(ctrl)
mDB.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(
func(f func(database.Store) error, _ *database.TxOptions) error { return f(mTx) },
).Times(1)
appCall := mTx.EXPECT().InsertOAuth2ProviderApp(...).Times(1)
secretCall := mTx.EXPECT().InsertOAuth2ProviderAppSecret(...).Times(1)
gomock.InOrder(appCall, secretCall)An insert issued outside the transaction then lands on mDB, which has no expectation for it, and gomock fails on the unexpected call. The same change applies to PublicClientSkipsSecretInsert at line 308, whose "absence of an expectation is the assertion" only holds once the two handles are distinct.
🤖
| // client_secret_basic and client_secret_post interchangeable, since both | ||
| // are confidential. | ||
| clientType := req.DetermineClientType() | ||
| if req.TokenEndpointAuthMethod != codersdk.OAuth2TokenEndpointAuthMethod(existingApp.TokenEndpointAuthMethod.String) && |
There was a problem hiding this comment.
P2 [CRF-9] The server reports token_endpoint_auth_method: "none" for legacy rows whose token exchange still requires a secret, and this PR is what makes a client likely to act on that report. (Meruem P2, Chopper P3, Kite P3, Ryosuke P3, Melody P3)
Not a re-raise of CRF-1. Five reviewers verified that fix is correct and complete for what it claimed. This is the state the guard deliberately preserves.
The server enforces on one column and reports the other. Nothing in coderd/oauth2provider reads token_endpoint_auth_method to decide behavior; IsPublic() reads client_type. But token_endpoint_auth_method is what GET /oauth2/clients/{id} reports (registration.go:262) and what the PUT response echoes (:418).
Before this PR that was inert, because
"none"meant nothing anywhere. This PR changes two things at once.DetermineClientTypenow makes"none"mean "no secret" for new registrations, andmetadata.go:39now advertises"none"intoken_endpoint_auth_methods_supported, which tells every client that Coder honors it.
Chopper traced it from the client's side: an MCP client registered months ago with "none", kept its secret because it had to, re-reads discovery, sees none advertised, reads its own metadata, sees "none", drops the secret, and gets 400 Missing required parameter: client_secret or 401 invalid_client with nothing naming the cause and no server-side log of the mismatch. The recovery, a PUT declaring client_secret_basic, is the one thing nobody is told about, and it is already covered by TestUpdateClientConfiguration_LegacyAuthMethodMismatch/MovingToSecretBasedMethodIsAccepted.
The population is not empty: coderd/oauth2_security_test.go:258 registers with none today.
Two fixes, and three reviewers converged on the first as the structural one. Report the auth method derived from client_type in both response builders, so what Coder tells a client always matches what Coder enforces; it round-trips through the guard (both derived types are confidential, so the update is accepted) and the row repairs itself on the client's next PUT. Or backfill once:
UPDATE oauth2_provider_apps a
SET token_endpoint_auth_method = 'client_secret_basic'
WHERE a.client_type = 'confidential'
AND a.token_endpoint_auth_method = 'none'
AND EXISTS (SELECT 1 FROM oauth2_provider_app_secrets s WHERE s.app_id = a.id);After either, derived(token_endpoint_auth_method) == client_type becomes a true invariant of the table, the guard's first conjunct stops being load-bearing, and the comment block explaining why the two columns may disagree goes away with it. Held at P2 because the client behavior that triggers it is spec-conformant but inferred, not observed.
🤖
|
|
||
| - `client_secret_basic` (recommended): HTTP Basic authentication (RFC 6749 §2.3.1). The username is `client_id` and the password is `client_secret`. | ||
| - `client_secret_post`: Form-based authentication where `client_id` and `client_secret` are sent in the request body. | ||
| - `none`: No client secret. The client is a public client and authenticates with PKCE alone (RFC 7591 §2, OAuth 2.1 §2.1). Use this for native, mobile, and CLI applications that cannot keep a secret confidential. |
There was a problem hiding this comment.
P3 [CRF-10] The new none bullet states as unconditional fact something that is false for every client registered with none before this change. (Mafuuu P3, Leorio P3, Kite Note, Razor Note)
This is the line CRF-2's fix added, and the fix is incomplete rather than undone: the added text is accurate for new registrations and wrong for pre-existing none clients, a case your PR description documents and the page does not.
The line reads "
none: No client secret. The client is a public client and authenticates with PKCE alone". [...] The docs page is the one place a human goes to reconcile whatGET /oauth2/clients/{id}reports with what/oauth2/tokensdemands, and for those clients it reportsnonewhile demanding a secret.client_typeis exposed by no API surface, so the docs are the only available explanation and they contradict the behavior.
Leorio traced the reader: they do the RFC 7592 GET, see "none", read this bullet, drop the secret, get 401 invalid_client: The client credentials are invalid, and spend the evening re-reading their PKCE code, because the page told them PKCE is what authenticates them.
One sentence after the bullet closes it, and it is also the only place a reader can learn what to do instead, since line 127 tells them the RFC 7592 update will be rejected but not the way out: clients that registered with none before this feature shipped were issued a secret and remain confidential, they must keep sending client_secret, and re-registering is how they become public.
If CRF-9 is fixed by deriving the reported auth method from client_type, this sentence becomes unnecessary. Do one or the other, not neither.
🤖
| // An unset or unrecognized client type reads as confidential, so an app can | ||
| // never skip client authentication by accident. | ||
| func (a OAuth2ProviderApp) IsPublic() bool { | ||
| return a.ClientType.String == OAuth2ProviderAppClientTypePublic |
There was a problem hiding this comment.
P3 [CRF-13] client_type now decides whether client authentication runs at all, and the column accepts any text. (Knuckle P3, Kurapika P3, Knov P3, Kite Note)
CRF-3's fix stops at the driver boundary. It makes the two Go spellings compiler-equal, which is real, but dump.sql:2655 declares client_type text DEFAULT 'confidential'::text: nullable, no CHECK, no enum. Three reviewers grepped independently and found no check constraint on any oauth2_provider_* table.
No current Go path can write a bad value, and IsPublic() fails closed on anything unrecognized, which ZeroValueClientTypeDefaultsToRequiringSecret and UnrecognizedClientTypeDefaultsToRequiringSecret pin. So the read side is safe today. The point is which failure the schema still permits:
the one string that matters is the one a future migration is most likely to write correctly by accident, and writing
'public'onto a row that holds a secret turns off client authentication for that app with nothing to catch it: no constraint, no log, no audit entry, no test.
This PR is the one that promotes the column from decoration to the gate. Layer 1 (#27712) owned the schema and deliberately added no capability, so the constraint belongs with the capability, and there is no third layer to put it in. The repo already has the pattern (mcp_server_configs_auth_type_check, and a real enum for the unrelated chats.client_type).
UPDATE oauth2_provider_apps SET client_type = 'confidential' WHERE client_type IS NULL;
ALTER TABLE oauth2_provider_apps
ADD CONSTRAINT oauth2_provider_apps_client_type_check
CHECK (client_type IN ('confidential', 'public'));
ALTER TABLE oauth2_provider_apps ALTER COLUMN client_type SET NOT NULL;The UPDATE should touch zero rows (migration 000344 already backfilled with COALESCE). Both ALTERs take ACCESS EXCLUSIVE and scan a table holding one row per registered client, so the lock is milliseconds. token_endpoint_auth_method is the sibling instance, also unconstrained nullable text and also compared against codersdk constants.
🤖
| // differ for a legacy row whose stored type and auth method | ||
| // disagree, silently converting it to public while it still holds a | ||
| // secret. | ||
| ClientType: existingApp.ClientType, |
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.
🤖
| // they resend the exact metadata GET reports, leaving re-registration as | ||
| // the only recovery. | ||
| // | ||
| // Comparing derived types rather than raw auth methods keeps |
There was a problem hiding this comment.
Note [CRF-46] A client's type is now permanent, and the only way out is a new client_id. (Luffy, Razor)
The guard is correct and neither reviewer asks for a change. Naming the cost: a developer who registers with the default, which is client_secret_basic and therefore confidential, and then realizes they are shipping a CLI cannot fix it through RFC 7592. They re-register, get a new client_id, and every user who already consented consents again.
The damaging direction is public to confidential, which the PR description explains well. Confidential to public isn't damaging, it's a downgrade the client is explicitly asking for with a registration access token in hand. Both are rejected because that's simpler, and simpler is probably right for a v1 that mostly serves clients who auto-register on first run and never edit.
Recorded so the next person reading a bug report titled "can't convert my client" knows it is deliberate and knows the answer is re-register.
Separately, on the comment at this line: Razor notes it says the guard compares derived types "rather than raw auth methods", while the condition it documents compares both. The behavioral conclusion is still true, since the derived conjunct is what keeps client_secret_basic and client_secret_post interchangeable. Only the description of the mechanism contradicts the paragraph above it. Worth fixing while doing CRF-33.
🤖
| - `none`: No client secret. The client is a public client and authenticates with PKCE alone (RFC 7591 §2, OAuth 2.1 §2.1). Use this for native, mobile, and CLI applications that cannot keep a secret confidential. | ||
|
|
||
| Coder supports both methods for compatibility; existing integrations using `client_secret_post` do not need to change. | ||
| Coder supports both secret-based methods for compatibility; existing integrations using `client_secret_post` do not need to change. |
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) <noreply@anthropic.com>
An RFC 7591 registration requesting `token_endpoint_auth_method: "none"` now produces a public client: no secret is minted, `client_type` is persisted as `public`, and the token endpoint accepts that client's authorization_code exchange with PKCE alone. Discovery advertises `"none"` as a supported auth method. PKCE was already mandatory for every authorization_code flow, so public clients inherit it unchanged. That makes the code ownership check (`dbCode.AppID != app.ID`) the sole binding between the exchange and the app named by `client_id` for a public client, where it was defense in depth for confidential ones. It is retained and covered with a public client, along with the refresh and revocation paths, which are the first to see a NULL `app_secret_id`. Verifier length is now checked against RFC 7636 §4.1, since for a public client PKCE is the only client authentication and a one-character verifier hashes to a well-formed challenge. Registration writes the app and its secret in one transaction. Two independently committed inserts could leave a permanently committed app that can never authenticate while still holding a registration access token. An RFC 7592 update can no longer move a client between public and confidential, and secret creation is rejected for a public app: the token endpoint would never validate that secret, and deleting it revokes nothing because a public client's tokens carry a NULL `app_secret_id` instead of cascading from the secret. Clients registered with `"none"` before it was honored are stored confidential and still require their secret, so an update that resends their own metadata is accepted rather than rejected, and the auth method reported back is the one the server actually enforces. Depends on #27931 for the client_type constraint and the schema-level alignment of the two columns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… assertions Addresses CRF-7 and CRF-5 on #27931. CRF-7: the parallel subtests queried through the parent's context. Its deadline starts when it is created, but a parallel subtest does not run until a -parallel slot frees, so most of the budget can be spent queued behind other tests in the package before the first query runs. The reported failure would be "context deadline exceeded" on a sub-20ms single-row read, naming no code and worsening as the package grows. paralleltestctx does not catch it because the context arrives through a helper's return value rather than a direct call in the subtest. Each subtest now creates its own. CRF-5, first half: no public + NULL row was seeded, so the second UPDATE's IS NULL arm matched nothing and removing it left the test green. That shape is now seeded and asserted. CRF-5, second half: the closing invariant used <>, which is NULL-blind. With a NULL declaration the comparison is NULL, WHERE drops the row, and an unrepaired NULL counts as consistent. Now IS DISTINCT FROM. This matters past this test: the predicate is the obvious candidate for the permanent cross-column CHECK after #27873, and a CHECK is more forgiving still, since NULL reads as not-violated. Mutation-checked. Removing the second UPDATE's IS NULL arm now fails both the publicNull case and the invariant count; with the old <> form only the former fires, which is what made the blind spot invisible. Full package passes at -parallel=1, the worst case for queue wait. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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