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
+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 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<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)
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
// M2: return 404 when MCP is disabled