diff --git a/app/(app)/dashboard/projects/[id]/social/api-tokens/connected-apps.tsx b/app/(app)/dashboard/projects/[id]/social/api-tokens/connected-apps.tsx new file mode 100644 index 00000000..dd9938d4 --- /dev/null +++ b/app/(app)/dashboard/projects/[id]/social/api-tokens/connected-apps.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; + +export type Grant = { + id: string; + client_name: string; + client_uri: string | null; + created_at: string; + last_used_at: string | null; +}; + +/** Apps signed in to the MCP server over OAuth (ChatGPT, claude.ai, Claude Code), with Disconnect. */ +export function ConnectedApps({ grants: initial }: { grants: Grant[] }) { + const [grants, setGrants] = useState(initial); + const [error, setError] = useState(null); + + async function disconnect(id: string) { + setError(null); + const res = await fetch(`/api/oauth/grants?id=${encodeURIComponent(id)}`, { method: "DELETE" }); + if (res.ok) setGrants((g) => g.filter((x) => x.id !== id)); + else setError("Could not disconnect that app. Try again."); + } + + if (grants.length === 0) { + return

No apps connected yet.

; + } + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/app/(app)/dashboard/projects/[id]/social/api-tokens/page.tsx b/app/(app)/dashboard/projects/[id]/social/api-tokens/page.tsx index 5b5b43f1..50c520ab 100644 --- a/app/(app)/dashboard/projects/[id]/social/api-tokens/page.tsx +++ b/app/(app)/dashboard/projects/[id]/social/api-tokens/page.tsx @@ -2,6 +2,9 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; import { ApiTokensClient } from "./client"; +import { ConnectedApps, type Grant } from "./connected-apps"; +import { listGrants } from "@/lib/mcp-oauth"; +import { serviceClient } from "@/lib/supabase/service"; export const metadata = { title: "Social · API tokens" }; @@ -32,6 +35,7 @@ export default async function ApiTokensPage({ .select("id, name, prefix, last_used_at, revoked_at, created_at, scope") .eq("user_id", user.id) .order("created_at", { ascending: false }); + const grants = (await listGrants(serviceClient(), user.id)) as Grant[]; return (
@@ -70,6 +74,19 @@ export default async function ApiTokensPage({ + +
+
+

Connected apps

+

+ ChatGPT, claude.ai and Claude Code need no token: add{" "} + {`${process.env.NEXT_PUBLIC_SITE_URL ?? "https://crawlproof.com"}/api/mcp`} as a + connector and sign in when asked (OAuth 2.1). Apps you allowed are listed here; disconnecting one stops + its access at once. +

+
+ +
); } diff --git a/app/.well-known/oauth-authorization-server/[...path]/route.ts b/app/.well-known/oauth-authorization-server/[...path]/route.ts new file mode 100644 index 00000000..dd9f71ca --- /dev/null +++ b/app/.well-known/oauth-authorization-server/[...path]/route.ts @@ -0,0 +1,3 @@ +export { GET, OPTIONS } from "../route"; + +export const dynamic = "force-dynamic"; diff --git a/app/.well-known/oauth-authorization-server/route.ts b/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 00000000..0ee88abe --- /dev/null +++ b/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { authorizationServerMetadata, CORS_HEADERS } from "@/lib/mcp-oauth"; + +export const dynamic = "force-dynamic"; + +// RFC 8414 authorization server metadata. The [...path] sibling answers the +// path-suffixed form some clients try first. +export function GET() { + return NextResponse.json(authorizationServerMetadata(), { + headers: { ...CORS_HEADERS, "cache-control": "public, max-age=3600" }, + }); +} + +export function OPTIONS() { + return new NextResponse(null, { status: 204, headers: CORS_HEADERS }); +} diff --git a/app/.well-known/oauth-protected-resource/[...path]/route.ts b/app/.well-known/oauth-protected-resource/[...path]/route.ts new file mode 100644 index 00000000..dd9f71ca --- /dev/null +++ b/app/.well-known/oauth-protected-resource/[...path]/route.ts @@ -0,0 +1,3 @@ +export { GET, OPTIONS } from "../route"; + +export const dynamic = "force-dynamic"; diff --git a/app/.well-known/oauth-protected-resource/route.ts b/app/.well-known/oauth-protected-resource/route.ts new file mode 100644 index 00000000..f499f79a --- /dev/null +++ b/app/.well-known/oauth-protected-resource/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { CORS_HEADERS, protectedResourceMetadata } from "@/lib/mcp-oauth"; + +export const dynamic = "force-dynamic"; + +// RFC 9728 protected-resource metadata for the MCP server at /api/mcp. The +// [...path] sibling answers the path-suffixed form (/.well-known/oauth-protected-resource/api/mcp). +export function GET() { + return NextResponse.json(protectedResourceMetadata(), { + headers: { ...CORS_HEADERS, "cache-control": "public, max-age=3600" }, + }); +} + +export function OPTIONS() { + return new NextResponse(null, { status: 204, headers: CORS_HEADERS }); +} diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 76715379..016d152c 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -1,13 +1,19 @@ // CrawlProof MCP server — a real Model Context Protocol server (official // @modelcontextprotocol/sdk via the mcp-handler Next adapter) mounted on the // existing app at https://crawlproof.com/api/mcp. Agents connect here with a -// crp_ API token (Authorization: Bearer crp_…) and get the Promote toolset. +// crp_ API token (Authorization: Bearer crp_…) or an OAuth 2.1 access token +// (crp_oat_…, lib/mcp-oauth.ts) that ChatGPT, claude.ai and Claude Code obtain +// themselves via dynamic client registration + PKCE. Any request without a +// valid credential is 401 + WWW-Authenticate resource_metadata, which is what +// starts the OAuth flow in those clients. // // Add capabilities by registering more tool modules in the initializer below. import { createMcpHandler, withMcpAuth } from "mcp-handler"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { authenticateToken } from "@/lib/sp/apiAuth"; +import { ACCESS_PREFIX, resolveAccessToken, siteOrigin } from "@/lib/mcp-oauth"; +import { serviceClient } from "@/lib/supabase/service"; import { registerPromoteTools } from "@/lib/mcp/promote"; import { registerStatsTools } from "@/lib/mcp/stats"; import { registerAuditTools } from "@/lib/mcp/audits"; @@ -34,6 +40,7 @@ const handler = createMcpHandler( registerCreditTools(server); registerWebhookTools(server); registerTrendTools(server); + addListCacheFields(server); }, {}, // The route is mounted at /api/mcp, so mcp-handler must derive its endpoint @@ -43,11 +50,33 @@ const handler = createMcpHandler( { basePath: "/api", maxDuration: 120 }, ); +type ListHandler = (req: unknown, extra: unknown) => Promise>; + +// Claude Code's MCP client (revision 2026-07-28) rejects a tools/list result +// without resultType + ttlMs + cacheScope ("connected · tools fetch failed"). +// The SDK does not add them, so wrap its tools/list handler. +function addListCacheFields(server: { server: unknown }) { + const handlers = (server.server as { _requestHandlers?: Map })._requestHandlers; + const original = handlers?.get("tools/list"); + if (!handlers || !original) return; + handlers.set("tools/list", async (req, extra) => ({ + ...(await original(req, extra)), + resultType: "complete", + ttlMs: 300_000, + cacheScope: "private", + })); +} + // Resolve the crp_ bearer token to a CrawlProof user; stash the user id on the // AuthInfo so tool handlers can scope to the caller. Returning undefined (with // required:true) makes mcp-handler answer 401. async function verifyToken(_req: Request, bearerToken?: string): Promise { if (!bearerToken) return undefined; + if (bearerToken.startsWith(ACCESS_PREFIX)) { + const grant = await resolveAccessToken(serviceClient(), bearerToken); + if (!grant) return undefined; + return { token: bearerToken, clientId: grant.userId, scopes: ["mcp"], extra: { userId: grant.userId, via: "oauth" } }; + } const auth = await authenticateToken(bearerToken); if (!auth.ok) return undefined; return { @@ -58,6 +87,9 @@ async function verifyToken(_req: Request, bearerToken?: string): Promise + withMcpAuth(handler, verifyToken, { required: true, resourceUrl: siteOrigin() })(req); export { authHandler as GET, authHandler as POST, authHandler as DELETE }; diff --git a/app/api/oauth/authorize/route.ts b/app/api/oauth/authorize/route.ts new file mode 100644 index 00000000..31283fdf --- /dev/null +++ b/app/api/oauth/authorize/route.ts @@ -0,0 +1,60 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { AUTHORIZE_FIELDS, checkAuthorize, errorRedirect, issueCode, OAuthError, sameOrigin, siteOrigin, type AuthorizeParams } from "@/lib/mcp-oauth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NO_STORE = { "cache-control": "no-store" }; + +const see = (url: string) => NextResponse.redirect(url, { status: 303, headers: NO_STORE }); + +/** + * POST /api/oauth/authorize: the consent form at /oauth/authorize (the + * advertised authorization_endpoint) posts here, form-encoded, with + * decision=allow|deny and the original request as hidden fields. The user is + * the signed-in Supabase session (cookie). Answers 303 to the client's + * redirect_uri with code, state and iss (RFC 9207), or with the error. + */ +export async function POST(req: NextRequest) { + if (!sameOrigin(req)) { + return NextResponse.json({ error: "invalid_request", error_description: "Cross-site request refused" }, { status: 403, headers: NO_STORE }); + } + const form = new URLSearchParams(await req.text()); + const p: AuthorizeParams = {}; + for (const k of AUTHORIZE_FIELDS) { + const v = form.get(k); + if (v !== null) (p as Record)[k] = v; + } + const decision = form.get("decision"); + if (decision !== "allow" && decision !== "deny") { + return NextResponse.json({ error: "invalid_request", error_description: "decision must be allow or deny" }, { status: 400, headers: NO_STORE }); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + const qs = new URLSearchParams(p as Record).toString(); + if (!user) { + return see(`${siteOrigin()}/login?redirect=${encodeURIComponent(`/oauth/authorize?${qs}`)}`); + } + + const admin = serviceClient(); + const check = await checkAuthorize(admin, p); + if (!check.ok) { + if ("redirect" in check) return see(check.redirect); + return NextResponse.json({ error: check.error, error_description: check.description }, { status: 400, headers: NO_STORE }); + } + if (decision === "deny") { + return see(errorRedirect(check.redirectUri, "access_denied", "The user denied the request", check.state)); + } + try { + return see(await issueCode(admin, check, user.id)); + } catch (err) { + if (err instanceof OAuthError) return NextResponse.json(err.body(), { status: err.status, headers: NO_STORE }); + console.error("[oauth] authorize failed", (err as Error).message); + return NextResponse.json({ error: "server_error" }, { status: 500, headers: NO_STORE }); + } +} diff --git a/app/api/oauth/grants/route.ts b/app/api/oauth/grants/route.ts new file mode 100644 index 00000000..3e957e6f --- /dev/null +++ b/app/api/oauth/grants/route.ts @@ -0,0 +1,36 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { listGrants, revokeGrant } from "@/lib/mcp-oauth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +async function userId(): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + return user?.id ?? null; +} + +const unauthorized = () => NextResponse.json({ error: "unauthenticated" }, { status: 401 }); + +// GET /api/oauth/grants: the signed-in user's connected apps (ChatGPT, Claude, …). +export async function GET() { + const uid = await userId(); + if (!uid) return unauthorized(); + return NextResponse.json({ grants: await listGrants(serviceClient(), uid) }, { headers: { "cache-control": "no-store" } }); +} + +// DELETE /api/oauth/grants?id=: disconnect one app; its tokens stop working at once. +export async function DELETE(req: NextRequest) { + const uid = await userId(); + if (!uid) return unauthorized(); + const id = req.nextUrl.searchParams.get("id") ?? ""; + if (!UUID.test(id)) return NextResponse.json({ error: "id is required" }, { status: 400 }); + const ok = await revokeGrant(serviceClient(), uid, id); + return ok ? NextResponse.json({ revoked: true }) : NextResponse.json({ error: "No such connected app" }, { status: 404 }); +} diff --git a/app/api/oauth/register/route.ts b/app/api/oauth/register/route.ts new file mode 100644 index 00000000..28b6b13d --- /dev/null +++ b/app/api/oauth/register/route.ts @@ -0,0 +1,32 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { CORS_HEADERS, OAuthError, registerClient } from "@/lib/mcp-oauth"; +import { serviceClient } from "@/lib/supabase/service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// POST /api/oauth/register: RFC 7591 dynamic client registration (public clients). +export async function POST(req: NextRequest) { + let body: Record; + try { + body = (await req.json()) as Record; + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error(); + } catch { + return NextResponse.json( + { error: "invalid_client_metadata", error_description: "Send the client metadata as a JSON object" }, + { status: 400, headers: CORS_HEADERS }, + ); + } + try { + const client = await registerClient(serviceClient(), body); + return NextResponse.json(client, { status: 201, headers: { ...CORS_HEADERS, "cache-control": "no-store" } }); + } catch (err) { + if (err instanceof OAuthError) return NextResponse.json(err.body(), { status: err.status, headers: CORS_HEADERS }); + console.error("[oauth] register failed", (err as Error).message); + return NextResponse.json({ error: "server_error" }, { status: 500, headers: CORS_HEADERS }); + } +} + +export function OPTIONS() { + return new NextResponse(null, { status: 204, headers: CORS_HEADERS }); +} diff --git a/app/api/oauth/revoke/route.ts b/app/api/oauth/revoke/route.ts new file mode 100644 index 00000000..35f9ba42 --- /dev/null +++ b/app/api/oauth/revoke/route.ts @@ -0,0 +1,20 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { CORS_HEADERS, revokeToken } from "@/lib/mcp-oauth"; +import { serviceClient } from "@/lib/supabase/service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// POST /api/oauth/revoke: RFC 7009. Always 200; revoking either token ends the grant. +export async function POST(req: NextRequest) { + const type = req.headers.get("content-type") ?? ""; + const p = type.includes("application/json") + ? (((await req.json().catch(() => null)) as Record | null) ?? {}) + : Object.fromEntries(new URLSearchParams(await req.text())); + if (typeof p.token === "string" && p.token) await revokeToken(serviceClient(), p.token); + return new NextResponse(null, { status: 200, headers: CORS_HEADERS }); +} + +export function OPTIONS() { + return new NextResponse(null, { status: 204, headers: CORS_HEADERS }); +} diff --git a/app/api/oauth/token/route.ts b/app/api/oauth/token/route.ts new file mode 100644 index 00000000..a7cc0bcc --- /dev/null +++ b/app/api/oauth/token/route.ts @@ -0,0 +1,39 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { CORS_HEADERS, exchangeCode, OAuthError, refreshTokens } from "@/lib/mcp-oauth"; +import { serviceClient } from "@/lib/supabase/service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NO_STORE = { ...CORS_HEADERS, "cache-control": "no-store", pragma: "no-cache" }; + +/** The token endpoint takes application/x-www-form-urlencoded (RFC 6749); JSON is accepted too. */ +async function params(req: NextRequest): Promise> { + const type = req.headers.get("content-type") ?? ""; + if (type.includes("application/json")) { + const body = await req.json().catch(() => null); + return body && typeof body === "object" ? (body as Record) : {}; + } + return Object.fromEntries(new URLSearchParams(await req.text())); +} + +// POST /api/oauth/token: authorization_code (PKCE S256) and refresh_token grants. +export async function POST(req: NextRequest) { + try { + const p = await params(req); + const admin = serviceClient(); + let out; + if (p.grant_type === "authorization_code") out = await exchangeCode(admin, p); + else if (p.grant_type === "refresh_token") out = await refreshTokens(admin, p); + else throw new OAuthError("unsupported_grant_type", "grant_type must be authorization_code or refresh_token"); + return NextResponse.json(out, { headers: NO_STORE }); + } catch (err) { + if (err instanceof OAuthError) return NextResponse.json(err.body(), { status: err.status, headers: NO_STORE }); + console.error("[oauth] token failed", (err as Error).message); + return NextResponse.json({ error: "server_error" }, { status: 500, headers: NO_STORE }); + } +} + +export function OPTIONS() { + return new NextResponse(null, { status: 204, headers: CORS_HEADERS }); +} diff --git a/app/oauth/authorize/page.tsx b/app/oauth/authorize/page.tsx new file mode 100644 index 00000000..8c8397d2 --- /dev/null +++ b/app/oauth/authorize/page.tsx @@ -0,0 +1,88 @@ +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { AUTHORIZE_FIELDS, checkAuthorize, type AuthorizeParams } from "@/lib/mcp-oauth"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: "Connect an app", + description: "Allow an AI app (ChatGPT, Claude, …) to use your CrawlProof account through the CrawlProof MCP server.", + robots: { index: false, follow: false }, +}; + +/** + * The OAuth 2.1 authorization endpoint for the MCP server at /api/mcp. + * Validates the request (unknown client or redirect is shown here, never + * followed), sends a signed-out visitor through /login and back, then asks the + * signed-in user to allow or deny. The form posts to /api/oauth/authorize. + */ +export default async function OAuthAuthorizePage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const sp = await searchParams; + const p: AuthorizeParams = {}; + for (const k of AUTHORIZE_FIELDS) { + const v = sp[k]; + if (typeof v === "string") (p as Record)[k] = v; + } + + const check = await checkAuthorize(serviceClient(), p); + if (!check.ok) { + if ("redirect" in check) redirect(check.redirect); + return ( +
+

This connection request is not valid

+

+ {check.description} +

+

+ Go back to the app you came from and connect CrawlProof again. +

+
+ ); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + const qs = new URLSearchParams(p as Record).toString(); + redirect(`/login?redirect=${encodeURIComponent(`/oauth/authorize?${qs}`)}`); + } + + const redirectHost = new URL(check.redirectUri).host; + return ( +
+

+ Allow {check.client.client_name} to use your CrawlProof account? +

+

+ Signed in as {user.email ?? "your account"}. +

+
    +
  • It can use every CrawlProof MCP tool as you: projects, audits, stats, promote, leads, webhooks and credits.
  • +
  • You will be sent back to {redirectHost}.
  • +
  • Access lasts until you disconnect it; tokens expire hourly and are refreshed.
  • +
+ {check.client.client_uri && ( +

App site: {check.client.client_uri}

+ )} +
+ {AUTHORIZE_FIELDS.map((k) => { + const v = (p as Record)[k]; + return typeof v === "string" ? : null; + })} + + + +
+ ); +} diff --git a/lib/mcp-oauth.ts b/lib/mcp-oauth.ts new file mode 100644 index 00000000..0387ae60 --- /dev/null +++ b/lib/mcp-oauth.ts @@ -0,0 +1,491 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +/** + * OAuth 2.1 authorization server for the MCP resource at /api/mcp, per the MCP + * authorization spec: protected-resource metadata (RFC 9728), authorization + * server metadata (RFC 8414), dynamic client registration (RFC 7591), the + * authorization code grant with PKCE S256 only, and rotating refresh tokens. + * Public clients only (ChatGPT, claude.ai and Claude Code all register as + * `token_endpoint_auth_method: none`). + * + * An access token acts as the user who approved it, with the same rights as + * a full-access crp_ API token, but only on /api/mcp: it is resolved by the MCP + * route, never by lib/sp/apiAuth.ts, so no other API accepts it. Existing crp_ + * keys keep working on /api/mcp unchanged. Tables: + * supabase/migrations/*_mcp_oauth.sql (hashes only). Ported from ThreatCrush. + */ + +export const ACCESS_PREFIX = "crp_oat_"; +export const REFRESH_PREFIX = "crp_ort_"; +export const SCOPE = "mcp"; +export const ACCESS_TTL_S = 3600; +export const REFRESH_TTL_S = 90 * 24 * 3600; +export const CODE_TTL_S = 300; + +const env = (name: string) => process.env[name]; + +/** The public origin, read at runtime (Next inlines literal process.env.X at build). */ +export function siteOrigin(): string { + return (env("MCP_OAUTH_ISSUER") || env("NEXT_PUBLIC_SITE_URL") || "https://crawlproof.com").replace(/\/+$/, ""); +} + +export const resourceUrl = (origin = siteOrigin()) => `${origin}/api/mcp`; +export const resourceMetadataUrl = (origin = siteOrigin()) => `${origin}/.well-known/oauth-protected-resource`; + +export function protectedResourceMetadata(origin = siteOrigin()) { + return { + resource: resourceUrl(origin), + authorization_servers: [origin], + bearer_methods_supported: ["header"], + scopes_supported: [SCOPE], + resource_name: "CrawlProof", + resource_documentation: `${origin}/docs`, + }; +} + +export function authorizationServerMetadata(origin = siteOrigin()) { + return { + issuer: origin, + authorization_endpoint: `${origin}/oauth/authorize`, + token_endpoint: `${origin}/api/oauth/token`, + registration_endpoint: `${origin}/api/oauth/register`, + revocation_endpoint: `${origin}/api/oauth/revoke`, + scopes_supported: [SCOPE], + response_types_supported: ["code"], + response_modes_supported: ["query"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + revocation_endpoint_auth_methods_supported: ["none"], + service_documentation: `${origin}/docs`, + }; +} + +/** OpenID-shaped discovery for clients that only look there. No id_token is issued. */ +export function openidConfiguration(origin = siteOrigin()) { + return { + ...authorizationServerMetadata(origin), + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + }; +} + +/** CORS for the metadata, registration, token and revoke endpoints: no cookies are involved. */ +export const CORS_HEADERS = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "authorization, content-type, mcp-protocol-version", + "access-control-max-age": "86400", +}; + +// ─── Secrets ─── + +export const hashToken = (secret: string) => createHash("sha256").update(secret).digest("hex"); +const secret = () => randomBytes(32).toString("base64url"); + +/** base64url(SHA-256(verifier)), constant-time against the stored challenge. */ +export function pkceMatches(verifier: string, challenge: string): boolean { + if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) return false; + const a = Buffer.from(createHash("sha256").update(verifier).digest("base64url")); + const b = Buffer.from(challenge); + return a.length === b.length && timingSafeEqual(a, b); +} + +export const isS256Challenge = (v: unknown): v is string => typeof v === "string" && /^[A-Za-z0-9_-]{43}$/.test(v); + +// ─── Redirect URIs ─── + +const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** https anywhere (no fragment, no credentials), or http on a loopback host (RFC 8252). */ +export function isAllowedRedirectUri(value: unknown): value is string { + if (typeof value !== "string" || value.length > 2000) return false; + let u: URL; + try { + u = new URL(value); + } catch { + return false; + } + if (u.hash || u.username || u.password) return false; + if (u.protocol === "https:") return true; + return u.protocol === "http:" && LOOPBACK.has(u.hostname); +} + +/** + * Exact match, except that a loopback redirect may use any port (RFC 8252 + * §7.3): Claude Code listens on whichever port is free. + */ +export function redirectUriMatches(registered: string[], asked: string): boolean { + if (registered.includes(asked)) return true; + let a: URL; + try { + a = new URL(asked); + } catch { + return false; + } + if (a.protocol !== "http:" || !LOOPBACK.has(a.hostname)) return false; + return registered.some((r) => { + try { + const u = new URL(r); + return u.protocol === "http:" && u.hostname === a.hostname && u.pathname === a.pathname && u.search === a.search; + } catch { + return false; + } + }); +} + +// ─── Errors ─── + +export class OAuthError extends Error { + constructor( + public code: string, + message: string, + public status = 400, + ) { + super(message); + } + body() { + return { error: this.code, error_description: this.message }; + } +} + +// ─── Clients (RFC 7591) ─── + +export interface OAuthClient { + client_id: string; + client_name: string; + redirect_uris: string[]; + client_uri: string | null; + logo_uri: string | null; +} + +const cleanName = (v: unknown) => + typeof v === "string" ? v.replace(/[\u0000-\u001f\u007f<>]/g, "").trim().slice(0, 100) : ""; + +const optionalHttps = (v: unknown) => { + if (typeof v !== "string") return null; + try { + return new URL(v).protocol === "https:" ? v.slice(0, 500) : null; + } catch { + return null; + } +}; + +export async function registerClient(admin: SupabaseClient, body: Record) { + const uris = body.redirect_uris; + if (!Array.isArray(uris) || uris.length === 0 || uris.length > 20) { + throw new OAuthError("invalid_redirect_uri", "redirect_uris must list 1 to 20 URIs"); + } + for (const u of uris) { + if (!isAllowedRedirectUri(u)) { + throw new OAuthError("invalid_redirect_uri", `Not allowed: ${String(u).slice(0, 200)} (https, or http on localhost/127.0.0.1)`); + } + } + const method = body.token_endpoint_auth_method ?? "none"; + if (method !== "none") { + throw new OAuthError("invalid_client_metadata", "Only public clients are supported: token_endpoint_auth_method must be none"); + } + const grants = Array.isArray(body.grant_types) ? body.grant_types.map(String) : ["authorization_code", "refresh_token"]; + if (grants.some((g) => g !== "authorization_code" && g !== "refresh_token")) { + throw new OAuthError("invalid_client_metadata", "grant_types may only be authorization_code and refresh_token"); + } + const types = Array.isArray(body.response_types) ? body.response_types.map(String) : ["code"]; + if (types.some((t) => t !== "code")) throw new OAuthError("invalid_client_metadata", "response_types may only be code"); + + const row = { + client_id: `crp_client_${randomBytes(16).toString("hex")}`, + client_name: cleanName(body.client_name) || "MCP client", + redirect_uris: [...new Set(uris as string[])], + client_uri: optionalHttps(body.client_uri), + logo_uri: optionalHttps(body.logo_uri), + }; + const { error } = await admin.from("oauth_clients").insert(row); + if (error) throw new OAuthError("server_error", "Could not register the client", 500); + return { + ...row, + client_id_issued_at: Math.floor(Date.now() / 1000), + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: SCOPE, + }; +} + +export async function getClient(admin: SupabaseClient, clientId: unknown): Promise { + if (typeof clientId !== "string" || !/^[\w-]{1,100}$/.test(clientId)) return null; + const { data } = await admin + .from("oauth_clients") + .select("client_id, client_name, redirect_uris, client_uri, logo_uri") + .eq("client_id", clientId) + .maybeSingle(); + return (data as OAuthClient | null) ?? null; +} + +// ─── Authorization request ─── + +export interface AuthorizeParams { + response_type?: unknown; + client_id?: unknown; + redirect_uri?: unknown; + code_challenge?: unknown; + code_challenge_method?: unknown; + state?: unknown; + scope?: unknown; + resource?: unknown; +} + +/** The OAuth request fields the consent form carries back as hidden inputs. */ +export const AUTHORIZE_FIELDS = [ + "response_type", + "client_id", + "redirect_uri", + "code_challenge", + "code_challenge_method", + "state", + "scope", + "resource", +] as const; + +/** + * A cookie-authenticated POST must come from our own consent page. Browsers + * send Origin on every form POST; Sec-Fetch-Site covers the rest. The Supabase + * session cookie is SameSite=Lax as well, so this is belt and braces. + */ +export function sameOrigin(req: Request): boolean { + const origin = req.headers.get("origin"); + if (origin) return origin === siteOrigin(); + return req.headers.get("sec-fetch-site") === "same-origin"; +} + +export type AuthorizeCheck = + /** Unknown client or redirect: show the error, never redirect (RFC 6749 §4.1.2.1). */ + | { ok: false; error: string; description: string } + /** The redirect is trusted but the request is bad: send the error back to the client. */ + | { ok: false; redirect: string } + | { ok: true; client: OAuthClient; redirectUri: string; challenge: string; state: string | null; resource: string | null }; + +export function errorRedirect(redirectUri: string, error: string, description: string, state: string | null): string { + const u = new URL(redirectUri); + u.searchParams.set("error", error); + u.searchParams.set("error_description", description); + if (state) u.searchParams.set("state", state); + u.searchParams.set("iss", siteOrigin()); + return u.toString(); +} + +export async function checkAuthorize(admin: SupabaseClient, p: AuthorizeParams): Promise { + const client = await getClient(admin, p.client_id); + if (!client) return { ok: false, error: "invalid_client", description: "Unknown client_id. Remove the connector and add it again." }; + const redirectUri = typeof p.redirect_uri === "string" && p.redirect_uri ? p.redirect_uri : client.redirect_uris.length === 1 ? client.redirect_uris[0] : ""; + if (!redirectUri || !redirectUriMatches(client.redirect_uris, redirectUri)) { + return { ok: false, error: "invalid_request", description: "redirect_uri is not registered for this client." }; + } + const state = typeof p.state === "string" && p.state ? p.state.slice(0, 1000) : null; + const back = (error: string, description: string) => ({ ok: false as const, redirect: errorRedirect(redirectUri, error, description, state) }); + if (p.response_type !== "code") return back("unsupported_response_type", "response_type must be code"); + if (!isS256Challenge(p.code_challenge)) return back("invalid_request", "PKCE is required: code_challenge (S256)"); + if (p.code_challenge_method !== "S256") return back("invalid_request", "code_challenge_method must be S256"); + if (typeof p.scope === "string" && p.scope.trim() && !p.scope.split(/\s+/).every((s) => s === SCOPE)) { + return back("invalid_scope", `The only scope is ${SCOPE}`); + } + let resource: string | null = null; + if (typeof p.resource === "string" && p.resource) { + if (!resourceMatches(p.resource)) return back("invalid_target", `resource must be ${resourceUrl()}`); + resource = p.resource; + } + return { ok: true, client, redirectUri, challenge: p.code_challenge, state, resource }; +} + +/** The MCP resource, with or without a trailing slash or query (the ?login variant). */ +function resourceMatches(value: string): boolean { + try { + const u = new URL(value); + const r = new URL(resourceUrl()); + return u.origin === r.origin && u.pathname.replace(/\/+$/, "") === r.pathname; + } catch { + return false; + } +} + +/** Issue a single-use code for an approved request; returns the redirect with code + state. */ +export async function issueCode( + admin: SupabaseClient, + check: Extract, + userId: string, +): Promise { + const code = secret(); + const { error } = await admin.from("oauth_codes").insert({ + code_hash: hashToken(code), + client_id: check.client.client_id, + user_id: userId, + redirect_uri: check.redirectUri, + code_challenge: check.challenge, + scope: SCOPE, + resource: check.resource, + expires_at: new Date(Date.now() + CODE_TTL_S * 1000).toISOString(), + }); + if (error) throw new OAuthError("server_error", "Could not issue an authorization code", 500); + const u = new URL(check.redirectUri); + u.searchParams.set("code", code); + if (check.state) u.searchParams.set("state", check.state); + u.searchParams.set("iss", siteOrigin()); + return u.toString(); +} + +// ─── Token endpoint ─── + +export interface TokenResponse { + access_token: string; + token_type: "Bearer"; + expires_in: number; + refresh_token: string; + scope: string; +} + +function newPair() { + const access = ACCESS_PREFIX + secret(); + const refresh = REFRESH_PREFIX + secret(); + const now = Date.now(); + return { + access, + refresh, + row: { + access_hash: hashToken(access), + refresh_hash: hashToken(refresh), + access_expires_at: new Date(now + ACCESS_TTL_S * 1000).toISOString(), + refresh_expires_at: new Date(now + REFRESH_TTL_S * 1000).toISOString(), + }, + }; +} + +const respond = (access: string, refresh: string): TokenResponse => ({ + access_token: access, + token_type: "Bearer", + expires_in: ACCESS_TTL_S, + refresh_token: refresh, + scope: SCOPE, +}); + +export async function exchangeCode(admin: SupabaseClient, p: Record): Promise { + const code = typeof p.code === "string" ? p.code : ""; + const verifier = typeof p.code_verifier === "string" ? p.code_verifier : ""; + if (!code) throw new OAuthError("invalid_request", "code is required"); + if (!verifier) throw new OAuthError("invalid_request", "code_verifier is required (PKCE)"); + + // Single use: the conditional update is the guard, so two racing requests cannot both redeem it. + const { data: row } = await admin + .from("oauth_codes") + .update({ used_at: new Date().toISOString() }) + .eq("code_hash", hashToken(code)) + .is("used_at", null) + .gt("expires_at", new Date().toISOString()) + .select("client_id, user_id, redirect_uri, code_challenge, scope") + .maybeSingle(); + if (!row) throw new OAuthError("invalid_grant", "The authorization code is invalid, expired or already used"); + if (typeof p.client_id === "string" && p.client_id && p.client_id !== row.client_id) { + throw new OAuthError("invalid_grant", "The code was issued to another client"); + } + if (p.redirect_uri !== row.redirect_uri) throw new OAuthError("invalid_grant", "redirect_uri does not match the authorization request"); + if (!pkceMatches(verifier, row.code_challenge)) throw new OAuthError("invalid_grant", "code_verifier does not match the code_challenge"); + + const pair = newPair(); + const { error } = await admin.from("oauth_tokens").insert({ + client_id: row.client_id, + user_id: row.user_id, + scope: row.scope, + ...pair.row, + }); + if (error) throw new OAuthError("server_error", "Could not issue tokens", 500); + await admin.from("oauth_clients").update({ last_used_at: new Date().toISOString() }).eq("client_id", row.client_id); + return respond(pair.access, pair.refresh); +} + +export async function refreshTokens(admin: SupabaseClient, p: Record): Promise { + const refresh = typeof p.refresh_token === "string" ? p.refresh_token : ""; + if (!refresh.startsWith(REFRESH_PREFIX)) throw new OAuthError("invalid_grant", "The refresh token is invalid"); + const oldHash = hashToken(refresh); + const { data: grant } = await admin + .from("oauth_tokens") + .select("id, client_id") + .eq("refresh_hash", oldHash) + .is("revoked_at", null) + .gt("refresh_expires_at", new Date().toISOString()) + .maybeSingle(); + if (!grant) throw new OAuthError("invalid_grant", "The refresh token is invalid, expired or revoked"); + if (typeof p.client_id === "string" && p.client_id && p.client_id !== grant.client_id) { + throw new OAuthError("invalid_grant", "The refresh token was issued to another client"); + } + // Rotate in place, conditional on the old hash: a replayed refresh token loses the race. + const pair = newPair(); + const { data: rotated } = await admin + .from("oauth_tokens") + .update(pair.row) + .eq("id", grant.id) + .eq("refresh_hash", oldHash) + .select("id") + .maybeSingle(); + if (!rotated) throw new OAuthError("invalid_grant", "The refresh token was already used"); + return respond(pair.access, pair.refresh); +} + +/** RFC 7009: always 200, whether or not the token was known. Ends the whole grant. */ +export async function revokeToken(admin: SupabaseClient, token: string): Promise { + const col = token.startsWith(ACCESS_PREFIX) ? "access_hash" : token.startsWith(REFRESH_PREFIX) ? "refresh_hash" : null; + if (!col) return; + await admin.from("oauth_tokens").update({ revoked_at: new Date().toISOString() }).eq(col, hashToken(token)).is("revoked_at", null); +} + +// ─── Resolving an access token (MCP route only) ─── + +export async function resolveAccessToken(admin: SupabaseClient, token: string): Promise<{ userId: string } | null> { + if (!token.startsWith(ACCESS_PREFIX)) return null; + const { data: grant } = await admin + .from("oauth_tokens") + .select("id, user_id, last_used_at") + .eq("access_hash", hashToken(token)) + .is("revoked_at", null) + .gt("access_expires_at", new Date().toISOString()) + .maybeSingle(); + if (!grant) return null; + // At most one write a minute per grant. + if (!grant.last_used_at || Date.now() - Date.parse(grant.last_used_at) > 60_000) { + await admin.from("oauth_tokens").update({ last_used_at: new Date().toISOString() }).eq("id", grant.id); + } + return { userId: grant.user_id as string }; +} + +// ─── Connected apps (/account) ─── + +export async function listGrants(admin: SupabaseClient, userId: string) { + const { data } = await admin + .from("oauth_tokens") + .select("id, client_id, scope, created_at, last_used_at, oauth_clients(client_name, client_uri)") + .eq("user_id", userId) + .is("revoked_at", null) + .gt("refresh_expires_at", new Date().toISOString()) + .order("created_at", { ascending: false }); + return (data ?? []).map((g: Record) => { + const c = (Array.isArray(g.oauth_clients) ? g.oauth_clients[0] : g.oauth_clients) as { client_name?: string; client_uri?: string } | null; + return { + id: g.id as string, + client_name: c?.client_name ?? "MCP client", + client_uri: c?.client_uri ?? null, + scope: g.scope as string, + created_at: g.created_at as string, + last_used_at: (g.last_used_at as string | null) ?? null, + }; + }); +} + +export async function revokeGrant(admin: SupabaseClient, userId: string, id: string): Promise { + const { data } = await admin + .from("oauth_tokens") + .update({ revoked_at: new Date().toISOString() }) + .eq("id", id) + .eq("user_id", userId) + .is("revoked_at", null) + .select("id") + .maybeSingle(); + return Boolean(data); +} diff --git a/next.config.ts b/next.config.ts index 3ca7a4ef..47c5ec9f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -50,6 +50,14 @@ const nextConfig: NextConfig = { source: "/install.sh", headers: [{ key: "Content-Type", value: "text/plain; charset=utf-8" }], }, + // The OAuth consent page must never be framed (clickjacking an Allow). + { + source: "/oauth/authorize", + headers: [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "Content-Security-Policy", value: "frame-ancestors 'none'" }, + ], + }, ]; }, // Permanent redirect: www.crawlproof.com → crawlproof.com (308 preserves diff --git a/proxy.ts b/proxy.ts index b6069aba..9c733030 100644 --- a/proxy.ts +++ b/proxy.ts @@ -10,6 +10,10 @@ type Cookie = { name: string; value: string; options?: CookieOptions }; export async function proxy(request: NextRequest) { // The status page's health probe: no gateway, no session refresh. if (request.nextUrl.pathname === "/api/health") return NextResponse.next(); + // OAuth discovery, registration, token and revoke (lib/mcp-oauth.ts) are + // called by ChatGPT/Claude backends with no session and a 10 s budget: no + // crawl gateway, no Supabase session refresh. + if (isOAuthMachinePath(request.nextUrl.pathname)) return NextResponse.next(); // Ad routes enforce their gate themselves, before DB work. Avoid counting a // request twice or making an unrelated Supabase Auth call for each click. if (isAdClickPath(request.nextUrl.pathname)) return NextResponse.next(); @@ -103,6 +107,15 @@ export async function proxy(request: NextRequest) { return trackReferralCode(request, response as any); } +function isOAuthMachinePath(path: string): boolean { + return ( + path.startsWith("/.well-known/oauth-") || + path === "/api/oauth/register" || + path === "/api/oauth/token" || + path === "/api/oauth/revoke" + ); +} + export const config = { matcher: [ "/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|llms.txt|skill.md|install.sh|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", diff --git a/supabase/migrations/20261111140000_mcp_oauth.sql b/supabase/migrations/20261111140000_mcp_oauth.sql new file mode 100644 index 00000000..d46a5c10 --- /dev/null +++ b/supabase/migrations/20261111140000_mcp_oauth.sql @@ -0,0 +1,63 @@ +-- OAuth 2.1 for the MCP server at /api/mcp (ChatGPT apps, claude.ai custom +-- connectors, Claude Code), per the MCP authorization spec. +-- Created: 2026-11-11 +-- +-- oauth_clients dynamic client registration (RFC 7591): public clients only +-- (token_endpoint_auth_method none), so there is no secret. +-- oauth_codes authorization codes: single use, ~5 minutes, bound to the +-- client, redirect_uri, PKCE S256 challenge and the user. +-- oauth_tokens one row per grant ("connected app"): the current access +-- and refresh token hashes. A refresh rotates both in place. +-- revoked_at ends the grant; it is listed and revocable on +-- /api/oauth/grants. +-- +-- Only SHA-256 hashes of codes and tokens are stored. Service role only, like +-- every other credential table here (sp_api_token): RLS on, +-- no policies, every read and write goes through the API (lib/mcp-oauth.ts). + +CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_name TEXT NOT NULL CHECK (length(client_name) BETWEEN 1 AND 200), + redirect_uris TEXT[] NOT NULL CHECK (cardinality(redirect_uris) BETWEEN 1 AND 20), + client_uri TEXT, + logo_uri TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMP WITH TIME ZONE +); + +CREATE TABLE IF NOT EXISTS oauth_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'mcp', + resource TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE +); +CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at); + +CREATE TABLE IF NOT EXISTS oauth_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + scope TEXT NOT NULL DEFAULT 'mcp', + access_hash TEXT NOT NULL UNIQUE, + refresh_hash TEXT NOT NULL UNIQUE, + access_expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + refresh_expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMP WITH TIME ZONE, + revoked_at TIMESTAMP WITH TIME ZONE +); +CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id); + +ALTER TABLE oauth_clients ENABLE ROW LEVEL SECURITY; +ALTER TABLE oauth_codes ENABLE ROW LEVEL SECURITY; +ALTER TABLE oauth_tokens ENABLE ROW LEVEL SECURITY; + +-- Supabase grants table privileges to anon/authenticated by default; RLS with +-- no policy already refuses them, this makes it explicit. +REVOKE ALL ON oauth_clients, oauth_codes, oauth_tokens FROM anon, authenticated; diff --git a/tests/mcp-oauth.test.ts b/tests/mcp-oauth.test.ts new file mode 100644 index 00000000..38603374 --- /dev/null +++ b/tests/mcp-oauth.test.ts @@ -0,0 +1,288 @@ +// OAuth 2.1 for the MCP server at /api/mcp (lib/mcp-oauth.ts): discovery, +// dynamic client registration, the consent POST, PKCE code exchange, refresh +// rotation, revoke, and the MCP route itself (401 + WWW-Authenticate, OAuth +// token works, an existing crp_ key still works). +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { createHash, randomBytes } from "node:crypto"; + +process.env.NEXT_PUBLIC_SITE_URL = "https://crawlproof.com"; + +// In-memory tables behind a query builder with just the calls lib/mcp-oauth.ts +// makes: insert/select/update, eq/is/gt filters, order, maybeSingle. +type Row = Record; +const tables: Record = {}; +let signedInAs: string | null = null; + +function builder(table: string) { + const filters: Array<(r: Row) => boolean> = []; + let op: "select" | "insert" | "update" = "select"; + let payload: Row = {}; + const rows = () => (tables[table] ??= []); + const run = (): Row[] => { + if (op === "insert") { + const row: Row = { id: crypto.randomUUID(), created_at: new Date().toISOString(), revoked_at: null, used_at: null, last_used_at: null, ...payload }; + rows().push(row); + return [row]; + } + const matched = rows().filter((r) => filters.every((f) => f(r))); + if (op === "update") for (const r of matched) Object.assign(r, payload); + return matched.map((r) => (table === "oauth_tokens" ? { ...r, oauth_clients: (tables.oauth_clients ?? []).find((c) => c.client_id === r.client_id) } : r)); + }; + const b = { + insert(p: Row) { op = "insert"; payload = p; return Promise.resolve({ data: null, error: (run(), null) }); }, + update(p: Row) { op = "update"; payload = p; return b; }, + select() { return b; }, + order() { return b; }, + eq(col: string, val: unknown) { filters.push((r) => r[col] === val); return b; }, + is(col: string, val: unknown) { filters.push((r) => (r[col] ?? null) === val); return b; }, + gt(col: string, val: string) { filters.push((r) => String(r[col]) > val); return b; }, + async maybeSingle() { const [r] = run(); return { data: r ?? null, error: null }; }, + then(resolve: (v: { data: Row[]; error: null }) => void) { resolve({ data: run(), error: null }); }, + }; + return b; +} + +vi.mock("@/lib/supabase/service", () => ({ serviceClient: () => ({ from: (t: string) => builder(t) }) })); +vi.mock("@/lib/supabase/server", () => ({ + createClient: async () => ({ + auth: { getUser: async () => ({ data: { user: signedInAs ? { id: signedInAs, email: `${signedInAs}@example.com` } : null }, error: null }) }, + }), +})); +// The existing crp_ API key path, unchanged: one known key. +const OLD_KEY = "crp_" + "k".repeat(43); +vi.mock("@/lib/sp/apiAuth", () => ({ + authenticateToken: async (t: string) => (t === OLD_KEY ? { ok: true, userId: "key-user", tokenId: "t1", scope: null } : { ok: false, status: 401, error: "Invalid or revoked token." }), +})); + +const { POST: register } = await import("../app/api/oauth/register/route"); +const { POST: token } = await import("../app/api/oauth/token/route"); +const { POST: decide } = await import("../app/api/oauth/authorize/route"); +const { GET: listGrants, DELETE: dropGrant } = await import("../app/api/oauth/grants/route"); +const { POST: revoke } = await import("../app/api/oauth/revoke/route"); +const { GET: resourceMeta } = await import("../app/.well-known/oauth-protected-resource/route"); +const { GET: asMeta } = await import("../app/.well-known/oauth-authorization-server/route"); +const mcp = await import("../app/api/mcp/route"); +const oauth = await import("@/lib/mcp-oauth"); + +const O = "https://crawlproof.com"; +const CLAUDE = "https://claude.ai/api/mcp/auth_callback"; +const CHATGPT = "https://chatgpt.com/connector_platform_oauth_redirect"; +const json = (url: string, body: unknown, headers: Record = {}) => + new NextRequest(url, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body) }); +const form = (body: Record, url = `${O}/api/oauth/token`, headers: Record = {}) => + new NextRequest(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...headers }, body: new URLSearchParams(body).toString() }); +const consent = (body: Record, origin: string | null = O) => + form(body, `${O}/api/oauth/authorize`, origin ? { origin } : {}); + +function pkce() { + const verifier = randomBytes(32).toString("base64url"); + return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") }; +} + +async function newClient(redirect_uris = [CLAUDE, CHATGPT, "http://localhost:33418/callback"]) { + const res = await register(json(`${O}/api/oauth/register`, { client_name: "Claude", redirect_uris, token_endpoint_auth_method: "none" })); + expect(res.status).toBe(201); + return (await res.json()) as { client_id: string; token_endpoint_auth_method: string }; +} + +const req = (client_id: string, challenge: string, redirect = CLAUDE) => ({ + response_type: "code", client_id, redirect_uri: redirect, code_challenge: challenge, code_challenge_method: "S256", state: "xyz", resource: `${O}/api/mcp`, +}); + +async function authorize(clientId: string, challenge: string, redirect = CLAUDE) { + signedInAs = "user-1"; + const res = await decide(consent({ ...req(clientId, challenge, redirect), decision: "allow" })); + expect(res.status).toBe(303); + const u = new URL(res.headers.get("location")!); + expect(u.searchParams.get("state")).toBe("xyz"); + expect(u.searchParams.get("iss")).toBe(O); + return u.searchParams.get("code")!; +} + +async function fullToken(redirect = CLAUDE) { + const { client_id } = await newClient(); + const { verifier, challenge } = pkce(); + const code = await authorize(client_id, challenge, redirect); + const res = await token(form({ grant_type: "authorization_code", code, redirect_uri: redirect, client_id, code_verifier: verifier })); + expect(res.status).toBe(200); + return { client_id, t: (await res.json()) as { access_token: string; refresh_token: string } }; +} + +const rpc = (method: string, auth?: string, id = 1) => + new Request(`${O}/api/mcp`, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...(auth ? { authorization: `Bearer ${auth}` } : {}) }, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params: method === "initialize" ? { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "1" } } : {} }), + }); + +async function rpcResult(res: Response) { + const text = await res.text(); + const line = text.split("\n").find((l) => l.startsWith("data: ")); + return JSON.parse(line ? line.slice(6) : text); +} + +beforeEach(() => { + for (const k of Object.keys(tables)) delete tables[k]; + signedInAs = null; +}); + +describe("discovery", () => { + it("protected-resource metadata names the MCP URL exactly and this site as the AS", async () => { + const r = await resourceMeta().json(); + expect(r.resource).toBe(`${O}/api/mcp`); + expect(r.authorization_servers[0]).toBe(O); + }); + it("authorization-server metadata advertises DCR, S256, code, refresh and public clients", async () => { + const a = await asMeta().json(); + expect(a).toMatchObject({ + issuer: O, + authorization_endpoint: `${O}/oauth/authorize`, + token_endpoint: `${O}/api/oauth/token`, + registration_endpoint: `${O}/api/oauth/register`, + code_challenge_methods_supported: ["S256"], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: ["none"], + }); + }); +}); + +describe("redirect URIs", () => { + it("allows Claude, ChatGPT, https and loopback http, nothing else", () => { + for (const ok of [CLAUDE, CHATGPT, "http://localhost:3000/callback", "http://127.0.0.1:5555/callback"]) expect(oauth.isAllowedRedirectUri(ok)).toBe(true); + for (const bad of ["http://evil.com/cb", "javascript:alert(1)", "https://a.com/cb#frag", "https://u:p@a.com/", "custom://x", 7]) expect(oauth.isAllowedRedirectUri(bad)).toBe(false); + }); + it("matches exactly, except any port on loopback", () => { + const reg = [CLAUDE, "http://localhost:33418/callback"]; + expect(oauth.redirectUriMatches(reg, "http://localhost:50123/callback")).toBe(true); + expect(oauth.redirectUriMatches(reg, "http://localhost:50123/other")).toBe(false); + expect(oauth.redirectUriMatches(reg, `${CLAUDE}2`)).toBe(false); + }); +}); + +describe("registration (RFC 7591)", () => { + it("registers a public client", async () => { + const c = await newClient(); + expect(c.client_id).toMatch(/^crp_client_/); + expect(c.token_endpoint_auth_method).toBe("none"); + }); + it("refuses confidential clients and bad redirects", async () => { + expect((await register(json(`${O}/api/oauth/register`, { redirect_uris: [CLAUDE], token_endpoint_auth_method: "client_secret_basic" }))).status).toBe(400); + const bad = await register(json(`${O}/api/oauth/register`, { redirect_uris: ["http://evil.com/cb"] })); + expect((await bad.json()).error).toBe("invalid_redirect_uri"); + }); +}); + +describe("consent POST", () => { + it("refuses a cross-site POST", async () => { + const { client_id } = await newClient(); + signedInAs = "user-1"; + const res = await decide(consent({ ...req(client_id, pkce().challenge), decision: "allow" }, "https://evil.example")); + expect(res.status).toBe(403); + }); + it("sends a signed-out user to /login and back", async () => { + const { client_id } = await newClient(); + const res = await decide(consent({ ...req(client_id, pkce().challenge), decision: "allow" })); + expect(res.status).toBe(303); + const loc = new URL(res.headers.get("location")!); + expect(loc.pathname).toBe("/login"); + expect(loc.searchParams.get("redirect")).toMatch(/^\/oauth\/authorize\?/); + }); + it("never redirects to an unregistered redirect_uri", async () => { + const { client_id } = await newClient(); + signedInAs = "user-1"; + const res = await decide(consent({ ...req(client_id, pkce().challenge, "https://evil.com/cb"), decision: "allow" })); + expect(res.status).toBe(400); + }); + it("deny returns access_denied with iss", async () => { + const { client_id } = await newClient(); + signedInAs = "user-1"; + const res = await decide(consent({ ...req(client_id, pkce().challenge), decision: "deny" })); + const u = new URL(res.headers.get("location")!); + expect(u.searchParams.get("error")).toBe("access_denied"); + expect(u.searchParams.get("iss")).toBe(O); + expect(u.searchParams.get("code")).toBeNull(); + }); + it("sends a request without PKCE back to the client with an error", async () => { + const { client_id } = await newClient(); + signedInAs = "user-1"; + const { code_challenge: _c, ...noPkce } = req(client_id, "x"); + const res = await decide(consent({ ...noPkce, decision: "allow" })); + expect(new URL(res.headers.get("location")!).searchParams.get("error")).toBe("invalid_request"); + }); +}); + +describe("token endpoint", () => { + it("code + PKCE (form-encoded) -> tokens -> refresh rotates -> grants list -> disconnect", async () => { + const { client_id } = await newClient(); + const { verifier, challenge } = pkce(); + const code = await authorize(client_id, challenge, CHATGPT); + const bad = await token(form({ grant_type: "authorization_code", code, redirect_uri: CHATGPT, client_id, code_verifier: pkce().verifier })); + expect((await bad.json()).error).toBe("invalid_grant"); + + const code2 = await authorize(client_id, challenge, CHATGPT); + const res = await token(form({ grant_type: "authorization_code", code: code2, redirect_uri: CHATGPT, client_id, code_verifier: verifier })); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("no-store"); + const t = await res.json(); + expect(t).toMatchObject({ token_type: "Bearer", expires_in: 3600, scope: "mcp" }); + expect(t.access_token.startsWith("crp_oat_")).toBe(true); + expect(JSON.stringify(tables.oauth_tokens)).not.toContain(t.access_token); + // Replay of a used code fails. + expect((await token(form({ grant_type: "authorization_code", code: code2, redirect_uri: CHATGPT, client_id, code_verifier: verifier }))).status).toBe(400); + + const admin = { from: (x: string) => builder(x) } as never; + expect(await oauth.resolveAccessToken(admin, t.access_token)).toEqual({ userId: "user-1" }); + const r2 = await (await token(form({ grant_type: "refresh_token", refresh_token: t.refresh_token, client_id }))).json(); + expect(await oauth.resolveAccessToken(admin, t.access_token)).toBeNull(); + expect(await oauth.resolveAccessToken(admin, r2.access_token)).toEqual({ userId: "user-1" }); + expect((await token(form({ grant_type: "refresh_token", refresh_token: t.refresh_token, client_id }))).status).toBe(400); + + signedInAs = "user-1"; + const grants = (await (await listGrants()).json()).grants; + expect(grants).toHaveLength(1); + signedInAs = "someone-else"; + expect((await dropGrant(new NextRequest(`${O}/api/oauth/grants?id=${grants[0].id}`, { method: "DELETE" }))).status).toBe(404); + signedInAs = "user-1"; + expect((await dropGrant(new NextRequest(`${O}/api/oauth/grants?id=${grants[0].id}`, { method: "DELETE" }))).status).toBe(200); + expect(await oauth.resolveAccessToken(admin, r2.access_token)).toBeNull(); + }); + it("RFC 7009 revoke always 200 and ends the grant", async () => { + const { t } = await fullToken(); + expect((await revoke(form({ token: t.refresh_token }, `${O}/api/oauth/revoke`))).status).toBe(200); + expect((await revoke(form({ token: "garbage" }, `${O}/api/oauth/revoke`))).status).toBe(200); + expect(await oauth.resolveAccessToken({ from: (x: string) => builder(x) } as never, t.access_token)).toBeNull(); + }); + it("loopback redirect on another port (Claude Code)", async () => { + await fullToken("http://localhost:50999/callback"); + }); + it("rejects unknown grant types", async () => { + expect((await (await token(form({ grant_type: "password" }))).json()).error).toBe("unsupported_grant_type"); + }); +}); + +describe("/api/mcp", () => { + it("unauthenticated -> 401 with WWW-Authenticate resource_metadata", async () => { + const res = await mcp.POST(rpc("initialize")); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toContain(`resource_metadata="${O}/.well-known/oauth-protected-resource"`); + }); + it("a bad or expired OAuth token -> 401", async () => { + expect((await mcp.POST(rpc("tools/list", "crp_oat_nope"))).status).toBe(401); + }); + it("an OAuth access token lists tools, with the list cache fields", async () => { + const { t } = await fullToken(); + const res = await mcp.POST(rpc("tools/list", t.access_token)); + expect(res.status).toBe(200); + const body = await rpcResult(res); + expect(body.result.tools.length).toBeGreaterThan(10); + expect(body.result).toMatchObject({ resultType: "complete", cacheScope: "private" }); + expect(typeof body.result.ttlMs).toBe("number"); + }); + it("the existing crp_ API key still works", async () => { + const res = await mcp.POST(rpc("tools/list", OLD_KEY)); + expect(res.status).toBe(200); + expect((await rpcResult(res)).result.tools.length).toBeGreaterThan(10); + }); +});