From cb3aeda8e0eaf08a4ceb515ed100cc0de9d1425d Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 10 Apr 2026 05:42:00 +0200 Subject: [PATCH] fix(oauth): add public RFC 7591 DCR endpoint at POST /oauth/register Claude.ai's start-auth flow POSTs to the registration_endpoint advertised in the discovery document, but no public handler existed at /oauth/register (only /api/oauth/register with browser cookie auth). This caused a start_error redirect immediately on every connect attempt. - Add POST /oauth/register to oauthPublicRouter following RFC 7591 - Make oauth_clients.user_id nullable via a raw (no-transaction) migration so anonymous DCR clients can be created without a user context - Update migration runner to support { raw: () => void } migrations for DDL that requires PRAGMA foreign_keys = OFF outside a transaction - Update createOAuthClient to accept userId: number | null with a global cap (500) for anonymous DCR clients in place of the per-user limit --- server/src/db/migrations.ts | 42 +++++++++++++++++++++-- server/src/routes/oauth.ts | 52 +++++++++++++++++++++++++++++ server/src/services/oauthService.ts | 12 +++++-- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index e2064bc5..cffc990e 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -19,7 +19,8 @@ function runMigrations(db: Database.Database): void { } } - const migrations: Array<() => void> = [ + type Migration = (() => void) | { raw: () => void }; + const migrations: Migration[] = [ () => db.exec('ALTER TABLE users ADD COLUMN unsplash_api_key TEXT'), () => db.exec('ALTER TABLE users ADD COLUMN openweather_api_key TEXT'), () => db.exec('ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60'), @@ -940,13 +941,50 @@ function runMigrations(db: Database.Database): void { ALTER TABLE oauth_clients ADD COLUMN created_via TEXT NOT NULL DEFAULT 'settings_ui'; `); }, + // Migration: Make oauth_clients.user_id nullable to support anonymous RFC 7591 DCR clients + // (must run outside a transaction because PRAGMA foreign_keys cannot change mid-transaction) + { + raw: () => { + db.exec('PRAGMA foreign_keys = OFF'); + try { + db.transaction(() => { + db.exec(` + CREATE TABLE IF NOT EXISTS oauth_clients_new ( + id TEXT PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + client_id TEXT UNIQUE NOT NULL, + client_secret_hash TEXT NOT NULL, + redirect_uris TEXT NOT NULL DEFAULT '[]', + allowed_scopes TEXT NOT NULL DEFAULT '[]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_public INTEGER NOT NULL DEFAULT 0, + created_via TEXT NOT NULL DEFAULT 'settings_ui' + ) + `); + db.exec(`INSERT INTO oauth_clients_new SELECT id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients`); + db.exec(`DROP TABLE oauth_clients`); + db.exec(`ALTER TABLE oauth_clients_new RENAME TO oauth_clients`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`); + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`); + })(); + } finally { + db.exec('PRAGMA foreign_keys = ON'); + } + }, + }, ]; if (currentVersion < migrations.length) { for (let i = currentVersion; i < migrations.length; i++) { console.log(`[DB] Running migration ${i + 1}/${migrations.length}`); try { - db.transaction(() => migrations[i]())(); + const migration = migrations[i]; + if (typeof migration === 'function') { + db.transaction(migration)(); + } else { + migration.raw(); + } } catch (err) { console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err); process.exit(1); diff --git a/server/src/routes/oauth.ts b/server/src/routes/oauth.ts index 5d79cda3..bc58e2a4 100644 --- a/server/src/routes/oauth.ts +++ b/server/src/routes/oauth.ts @@ -59,6 +59,7 @@ function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Req const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`); const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown'); const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown'); +const dcrLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown'); // --------------------------------------------------------------------------- // Public router: /.well-known, /oauth/token, /oauth/revoke @@ -173,6 +174,57 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` }); }); +// RFC 7591 Dynamic Client Registration endpoint +oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Response) => { + if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); + + const body: Record = typeof req.body === 'object' && req.body !== null ? req.body : {}; + const ip = getClientIp(req); + + const redirectUris: string[] = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u): u is string => typeof u === 'string') : []; + if (redirectUris.length === 0) { + return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required and must be a non-empty array' }); + } + + const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : ''; + const clientName = rawName || 'MCP Client'; + + // Determine if the client wants to be public (no secret) — MCP clients typically use PKCE only + const authMethod = typeof body.token_endpoint_auth_method === 'string' ? body.token_endpoint_auth_method : 'client_secret_post'; + const isPublic = authMethod === 'none'; + + // Resolve requested scopes — default to all supported scopes if not specified + const rawScope = typeof body.scope === 'string' ? body.scope : ALL_SCOPES.join(' '); + const requestedScopes = rawScope.split(' ').filter(s => (ALL_SCOPES as string[]).includes(s)); + if (requestedScopes.length === 0) { + return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'No valid scopes requested' }); + } + + const result = createOAuthClient(null, clientName, redirectUris, requestedScopes, ip, { + isPublic, + createdVia: 'dcr', + }); + + if (result.error) { + return res.status(result.status || 400).json({ error: 'invalid_client_metadata', error_description: result.error }); + } + + const client = result.client!; + const now = Math.floor(Date.now() / 1000); + + return res.status(201).json({ + client_id: client.client_id, + ...(client.client_secret ? { client_secret: client.client_secret, client_secret_expires_at: 0 } : {}), + client_id_issued_at: now, + redirect_uris: client.redirect_uris, + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: (client.allowed_scopes as string[]).join(' '), + client_name: client.name, + token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post', + }); +}); + // Token revocation endpoint (RFC 7009) oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => { // M2: return 404 when MCP is disabled diff --git a/server/src/services/oauthService.ts b/server/src/services/oauthService.ts index a36363da..3ffd1c45 100644 --- a/server/src/services/oauthService.ts +++ b/server/src/services/oauthService.ts @@ -123,7 +123,7 @@ export function isValidRedirectUri(uri: string): boolean { } export function createOAuthClient( - userId: number, + userId: number | null, name: string, redirectUris: string[], allowedScopes: string[], @@ -151,8 +151,14 @@ export function createOAuthClient( const { valid, invalid } = validateScopes(allowedScopes); if (!valid) return { error: `Invalid scopes: ${invalid.join(', ')}`, status: 400 }; - const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?').get(userId) as { count: number }).count; - if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 }; + if (userId !== null) { + const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?').get(userId) as { count: number }).count; + if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 }; + } else { + // Anonymous DCR clients: enforce a global cap to prevent unbounded registration abuse + const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id IS NULL').get() as { count: number }).count; + if (count >= 500) return { error: 'server_error', status: 503 }; + } const isPublic = options?.isPublic ?? false; const createdVia = options?.createdVia ?? 'settings_ui';