8.2 KiB
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:
- Client calls
POST /mcp-> gets401 UnauthorizedwithWWW-Authenticateheader - Client fetches
GET /.well-known/oauth-authorization-serverfor endpoint discovery - Client calls
POST /oauth/register(Dynamic Client Registration, RFC 7591) to get aclient_id - Client opens browser to
GET /oauth/authorize?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256&redirect_uri=... - User sees a login form, enters their GearBox password
- Server validates credentials, generates auth code, redirects to
redirect_uri?code=xyz - Client calls
POST /oauth/tokenwithgrant_type=authorization_code&code=xyz&code_verifier=... - Server validates PKCE, returns
{ access_token, refresh_token, token_type, expires_in } - 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 recordgetClient(clientId)-> client record or nullcreateAuthorizationCode(clientId, codeChallenge, codeChallengeMethod, redirectUri)->{ code }- generates code, stores in DBexchangeCode(code, codeVerifier, clientId, redirectUri)->{ accessToken, refreshToken, expiresIn }- validates PKCE, marks code used, creates tokensrefreshAccessToken(refreshToken, clientId)->{ accessToken, refreshToken, expiresIn }- rotates refresh tokenverifyAccessToken(token)-> boolean - checks hash against DB, checks expirycleanExpiredTokens()-> 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:
{
"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-serverat app root - Mount
/oauthroutes
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=1after 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
/mcpswitches to JWT verification - API key auth stays (it's in the local DB regardless)