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
This commit is contained in:
jubnl
2026-04-10 05:42:00 +02:00
parent 9b1baaf7b8
commit cb3aeda8e0
3 changed files with 101 additions and 5 deletions
+40 -2
View File
@@ -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 unsplash_api_key TEXT'),
() => db.exec('ALTER TABLE users ADD COLUMN openweather_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'), () => 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'; 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) { if (currentVersion < migrations.length) {
for (let i = currentVersion; i < migrations.length; i++) { for (let i = currentVersion; i < migrations.length; i++) {
console.log(`[DB] Running migration ${i + 1}/${migrations.length}`); console.log(`[DB] Running migration ${i + 1}/${migrations.length}`);
try { try {
db.transaction(() => migrations[i]())(); const migration = migrations[i];
if (typeof migration === 'function') {
db.transaction(migration)();
} else {
migration.raw();
}
} catch (err) { } catch (err) {
console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err); console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err);
process.exit(1); process.exit(1);
+52
View File
@@ -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 tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown'); const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
const revokeLimiter = makeRateLimiter(10, 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 // 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}` }); 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<string, unknown> = 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) // Token revocation endpoint (RFC 7009)
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => { oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
// M2: return 404 when MCP is disabled // M2: return 404 when MCP is disabled
+9 -3
View File
@@ -123,7 +123,7 @@ export function isValidRedirectUri(uri: string): boolean {
} }
export function createOAuthClient( export function createOAuthClient(
userId: number, userId: number | null,
name: string, name: string,
redirectUris: string[], redirectUris: string[],
allowedScopes: string[], allowedScopes: string[],
@@ -151,8 +151,14 @@ export function createOAuthClient(
const { valid, invalid } = validateScopes(allowedScopes); const { valid, invalid } = validateScopes(allowedScopes);
if (!valid) return { error: `Invalid scopes: ${invalid.join(', ')}`, status: 400 }; 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 (userId !== null) {
if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', 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 };
} 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 isPublic = options?.isPublic ?? false;
const createdVia = options?.createdVia ?? 'settings_ui'; const createdVia = options?.createdVia ?? 'settings_ui';