From d375c06c11e5542b44ef6d93fc2dcb1381bfafa8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 11 Nov 2026 21:44:32 +0000 Subject: [PATCH] feat(mcp): open OAuth so connector clients can finish their sign-in (0.7.0) claude.ai's connector flow runs OAuth discovery and dynamic client registration even for a no-auth server (nginx: POST /api/mcp 200, then .well-known lookups and POST /register, all 404 -> 'Couldn't register with p0dcasters's sign-in service'). The site now answers every step and grants nothing by it: RFC 9728/8414 metadata, stateless RFC 7591 registration, authorize with no login (https or loopback redirects only), PKCE-checked token endpoint issuing opaque open_ tokens that bearerPrincipal ignores. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- .../[[...path]]/route.ts | 5 + .../[[...path]]/route.ts | 5 + src/app/api/v1/mcp/oauth/authorize/route.ts | 13 ++ src/app/api/v1/mcp/oauth/register/route.ts | 16 ++ src/app/api/v1/mcp/oauth/token/route.ts | 17 ++ src/lib/crawl-gateway.ts | 4 +- src/lib/mcp/open-oauth.ts | 182 ++++++++++++++++++ test/open-oauth.test.mjs | 71 +++++++ 9 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 src/app/.well-known/oauth-authorization-server/[[...path]]/route.ts create mode 100644 src/app/.well-known/oauth-protected-resource/[[...path]]/route.ts create mode 100644 src/app/api/v1/mcp/oauth/authorize/route.ts create mode 100644 src/app/api/v1/mcp/oauth/register/route.ts create mode 100644 src/app/api/v1/mcp/oauth/token/route.ts create mode 100644 src/lib/mcp/open-oauth.ts create mode 100644 test/open-oauth.test.mjs diff --git a/package.json b/package.json index a6813a8..7c2cecd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "p0dcasters", - "version": "0.6.3", + "version": "0.7.0", "private": true, "type": "module", "scripts": { diff --git a/src/app/.well-known/oauth-authorization-server/[[...path]]/route.ts b/src/app/.well-known/oauth-authorization-server/[[...path]]/route.ts new file mode 100644 index 0000000..8bb88e7 --- /dev/null +++ b/src/app/.well-known/oauth-authorization-server/[[...path]]/route.ts @@ -0,0 +1,5 @@ +import { authorizationServerMetadata, json, preflight } from "@/lib/mcp/open-oauth"; + +// RFC 8414. Open sign-in for the MCP endpoint: see lib/mcp/open-oauth.ts. +export const GET = () => json(authorizationServerMetadata()); +export const OPTIONS = () => preflight(); diff --git a/src/app/.well-known/oauth-protected-resource/[[...path]]/route.ts b/src/app/.well-known/oauth-protected-resource/[[...path]]/route.ts new file mode 100644 index 0000000..c6120a0 --- /dev/null +++ b/src/app/.well-known/oauth-protected-resource/[[...path]]/route.ts @@ -0,0 +1,5 @@ +import { json, protectedResourceMetadata, preflight } from "@/lib/mcp/open-oauth"; + +// RFC 9728, at both the root and the /api/mcp path-suffixed location. +export const GET = () => json(protectedResourceMetadata()); +export const OPTIONS = () => preflight(); diff --git a/src/app/api/v1/mcp/oauth/authorize/route.ts b/src/app/api/v1/mcp/oauth/authorize/route.ts new file mode 100644 index 0000000..e46cf8a --- /dev/null +++ b/src/app/api/v1/mcp/oauth/authorize/route.ts @@ -0,0 +1,13 @@ +import { authorize, json } from "@/lib/mcp/open-oauth"; + +export const dynamic = "force-dynamic"; + +// No login and no consent page: the MCP endpoint is public, so this only +// completes the handshake connector clients insist on. +export function GET(req: Request) { + const answer = authorize(new URL(req.url).searchParams); + if ("location" in answer) { + return new Response(null, { status: 302, headers: { location: answer.location, "cache-control": "no-store" } }); + } + return json(answer.body, answer.status); +} diff --git a/src/app/api/v1/mcp/oauth/register/route.ts b/src/app/api/v1/mcp/oauth/register/route.ts new file mode 100644 index 0000000..9219078 --- /dev/null +++ b/src/app/api/v1/mcp/oauth/register/route.ts @@ -0,0 +1,16 @@ +import { json, register, preflight } from "@/lib/mcp/open-oauth"; + +export const dynamic = "force-dynamic"; + +// RFC 7591 dynamic client registration; every client is welcome. +export async function POST(req: Request) { + let meta: Record; + try { + meta = (await req.json()) as Record; + } catch { + return json({ error: "invalid_client_metadata" }, 400); + } + const { status, body } = register(meta ?? {}); + return json(body, status); +} +export const OPTIONS = () => preflight(); diff --git a/src/app/api/v1/mcp/oauth/token/route.ts b/src/app/api/v1/mcp/oauth/token/route.ts new file mode 100644 index 0000000..5303ca7 --- /dev/null +++ b/src/app/api/v1/mcp/oauth/token/route.ts @@ -0,0 +1,17 @@ +import { json, token, preflight } from "@/lib/mcp/open-oauth"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + const type = req.headers.get("content-type") ?? ""; + let form: URLSearchParams; + if (type.includes("application/json")) { + const body = (await req.json().catch(() => ({}))) as Record; + form = new URLSearchParams(body); + } else { + form = new URLSearchParams(await req.text()); + } + const { status, body } = await token(form); + return json(body, status); +} +export const OPTIONS = () => preflight(); diff --git a/src/lib/crawl-gateway.ts b/src/lib/crawl-gateway.ts index daf0ef7..948ca71 100644 --- a/src/lib/crawl-gateway.ts +++ b/src/lib/crawl-gateway.ts @@ -80,7 +80,9 @@ export const gateway = createGateway({ // without WWW-Authenticate as "needs sign-in", find no OAuth metadata and // fail with "couldn't register with the sign-in service" (2026-11-11). The // endpoint keeps its own per-caller budget in lib/throttle.ts. - openPaths: ["/llms.txt", "/skill.md", "/api/mcp"], + // /api/v1/mcp/oauth/ is the open sign-in those same clients insist on + // (lib/mcp/open-oauth.ts); /.well-known/ is open by default. + openPaths: ["/llms.txt", "/skill.md", "/api/mcp", "/api/v1/mcp/oauth/"], training: TRAINING, retrieval: RETRIEVAL, denyCidrs: OVH_VPS_RANGES, diff --git a/src/lib/mcp/open-oauth.ts b/src/lib/mcp/open-oauth.ts new file mode 100644 index 0000000..5154e6b --- /dev/null +++ b/src/lib/mcp/open-oauth.ts @@ -0,0 +1,182 @@ +/** + * Open OAuth for the MCP endpoint: a sign-in that lets everyone in. + * + * /api/mcp needs no credential, but claude.ai's connector flow runs OAuth + * discovery and dynamic client registration even for a server marked "no + * auth" (dev2 nginx, 2026-11-11: POST /api/mcp 200, then the three + * .well-known lookups and POST /register, all 404 -> "Couldn't register with + * p0dcasters's sign-in service"). So the site answers every step of that + * dance and grants nothing by it: + * + * register any client, stateless, client_id is random + * authorize no login, no consent page: straight back to redirect_uri with + * a code (https, or http on loopback, only -- never javascript:) + * token honours PKCE S256 when the code carries a challenge, then hands + * out an opaque `open_` token that unlocks nothing + * + * The token is not an OpenAccess token, so bearerPrincipal() resolves it to + * null: the public tools ignore it and the review and profile tools still + * refuse without a real OpenAccess grant. Pure apart from crypto. + */ + +export const ISSUER = "https://p0dcasters.com"; +export const RESOURCE = `${ISSUER}/api/mcp`; +const BASE = `${ISSUER}/api/v1/mcp/oauth`; +const CODE_TTL_MS = 10 * 60 * 1000; +const TOKEN_TTL_S = 30 * 24 * 60 * 60; + +export function protectedResourceMetadata() { + return { + resource: RESOURCE, + authorization_servers: [ISSUER], + bearer_methods_supported: ["header"], + scopes_supported: ["mcp"], + resource_name: "p0dcasters", + resource_documentation: `${ISSUER}/mcp`, + }; +} + +export function authorizationServerMetadata() { + return { + issuer: ISSUER, + authorization_endpoint: `${BASE}/authorize`, + token_endpoint: `${BASE}/token`, + registration_endpoint: `${BASE}/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none", "client_secret_post", "client_secret_basic"], + scopes_supported: ["mcp"], + service_documentation: `${ISSUER}/mcp`, + }; +} + +const b64url = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +const random = (n = 24) => b64url(crypto.getRandomValues(new Uint8Array(n))); +const encode = (o: unknown) => b64url(new TextEncoder().encode(JSON.stringify(o))); +function decode(s: string): T | null { + try { + const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)))) as T; + } catch { + return null; + } +} + +/** https anywhere, or http on loopback (desktop clients). Nothing else. */ +export function safeRedirect(uri: unknown): URL | null { + if (typeof uri !== "string") return null; + let u: URL; + try { + u = new URL(uri); + } catch { + return null; + } + if (u.protocol === "https:") return u; + if (u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname)) return u; + return null; +} + +type Json = Record; +type Answer = { status: number; body: Json }; + +export function register(meta: Json): Answer { + const uris = Array.isArray(meta.redirect_uris) ? meta.redirect_uris : []; + if (uris.length === 0 || !uris.every((u) => safeRedirect(u))) { + return { status: 400, body: { error: "invalid_redirect_uri", error_description: "redirect_uris must be https, or http on localhost" } }; + } + const method = typeof meta.token_endpoint_auth_method === "string" ? meta.token_endpoint_auth_method : "none"; + const body: Json = { + ...meta, + client_id: `open_${random(12)}`, + client_id_issued_at: Math.floor(Date.now() / 1000), + redirect_uris: uris, + token_endpoint_auth_method: method, + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }; + if (method !== "none") { + body.client_secret = random(24); + body.client_secret_expires_at = 0; + } + return { status: 201, body }; +} + +type Code = { ru: string; cc?: string; exp: number }; + +/** The redirect for GET /authorize, or an error when there is nowhere safe to send one. */ +export function authorize(params: URLSearchParams): { location: string } | Answer { + const target = safeRedirect(params.get("redirect_uri")); + if (!target) return { status: 400, body: { error: "invalid_request", error_description: "redirect_uri must be https, or http on localhost" } }; + const state = params.get("state"); + const back = (q: Record) => { + for (const [k, v] of Object.entries(q)) target.searchParams.set(k, v); + if (state) target.searchParams.set("state", state); + return { location: target.toString() }; + }; + if (params.get("response_type") !== "code") return back({ error: "unsupported_response_type" }); + const method = params.get("code_challenge_method") ?? "S256"; + if (params.get("code_challenge") && method !== "S256") return back({ error: "invalid_request", error_description: "S256 only" }); + const code: Code = { ru: params.get("redirect_uri") as string, exp: Date.now() + CODE_TTL_MS }; + const cc = params.get("code_challenge"); + if (cc) code.cc = cc; + return back({ code: `${encode(code)}.${random(8)}`, iss: ISSUER }); +} + +async function s256(verifier: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + return b64url(new Uint8Array(digest)); +} + +function tokens(): Json { + return { + access_token: `open_${random(24)}`, + token_type: "Bearer", + expires_in: TOKEN_TTL_S, + refresh_token: `open_r_${random(24)}`, + scope: "mcp", + }; +} + +export async function token(form: URLSearchParams): Promise { + const grant = form.get("grant_type"); + if (grant === "refresh_token") { + if (!form.get("refresh_token")) return { status: 400, body: { error: "invalid_request" } }; + return { status: 200, body: tokens() }; + } + if (grant !== "authorization_code") return { status: 400, body: { error: "unsupported_grant_type" } }; + const raw = form.get("code") ?? ""; + const code = decode(raw.split(".")[0] ?? ""); + if (!code || typeof code.exp !== "number" || code.exp < Date.now()) { + return { status: 400, body: { error: "invalid_grant", error_description: "unknown or expired code" } }; + } + const ru = form.get("redirect_uri"); + if (ru && ru !== code.ru) return { status: 400, body: { error: "invalid_grant", error_description: "redirect_uri mismatch" } }; + if (code.cc) { + const verifier = form.get("code_verifier"); + if (!verifier || (await s256(verifier)) !== code.cc) { + return { status: 400, body: { error: "invalid_grant", error_description: "PKCE verification failed" } }; + } + } + return { status: 200, body: tokens() }; +} + +export function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "access-control-allow-origin": "*", + "access-control-allow-headers": "content-type, authorization, mcp-protocol-version", + "access-control-allow-methods": "GET, POST, OPTIONS", + }, + }); +} + +/** CORS preflight for the routes above (a 204 may not carry a body). */ +export function preflight(): Response { + const headers = json({}).headers; + return new Response(null, { status: 204, headers }); +} diff --git a/test/open-oauth.test.mjs b/test/open-oauth.test.mjs new file mode 100644 index 0000000..cfbe935 --- /dev/null +++ b/test/open-oauth.test.mjs @@ -0,0 +1,71 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash, randomBytes } from 'node:crypto'; +import { + authorizationServerMetadata, protectedResourceMetadata, register, authorize, token, safeRedirect, RESOURCE, +} from '../src/lib/mcp/open-oauth.ts'; + +const b64url = (b) => Buffer.from(b).toString('base64url'); +const CALLBACK = 'https://claude.ai/api/mcp/auth_callback'; + +test('discovery points the MCP resource at this site as its own authorization server', () => { + const pr = protectedResourceMetadata(); + assert.equal(pr.resource, RESOURCE); + const as = authorizationServerMetadata(); + assert.deepEqual(pr.authorization_servers, [as.issuer]); + assert.ok(as.registration_endpoint.endsWith('/register')); + assert.deepEqual(as.code_challenge_methods_supported, ['S256']); +}); + +test('the full connector handshake: register, authorize without login, PKCE token', async () => { + const reg = register({ redirect_uris: [CALLBACK], client_name: 'claudeai', token_endpoint_auth_method: 'none' }); + assert.equal(reg.status, 201); + assert.match(reg.body.client_id, /^open_/); + assert.equal(reg.body.client_secret, undefined); + + const verifier = b64url(randomBytes(32)); + const challenge = b64url(createHash('sha256').update(verifier).digest()); + const a = authorize(new URLSearchParams({ + response_type: 'code', client_id: reg.body.client_id, redirect_uri: CALLBACK, + code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz', resource: RESOURCE, + })); + assert.ok('location' in a); + const back = new URL(a.location); + assert.equal(back.origin + back.pathname, CALLBACK); + assert.equal(back.searchParams.get('state'), 'xyz'); + const code = back.searchParams.get('code'); + assert.ok(code); + + const t = await token(new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: CALLBACK, code_verifier: verifier, client_id: reg.body.client_id })); + assert.equal(t.status, 200); + assert.match(t.body.access_token, /^open_/); + assert.equal(t.body.token_type, 'Bearer'); + + const bad = await token(new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: CALLBACK, code_verifier: 'wrong' })); + assert.equal(bad.status, 400); + + const r = await token(new URLSearchParams({ grant_type: 'refresh_token', refresh_token: t.body.refresh_token })); + assert.equal(r.status, 200); +}); + +test('a confidential client gets a secret', () => { + const reg = register({ redirect_uris: [CALLBACK], token_endpoint_auth_method: 'client_secret_post' }); + assert.ok(reg.body.client_secret); +}); + +test('redirects only to https, or http on loopback', () => { + assert.ok(safeRedirect('http://localhost:33418/callback')); + assert.ok(safeRedirect('http://127.0.0.1:9/cb')); + assert.equal(safeRedirect('http://evil.example/cb'), null); + assert.equal(safeRedirect('javascript:alert(1)'), null); + assert.equal(register({ redirect_uris: ['javascript:alert(1)'] }).status, 400); + const a = authorize(new URLSearchParams({ response_type: 'code', redirect_uri: 'data:text/html,hi' })); + assert.equal(a.status, 400); +}); + +test('the issued token is not an OpenAccess token, so account tools still refuse', async () => { + const { TOOLS_BY_NAME } = await import('../src/lib/mcp/tools.ts'); + const t = await token(new URLSearchParams({ grant_type: 'refresh_token', refresh_token: 'x' })); + const ctx = { header: (n) => (n === 'authorization' ? `Bearer ${t.body.access_token}` : null) }; + await assert.rejects(() => TOOLS_BY_NAME.get('list_submissions').run({}, ctx), /submissions:review/); +});