src/server/mcp-oauth.ts
Rama main · réplica de lectura del repositorio original.
/**
* Embedded OAuth 2.1 Authorization Server for the MCP endpoint (`/mcp`).
*
* Lets claude.ai (and any MCP client following the MCP Authorization spec) connect to the
* remote MCP server via the standard discovery → DCR → authorize → token handshake, instead
* of a static `uck_` API key.
*
* Provider-agnostic by design: the only place a user proves identity is `/oauth/authorize/consent`,
* which is guarded by `requireAuth` AND requires an interactive login (principal kind `user` — never an API key or
* internal token, which would otherwise mint themselves a login-equivalent MCP token) — the same seam that handles Firebase today and email-password
* later. Access/refresh tokens are stateless HMAC tokens (see auth.ts:signMcpToken). No DB:
* clients live in a flat JSON file, auth codes in a short-TTL in-memory map.
*/
import type { Express, Request, Response, NextFunction } from 'express';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { randomBytes, createHash } from 'node:crypto';
import { AUTH_PROVIDER, MCP_TOKEN_TTL, requireAuth, signMcpToken, verifyMcpToken, type AuthUser } from './auth.ts';
import { dataPath } from './paths.ts';
import { loginAllowed, security } from './security/runtime.ts';
import { fromAuthUser } from './security/principal.ts';
import { tokenRevoked } from './security/revocation.ts';
// Shared with auth.ts: a legacy token's implied issued-at is exp - TTL, so the TTLs must be the ones signed with.
const ACCESS_TTL = MCP_TOKEN_TTL.access; // 1h
const REFRESH_TTL = MCP_TOKEN_TTL.refresh; // 30d
const CODE_TTL_MS = 60_000; // 1min
const CLIENTS_PATH = () => dataPath('oauth-clients.json');
interface OAuthClient {
client_id: string;
redirect_uris: string[];
client_name?: string;
created_at: number;
}
function loadClients(): Record<string, OAuthClient> {
const p = CLIENTS_PATH();
if (!existsSync(p)) return {};
try { return JSON.parse(readFileSync(p, 'utf8')); }
catch (e) { console.warn('[mcp-oauth] failed to parse oauth-clients.json:', e); return {}; }
}
function saveClients(clients: Record<string, OAuthClient>) {
writeFileSync(CLIENTS_PATH(), JSON.stringify(clients, null, 2));
}
interface AuthCode {
uid: string;
email: string;
clientId: string;
redirectUri: string;
challenge: string; // PKCE code_challenge (S256)
resource?: string;
expiresAt: number;
/** Epoch seconds — checked against tokensValidAfter at exchange, so a revocation also kills unexchanged codes. */
issuedAt: number;
}
const codes = new Map<string, AuthCode>();
function pruneCodes() {
const now = Date.now();
for (const [k, v] of codes) if (v.expiresAt < now) codes.delete(k);
}
function baseUrl(req: Request): string {
const proto = (req.get('x-forwarded-proto') || req.protocol).split(',')[0];
return `${proto}://${req.get('host')}`;
}
function oauthCors(req: Request, res: Response, next: NextFunction) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
if (req.method === 'OPTIONS') return void res.sendStatus(204);
next();
}
function pkceVerify(verifier: string, challenge: string): boolean {
const computed = createHash('sha256').update(verifier).digest('base64url');
return computed === challenge;
}
export function registerMcpOAuthRoutes(app: Express) {
// ── Discovery: Protected Resource Metadata (RFC 9728) ───────────────────────
const prm = (req: Request, res: Response) => {
const base = baseUrl(req);
res.json({
resource: `${base}/mcp`,
authorization_servers: [base],
bearer_methods_supported: ['header'],
});
};
app.get('/.well-known/oauth-protected-resource', oauthCors, prm);
app.get('/.well-known/oauth-protected-resource/mcp', oauthCors, prm); // path-suffixed probe
// ── Discovery: Authorization Server Metadata (RFC 8414) ─────────────────────
app.get('/.well-known/oauth-authorization-server', oauthCors, (req: Request, res: Response) => {
const base = baseUrl(req);
res.json({
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none'],
scopes_supported: ['mcp'],
});
});
// ── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
app.post('/oauth/register', oauthCors, (req: Request, res: Response) => {
const body = (req.body ?? {}) as { redirect_uris?: string[]; client_name?: string };
const redirect_uris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u) => typeof u === 'string') : [];
if (redirect_uris.length === 0) {
return void res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris required' });
}
const clients = loadClients();
const client_id = `mcpc_${randomBytes(16).toString('hex')}`;
const client: OAuthClient = { client_id, redirect_uris, client_name: body.client_name, created_at: Date.now() };
clients[client_id] = client;
saveClients(clients);
console.log(`[mcp-oauth] registered client ${client_id} (${body.client_name ?? 'unnamed'})`);
res.status(201).json({
client_id,
redirect_uris,
client_name: body.client_name,
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
client_id_issued_at: Math.floor(Date.now() / 1000),
});
});
// NOTE: GET /oauth/authorize is intentionally NOT handled here — it falls through to the SPA
// (index.html), which renders the consent screen and POSTs to /oauth/authorize/consent below.
// ── Consent → issue authorization code (identity via requireAuth seam) ───────
app.post('/oauth/authorize/consent', oauthCors, requireAuth, (req: Request, res: Response) => {
const user = (req as any).user as AuthUser;
// Consent mints an OAuth grant AS this identity — only an interactive login may give it. An API key or internal
// token must not launder itself into a login-equivalent (possibly owner) MCP token.
const kind = user.principal?.kind ?? 'unknown';
if (kind !== 'user') {
console.warn(`[mcp-oauth] consent refused: non-interactive credential (${kind})`);
security()?.authDeny('oauth:consent', `non-interactive:${kind}`, req.ip);
return void res.status(403).json({ error: 'access_denied', error_description: 'OAuth consent requires an interactive login' });
}
const { client_id, redirect_uri, code_challenge, code_challenge_method, resource } = (req.body ?? {}) as Record<string, string>;
if (!client_id || !redirect_uri || !code_challenge) {
return void res.status(400).json({ error: 'invalid_request', error_description: 'client_id, redirect_uri, code_challenge required' });
}
if (code_challenge_method && code_challenge_method !== 'S256') {
return void res.status(400).json({ error: 'invalid_request', error_description: 'only S256 PKCE supported' });
}
const client = loadClients()[client_id];
if (!client) return void res.status(400).json({ error: 'invalid_client' });
if (!client.redirect_uris.includes(redirect_uri)) {
return void res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uri not registered for client' });
}
pruneCodes();
const code = randomBytes(32).toString('hex');
codes.set(code, {
uid: user.uid, email: user.email, clientId: client_id, redirectUri: redirect_uri,
challenge: code_challenge, resource, expiresAt: Date.now() + CODE_TTL_MS, issuedAt: Math.floor(Date.now() / 1000),
});
console.log(`[mcp-oauth] issued auth code for ${user.email} → client ${client_id}`);
res.json({ code });
});
// ── Token endpoint ──────────────────────────────────────────────────────────
app.post('/oauth/token', oauthCors, (req: Request, res: Response) => {
const b = (req.body ?? {}) as Record<string, string>;
const grant = b.grant_type;
if (grant === 'authorization_code') {
pruneCodes();
const entry = b.code ? codes.get(b.code) : undefined;
if (!entry) return void res.status(400).json({ error: 'invalid_grant', error_description: 'unknown or expired code' });
codes.delete(b.code); // single-use
if (entry.redirectUri !== b.redirect_uri) return void res.status(400).json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
if (entry.clientId !== b.client_id) return void res.status(400).json({ error: 'invalid_grant', error_description: 'client_id mismatch' });
if (!b.code_verifier || !pkceVerify(b.code_verifier, entry.challenge)) {
return void res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
}
if (tokenRevoked(fromAuthUser({ uid: entry.uid, email: entry.email }).id, entry.issuedAt)) {
console.warn(`[mcp-oauth] auth code for ${entry.email} predates a token revocation — refused`);
return void res.status(400).json({ error: 'invalid_grant', error_description: 'authorization revoked — sign in again' });
}
return void res.json({
access_token: signMcpToken(entry.uid, entry.email, 'access', ACCESS_TTL),
token_type: 'Bearer',
expires_in: ACCESS_TTL,
refresh_token: signMcpToken(entry.uid, entry.email, 'refresh', REFRESH_TTL),
scope: 'mcp',
});
}
if (grant === 'refresh_token') {
const id = b.refresh_token ? verifyMcpToken(b.refresh_token) : null;
if (!id || id.kind !== 'refresh') return void res.status(400).json({ error: 'invalid_grant', error_description: 'invalid refresh_token' });
// Re-apply the Firebase login gate: a 30d refresh must not outlive the user's removal from the policy bindings.
if (AUTH_PROVIDER === 'firebase' && !loginAllowed(fromAuthUser(id))) {
security()?.authDeny('mcp:oauth-refresh', 'not-whitelisted', req.ip, fromAuthUser(id));
return void res.status(400).json({ error: 'invalid_grant', error_description: 'User not in whitelist — sign in again' });
}
return void res.json({
access_token: signMcpToken(id.uid, id.email, 'access', ACCESS_TTL),
token_type: 'Bearer',
expires_in: ACCESS_TTL,
refresh_token: signMcpToken(id.uid, id.email, 'refresh', REFRESH_TTL),
scope: 'mcp',
});
}
res.status(400).json({ error: 'unsupported_grant_type' });
});
console.log('[mcp-oauth] OAuth routes mounted (.well-known, /oauth/register, /oauth/authorize/consent, /oauth/token)');
}