Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "p0dcasters",
"version": "0.6.3",
"version": "0.7.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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();
13 changes: 13 additions & 0 deletions src/app/api/v1/mcp/oauth/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
16 changes: 16 additions & 0 deletions src/app/api/v1/mcp/oauth/register/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
try {
meta = (await req.json()) as Record<string, unknown>;
} catch {
return json({ error: "invalid_client_metadata" }, 400);
}
const { status, body } = register(meta ?? {});
return json(body, status);
}
export const OPTIONS = () => preflight();
17 changes: 17 additions & 0 deletions src/app/api/v1/mcp/oauth/token/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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();
4 changes: 3 additions & 1 deletion src/lib/crawl-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
182 changes: 182 additions & 0 deletions src/lib/mcp/open-oauth.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<string, unknown>;
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<string, string>) => {
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<string> {
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<Answer> {
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<Code>(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 });
}
71 changes: 71 additions & 0 deletions test/open-oauth.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading