feat: register public clients without a secret by BobbyHo · Pull Request #28046 · coder/coder · GitHub
Skip to content

feat: register public clients without a secret - #28046

Merged
BobbyHo merged 33 commits into
mainfrom
oauth2-public-clients-registration
Aug 20, 2026
Merged

feat: register public clients without a secret#28046
BobbyHo merged 33 commits into
mainfrom
oauth2-public-clients-registration

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Third in the stack, split out of #27873. This is where dynamic client registration first produces a public client.

  • A registration requesting token_endpoint_auth_method none issues no secret and persists the client_type derived in feat: derive OAuth2 client type from token_endpoint_auth_method #28043. The response omits client_secret entirely, per RFC 7591 §3.2.1.
  • Discovery still advertises only the two secret-based methods. none is withheld until the token endpoint honors it, so a conforming client is not told to attempt an exchange that would be rejected.
  • The app and its secret are written in one transaction. They were two independent inserts, so a failed second one left a committed app that could never authenticate while still holding a registration access token. Pre-existing, but a public client's missing secret row makes the orphaned case unspottable by inspection.
  • registration_client_uri uses url.JoinPath, fixing a trailing-slash access URL producing //oauth2/clients/{id}.
  • Docs state both limits above, so the page does not describe a flow that returns 400 until feat: accept PKCE-only token exchange for public clients #28047 lands.

A client registered here cannot obtain a token yet; #28047 adds that. Dynamic client registration is off by default, so none of this is user-visible until then.

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

…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.
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

@BobbyHo BobbyHo changed the title feat(coderd/oauth2provider): register public clients without a secret feat: register public clients without a secret Aug 11, 2026
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.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-vocabulary branch from 0d8a377 to 01ec6b3 Compare August 12, 2026 00:18
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-registration branch from 872b5e3 to 7c8d3e5 Compare August 12, 2026 00:18
…/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.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-vocabulary branch from 01ec6b3 to 3d4b95e Compare August 12, 2026 01:33
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-registration branch from 7c8d3e5 to 800fda7 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): 20 reviewers, 5 Nit, 5 Note, 2 P2, 4 P3, COMMENT. Review

deep-review v0.9.0 | Round 1 | 3d4b95e..800fda7

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

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Open coderd/oauth2provider/registration.go:346 UpdateClientConfiguration flips client_type without reconciling secret rows R1 Knov P1, Chopper P2, Hisoka P2, Pariston P2, Mafuuu P2, Melody P2, Ryosuke P3, Razor P3, Kurapika P3, Meruem P3, Mafu-san P4 Yes
CRF-2 P2 Open docs/admin/integrations/oauth2-provider.md:130 Redirect-URI callout contradicts validateRedirectURIs (omits https, wrong loopback set, wrong confidential rule) R1 Zoro P2, Mafu-san P2, Mafuuu P3, Razor P3, Chopper Nit Yes
CRF-3 P3 Open coderd/oauth2provider/metadata.go:44 Discovery advertises "none" while /oauth2/tokens rejects any exchange without client_secret R1 Knov P2, Kurapika P3, Meruem P3, Melody P3, Mafuuu Note, Mafu-san Note, Pariston Note, Kite Note, Luffy Note Yes
CRF-4 P3 Open codersdk/oauth2.go:622 client_secret_expires_at has omitempty; RFC 7591 §3.2.1 requires the field when client_secret is issued R1 Chopper P3 Yes
CRF-5 P3 Open coderd/oauth2provider/registration.go:139 JoinPath trailing-slash fix has no regression test; revert to fmt.Sprintf would pass suite R1 Mafu-san P3, Chopper Nit, Meruem Nit, Ryosuke Note Yes
CRF-6 P3 Open coderd/oauth2provider/registration.go:149 "Create client secret - parse the formatted secret to get components" mislocates the operation (block parses to extract prefix; the secret was minted at line 82) R1 Gon P2 Yes
CRF-7 Nit Open coderd/oauth2provider/registration.go:109 Em-dash cleanup applied to two touched //nolint:gocritic lines but not to 10 sibling instances (registration.go 227,313,338,421,445,497; tokens.go 261,289,451,488) R1 Kite Nit Yes
CRF-8 Note Open coderd/oauth2provider/metadata_test.go:47 Contains-only assertion; ElementsMatch against AllOAuth2TokenEndpointAuthMethods would pin the drift invariant R1 Bisky Note Yes
CRF-9 Nit Open docs/admin/integrations/oauth2-provider.md:131 "the out-of-band URN" not spelled as urn:ietf:wg:oauth:2.0:oob R1 Leorio Nit Yes
CRF-10 Note Open coderd/oauth2provider/oauth2providertest/helpers.go:85 RegisterPublicClient has no in-tree caller + ctx-parameter signature drift from surrounding helpers R1 Netero Note, Chopper Note, Gon Note, Hisoka Nit, Luffy Note, Mafu-san Note, Razor Note, Zoro Note+Nit Yes
CRF-11 Nit Open coderd/oauth2provider/metadata.go:39 5-line comment duplicates godoc on codersdk.AllOAuth2TokenEndpointAuthMethods; trim to the why-not-what unique to this site R1 Gon P2 Yes
CRF-12 Nit Open coderd/oauth2provider/registration_test.go:159 Trailing "The docs promise the field is absent" sentence restates the opening two lines R1 Gon P2 Yes
CRF-13 Nit Open coderd/oauth2provider/registration.go:76 "// Generate client credentials." narrates the mechanism of the two lines that follow R1 Gon Nit Yes
CRF-14 Note Open coderd/oauth2provider/registration_test.go:249 Mock InsertOAuth2ProviderApp hardcodes ClientType=Confidential regardless of params; will drift silently if scaffolding is reused for a public request R1 Ryosuke Note Yes
CRF-15 Note Open coderd/oauth2provider/registration.go:107 now and uuid.New() captured outside InTx closure; latent footgun if anyone later wraps InTx with retry semantics R1 Pariston Note Yes
CRF-16 Note Open coderd/oauth2provider/registration_test.go:301 PublicClientSkipsSecretInsert overlaps NoneIsPublicWithNoSecret (mechanism vs outcome); decide whether both should live R1 Luffy Note, Zoro Note Yes

Round log

Round 1

Panel. Base 3d4b95e..800fda7. Netero first-pass (no P0-P2 findings; one Note on unused RegisterPublicClient), then 19-reviewer panel: Bisky, Chopper, Ging-Go, Gon, Hisoka, Kite, Knov, Komugi, Kurapika, Leorio, Mafu-san, Mafuuu, Melody, Meruem, Pariston, Razor, Robin, Ryosuke, plus wildcards Zoro and Luffy. 16 findings written to inventory: 2 P2, 5 P3, 4 Nit, 5 Note. Dominant convergent finding is CRF-1 (11 reviewers): the RFC 7592 PUT handler at registration.go:346 flips client_type without reconciling oauth2_provider_app_secrets, so the "public means no secret row" invariant this PR's InTx establishes at registration is broken by the sibling update path.

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.

The change stands up cleanly on the create side. Wrapping the app and secret inserts in a single InTx closes the pre-existing orphan-confidential race and does it with a two-mock-store test setup (mDB outer, mTx closure) that fails an insert issued off-transaction as an unexpected call, so the InTx contract is enforced by construction rather than by convention. Raw-body assertion on absent client_secret pins the RFC 7591 §3.2.1 wire contract rather than the decoded struct, which cannot distinguish absent from empty. Deriving discovery and registration from a single AllOAuth2TokenEndpointAuthMethods() list is the right single-owner narrowing for the advertised-vs-accepted pair. The JoinPath swap fixes a real latent double-slash bug.

From Hisoka on the dominant finding: "Bungee Gum. Pull the new InTx thread and it moves POST. It does not move PUT."

Severity: 2 P2, 5 P3, 4 Nit, 5 Note (16 findings).

Two P2 items need attention before this merges:

  • [CRF-1] (11 reviewers converged, one at P1) The RFC 7592 PUT handler at registration.go:346 is the sibling of the invariant this PR just installed on the create side. UpdateClientConfiguration recomputes client_type from token_endpoint_auth_method and writes it straight to the app row with no touch of oauth2_provider_app_secrets. Both directions reproduce broken states: public→confidential leaves an app with client_type='confidential' and zero secret rows (the exact permanently-uninhabitable state the create-side transaction exists to prevent), and confidential→public leaves an orphan secret row against a nominally public client. This PR is what makes those transitions load-bearing. Treating the PR as if no follow-up will ever touch this code, this needs a decision here: reject client_type changes on PUT (RFC 7592 does not require supporting them), or wire the same InTx pattern with matching secret insert/delete on the transition. If it must ship deferred, please file a linked issue rather than leaving it silent, because it cannot be agent-accepted as permanent.
  • [CRF-2] The new IMPORTANT callout at docs/admin/integrations/oauth2-provider.md:130 disagrees with validateRedirectURIs on three independently checkable points: it omits https:// to arbitrary hosts (the RFC 8252 §7.2 preferred method for native apps, which the code accepts), it lists only 127.0.0.1 for loopback while the validator also accepts localhost and [::1], and its "http to any other host" carve-out for confidential clients is wrong (confidential rejects non-loopback http too).

Two P3 items are pure regression-guard gaps that pin invariants your PR description names as goals: [CRF-5] no test sets a trailing-slash accessURL (a revert of url.JoinPath back to fmt.Sprintf passes the suite), and [CRF-8] TokenEndpointAuthMethodsSupported is asserted with require.Contains instead of require.ElementsMatch against codersdk.AllOAuth2TokenEndpointAuthMethods(). Both are one-line changes.

Stack-context observation: [CRF-3] (discovery advertises none while /oauth2/tokens rejects any exchange without client_secret) and [CRF-10] (RegisterPublicClient has no in-tree caller) both trace to the next PR in the stack. Reviewing this PR standalone, they need to be answered even if the eventual answer is "these ship together in a merge queue." Either hold "none" out of the advertised list until the token endpoint accepts it, or explicitly gate merging this PR on the follow-up landing in the same train. Same for the helper: fold into the PR that first uses it, or add a minimal exercise here.

Bundle observation: the InTx wrap and the JoinPath swap are two independent fixes riding along with the public-client feature. Each is defensible on its own reasoning and the PR description acknowledges the bundling. Called out only so the review record shows the bundle was noticed rather than missed.


coderd/oauth2provider/registration.go:346

P2 [CRF-1] The RFC 7592 PUT handler flips client_type without reconciling oauth2_provider_app_secrets, reintroducing the exact orphaned-confidential state this PR's InTx was written to prevent on the create side. (Knov P1, Hisoka P2, Chopper P2, Pariston P2, Mafuuu P2, Melody P2, Ryosuke P3, Razor P3, Kurapika P3, Meruem P3, Mafu-san P4)

From Hisoka: "Pull the new InTx thread and it moves POST. It does not move PUT. The invariant this PR just installed, 'public means no secret row', starts life legitimate on the POST side and is left undefended on the PUT side."

From Knov, the direct sequence: "1. Register a confidential client (POST); receives a client_secret and a secret row. 2. PUT the same client with token_endpoint_auth_method: \"none\". The row now has client_type = public, but the secret row still exists. Or the reverse: PUT a public client with token_endpoint_auth_method: \"client_secret_basic\". The row now has client_type = confidential, with no secret row, the exact state POST was just rewritten to prevent."

From Mafuuu on the security half of confidential→public: "Once the next PR in the stack teaches the token endpoint to accept a public client's PKCE-only exchange, that residual row is a stealth credential valid for a client that should authenticate with PKCE alone. Anyone who ever saw the original client_secret (support ticket, log, backup) can present it against a client the operator now considers public."

The PR description grounds the create-side transaction in exactly this invariant ("making a public client's 'no secret row' a legitimate state removes the ability to spot the orphaned confidential case by inspection later, so it's fixed here alongside the rest of this change"). This PR is what makes the class-of-bug reachable on the PUT side; before this PR, no dynamically-registered client was public in practice. The pattern-inheritance argument ("the update path already worked this way") does not carry: the precondition that made the pattern safe (client_type effectively constant) is exactly what this PR removes.

Narrowing options, in order of narrowness:

  • Reject a PUT whose req.DetermineClientType() differs from existingApp.ClientType with invalid_client_metadata (RFC 7592 §2 does not require the type to be mutable).
  • If transitions must be supported, do them inside InTx matching the create-side shape: delete secret rows on confidential→public, mint and insert a fresh secret on public→confidential. The response type OAuth2ClientConfiguration has no client_secret field, so the second direction cannot return the new secret without a wider surface change; that is a design signal in favor of the reject option.

Human decision needed: fix in this PR, file a linked issue that names the two reachable states, or explicitly document why the transition is safe. A silent defer is not one of the options.

🤖

codersdk/oauth2.go:622

P3 [CRF-4] client_secret_expires_at is int64 with omitempty, so 0 (the RFC 7591 wire value for "never expires") disappears from the JSON registration response for every confidential client. (Chopper P3)

From Chopper (verified by marshalling OAuth2ClientRegistrationResponse{ClientSecret: "x", ClientSecretExpiresAt: 0}: client_secret present, client_secret_expires_at absent): "RFC 7591 §3.2.1 says client_secret_expires_at is REQUIRED when client_secret is issued, and that 0 is the on-the-wire value for 'never expires.' The recipient is any integrator whose registration client validates against the RFC schema: they see a required key missing and either fall back to a wrong default or reject the response outright."

Pre-existing but on the exact signal this PR is fixing (the registration response's RFC 7591 conformance), so it belongs in scope here. Drop omitempty from ClientSecretExpiresAt; if you need to omit the field for public clients whose response has no client_secret, do it structurally (custom MarshalJSON, or set the whole field only when ClientSecret != "").

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread docs/admin/integrations/oauth2-provider.md Outdated
Comment thread coderd/oauth2provider/metadata.go Outdated
Comment thread coderd/oauth2provider/registration.go
Comment thread coderd/oauth2provider/registration.go Outdated
Comment thread coderd/oauth2provider/registration.go
Comment thread coderd/oauth2provider/registration_test.go
Comment thread coderd/oauth2provider/registration.go
Comment thread coderd/oauth2provider/registration_test.go
Comment thread coderd/oauth2provider/registration.go
Comment thread coderd/oauth2provider/registration_test.go
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 and others added 3 commits August 12, 2026 07:36
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().
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-registration branch 2 times, most recently from ff8d29f to 4bd1e82 Compare August 12, 2026 19:58
…-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.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-registration branch from 4bd1e82 to cbdf0bc Compare August 12, 2026 22:06
Split out of #27873 to make that PR smaller to review. Third in the
stack; this is the point where dynamic client registration actually
produces a public client.

An RFC 7591 registration requesting token_endpoint_auth_method: "none"
now skips secret generation entirely: no secret is minted, and the app
is persisted with the client_type the previous PR in the stack derives
from that auth method.

Discovery does not yet advertise "none" as a supported method.
AdvertisedOAuth2TokenEndpointAuthMethods() excludes it until the token
endpoint actually accepts a public client's exchange, in the next PR in
the stack; advertising it earlier would tell a conforming client the
server accepts an exchange it will reject.

Registration now writes the app and its secret in one transaction. They
were two independently committed inserts, so a failure of the second
left a permanently committed app that can never authenticate while
still holding a registration access token. Pre-existing, but making a
public client's "no secret row" a legitimate state removes the ability
to spot the orphaned confidential case by inspection later, so it is
fixed here alongside the rest of this change.

The registration_client_uri now uses url.JoinPath instead of
fmt.Sprintf, fixing a latent bug where an access URL configured with a
trailing slash would mint "//oauth2/clients/{id}" as the client's
management endpoint. Pinned with a regression test against a
trailing-slash access URL.

The public-client redirect URI documentation is corrected to match
validateRedirectURIs: https is allowed for both client types, the
loopback list was incomplete, and the confidential-client restriction
was misstated.

RegisterPublicClient, a test helper for registering a public client end
to end, is exercised in this PR instead of landing unexercised for a
later PR to discover a bug in.

The token endpoint does not yet accept a public client's PKCE-only
exchange; that follows in the next PR in the stack, so a client
registered here cannot yet obtain a token.
@BobbyHo
BobbyHo force-pushed the oauth2-public-clients-registration branch from cbdf0bc to 7c124e7 Compare August 12, 2026 22:09
BobbyHo and others added 12 commits August 13, 2026 07:53
The none bullet described a token endpoint that accepts PKCE alone, but the
token endpoint here requires client_secret for every authorization_code
exchange, so a registered public client gets HTTP 400 invalid_request.
Discovery withholds none for the same reason. State both limits; the PR
that makes them false removes the note.

The redirect URI callout read as an exhaustive allowlist, while
validateRedirectURIs is a blocklist: any scheme not blocked for all clients
or barred for public ones is accepted. Reframe it as the http-only host
restriction it actually is, and link the scheme rules rather than restating
them, since the callout's custom-scheme line contradicted the
mailto/tel/sms restriction documented under Callback URL schemes.

Move the out-of-band URN to that section too. validateScheme accepts it
before any client-type branching, so it is not a public client property.
…omments

Secret and token creation now say "issued", and carrying a stored value
through an update says "unchanged". Comment text only, no behavior change.
Base automatically changed from oauth2-public-clients-vocabulary to main August 19, 2026 04:12
BobbyHo and others added 3 commits August 19, 2026 04:13
…' into oauth2-public-clients-registration

# Conflicts:
#	codersdk/oauth2.go
…-registration

# Conflicts:
#	coderd/oauth2provider/registration.go
#	coderd/oauth2provider/registration_test.go
#	codersdk/oauth2.go
@BobbyHo
BobbyHo marked this pull request as ready for review August 19, 2026 14:55
@BobbyHo
BobbyHo requested review from Emyrk and geokat August 19, 2026 15:08

@geokat geokat 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.

LTGM: couple nits, but good to merge! 👍

Comment thread coderd/oauth2provider/registration.go
Comment thread coderd/oauth2provider/registration_test.go
@BobbyHo
BobbyHo merged commit 6348c83 into main Aug 20, 2026
31 checks passed
@BobbyHo
BobbyHo deleted the oauth2-public-clients-registration branch August 20, 2026 19:49
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants