Conversation
Manual verification: public (secretless, PKCE-only) OAuth2 clientsRan the flows below against a local dev Coderd ( Summary
1. Discovery: metadata advertises
|
| Manual step | Automated test |
|---|---|
| 1 | TestOAuth2AuthorizationServerMetadata |
| 2, 2a | TestOAuth2PublicClient/RegistrationReturnsNoSecret, TestCreateDynamicClientRegistration |
| 3, 3a, 3b | TestOAuth2PublicClient/TokenExchange/* |
| 4, 4a | TestOAuth2RevokeTokenOwnership |
| 5 | TestOAuth2PublicClient/PUTFlipsConfidentialClientToPublic |
| 6 | TestOAuth2ProviderAppsByUserIDAndBulkRevoke |
| all of the above, plus a real MCP client | TestMCPHTTP_E2E_OAuth2_EndToEnd/DynamicClientRegistrationWithMCPFlowPublicClient |
| all of the above, as a shell script | scripts/oauth2/test-mcp-oauth2.sh (Test 8) |
Documentation CheckUpdates Needed
Note The Automated review via Coder Agents |
Dynamically registered clients that requested token_endpoint_auth_method: "none" were always treated as confidential. DetermineClientType() ignored the request entirely, the token endpoint unconditionally required a client_secret, and the metadata endpoint never advertised "none" as supported. Every MCP client, CLI browser-auth flow, and IDE plugin is a public client by construction, so none of them could complete a secretless, PKCE-only flow, even though PKCE was already mandatory for every client. Add app_id to oauth2_provider_app_tokens and make app_secret_id nullable, so a public client's tokens (which have no secret) can still be attributed to their owning app for revocation and listing, without joining through a secret that doesn't exist. Wrap the dynamic registration app+secret insert in a single transaction to close a pre-existing orphan-row and visibility-race gap in the same code path.
Convert TestOAuth2ClientRegistrationRequest_DetermineClientType to a single table-driven loop. Add TestCreateDynamicClientRegistration, a focused unit test on the RFC 7591 registration handler itself (via httptest, not the full coderdtest HTTP server), covering whether a client_secret is minted and what client_type is persisted for confidential vs. public clients.
isPublic := app.ClientType.String == "public" was duplicated across extractTokenRequest and authorizationCodeGrant. Move it to a method on database.OAuth2ProviderApp so both call sites stay in sync.
…stration transaction Add a table-driven test for extractTokenRequest's public-vs-confidential client_secret requirement (D1-02), and a mock-based test proving the dynamic registration app+secret insert share a single transaction (item 10 from the public-client-support proposal) rather than two independently committed statements.
… app listing Consolidate TestOAuth2PublicClient's three PKCE exchange subtests into a single table-driven TokenExchange group; the other subtests test different endpoints and were left as-is. Add TestOAuth2RevokeTokenOwnership, covering the RFC 7009 revocation ownership check for both refresh and access tokens, and for both confidential and public token owners, including the negative case where an unrelated client tries to revoke a token it does not own. Add TestOAuth2ProviderAppsByUserIDAndBulkRevoke, covering the two queries that resolve a token's owning app through app_id directly: the authorized-apps listing and the bulk revoke-all-access endpoint, for both confidential and public clients. Also drop internal design-doc references (D1-0x, section numbers) from test comments added earlier, since they carry no meaning for reviewers.
…ript coderd/mcp/mcp_e2e_test.go: extract the confidential DCR+MCP flow test into a shared closure parameterized by token_endpoint_auth_method, and add a public client subtest that asserts no client_secret is issued and completes the full authorize, exchange, MCP tool call, and refresh flow with no client_secret sent at any step. scripts/oauth2/test-mcp-oauth2.sh: add a public client registration and PKCE-only exchange case to the manual OAuth2 test suite, which this repo's docs call out to run after any OAuth2 change.
main merged its own 000543_chat_status_remove_unused migration after this branch created 000543_oauth2_public_client_tokens, so golang-migrate panics on the duplicate version number once both are present. Renumber to 000544 via fix_migration_numbers.sh and update the matching backfill test's references and step-to version.
…ith main main merged another migration at 000544 after this branch's prior renumbering (543 -> 544), so the rebase collided again. Renumber to 000562 via fix_migration_numbers.sh and update the backfill test's references and step-to version to match. Co-Authored-By: Claude Sonnet 5 <[email protected]>
a7629e2 to
94830f1
Compare
main added a dynamic-client-registration-enabled gate (defaulting to disabled) after this branch forked, so POST /oauth2/register now returns 403 for tests that never opted in. Call oauth2providertest.EnableDCR in TestOAuth2PublicClient, TestOAuth2RevokeTokenOwnership, and TestOAuth2ProviderAppsByUserIDAndBulkRevoke, and add the matching mock expectation for GetOAuth2DCREnabled in the gomock-based TestCreateDynamicClientRegistration_Transaction. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Layer 1 of a multi-PR split of #27195 (public/secretless PKCE-only OAuth2 clients), broken up for easier review: database schema, then the oauth2provider handler logic, then API/e2e integration tests. Add app_id to oauth2_provider_app_tokens, backfilled from the existing app_secret_id -> app_id join, so a future public (secretless) client's tokens can be attributed to their owning app without joining through a secret that doesn't exist. app_id stays nullable and app_secret_id stays required for now: no write path populates app_id yet, and no public clients can be created until a later migration loosens app_secret_id once the application code that relies on this column lands. This keeps the schema change fully backward compatible and independently mergeable: nothing outside coderd/database changes, and the full repo builds and tests pass unmodified. Co-Authored-By: Claude Sonnet 5 <[email protected]>
…ema layer Add app_id to oauth2_provider_app_tokens and make app_secret_id nullable, so a public (secretless, PKCE-only) client's tokens can still be attributed to their owning app for revocation and listing, without joining through a secret that does not exist. This is a single, final migration: app_id is populated at insert time from day one, so there is no window where new rows are written with a NULL app_id, and no second backfill will ever be needed. Fixes a review finding from an earlier version of this branch: the migration previously only added app_id as an unenforced, unpopulated column, deferring app_secret_id's nullability and app_id's write path to a later PR. That left a gap where any token created between this PR merging and the next one landing would carry a NULL app_id permanently, defeating the column's purpose. Doing the full migration now, with the insert path updated in the same change, closes that gap entirely. This requires touching a few call sites outside coderd/database, but only mechanically: revoke.go's ownership checks now compare app_id directly instead of joining through app_secret_id (a genuine simplification, not a shim), and tokens.go's two insert call sites supply the new app_id column and wrap app_secret_id as a NullUUID. No client-type branching exists yet, no public client can be registered, and behavior for today's confidential-only clients is unchanged. That capability (registration skipping secret generation, the token endpoint accepting PKCE alone, discovery advertising "none") lands in a follow-up PR. Layer 1 of a multi-PR split of #27195 (public/secretless PKCE-only OAuth2 clients), broken up for easier review: database schema (this PR), then the oauth2provider handler logic, then API/e2e integration tests. Co-Authored-By: Claude Sonnet 5 <[email protected]>
…#27712) Layer 1 of a multi-PR split of #27195 (public/secretless PKCE-only OAuth2 clients), broken up for easier review: **database schema (this PR)** → oauth2provider handler logic → API/e2e integration tests. ## Goal Coder's OAuth2 provider only works correctly for confidential clients today. Public clients — native apps that can't safely hold a shared secret, such as the CLI's browser-based login flow, IDE plugins (VS Code, JetBrains), desktop apps, and MCP clients — cannot complete a real OAuth2 flow against Coder, even though OAuth 2.1 §2.1 explicitly defines this client type and RFC 8252 §8.5 requires PKCE alone to be sufficient authentication for it. Every MCP client, CLI login flow, and IDE plugin is a public client by construction, and none of them can complete a secretless flow against Coder today: dynamic registration always classifies a client as confidential regardless of what it asks for, the token endpoint unconditionally requires a `client_secret`, and discovery metadata never advertises `"none"` as a supported auth method. Full write-up: [ENG-3029](https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client) ### Overall design (end state across the full PR stack) `[PR2]` marks handler-layer changes landing in the next PR in this stack. The green box is what this PR implements. ```mermaid sequenceDiagram autonumber participant C as Public Client (CLI/MCP/IDE plugin) participant S as coderd (chi router) participant H as oauth2provider handlers participant DB as PostgreSQL Note over C,S: Discovery C->>S: GET /.well-known/oauth-authorization-server S->>H: GetAuthorizationServerMetadata() Note over H: [PR2] add "none" to<br/>the returned auth methods list H-->>C: [PR2] 200 { token_endpoint_auth_methods_supported:<br/>[..., "none"] } Note over C,S: Dynamic Client Registration C->>S: POST /oauth2/register<br/>{redirect_uris, token_endpoint_auth_method: "none"} S->>H: CreateDynamicClientRegistration() Note over H: [PR2] client type now reads<br/>the request -> "public" Note over H: [PR2] skip secret generation<br/>for public clients H->>DB: [PR2] INSERT app row<br/>(client_type = 'public') DB-->>H: app row Note over H: [PR2] skip secret insert entirely H-->>C: [PR2] 201 { client_id }<br/>(no client_secret field) Note over C,S: Authorization Code + PKCE flow C->>S: GET /oauth2/authorize?client_id=...&code_challenge=... C->>S: POST /oauth2/tokens (grant_type=authorization_code)<br/>no client_secret S->>H: extractTokenRequest() Note over H: [PR2] client_secret no longer required<br/>for public clients H->>H: authorizationCodeGrant() Note over H: [PR2] skip secret lookup for public clients Note over H: PKCE verification — already mandatory, unchanged rect rgb(198, 239, 206) Note over H,DB: [THIS PR] oauth2_provider_app_tokens.app_id<br/>column added (NOT NULL, populated at insert<br/>time from app.ID) and app_secret_id loosened<br/>to nullable. Revocation now checks app_id<br/>directly. Confidential-client behavior is<br/>unchanged — no public client can be created yet. H->>DB: [PR2] INSERT refresh token row<br/>(no secret reference, for public clients) end DB-->>H: token row H-->>C: 200 { access_token, refresh_token } ``` ## This PR: database schema A public client has no `client_secret`, so it has nothing to put in `oauth2_provider_app_tokens.app_secret_id`, which was `NOT NULL`. This PR makes that column nullable and instead attributes a token to its owning app through a new, always-populated `app_id` column — so ownership checks (e.g. revocation) work identically for public and confidential clients without joining through a secret that may not exist. | Column | Before | After (this PR) | |---|---|---| | `app_secret_id` | `uuid NOT NULL` | **nullable** | | `app_id` | — | **new**: `uuid NOT NULL`, `FOREIGN KEY → oauth2_provider_apps(id) ON DELETE CASCADE`, backfilled for every existing row and populated on every new insert from that point on | This is a single, complete migration — not staged across multiple PRs. An earlier version of this branch deferred `app_secret_id`'s nullability and the insert-time population of `app_id` to a later PR, keeping this PR's diff limited to `coderd/database`. [Automated review](#27712 (comment)) correctly flagged that as unsafe: the migration would backfill existing rows once, but nothing would populate `app_id` for rows written afterward, so the moment this PR merged, new tokens would start accumulating a permanently `NULL` app_id — and if a release happened to be cut before the follow-up PR landed, that gap could ship to customers and would need a second, later backfill to close. Doing the full migration now avoids that: `app_id` is correct from the first row written, and the promised `NOT NULL` constraint requires no data repair because it's already enforced. Closing that gap requires a few mechanical, non-branching touches outside `coderd/database`: - `revoke.go`'s two ownership checks now compare `dbToken.AppID` directly instead of looking up the app through `app_secret_id` — a genuine simplification (and slightly less code), not a temporary shim. - `tokens.go`'s two `InsertOAuth2ProviderAppToken` call sites supply the new `app_id` column and wrap `app_secret_id` as a `NullUUID`. - `oauth2_test.go`'s one direct-insert test fixture does the same. None of these introduce client-type branching or new capability — every client today is still confidential-only, still always presents a secret, and behavior is unchanged. The full repo builds, vets, and all existing tests pass unmodified in behavior. ## Coming next - **PR2 (handler layer)**: `codersdk`'s `DetermineClientType()` reading the requested `token_endpoint_auth_method`; `registration.go` skipping secret generation for public clients (and wrapping the app+secret insert in a single transaction, fixing a pre-existing orphan-row/visibility-race gap); `tokens.go` making the secret check conditional so PKCE alone authenticates a public client; `metadata.go` advertising `"none"` in discovery. No further migration is needed — the schema this PR ships is already final. - **PR3 (API/e2e layer)**: integration tests through the real HTTP API (`coderd/oauth2_test.go`), the MCP OAuth2 e2e flow (`coderd/mcp/mcp_e2e_test.go`), and the manual test script (`scripts/oauth2/test-mcp-oauth2.sh`). Depends on: #27195 (original combined PR, being superseded by this stack) --------- Co-authored-by: Claude Sonnet 5 <[email protected]>
Dynamically registered clients that requested
token_endpoint_auth_method: "none"were always treated as confidential.DetermineClientType()ignored the request entirely, the token endpoint unconditionally required aclient_secret, and the metadata endpoint never advertised"none"as supported. Every MCP client, CLI browser-auth flow, and IDE plugin is a public client by construction, so none of them could complete a secretless, PKCE-only flow, even though PKCE was already mandatory for every client.Add
app_idtooauth2_provider_app_tokensand makeapp_secret_idnullable, so a public client's tokens (which have no secret) can still be attributed to their owning app for revocation and listing, without joining through a secret that doesn't exist. Wrap the dynamic registration app+secret insert in a single transaction to close a pre-existing orphan-row and visibility-race gap in the same code path.Address issue described in PLAT-445
How to review this PR
The core fix is small; most of the diff is tests and generated code. Suggested reading order:
codersdk/oauth2.go—DetermineClientType()now reads the requestedtoken_endpoint_auth_methodinstead of hardcoding"confidential". This is the root fix everything else depends on.coderd/oauth2provider/metadata.go— advertises"none"intoken_endpoint_auth_methods_supported.coderd/database/queries/oauth2.sql+coderd/database/migrations/000544_oauth2_public_client_tokens.{up,down}.sql— the schema change: addsapp_idtooauth2_provider_app_tokens, makesapp_secret_idnullable, and rewrites two queries to resolve a token's owning app throughapp_idinstead of joining through the now-nullable secret.coderd/oauth2provider/registration.go—CreateDynamicClientRegistrationskips secret generation for public clients, and wraps the app+secret insert in a single transaction (a pre-existing bug found while making this change, unrelated to public clients but touching the same lines).coderd/oauth2provider/tokens.go—client_secretis no longer required for public clients at the token endpoint; PKCE, already mandatory for every client, is their sole authentication.coderd/oauth2provider/revoke.go— ownership checks now compareAppIDdirectly instead of joining through a secret that may not exist.coderd/database/modelmethods.go— smallOAuth2ProviderApp.IsPublic()helper used by (5) and (6).Everything else is tests, roughly mirroring the order above:
codersdk/oauth2_test.go,coderd/oauth2provider/metadata_test.go,coderd/oauth2provider/registration_test.go,coderd/oauth2provider/tokens_internal_test.go,coderd/database/migrations/migrate_test.go— focused unit/handler-level tests for each change above.coderd/database/dbauthz/dbauthz_test.go,coderd/database/dbgen/dbgen.go— updated RBAC fixtures for the newapp_idcolumn and nullableapp_secret_id.coderd/oauth2_test.go— full HTTP-level integration tests (TestOAuth2PublicClient,TestOAuth2RevokeTokenOwnership,TestOAuth2ProviderAppsByUserIDAndBulkRevoke).coderd/mcp/mcp_e2e_test.go,scripts/oauth2/test-mcp-oauth2.sh— end-to-end coverage for the flagship use case (MCP clients), both as a Go test and a manual dev script.coderd/database/{dump.sql,queries.sql.go,models.go,querier.go}and thedbmock/dbmetricspackages are fully generated (make gen) from the changes above; no need to review them directly.