diff --git a/docs/superpowers/specs/2026-04-04-mcp-oauth-design.md b/docs/superpowers/specs/2026-04-04-mcp-oauth-design.md new file mode 100644 index 0000000..635ccfd --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-mcp-oauth-design.md @@ -0,0 +1,182 @@ +# 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 ` + +## 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: +```json +{ + "issuer": "", + "authorization_endpoint": "/oauth/authorize", + "token_endpoint": "/oauth/token", + "registration_endpoint": "/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 -> 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)