docs: add MCP OAuth 2.1 server design spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 09:03:11 +02:00
parent 1344f2f87f
commit 6a77995530

View File

@@ -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 <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:
```json
{
"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)