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:
182
docs/superpowers/specs/2026-04-04-mcp-oauth-design.md
Normal file
182
docs/superpowers/specs/2026-04-04-mcp-oauth-design.md
Normal 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)
|
||||
Reference in New Issue
Block a user