Skip to content

feat: derive OAuth2 client type from token_endpoint_auth_method - #28043

Draft
BobbyHo wants to merge 1 commit into
oauth2-custom-scheme-fixfrom
oauth2-public-clients-vocabulary
Draft

feat: derive OAuth2 client type from token_endpoint_auth_method#28043
BobbyHo wants to merge 1 commit into
oauth2-custom-scheme-fixfrom
oauth2-public-clients-vocabulary

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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 requested token_endpoint_auth_method. It now derives the type via the new ClientTypeFor() mapping, which is the single owner of the auth-method-to-client-type relationship: registration derives the stored client_type from it, and redirect URI validation uses it to pick which RFC 8252 rules apply, so the two cannot disagree about what "public" means.

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

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

registration.go and app registration itself do not yet skip secret issuance for a public client; that follows in the next PR in the stack.

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

@BobbyHo BobbyHo changed the title feat(codersdk,coderd/database): add OAuth2ClientType and derive it from auth method feat: add OAuth2ClientType and derive it from auth method Aug 11, 2026
@BobbyHo
BobbyHo force-pushed the oauth2-custom-scheme-fix branch from d95d4f9 to 9440708 Compare August 12, 2026 00:07
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-vocabulary branch 2 times, most recently from 01ec6b3 to 3d4b95e Compare August 12, 2026 01:33
@BobbyHo

BobbyHo commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-08-12 01:33 UTC by @BobbyHo

Review history
  • R1 (2026-08-12): 16 reviewers, 7 Nit, 2 Note, 1 P2, 4 P3, COMMENT. Review

deep-review v0.9.0 | Round 1 | 450d037..3d4b95e

Last posted: Round 1, 14 findings (1 P2, 4 P3, 7 Nit, 2 Note), COMMENT. Review

Finding inventory

Finding inventory: PR #28043

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Open codersdk/oauth2.go:274 AllOAuth2TokenEndpointAuthMethods doc claims discovery derives from it; discovery still hardcoded R1 Netero P3, Mafu-san P2, Mafuuu P3, Gon P2, Leorio P3, Knov P2, Razor P3, Meruem P3, Ryosuke P3, Kite Nit, Zoro Nit, Kurapika Nit, Hisoka Note Yes
CRF-2 P3 Open coderd/oauth2provider/registration.go:324 RFC 7592 PUT silently flips public client to confidential when token_endpoint_auth_method omitted R1 Hisoka P3, Kurapika Note, Razor Note Yes
CRF-3 P3 Open codersdk/oauth2.go:292 OAuth2ClientType doc claims token endpoint reads it; no code does yet R1 Mafu-san P3, Leorio P3 Yes
CRF-4 P3 Open coderd/database/constants.go:28 "do not delete" comment names phantom test TestCreateDynamicClientRegistration_ClientType R1 Leorio P3, Ryosuke P3, Gon P2, Razor Nit, Meruem Nit, Kite Nit, Zoro Nit, Mafuuu Nit, Hisoka Nit, Bisky Nit, Pariston Nit Yes
CRF-5 P3 Open coderd/database/constants.go:14 Comment describes client_type as "nullable column" but it's NOT NULL R1 Gon P2, Knov Nit, Meruem Nit Yes
CRF-6 Nit Open coderd/database/dbgen/dbgen.go:1736, coderd/database/dbauthz/dbauthz_test.go:5828 Test fixtures still write raw "confidential" literal, missed by rename R1 Robin Nit, Mafu-san Nit, Knov Nit, Razor Nit, Meruem Nit Yes
CRF-7 Nit Open coderd/oauth2provider/apps.go:101 Sibling magic string "client_secret_post" untouched next to the constant this PR introduced R1 Ryosuke Nit Yes
CRF-8 Nit Open codersdk/oauth2.go:284 Valid() uses slices.Contains, breaks parity with sibling switch-based Valid() methods and allocates a fresh slice on every call R1 Zoro Nit, Ryosuke Nit Yes
CRF-9 Nit Open codersdk/oauth2.go:572 ClientTypeFor has one caller (DetermineClientType); can inline unless follow-up needs the bare-method form R1 Zoro Note Yes
CRF-10 Nit Open coderd/database/modelmethods_internal_test.go:232 Table-driven cases key on tt.clientType; empty and whitespace cases render as anonymous or ambiguous sub-test names R1 Kite Nit Yes
CRF-11 Nit Open commit 3d4b95e subject Scope (codersdk,coderd/database) excludes touched paths; subject is 84 chars R1 Leorio Nit Yes
CRF-12 Nit Open codersdk/oauth2_test.go:21 // authMethod is the requested token_endpoint_auth_method. restates the field name R1 Gon P2 Yes
CRF-13 Note Open codersdk/oauth2_test.go:14, coderd/database/modelmethods_internal_test.go:229 ApplyDefaults-before-vs-after invariant for DetermineClientType is not pinned by a test that runs ApplyDefaults() with token_endpoint_auth_method="none" R1 Meruem Note Yes
CRF-14 Note Open codersdk/oauth2.go:300, coderd/database/modelmethods.go:690 OAuth2ClientType.Valid() and OAuth2ProviderApp.IsPublic() have no production caller in this PR (deferred to next stack PR) R1 Netero Note, Razor Note Yes

Round log

Round 1

Panel + 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-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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) and OAuth2ClientType (the token endpoint reads it to decide whether a client secret is required) both describe the endpoint of the stack. Today registration accepts token_endpoint_auth_method=none and /.well-known/oauth-authorization-server does not advertise none, so the drift the first comment says cannot happen is present. Either wire metadata.go:39 to AllOAuth2TokenEndpointAuthMethods() and drop IsPublic()-based enforcement to the next PR only, or scale each doc to Valid() derives from this; discovery/token endpoint will in the follow-up. Gon's constants.go review 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.

  2. UpdateClientConfiguration at registration.go:324 now derives client_type on every PUT. Combined with ApplyDefaults() at line 288, a public client that PUTs any subset of its registration without re-asserting token_endpoint_auth_method: "none" silently rewrites its stored row to client_type=confidential and token_endpoint_auth_method=client_secret_basic. client_type is ActionTrack in enterprise/audit/table.go:320, so an audit entry gets written recording a change the client did not request. Nothing in this PR reads IsPublic() at runtime, so the flip is inert today; once the follow-up wires IsPublic() 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 at Validate(); 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.

Comment thread codersdk/oauth2.go Outdated
switch m {
case OAuth2TokenEndpointAuthMethodClientSecretBasic,
// AllOAuth2TokenEndpointAuthMethods returns every accepted token endpoint auth
// method. Both Valid() and the discovery metadata are derived from it, so what

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

🤖

Comment thread codersdk/oauth2.go Outdated
// (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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

🤖

Comment thread coderd/database/constants.go Outdated
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-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 touches coderd/oauth2provider/apps.go, coderd/oauth2provider/registration.go, and site/src/api/typesGenerated.ts; the declared scope covers neither coderd/oauth2provider/ nor site/. 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.

🤖

Comment thread codersdk/oauth2_test.go
name string
// authMethod is the requested token_endpoint_auth_method.
authMethod codersdk.OAuth2TokenEndpointAuthMethod
// applyDefaults runs ApplyDefaults() before DetermineClientType(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-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.

🤖

Comment thread codersdk/oauth2_test.go
// 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-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.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-vocabulary branch from 3d4b95e to 8c4a1c0 Compare August 12, 2026 04:54
@BobbyHo BobbyHo changed the title feat: add OAuth2ClientType and derive it from auth method feat: derive OAuth2 client type from token_endpoint_auth_method Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant