feat: derive OAuth2 client type from token_endpoint_auth_method - #28043
feat: derive OAuth2 client type from token_endpoint_auth_method#28043BobbyHo wants to merge 19 commits into
Conversation
…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.
…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.
…_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.
The code was deleted only inside the success-path transaction, so every PKCE rejection (errInvalidPKCE) left it live in the database. RFC 6749 §10.5 requires authorization codes to be single-use; without that, an attacker holding a leaked code (the exact threat PKCE defends against, since codes and challenges land in browser history, referrer headers, and proxy logs) could retry the token endpoint with different code_verifier guesses for the entire 10-minute code lifetime, unthrottled. The 43-character length floor bounds guess format, not entropy. Add revokeOAuth2CodeOnPKCEFailure, called from both PKCE rejection paths in authorizationCodeGrant. It deletes the code using the same system authz context already used for reads in this function; a deletion failure is noted on the request's log line rather than changing the response, since surfacing it as a different error would let a caller distinguish delete success from failure, itself a new oracle. Added TestOAuth2PKCEFailureConsumesCode to verify the code is unredeemable, even with the correct verifier, once a PKCE mismatch has occurred.
Tighten the ValidPKCEFormat doc comment and correct a false claim (CRF-8, CRF-10). The rationale restated the same threat model across three separate rhetorical framings, and claimed PKCE is the only client authentication some clients have, which is false today since authorizationCodeGrant validates a client secret before PKCE ever runs; that claim only becomes true once #27873 adds public clients. Trim the paragraph to a single concrete why and note the caveat. Delete four boundary-case comments in pkce_test.go (CRF-9). Each one restated the case name and the strings.Repeat literal beside it; the RFC provenance already lives on ValidPKCEFormat's doc comment and the pkceVerifierMinLength/pkceVerifierMaxLength constants, so the comments carried no information and would drift if either constant changed. Replace an em-dash with a comma in a comment inside the block this PR's PKCE-failure handling touches (CRF-2), per the repo's no-emdash rule. It survived lint because the check scans only changed lines by default, and this comment was pre-existing context rather than a line this PR added. Fix the PKCE example in docs/admin/integrations/oauth2-provider.md (CRF-7). tr -d "=+/" deleted reserved base64 characters instead of translating them to the URL-safe alphabet, so the example computed a code_challenge that failed to verify roughly 74% of the time. Also strip the newline openssl base64 inserts at its default 64-column wrap, which the 96-byte verifier example crosses; the prior cut -c1-128 never merged the wrapped lines back together either.
The PKCE Flow section showed how to generate a code_verifier and code_challenge but never stated the bound now enforced server-side: 43 to 128 characters from the unreserved set [A-Za-z0-9-._~] (RFC 7636 §4.1). A value outside these bounds returns invalid_request, at the token endpoint for code_verifier and at the authorization endpoint for code_challenge.
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 client, so unlike vscode:// or jetbrains://, none of them can deliver an authorization code. A public client's redirect URI scheme is its only mechanism for regaining control, so they are rejected again here, scoped specifically to public clients rather than folded into validateScheme's blocklist, since they are harmless for a confidential client's redirect.
d95d4f9 to
9440708
Compare
0d8a377 to
01ec6b3
Compare
…/tel/sms scope, not an invented one The previous comment claimed mailto, tel, and sms are harmless for a confidential client's redirect specifically. That is not true: the client_secret only matters at token exchange, not at redirect delivery, so nothing about being confidential changes what happens when the browser is sent to one of these schemes. The actual reason they are checked only in the isPublicClient branch is that custom-scheme validation was already scoped there before this PR; confidential clients were never subject to any scheme-shape check here, independent of any judgment about these three schemes.
01ec6b3 to
3d4b95e
Compare
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.9.0 | Round 1 | Last posted: Round 1, 14 findings (1 P2, 4 P3, 7 Nit, 2 Note), COMMENT. Review Finding inventoryFinding inventory: PR #28043Findings
Round logRound 1Panel + Netero. 1 P2, 4 P3, 7 Nit, 2 Note. Reviewed against 450d037..3d4b95e. Panel: Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Gon, Leorio, Kurapika, Knov, Razor, Meruem, Ryosuke, Ging-Go, Robin, Kite, Zoro. Wildcards: Kite, Zoro. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
Vocabulary split at the right seam. codersdk.OAuth2ClientType + ClientTypeFor gives one owner of the auth-method-to-client-type mapping; Validate() reusing DetermineClientType() collapses the last drift between the type that decides RFC 8252 rules and the type stored on the app; IsPublic() fails closed on unrecognized values and is pinned by a whitespace/case/empty table test. constants.go explicitly bridges the SDK enum into the DB layer so the value the writer writes and the value IsPublic reads back cannot differ, and the pinning-test note (once its ghost reference is fixed) is exactly the shape long-lived docs should carry. Deferrals to the next PR in the stack are anchored in the commit body and Linear ENG-3029.
Severity summary: 1 P2, 4 P3, 7 Nit, 2 Note.
Two threads to pull before this merges:
-
Every doc that names an invariant a follow-up PR enforces should say so in this PR's tense.
AllOAuth2TokenEndpointAuthMethods(Both Valid() and the discovery metadata are derived from it) andOAuth2ClientType(the token endpoint reads it to decide whether a client secret is required) both describe the endpoint of the stack. Today registration acceptstoken_endpoint_auth_method=noneand/.well-known/oauth-authorization-serverdoes not advertisenone, so the drift the first comment says cannot happen is present. Either wiremetadata.go:39toAllOAuth2TokenEndpointAuthMethods()and dropIsPublic()-based enforcement to the next PR only, or scale each doc toValid() derives from this; discovery/token endpoint will in the follow-up. Gon'sconstants.goreview nails the pattern: any sentence of the form 'X and Y derive from Z, so they cannot drift' must trace to code in this PR that makes the drift impossible. -
UpdateClientConfigurationatregistration.go:324now derivesclient_typeon every PUT. Combined withApplyDefaults()at line 288, a public client that PUTs any subset of its registration without re-assertingtoken_endpoint_auth_method: "none"silently rewrites its stored row toclient_type=confidentialandtoken_endpoint_auth_method=client_secret_basic.client_typeisActionTrackinenterprise/audit/table.go:320, so an audit entry gets written recording a change the client did not request. Nothing in this PR readsIsPublic()at runtime, so the flip is inert today; once the follow-up wiresIsPublic()into the token endpoint's secret check, the same PUT locks the client into needing a secret it was designed not to hold.DetermineClientType's own docstring already names this shape ("an omitted field compares as "" and looks like a change the client did not request") but points atValidate(); the actual write is here.
From Hisoka: "DetermineClientType's own docstring warns exactly this shape [...] but it points at the wrong caller: Validate isn't the one that records the drift, UpdateClientConfiguration is."
Process: commit subject feat(codersdk,coderd/database): ... is 84 chars and its scope excludes coderd/oauth2provider/ and site/; either omit the scope (change is cross-cutting) or extend it to every touched path. See CRF-11.
coderd/database/dbgen/dbgen.go:1736
Nit [CRF-6] Test fixture still writes the raw literal "confidential" instead of the new database.OAuth2ProviderAppClientTypeConfidential. (Robin, Mafu-san, Knov, Razor, Meruem)
This PR's own constants.go comment says the constants exist so "the value registration writes and the value OAuth2ProviderApp.IsPublic reads back cannot disagree." coderd/oauth2provider/apps.go:95 and both registration.go sites were migrated to the constant/derived value; dbgen.go:1736 and coderd/database/dbauthz/dbauthz_test.go:5828 still spell the literal. Every seeded app that omits ClientType lands via the raw string, which is exactly the drift the constant was introduced to eliminate. Change to takeFirst(seed.ClientType, database.OAuth2ProviderAppClientTypeConfidential) (and the matching swap in dbauthz_test.go).
🤖
coderd/oauth2provider/apps.go:101
Nit [CRF-7] Sibling magic string left in place next to the one this PR just fixed. (Ryosuke)
Line 95 replaces the hardcoded "confidential" with database.OAuth2ProviderAppClientTypeConfidential, the correct move. Six lines down, TokenEndpointAuthMethod: sql.NullString{String: "client_secret_post", Valid: true} still spells the wire value inline, even though codersdk.OAuth2TokenEndpointAuthMethodClientSecretPost exists for exactly this. Same class of bug (writer-side wire literal), same file, same struct literal. Use string(codersdk.OAuth2TokenEndpointAuthMethodClientSecretPost) here so the "spell it once in codersdk" pattern this PR establishes covers both fields the admin-create path writes.
🤖
🤖 This review was automatically generated with Coder Agents.
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.
3d4b95e to
8c4a1c0
Compare
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
UpdateClientConfiguration wrote ClientType: string(req.DetermineClientType()) on every PUT, recomputed from the request instead of read from storage. ApplyDefaults() fills an omitted token_endpoint_auth_method with client_secret_basic, so a public client's PUT that only touched an unrelated field (e.g. redirect_uris) silently converted it to confidential, since DetermineClientType() can now return "public" where it previously always returned "confidential". A client's type is fixed at registration; RFC 7592 §2.2 permits rejecting metadata the server will not accept. UpdateClientConfiguration now rejects a PUT that would move a client between public and confidential with 400 invalid_client_metadata, and carries the stored client_type through verbatim rather than re-deriving it. A legacy row whose stored client_type and token_endpoint_auth_method already disagree can still manage itself, as long as the update does not also ask to change the auth method. ClientTypeFor(), extracted as its own function in the previous commit, had exactly one caller and no second one materialized, so it is inlined back into DetermineClientType().
…-vocabulary Resolves a conflict in coderd/oauth2provider/tokens.go: #28003 hardened revokeOAuth2CodeOnPKCEFailure on main (detaches the delete from the request context, treats sql.ErrNoRows as non-error) after this branch's own copy of that function predated the hardening. Took main's version in full; this branch made no independent edits to it.
…omments Secret and token creation now say "issued", and carrying a stored value through an update says "unchanged". Comment text only, no behavior change.
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.
Behavior by client shape
client_typeis derived fromtoken_endpoint_auth_methodat 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.client_type/ methodclient_secret_basicconfidential/client_secret_basicclient_secret_basicnone→ 400invalid_client_metadatanone(new)public/nonenoneclient_secret_*→ 400invalid_client_metadatanone(before this PR)confidential/nonenoneconfidentialclient_typeis the only pinned one; the method may move within a type (client_secret_basic↔client_secret_post).client_typeyet, 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