import { NextResponse } from "next/server" import { z } from "zod" import { prisma } from "@/lib/prisma" import { rateLimit } from "@/lib/rate-limit" import { generateClientId, isRegisterableRedirectUri, oauthError, } from "@/lib/mcp/oauth" /** * RFC 7591 dynamic client registration. * * Unauthenticated by specification, which makes it the one endpoint on this * deployment that a stranger can create rows through. Hence the rate limit, the * global cap, and the pruning of registrations that never went on to be used. */ export const runtime = "nodejs" export const dynamic = "force-dynamic" /** Beyond this many registrations in a day, something is wrong. */ const DAILY_REGISTRATION_CAP = 500 const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000 const registerSchema = z.object({ client_name: z.string().min(1).max(200).optional(), redirect_uris: z.array(z.string().min(1)).min(1).max(10), grant_types: z.array(z.string()).optional(), response_types: z.array(z.string()).optional(), token_endpoint_auth_method: z.string().optional(), scope: z.string().max(500).optional(), client_uri: z.string().max(2048).optional(), logo_uri: z.string().max(2048).optional(), software_id: z.string().max(200).optional(), }) export async function POST(request: Request) { const forwarded = request.headers.get("x-forwarded-for") const hops = forwarded?.split(",").map((h) => h.trim()).filter(Boolean) ?? [] // Last hop, not first: everything before it is client-supplied and can be // forged for a fresh bucket. Same reasoning as the register route. const ip = hops.length > 0 ? hops[hops.length - 1] : "unknown" const rl = rateLimit(`dcr:${ip}`, 5, 60 * 60 * 1000) if (!rl.success) { return oauthError( "temporarily_unavailable", "Too many registration attempts.", 429 ) } const body = await request.json().catch(() => null) if (!body) return oauthError("invalid_client_metadata", "Body must be JSON.") const parsed = registerSchema.safeParse(body) if (!parsed.success) { return oauthError("invalid_client_metadata", parsed.error.issues[0]?.message) } const data = parsed.data // Public clients only. Accepting a confidential client would mean storing a // secret this server has no need for and no way to protect better than the // PKCE it already requires. const authMethod = data.token_endpoint_auth_method ?? "none" if (authMethod !== "none") { return oauthError( "invalid_client_metadata", "This server issues public clients only; token_endpoint_auth_method must be \"none\"." ) } const bad = data.redirect_uris.filter((u) => !isRegisterableRedirectUri(u)) if (bad.length) { return oauthError( "invalid_redirect_uri", `Redirect URIs must be https or a loopback address, with no fragment: ${bad.join(", ")}` ) } const since = new Date(Date.now() - 24 * 60 * 60 * 1000) const recent = await prisma.oAuthClient.count({ where: { createdAt: { gte: since } }, }) if (recent >= DAILY_REGISTRATION_CAP) { return oauthError( "temporarily_unavailable", "Registration is temporarily closed.", 503 ) } const clientId = generateClientId() const client = await prisma.oAuthClient.create({ data: { clientId, clientName: data.client_name ?? "Unnamed client", redirectUris: data.redirect_uris, grantTypes: data.grant_types ?? ["authorization_code", "refresh_token"], responseTypes: data.response_types ?? ["code"], tokenEndpointAuthMethod: "none", scope: data.scope ?? null, clientUri: data.client_uri ?? null, logoUri: data.logo_uri ?? null, softwareId: data.software_id ?? null, }, }) pruneUnusedClients() return NextResponse.json( { client_id: client.clientId, client_id_issued_at: Math.floor(client.createdAt.getTime() / 1000), client_name: client.clientName, redirect_uris: client.redirectUris, grant_types: client.grantTypes, response_types: client.responseTypes, token_endpoint_auth_method: "none", ...(client.scope ? { scope: client.scope } : {}), }, { status: 201, headers: { "Cache-Control": "no-store" } } ) } /** * Registrations that never led to a grant are abandoned handshakes. Cleared * opportunistically rather than on a schedule, because there is no scheduler. */ function pruneUnusedClients(): void { prisma.oAuthClient .deleteMany({ where: { createdAt: { lt: new Date(Date.now() - PRUNE_AFTER_MS) }, grants: { none: {} }, }, }) .then(({ count }) => { if (count > 0) console.log(`[oauth] pruned ${count} unused client(s)`) }) .catch((error) => console.warn("[oauth] client prune failed:", error)) }