Files
GearBox/docs/superpowers/specs/2026-04-04-mcp-oauth-design.md
2026-04-04 09:03:11 +02:00

8.2 KiB

MCP OAuth 2.1 Server Design

Date: 2026-04-04 Goal: Add OAuth 2.1 Authorization Code + PKCE support to the MCP server so it works with Claude mobile app and claude.ai remote MCP connectors.

Context

The GearBox MCP server currently authenticates via X-API-Key header. This works for Claude Code (CLI) and scripts, but Claude mobile and claude.ai require OAuth 2.1 for remote MCP server connections. The MCP spec (2025-03-26) defines exactly which OAuth endpoints a server must expose.

This is built against the current single-user auth system (username/password in SQLite). It will be replaced when the platform migrates to an external auth provider in v2.0.

OAuth Flow

The MCP authorization spec requires OAuth 2.1 Authorization Code + PKCE:

  1. Client calls POST /mcp -> gets 401 Unauthorized with WWW-Authenticate header
  2. Client fetches GET /.well-known/oauth-authorization-server for endpoint discovery
  3. Client calls POST /oauth/register (Dynamic Client Registration, RFC 7591) to get a client_id
  4. Client opens browser to GET /oauth/authorize?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256&redirect_uri=...
  5. User sees a login form, enters their GearBox password
  6. Server validates credentials, generates auth code, redirects to redirect_uri?code=xyz
  7. Client calls POST /oauth/token with grant_type=authorization_code&code=xyz&code_verifier=...
  8. Server validates PKCE, returns { access_token, refresh_token, token_type, expires_in }
  9. All subsequent MCP requests use Authorization: Bearer <access_token>

Database Schema

Three new tables in src/db/schema.ts:

oauthClients

Stores dynamically registered OAuth clients (one per Claude instance).

Column Type Notes
id integer PK Auto-increment
clientId text, unique UUID, generated on registration
clientName text From registration request, optional
redirectUris text JSON array of allowed redirect URIs
createdAt integer (timestamp)

oauthCodes

Short-lived authorization codes (10 minute TTL).

Column Type Notes
id integer PK Auto-increment
code text, unique Cryptographically random
clientId text FK to oauthClients.clientId
codeChallenge text PKCE S256 challenge
codeChallengeMethod text Always "S256"
redirectUri text Must match on token exchange
expiresAt integer (timestamp) 10 minutes from creation
used integer 0 or 1, prevents replay

oauthTokens

Access and refresh tokens.

Column Type Notes
id integer PK Auto-increment
accessTokenHash text, unique SHA-256 hash of token
refreshTokenHash text, unique SHA-256 hash of token
clientId text FK to oauthClients.clientId
expiresAt integer (timestamp) Access token expiry (1 hour)
createdAt integer (timestamp)

No userId column -- single-user app, only one user exists. Tokens implicitly belong to them.

New Files

src/server/services/oauth.service.ts

Pure business logic, no HTTP awareness. Functions:

  • registerClient(name?, redirectUris) -> { clientId } - creates client record
  • getClient(clientId) -> client record or null
  • createAuthorizationCode(clientId, codeChallenge, codeChallengeMethod, redirectUri) -> { code } - generates code, stores in DB
  • exchangeCode(code, codeVerifier, clientId, redirectUri) -> { accessToken, refreshToken, expiresIn } - validates PKCE, marks code used, creates tokens
  • refreshAccessToken(refreshToken, clientId) -> { accessToken, refreshToken, expiresIn } - rotates refresh token
  • verifyAccessToken(token) -> boolean - checks hash against DB, checks expiry
  • cleanExpiredTokens() -> void - housekeeping, called opportunistically

PKCE validation: SHA-256 hash the code_verifier, base64url-encode it, compare to stored code_challenge.

Token generation: crypto.randomBytes(32).toString('hex') for both access and refresh tokens. Stored as SHA-256 hashes.

src/server/routes/oauth.ts

Hono routes mounted at /oauth in src/server/index.ts:

GET /.well-known/oauth-authorization-server (mounted at app root, not under /oauth) Returns RFC 8414 metadata. The issuer URL is derived from the GEARBOX_URL environment variable (e.g., https://gearbox.example.com), falling back to the request's Origin or Host header for local development:

{
  "issuer": "<issuer_url>",
  "authorization_endpoint": "<issuer_url>/oauth/authorize",
  "token_endpoint": "<issuer_url>/oauth/token",
  "registration_endpoint": "<issuer_url>/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"]
}

POST /oauth/register (Dynamic Client Registration, RFC 7591) Request: { client_name?, redirect_uris: string[] } Response: { client_id, client_name, redirect_uris }

GET /oauth/authorize Query params: response_type=code, client_id, redirect_uri, code_challenge, code_challenge_method=S256, state Validates client_id and redirect_uri, then serves a simple HTML login form.

POST /oauth/authorize Form body: username, password (plus the OAuth params from the query string, passed as hidden fields) Validates credentials via existing verifyPassword(). On success, generates auth code and redirects: 302 redirect_uri?code=xyz&state=... On failure, re-renders login form with error message.

POST /oauth/token Handles two grant types:

  • authorization_code: validates code, PKCE verifier, redirect_uri, client_id. Returns tokens.
  • refresh_token: validates refresh token, rotates it. Returns new tokens.

Response: { access_token, refresh_token, token_type: "Bearer", expires_in: 3600 }

Login Form

The /oauth/authorize GET endpoint serves a minimal HTML login page. Self-contained (inline styles), matches GearBox's clean aesthetic. Shows:

  • GearBox logo/name
  • "Authorize [client_name] to access your GearBox data"
  • Username + password fields
  • "Authorize" button
  • Error message area

This is server-rendered HTML (not React) since it's a standalone page in the OAuth redirect flow.

Modified Files

src/server/mcp/index.ts

Update the auth middleware to accept both auth methods:

1. Check Authorization: Bearer <token> -> verify via oauth.service.verifyAccessToken()
2. Check X-API-Key header -> existing verifyApiKey() flow
3. No auth found -> return 401 with WWW-Authenticate: Bearer header

The WWW-Authenticate header is what triggers the OAuth flow in MCP clients.

src/server/index.ts

  • Mount /.well-known/oauth-authorization-server at app root
  • Mount /oauth routes

src/db/schema.ts

Add the 3 new table definitions.

Token Lifecycle

  • Access tokens: 1 hour expiry. Validated on every MCP request by hashing and checking DB.
  • Refresh tokens: 30 day expiry. Rotated on each use (old token invalidated, new one issued).
  • Auth codes: 10 minute expiry. Single-use (marked used=1 after exchange).
  • Cleanup: Expired tokens/codes cleaned up opportunistically during token operations.

What This Does NOT Include

  • Scopes/permissions: Single user with full access. No scope restrictions.
  • Consent screen: Login = consent. There's only one user authorizing themselves.
  • Token revocation endpoint: Not required by MCP spec. Tokens expire naturally.
  • CORS changes: OAuth uses browser redirects, not CORS.
  • Changes to existing API key auth: Fully preserved, works alongside OAuth.

Testing

  • Unit tests for oauth.service.ts: PKCE validation, code exchange, token refresh, expiry
  • Route-level tests for OAuth endpoints: registration, authorize flow, token exchange, error cases
  • Integration: verify Bearer token auth works on MCP endpoint alongside API key auth

Migration Path

When v2.0 replaces auth with an external OIDC provider:

  • The OAuth tables get dropped (external provider handles tokens)
  • The /oauth/* routes get replaced or proxied to the external provider
  • Bearer token validation on /mcp switches to JWT verification
  • API key auth stays (it's in the local DB regardless)