# 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)