Skip to content

feat: support OAuth 2.1 / MCP login flow#10

Open
acoshift wants to merge 5 commits into
mainfrom
feat/mcp-oauth21
Open

feat: support OAuth 2.1 / MCP login flow#10
acoshift wants to merge 5 commits into
mainfrom
feat/mcp-oauth21

Conversation

@acoshift
Copy link
Copy Markdown
Member

@acoshift acoshift commented May 27, 2026

Summary

Turns this service into an OAuth 2.1 authorization server that an MCP CLI client can authenticate against, while keeping the existing confidential web-client flow working unchanged. Flows are discriminated by the client's token_endpoint_auth_method: existing rows default to client_secret_post (unchanged); registered MCP clients are public (none) and PKCE-gated.

What's added

  • Discovery metadata (RFC 8414) at GET /.well-known/oauth-authorization-server
  • PKCE (S256) on the authorize and token endpoints
  • Public clients: /token accepts code_verifier instead of client_secret
  • Dynamic Client Registration (RFC 7591) at POST /register
  • Token introspection (RFC 7662) at POST /introspect for the (separate) resource server, guarded by INTROSPECTION_TOKEN
  • Loopback redirect URIs (RFC 8252) for native/CLI clients
  • Standard token responseaccess_token + expires_in; refresh_token retained for backward compatibility
  • BASE_URL / INTROSPECTION_TOKEN env vars; periodic cleanup of expired oauth2_sessions / oauth2_codes

Database migration

Apply against the existing database before deploying:

-- oauth2_clients: allow public clients (no secret) and dynamic client registration.
alter table oauth2_clients alter column secret drop not null;
alter table oauth2_clients alter column redirect_uri set default '';
alter table oauth2_clients add column if not exists redirect_uris              string[] not null default array[]::string[];
alter table oauth2_clients add column if not exists token_endpoint_auth_method string not null default 'client_secret_post';
alter table oauth2_clients add column if not exists client_name                string not null default '';

-- oauth2_codes: PKCE, bound redirect_uri, resource indicator (RFC 8707).
alter table oauth2_codes add column if not exists code_challenge        string not null default '';
alter table oauth2_codes add column if not exists code_challenge_method string not null default '';
alter table oauth2_codes add column if not exists redirect_uri          string not null default '';
alter table oauth2_codes add column if not exists resource              string not null default '';

-- oauth2_sessions: carry PKCE + resource across the Google round-trip.
alter table oauth2_sessions add column if not exists code_challenge        string not null default '';
alter table oauth2_sessions add column if not exists code_challenge_method string not null default '';
alter table oauth2_sessions add column if not exists resource              string not null default '';

Tests

First tests in the repo. They run against a real CockroachDB (cockroach-go/v2 testserver per test, schema applied from the embedded schema/*.sql migrations) and stub Google's token endpoint via the googleTokenURL package var — covering both the old confidential flow (regression) and the new public/PKCE flow, DCR, and introspection. go test ./..., go vet ./..., gofmt -l all clean.

Review notes

  • token_type case change — the legacy /token response used "token_type":"bearer"; it now returns "Bearer" (the RFC-registered value). refresh_token is preserved. If the existing web client compares token_type case-sensitively, this needs a revert to lowercase.
  • resource (RFC 8707) is stored but not enforced — issued tokens are the existing opaque 7-day tokens, not audience-bound. Full audience restriction would need per-resource tokens (larger change).
  • Resource-server side (out of scope here) — the MCP server must return 401 + WWW-Authenticate pointing at /.well-known/oauth-protected-resource, serve that metadata, and validate tokens via /introspect.

Test plan

  • go build ./..., go vet ./..., gofmt -l clean
  • Automated tests (go test ./...) cover old confidential + new PKCE flows, DCR, introspection
  • Apply migration; curl $BASE_URL/.well-known/oauth-authorization-server
  • End-to-end with a real client: claude mcp add --transport http …

🤖 Generated with Claude Code

acoshift and others added 5 commits May 27, 2026 07:47
Add the pieces an MCP CLI client needs to authenticate against this service
as an OAuth 2.1 authorization server, while keeping the existing confidential
web-client flow working.

- discovery metadata (RFC 8414) at /.well-known/oauth-authorization-server
- PKCE (S256) on the authorize and token endpoints
- public clients: /token accepts code_verifier instead of client_secret
- Dynamic Client Registration (RFC 7591) at /register
- token introspection (RFC 7662) at /introspect for the resource server
- loopback redirect URIs (RFC 8252) for native/CLI clients
- standard token response (access_token + expires_in; refresh_token kept
  for backward compatibility)
- BASE_URL / INTROSPECTION_TOKEN env vars; periodic cleanup of expired
  oauth2 sessions and codes

Flows are discriminated by client token_endpoint_auth_method: existing rows
default to client_secret_post (unchanged); registered MCP clients are public
(none) and PKCE-gated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the first tests to the repo, using go-sqlmock to fake the DB layer
(pgctx talks to a standard *sql.DB, no transactions) and an httptest stub
for Google's token endpoint.

Regression (old flow):
- TokenHandler confidential client_secret path still returns refresh_token
  (and now access_token/expires_in), wrong/missing secret rejected
- RedirectHandler glob redirect validation + Google redirect + session cookie
- CallbackHandler session lookup, state check, internal code issuance

New flow:
- TokenHandler public/PKCE: success, bad verifier, redirect mismatch,
  missing verifier, unsupported grant_type, unknown client, invalid code
- RedirectHandler public: PKCE required, S256 only, loopback redirect match
- CallbackHandler carries PKCE/redirect/resource onto the code
- Dynamic Client Registration validation + success
- Introspection: config/auth gates, active/unknown/empty token
- Discovery metadata; PKCE + redirect + registration URI helpers

To make the callback testable, googleTokenURL is now a package var.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow the ../dropbox pattern: a schema package embeds the SQL migrations
(01_init pre-MCP + user_tokens, 02_mcp the PR's ALTERs) and tu.Setup starts
an isolated in-memory cockroach-go/v2 testserver per test, applying the
migration. Tests seed via pgctx and assert against real DB state (token
persisted, code consumed, session PKCE carried through), which the sqlmock
version could not verify. As a bonus the suite exercises the MCP migration
SQL on a real CockroachDB.

Google's token endpoint is stubbed via a shared httptest server keyed by the
auth code (parallel-safe), set once through the googleTokenURL package var.

Drops the go-sqlmock dependency; adds cockroach-go/v2 (test only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch oauth2_clients.redirect_uris from a newline-delimited string to a
native CockroachDB string[] array. Reads scan straight into the []string
(pgsql wraps slice destinations with pq.Array); writes pass pq.Array. Drops
the strings join/split in oauth2.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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