Skip to content

feat: support public (secretless, PKCE-only) OAuth2 clients - #27195

Closed
BobbyHo wants to merge 10 commits into
mainfrom
coder-oauth-public-client-support-eng-3029
Closed

BobbyHo wants to merge 10 commits into
mainfrom
coder-oauth-public-client-support-eng-3029

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

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.

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:

  1. codersdk/oauth2.goDetermineClientType() now reads the requested token_endpoint_auth_method instead of hardcoding "confidential". This is the root fix everything else depends on.
  2. coderd/oauth2provider/metadata.go — advertises "none" in token_endpoint_auth_methods_supported.
  3. coderd/database/queries/oauth2.sql + coderd/database/migrations/000544_oauth2_public_client_tokens.{up,down}.sql — the schema change: adds app_id to oauth2_provider_app_tokens, makes app_secret_id nullable, and rewrites two queries to resolve a token's owning app through app_id instead of joining through the now-nullable secret.
  4. coderd/oauth2provider/registration.goCreateDynamicClientRegistration skips 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).
  5. coderd/oauth2provider/tokens.goclient_secret is no longer required for public clients at the token endpoint; PKCE, already mandatory for every client, is their sole authentication.
  6. coderd/oauth2provider/revoke.go — ownership checks now compare AppID directly instead of joining through a secret that may not exist.
  7. coderd/database/modelmethods.go — small OAuth2ProviderApp.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 new app_id column and nullable app_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 the dbmock/dbmetrics packages are fully generated (make gen) from the changes above; no need to review them directly.

@linear-code

linear-code Bot commented Jul 13, 2026

Copy link
Copy Markdown

ENG-3029

@BobbyHo BobbyHo changed the title feat: support public (secretless, PKCE-only) OAuth2 clients OAuth2Provider: support public (secretless, PKCE-only) OAuth2 clients Jul 13, 2026
@BobbyHo BobbyHo changed the title OAuth2Provider: support public (secretless, PKCE-only) OAuth2 clients feat: support public (secretless, PKCE-only) OAuth2 clients Jul 13, 2026
@BobbyHo
BobbyHo requested a review from Emyrk July 13, 2026 22:21
@BobbyHo

BobbyHo commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Manual verification: public (secretless, PKCE-only) OAuth2 clients

Ran the flows below against a local dev Coderd (./scripts/develop.sh) to confirm the behavior end-to-end, on top of the automated test suite. Each section shows the flow being exercised, then the actual request/response captured while testing (collapsed below each diagram).

Summary

# Test Result
1 Discovery metadata advertises none ✅ Pass
2 Register public client — no client_secret in response ✅ Pass
2a Regression: confidential registration unaffected ✅ Pass
3 PKCE-only authorize + token exchange, no client_secret sent ✅ Pass
3a Resulting access token authenticates a real API request ✅ Pass
3b Exchange rejected when PKCE verifier is missing ✅ Pass
4 Public client revokes its own token (RFC 7009) ✅ Pass
4a A different client's revoke attempt is silently a no-op ✅ Pass
5 RFC 7592 PUT flips an existing confidential client to public ✅ Pass
6 "Apps I've authorized" listing finds the public client ✅ Pass (bulk-revoke half + full cleanup still in progress, will follow up)

1. Discovery: metadata advertises none

Verifies coderd/oauth2provider/metadata.go.

sequenceDiagram
    participant C as Client
    participant S as coderd

    C->>S: GET /.well-known/oauth-authorization-server
    S-->>C: 200 OK - token_endpoint_auth_methods_supported now<br/>includes client_secret_basic, client_secret_post, none
    Note over C,S: none present means a public client can discover<br/>that a secretless registration is accepted
Loading
Command and output
curl -s "$BASE_URL/.well-known/oauth-authorization-server" | jq '.token_endpoint_auth_methods_supported'
[
  "client_secret_basic",
  "client_secret_post",
  "none"
]

✅ Passnone is present.


2. Register a public client (no client_secret in the response)

Verifies codersdk/oauth2.go's DetermineClientType() and coderd/oauth2provider/registration.go's conditional secret generation.

sequenceDiagram
    participant C as Public Client
    participant S as coderd

    C->>S: POST /oauth2/register<br/>token_endpoint_auth_method=none, redirect_uris=[...]
    S-->>C: 201 Created - client_id, registration_access_token<br/>returned, no client_secret key present
    Note over C,S: DetermineClientType reads none, resolves to public,<br/>secret generation skipped entirely
Loading
Command and output
PUBLIC_REG=$(curl -s -X POST "$BASE_URL/oauth2/register" \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "manual-test-public",
    "redirect_uris": ["http://localhost:9876/callback"],
    "token_endpoint_auth_method": "none"
  }')
echo "$PUBLIC_REG" | jq .
{
  "client_id": "9d3261d9-2842-4af5-ab27-b73287b595f5",
  "client_id_issued_at": 1784061510,
  "redirect_uris": [
    "http://localhost:9876/callback"
  ],
  "client_name": "manual-test-public",
  "grant_types": [
    "authorization_code",
    "refresh_token"
  ],
  "response_types": [
    "code"
  ],
  "token_endpoint_auth_method": "none",
  "registration_access_token": "GLie2AbltfjTBe77pP2Rbt4gXZTb8eXOMlY0piTO",
  "registration_client_uri": "http://127.0.0.1:3000/oauth2/clients/9d3261d9-2842-4af5-ab27-b73287b595f5"
}

✅ Passclient_id issued, token_endpoint_auth_method echoed back as none, and no client_secret key anywhere in the response.


2a. Regression check: confidential registration still gets a secret

sequenceDiagram
    participant C as Confidential Client
    participant S as coderd

    C->>S: POST /oauth2/register<br/>redirect_uris=[...], auth method omitted
    S-->>C: 201 Created - client_id, client_secret,<br/>token_endpoint_auth_method=client_secret_basic
    Note over C,S: Omitted auth method defaults to client_secret_basic,<br/>confidential and unchanged
Loading
Command and output
CONFIDENTIAL_REG=$(curl -s -X POST "$BASE_URL/oauth2/register" \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "manual-test-confidential",
    "redirect_uris": ["http://localhost:9876/callback"]
  }')
echo "$CONFIDENTIAL_REG" | jq '{client_id, client_secret, token_endpoint_auth_method}'
{
  "client_id": "5df4e5b5-5755-42e1-8449-24e7787e387b",
  "client_secret": "coder_rEw2LHyz8Y_tU8d71aEKjsWz7ev0E9J7r1njeiRTv1xGJge3DyX",
  "token_endpoint_auth_method": "client_secret_basic"
}

✅ Pass — confidential registration is unaffected: client_secret present and non-empty, token_endpoint_auth_method defaulted correctly.


3. PKCE-only authorize + token exchange for the public client

Verifies coderd/oauth2provider/tokens.go (conditional client_secret requirement) and the migration (app_id/nullable app_secret_id).

sequenceDiagram
    participant C as Public Client
    participant S as coderd

    Note over C: generate code_verifier and code_challenge, S256
    C->>S: POST /oauth2/authorize, client_id, code_challenge<br/>as logged-in Coder user
    S-->>C: 302 redirect, code=coder_9gYuJi1r...
    C->>S: POST /oauth2/tokens<br/>grant_type=authorization_code, code, client_id,<br/>code_verifier, no client_secret
    S-->>C: 200 OK - access_token, refresh_token,<br/>expires_in=86399
    Note over C,S: PKCE verifier alone authenticates the client,<br/>token row gets app_id set, app_secret_id NULL
Loading
Command and output
VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c -43)
CHALLENGE=$(echo -n "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d "=" | tr '+/' '-_')
STATE=$(openssl rand -hex 16)

AUTH_URL="$BASE_URL/oauth2/authorize?client_id=$PUBLIC_CLIENT_ID&response_type=code&redirect_uri=http://localhost:9876/callback&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"

REDIRECT=$(curl -s -X POST "$AUTH_URL" -H "$AUTH_HEADER" -w '\n%{redirect_url}' -o /dev/null)
CODE=$(echo "$REDIRECT" | grep -oE 'code=[^&]+' | sed 's/code=//')
echo "code: $CODE"

# no client_secret parameter at all
TOKEN_RESPONSE=$(curl -s -X POST "$BASE_URL/oauth2/tokens" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  -d "client_id=$PUBLIC_CLIENT_ID" \
  -d "redirect_uri=http://localhost:9876/callback" \
  -d "code_verifier=$VERIFIER")
echo "$TOKEN_RESPONSE" | jq .
code: coder_9gYuJi1rFm_SGEUBEdVdPUPF6xPyIyxdawjSqNHcyVieW9j88Mj
{
  "access_token": "DWpnLUyPoR-NdHRZTvJF8vX4bGKJNhzzV",
  "token_type": "Bearer",
  "expires_in": 86399,
  "refresh_token": "coder_tLzpVgf8Rp_DdklgjAcxNEbwC98WBbElIDR8AE1mXau6qybw6aG",
  "expiry": "2026-07-15T21:00:49.175071Z"
}

✅ Pass — authorization code obtained and exchanged for a valid access_token/refresh_token pair with no client_secret sent at any point.


3a. Verify the access token actually works

sequenceDiagram
    participant C as Public Client
    participant S as coderd

    C->>S: GET /api/v2/users/me<br/>Authorization Bearer DWpnLUyPoR...
    S-->>C: 200 OK
    Note over C,S: Access token is a normal Coder API key,<br/>authorized exactly like any session token
Loading
Command and output
curl -s -o /dev/null -w "%{http_code}\n" "$BASE_URL/api/v2/users/me" \
  -H "Authorization: Bearer $PUBLIC_ACCESS_TOKEN"
200

✅ Pass — the public client's access token authenticates a real API request.


3b. Negative case: exchange fails without PKCE verifier

sequenceDiagram
    participant C as Public Client
    participant S as coderd

    C->>S: POST /oauth2/tokens<br/>grant_type=authorization_code, code, client_id,<br/>code_verifier omitted
    S-->>C: 400 Bad Request - error=invalid_grant,<br/>error_description=The PKCE code verifier is invalid
    Note over C,S: No secret and no verifier means rejected,<br/>PKCE is the real security boundary
Loading
Command and output
curl -s -X POST "$BASE_URL/oauth2/tokens" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  -d "client_id=$PUBLIC_CLIENT_ID" \
  -d "redirect_uri=http://localhost:9876/callback" | jq .
{
  "error": "invalid_grant",
  "error_description": "The PKCE code verifier is invalid"
}

✅ Pass — rejected specifically for the missing PKCE verifier, confirming PKCE (not a secret) is the actual security boundary for a public client.


4. RFC 7009 revoke: the public client can revoke its own token

Verifies coderd/oauth2provider/revoke.go's AppID-based ownership check (previously went through app_secret_id, which is NULL for a public client — this call could not resolve ownership at all before the fix).

sequenceDiagram
    participant C as Public Client
    participant S as coderd

    C->>S: POST /oauth2/revoke<br/>token=coder_tLzpVgf8Rp..., client_id=9d3261d9-...
    S-->>C: 200 OK
    Note over S: revokeRefreshTokenInTx checks dbToken.AppID equals<br/>appID, deletes API key and token row
    C->>S: GET /api/v2/users/me<br/>Authorization Bearer DWpnLUyPoR... old token
    S-->>C: 401 Unauthorized
    Note over C,S: Own token successfully revoked via AppID match,<br/>previously impossible since app_secret_id was NULL
Loading
Commands and output
curl -s -X POST "$BASE_URL/oauth2/revoke" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=$PUBLIC_REFRESH_TOKEN" \
  -d "client_id=$PUBLIC_CLIENT_ID" \
  -w "\nHTTP %{http_code}\n"
HTTP 200
curl -s -o /dev/null -w "%{http_code}\n" "$BASE_URL/api/v2/users/me" \
  -H "Authorization: Bearer $PUBLIC_ACCESS_TOKEN"
401

✅ Pass — revoke request succeeded (200), and the access token that was valid (200) in step 3a now returns 401. Confirms revoke.go's AppID-based ownership check correctly resolves and revokes a public client's own token.


4a. Negative case: a different client can't revoke someone else's token

sequenceDiagram
    participant V as Victim Client
    participant A as Attacker Client
    participant S as coderd

    Note over V,S: Victim already holds a live access and refresh token
    A->>S: POST /oauth2/revoke<br/>token=victims refresh token, client_id=attacker's own id
    S-->>A: 200 OK, RFC 7009 never reveals ownership
    Note over S: revokeRefreshTokenInTx finds dbToken.AppID does not<br/>match appID, returns ErrTokenNotBelongsToClient,<br/>token is NOT deleted
    V->>S: GET /api/v2/users/me<br/>Authorization Bearer, victim's own access token
    S-->>V: 200 OK
    Note over V,A: Attacker's request was silently ignored,<br/>victim's token still valid
Loading
Commands and output

Registered a second ("victim") public client and completed the same flow as steps 2/3, plus a third ("attacker") public client unrelated to either. Then:

curl -s -X POST "$BASE_URL/oauth2/revoke" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=$VICTIM_REFRESH_TOKEN" \
  -d "client_id=$ATTACKER_CLIENT_ID" \
  -w "\nHTTP %{http_code}\n"
HTTP 200
curl -s -o /dev/null -w "%{http_code}\n" "$BASE_URL/api/v2/users/me" \
  -H "Authorization: Bearer $VICTIM_ACCESS_TOKEN"
200

✅ Pass — the attacker's revoke request against the victim's token returned 200 (per RFC 7009, never revealing ownership mismatch), but the victim's token is still valid afterward. Confirms revoke.go's AppID comparison correctly rejects a mismatched owning app instead of silently revoking someone else's token.


5. RFC 7592: flip an existing confidential client to public

Verifies DetermineClientType() is re-evaluated on every PUT, not just at initial registration.

sequenceDiagram
    participant C as Client, was confidential
    participant S as coderd

    C->>S: PUT /oauth2/clients/5df4e5b5-...<br/>Authorization Bearer, own registration_access_token<br/>token_endpoint_auth_method=none, redirect_uris=[...]
    S-->>C: 200 OK - token_endpoint_auth_method=none
    Note over C,S: DetermineClientType re-evaluated on every PUT,<br/>client_type flips from confidential to public in the DB
Loading
Command and output
REG_ACCESS_TOKEN=$(echo "$CONFIDENTIAL_REG" | jq -r '.registration_access_token')

UPDATED=$(curl -s -X PUT "$BASE_URL/oauth2/clients/$CONFIDENTIAL_CLIENT_ID" \
  -H "Authorization: Bearer $REG_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "manual-test-confidential",
    "redirect_uris": ["http://localhost:9876/callback"],
    "token_endpoint_auth_method": "none"
  }')
echo "$UPDATED" | jq '.token_endpoint_auth_method'
"none"

✅ Pass — the confidential client from step 2a was flipped to public via PUT.


6. "Apps I've authorized" listing + bulk revoke

Verifies GetOAuth2ProviderAppsByUserID / DeleteOAuth2ProviderAppTokensByAppAndUserID resolving through the new app_id column instead of the (nullable, for public clients) app_secret_id join.

sequenceDiagram
    participant U as User, browser session
    participant S as coderd

    U->>S: GET /api/v2/oauth2-provider/apps, user_id
    S-->>U: 200 OK - one app returned,<br/>id=b3e4b3b6-..., name=manual-test-public-victim
    Note over S: GetOAuth2ProviderAppsByUserID joins on<br/>token.app_id, not the NULL app_secret_id
    U->>S: DELETE /oauth2/tokens, client_id=b3e4b3b6-...
    S-->>U: 204 No Content
    Note over S: DeleteOAuth2ProviderAppTokensByAppAndUserID<br/>filters directly on app_id
    U->>S: GET /api/v2/oauth2-provider/apps, user_id
    S-->>U: 200 OK - empty array
    Note over U,S: App no longer listed once its tokens are gone
Loading
Command and output
USER_ID=$(curl -s "$BASE_URL/api/v2/users/me" -H "$AUTH_HEADER" | jq -r '.id')

curl -s "$BASE_URL/api/v2/oauth2-provider/apps?user_id=$USER_ID" -H "$AUTH_HEADER" \
  | jq '[.[] | select(.id == "'"$OTHER_CLIENT_ID"'")]'
[
  {
    "id": "b3e4b3b6-c3b9-4f92-a225-492c3bccedfa",
    "name": "manual-test-public-victim",
    "callback_url": "http://localhost:9876/callback",
    "icon": "",
    "endpoints": {
      "authorization": "http://127.0.0.1:3000/oauth2/authorize",
      "token": "http://127.0.0.1:3000/oauth2/tokens",
      "token_revoke": "http://127.0.0.1:3000/oauth2/revoke",
      "device_authorization": ""
    }
  }
]

✅ Pass (listing half) — the public client with a live token is correctly found, resolving via the new app_id column rather than the app_secret_id join. The bulk-revoke half of this test (DELETE /oauth2/tokens?client_id=...) and full cleanup of test apps are still in progress — will follow up with those results.


Automated equivalents

Every case above also has an automated test that's passing:

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)

@BobbyHo
BobbyHo marked this pull request as ready for review July 14, 2026 22:03
@coderagents

coderagents Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Documentation Check

Updates Needed

  • docs/admin/integrations/oauth2-provider.md - Add public (secretless, PKCE-only) client support. This PR makes token_endpoint_auth_method: "none" a first-class option, but the page still documents only confidential clients:
    • The Client Authentication Methods section lists only client_secret_basic and client_secret_post. It should add none and explain that a public client authenticates with PKCE alone and receives/sends no client_secret.
    • The Dynamic Client Registration note ("omit token_endpoint_auth_method → defaults to client_secret_basic") should mention that clients can register with "none" to become public clients, and that no client_secret is returned in that case.
    • The Standard OAuth2 Flow / PKCE Flow token-exchange examples assume a secret; consider adding a public-client variant that omits client_secret and relies on client_id + code_verifier only.
    • Discovery metadata now advertises none in token_endpoint_auth_methods_supported; the Discovery Endpoints section can note this.

Note

The oauth2 provider is an unsafe experiment, where new docs generally aren't required. This item is flagged only because the existing page already documents this exact surface (client authentication methods and Dynamic Client Registration), and this change makes those statements inaccurate. Not blocking.


Automated review via Coder Agents

@BobbyHo
BobbyHo marked this pull request as draft July 15, 2026 17:17
@github-actions github-actions Bot added the stale This issue is like stale bread. label Jul 26, 2026
@github-actions github-actions Bot closed this Jul 30, 2026
@BobbyHo BobbyHo reopened this Jul 30, 2026
BobbyHo and others added 8 commits July 30, 2026 10:06
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]>
@BobbyHo
BobbyHo force-pushed the coder-oauth-public-client-support-eng-3029 branch from a7629e2 to 94830f1 Compare July 30, 2026 19:10
BobbyHo and others added 2 commits July 30, 2026 13:56
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]>
BobbyHo added a commit that referenced this pull request Jul 30, 2026
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]>
BobbyHo added a commit that referenced this pull request Jul 30, 2026
…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]>
@github-actions github-actions Bot removed the stale This issue is like stale bread. label Jul 31, 2026
BobbyHo added a commit that referenced this pull request Aug 5, 2026
…#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]>

@BobbyHo BobbyHo left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Posted on the wrong PR by mistake; please disregard.

@github-actions github-actions Bot added the stale This issue is like stale bread. label Aug 18, 2026
@github-actions github-actions Bot closed this Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale This issue is like stale bread.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant