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 1 commit into
Conversation
d95d4f9 to
9440708
Compare
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.
| switch m { | ||
| case OAuth2TokenEndpointAuthMethodClientSecretBasic, | ||
| // AllOAuth2TokenEndpointAuthMethods returns every accepted token endpoint auth | ||
| // method. Both Valid() and the discovery metadata are derived from it, so what |
There was a problem hiding this comment.
P2 [CRF-1] AllOAuth2TokenEndpointAuthMethods doc claims discovery metadata derives from it, but discovery does not read this function; the drift the comment says cannot happen is present today. (Mafu-san P2, Gon P2, Knov P2, Netero P3, Mafuuu P3, Leorio P3, Razor P3, Meruem P3, Ryosuke P3, Kurapika Nit, Kite Nit, Zoro Nit)
Both Valid() and the discovery metadata are derived from it, so what registration accepts and what /.well-known advertises cannot drift apart.
Only the first half is true. Valid() at line 285 now calls slices.Contains(AllOAuth2TokenEndpointAuthMethods(), m) and accepts {basic, post, none}. The discovery handler at coderd/oauth2provider/metadata.go:39 hardcodes []codersdk.OAuth2TokenEndpointAuthMethod{codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic, codersdk.OAuth2TokenEndpointAuthMethodClientSecretPost} and never calls this function. A POST /oauth2/register with token_endpoint_auth_method=none succeeds; a GET /.well-known/oauth-authorization-server on the same server does not advertise none. A spec-following client that reads discovery first concludes public clients are unsupported even though registration would accept one.
Worse, the stated shape conflicts with the current advertise-vs-accept split: AllOAuth2TokenEndpointAuthMethods() returns the accept-set including none, but discovery deliberately does not advertise none until public clients are wired end-to-end. Making these "the single source" forces one of two wrong outcomes: advertise none before the token endpoint honors it, or drop none from the accept-set.
Fix in this PR is one of two things, no third option: (1) wire TokenEndpointAuthMethodsSupported: codersdk.AllOAuth2TokenEndpointAuthMethods() in metadata.go so the doc becomes true, or (2) rewrite the doc to what today actually is (e.g. Valid() derives from it. Discovery metadata will derive from it once the follow-up PR wires it in; until then, /.well-known does not advertise "none" even though registration accepts it.)
🤖
| CallbackURL: req.RedirectURIs[0], // Primary redirect URI | ||
| RedirectUris: req.RedirectURIs, | ||
| ClientType: req.DetermineClientType(), | ||
| ClientType: string(req.DetermineClientType()), |
There was a problem hiding this comment.
P3 [CRF-2] RFC 7592 UpdateClientConfiguration silently converts a public client to confidential when the request omits token_endpoint_auth_method. (Hisoka P3, Kurapika Note, Razor Note)
The handler runs Validate(), then ApplyDefaults() at line 288, then writes ClientType: string(req.DetermineClientType()) at line 324 and TokenEndpointAuthMethod: sql.NullString{String: string(req.TokenEndpointAuthMethod), Valid: true} at line 328. ApplyDefaults maps an omitted method to client_secret_basic, so a public client (registered with token_endpoint_auth_method: "none") that PUTs anything without re-sending the method lands here with method "client_secret_basic" and client_type "confidential". Hisoka reproduced this on the PR head:
registered: method="none" client_type="public" after PUT: method="client_secret_basic" client_type="confidential"
client_type is ActionTrack in enterprise/audit/table.go:320, so the flip writes an audit entry claiming the client asked to change its own type. Class-wise this is the same "hardcoded confidential swallowed a public request" shape the PR just fixed at CreateDynamicClientRegistration (registration.go:104); the fix has not reached the sibling handler. Once the follow-up wires IsPublic() into token endpoint auth, this silent flip flips the client's auth model too: a public client that ever PUTs anything without re-sending "none" gets locked into needing a secret it was designed not to hold.
DetermineClientType's own doc already warns this shape ("a caller that also compares the request's auth method against a stored one must apply defaults first, or an omitted field compares as "" and looks like a change the client did not request") but points at Validate; the actual write is here.
Fix in this handler: when the request omits token_endpoint_auth_method, inherit the existing app's method before deriving client_type and re-serializing, or reject PUTs that would change client_type.
🤖
| // (RFC 7591 §2, OAuth 2.1 §2.1). A confidential client authenticates with a | ||
| // secret; a public client authenticates with PKCE alone. It is derived from | ||
| // the requested token_endpoint_auth_method and stored on the app, and the | ||
| // token endpoint reads it to decide whether a client secret is required. |
There was a problem hiding this comment.
P3 [CRF-3] OAuth2ClientType doc says the token endpoint reads it; today no code does. (Mafu-san P3, Leorio P3)
Type comment ends with:
It is derived from the requested token_endpoint_auth_method and stored on the app, and the token endpoint reads it to decide whether a client secret is required.
coderd/oauth2provider/tokens.go never touches ClientType or IsPublic; it unconditionally requires req.ClientSecret != "" at line 96. grep -rn '\.IsPublic()' --include='*.go' under coderd/oauth2provider/ returns zero.
The PR body correctly discloses the deferral ("app registration itself do not yet skip secret issuance for a public client; that follows in the next PR in the stack"), so the reasoning is right; the artifact reads as if the hookup already exists. A developer opening IsPublic or OAuth2ClientType for the follow-up PR greps for the caller the doc names, finds nothing, and doubts either the tools or their reading.
Say the truth: A follow-up PR wires the token endpoint to read this when deciding whether to require a client secret.
🤖
| // type system can catch that; the tests that pin these spellings to the wire | ||
| // values do, so do not delete them as redundant: | ||
| // TestOAuth2ClientRegistrationRequest_DetermineClientType and | ||
| // TestCreateDynamicClientRegistration_ClientType. |
There was a problem hiding this comment.
P3 [CRF-4] The "do not delete" pin names TestCreateDynamicClientRegistration_ClientType, which does not exist in this PR. (Leorio P3, Ryosuke P3, Gon P2, Bisky Nit, Hisoka Nit, Pariston Nit, Mafuuu Nit, Razor Nit, Meruem Nit, Kite Nit, Zoro Nit)
the tests that pin these spellings to the wire values do, so do not delete them as redundant:
TestOAuth2ClientRegistrationRequest_DetermineClientType and
TestCreateDynamicClientRegistration_ClientType.
grep -rn TestCreateDynamicClientRegistration_ClientType --include='*.go' returns only this comment. The closest neighbor, TestCreateDynamicClientRegistration_DCREnabled at coderd/oauth2provider/registration_test.go:29, does not touch client_type. The actual DB-side wire-spelling guard is TestOAuth2ProviderAppIsPublic in coderd/database/modelmethods_internal_test.go:229 (this PR).
The block explicitly warns "do not delete them as redundant": leaving a load-bearing citation to a test that was never written invites a future editor to delete the constants ("the guard doesn't exist") or wastes a shift searching for a phantom.
Drop-in:
// ... the tests that pin these spellings to the wire values do, so do not
// delete them as redundant:
// TestOAuth2ClientRegistrationRequest_DetermineClientType (codersdk) and
// TestOAuth2ProviderAppIsPublic (coderd/database).Or add the DB-round-trip test the current name implies (register with each auth method, read the row back, assert the stored client_type string).
🤖
| // codersdk constant so the two cannot drift. | ||
| var PrebuildsSystemUserID = uuid.MustParse(codersdk.PrebuildsSystemUserID) | ||
|
|
||
| // Values stored in oauth2_provider_apps.client_type, as plain strings for |
There was a problem hiding this comment.
P3 [CRF-5] Comment calls client_type "the nullable column," but the column is NOT NULL after migration 000565. (Gon P2, Knov Nit, Meruem Nit)
coderd/database/dump.sql:2655 declares client_type text DEFAULT 'confidential'::text NOT NULL, and migration 000565_oauth2_client_type_constraint.up.sql:19 ran ALTER COLUMN client_type SET NOT NULL. The sqlc-generated field on OAuth2ProviderApp is ClientType string, not sql.NullString. The reason to declare these constants as string (rather than codersdk.OAuth2ClientType) is that the model's field is string, so a typed comparison would require a cast at every callsite. The "nullable column" framing is not just wrong about the schema, it misleads the next reader about what invariant IsPublic is enforcing: it guards against an unrecognized Go-side value, not a NULL from the DB.
Rewrite: Values stored in oauth2_provider_apps.client_type, as plain strings for comparison against the sqlc-generated string column.
🤖
| func TestOAuth2ProviderAppIsPublic(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| tests := []struct { |
There was a problem hiding this comment.
Nit [CRF-10] Table-driven cases key on tt.clientType; empty and whitespace cases render as anonymous or ambiguous sub-test names. (Kite)
The struct has no name field; t.Run(tt.clientType, ...) uses the input as the sub-test name. For {clientType: ""} this produces an anonymous sub-test in go test -v output, and the whitespace cases " public" and "public " produce sub-test paths that do not visibly distinguish which side the space is on without inspecting the source. Add a name field with human-readable case names (Empty, LeadingSpace, TrailingSpace, MixedCasePublic, AllCapsPublic, ...) so a failure line points at what broke without needing to count into the table.
🤖
|
|
||
| // IsPublic reports whether the app is a public (secretless, PKCE-only) | ||
| // OAuth2 client per RFC 7591 §2 / OAuth 2.1 §2.1, as opposed to confidential. | ||
| // An unset or unrecognized client type reads as confidential, so an app can |
There was a problem hiding this comment.
Nit [CRF-11] Commit subject feat(codersdk,coderd/database): add OAuth2ClientType and derive it from auth method has two AGENTS.md issues. (Leorio)
- Scope must be a real filesystem path containing every changed file, or omitted for cross-cutting changes (
AGENTS.md). This commit also touchescoderd/oauth2provider/apps.go,coderd/oauth2provider/registration.go, andsite/src/api/typesGenerated.ts; the declared scope covers neithercoderd/oauth2provider/norsite/. Omit the scope, since the change is cross-cutting. - 84 chars, over 72.
Suggested rewrites, either one:
feat: derive OAuth2 client type from token_endpoint_auth_method(62)feat: add OAuth2ClientType, derive from auth method(52)
The rest of the commit body is a real chart note. Leave that alone.
🤖
| name string | ||
| // authMethod is the requested token_endpoint_auth_method. | ||
| authMethod codersdk.OAuth2TokenEndpointAuthMethod | ||
| // applyDefaults runs ApplyDefaults() before DetermineClientType(), |
There was a problem hiding this comment.
Nit [CRF-12] // authMethod is the requested token_endpoint_auth_method. restates the field name and type. (Gon)
The field on the next line is authMethod codersdk.OAuth2TokenEndpointAuthMethod. The comment adds no invariant, no why-not-what, no trap: the code already teaches a reader everything the comment does. Delete the line. The neighbouring applyDefaults comment carries a real why-not-what (matches the real request path) and should stay.
🤖
| // TestOAuth2ClientRegistrationRequest_DetermineClientType verifies that the | ||
| // client type is derived from the requested token_endpoint_auth_method | ||
| // (RFC 7591 §2, OAuth 2.1 §2.1), not hardcoded to "confidential". | ||
| func TestOAuth2ClientRegistrationRequest_DetermineClientType(t *testing.T) { |
There was a problem hiding this comment.
Note [CRF-13] DetermineClientType is called from Validate() before ApplyDefaults() and from registration after; the "same result before and after defaults" invariant is documented but not test-pinned for the public path. (Meruem)
codersdk/oauth2_validation.go:21 calls req.DetermineClientType() inside Validate(), which the handlers in registration.go:66,281 run before ApplyDefaults(). The stored-column call at :104,324 runs after. Correctness relies on ClientTypeFor producing the same class for the empty auth method and for the defaulted client_secret_basic; today both fall through to confidential and DetermineClientType's doc calls this out explicitly.
This file covers the omitted-and-defaulted case and the omitted-and-not-defaulted case, but only for the confidential path; there is no case that runs ApplyDefaults() first with TokenEndpointAuthMethod = "none" to pin that none stays public across defaults. If ApplyDefaults ever grows a rule that rewrites none (a normalization step, for example), the validation call and the storage call would silently disagree, and the failure mode is a redirect URI validated under one rule set and stored under the other.
Add a case: {name: "NoneStaysPublicAfterApplyDefaults", applyDefaults: true, authMethod: OAuth2TokenEndpointAuthMethodNone, expectedType: "public"}.
🤖
|
|
||
| // IsPublic reports whether the app is a public (secretless, PKCE-only) | ||
| // OAuth2 client per RFC 7591 §2 / OAuth 2.1 §2.1, as opposed to confidential. | ||
| // An unset or unrecognized client type reads as confidential, so an app can |
There was a problem hiding this comment.
Note [CRF-14] OAuth2ProviderApp.IsPublic() and codersdk.OAuth2ClientType.Valid() have no production caller in this PR. (Netero, Razor)
Per the PR description, IsPublic() is consumed by the token endpoint / registration in the next PR in the stack (ENG-3029). OAuth2ClientType.Valid() follows the local convention of sibling untested enum Valid() methods in the same file. Flagged here only so a downstream reviewer does not re-discover them; not a defect on its own given the linked follow-up.
🤖
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. Second in the stack (on top of #28041); 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 requestedtoken_endpoint_auth_method. It now derives the type via the newClientTypeFor()mapping, which is the single owner of the auth-method-to-client-type relationship: registration derives the storedclient_typefrom 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 storedclient_typecolumn, added alongside matching database constants so the value registration writes and the valueIsPublicreads 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 bothValid()and (in a later PR in the stack) discovery metadata read from, so what registration accepts and what/.well-knownadvertises cannot drift apart either.registration.goand app registration itself do not yet skip secret issuance for a public client; that follows in the next PR in the stack.Refs https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client