183 lines
8.2 KiB
Markdown
183 lines
8.2 KiB
Markdown
# 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)
|