From 3a89e42977f7d45f7d0390be6e2bb25cb8c645c1 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 15 Jun 2026 11:49:59 +0100 Subject: [PATCH 01/61] Add email-capture auth to the MCP server Add a lightweight email->token authentication gate to the docs MCP server to capture users' work email addresses for lead capture and usage attribution. - New /mcp/register endpoint: users submit a work email and the bearer token is delivered ONLY by email (never in the HTTP response), so possession of a working token proves the address is real and owned. - Mandatory 4-layer validation: format, work-domain filter (reject free/ disposable providers), MX-record check, email delivery. - Tokens stored hashed in Netlify Blobs; auth middleware in mcp.mjs threads the authenticated email/domain to Kapa via _meta.user for attribution. - Bearer header and ?token= query fallback (for clients that can't set headers). - Gated behind REQUIRE_AUTH (grace period -> enforce); per-token rate limiting. - Captured emails -> Netlify Blobs + logs + optional CRM_WEBHOOK_URL forward. - Docs: registration + per-client setup + privacy/consent note; server-card and server.json advertise the token requirement. - 17 unit tests (tests/mcp-auth.test.ts). Co-Authored-By: Claude Opus 4.8 --- .../ROOT/pages/how-to-use-these-docs.adoc | 78 +++- netlify/edge-functions/mcp-server-card.ts | 9 +- netlify/functions/lib/auth.mjs | 168 ++++++++ netlify/functions/lib/email.mjs | 111 +++++ netlify/functions/lib/store.mjs | 65 +++ netlify/functions/mcp-register.mjs | 223 ++++++++++ netlify/functions/mcp.mjs | 87 +++- package-lock.json | 406 ++++++++++++++++++ package.json | 4 +- server.json | 4 +- tests/mcp-auth.test.ts | 146 +++++++ 11 files changed, 1274 insertions(+), 27 deletions(-) create mode 100644 netlify/functions/lib/auth.mjs create mode 100644 netlify/functions/lib/email.mjs create mode 100644 netlify/functions/lib/store.mjs create mode 100644 netlify/functions/mcp-register.mjs create mode 100644 tests/mcp-auth.test.ts diff --git a/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc b/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc index efaf1330..db28ca9f 100644 --- a/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc +++ b/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc @@ -86,16 +86,47 @@ Redpanda provides a remote link:https://modelcontextprotocol.io[Model Context Pr The MCP server is hosted at: `https://docs.redpanda.com/mcp`. You can add this endpoint to any AI agent that supports MCP. +[#authentication] +==== Get an access token + +The MCP server requires a free access token tied to your work email. We use this to understand who relies on the documentation tools and to attribute usage to your organization. + +. Go to https://docs.redpanda.com/mcp/register[`docs.redpanda.com/mcp/register`^]. +. Enter your *work email address*. Free providers (such as Gmail or Outlook) and disposable addresses aren't accepted. +. Check your inbox. We email your token to confirm the address is valid and belongs to you. The token isn't shown in the browser. +. Add the token to your MCP client as shown in the configuration for your tool below. + +Send the token in the `Authorization` header on every request: + +[source,text] +---- +Authorization: Bearer rp_mcp_your_token_here +---- + +For clients that can't set a custom header, append the token to the URL instead: + +[source,text] +---- +https://docs.redpanda.com/mcp?token=rp_mcp_your_token_here +---- + +[NOTE] +==== +*What we collect and why.* When you register, we store your work email address, its domain, and request counts to track usage and attribute it to your organization. This information may be shared with our customer systems and passed to our documentation search provider (Kapa) for usage attribution. We don't store the content of your queries. To revoke your token or request deletion of your data, reply to the token email. +==== + +During the initial rollout, authentication is optional and existing connections continue to work. After a transition period, a token will be required. Register now to avoid interruption. + [tabs] ==== Claude Code:: + -- -Run the following command to add the Redpanda MCP server to Claude Code: +Run the following command to add the Redpanda MCP server to Claude Code, replacing `rp_mcp_your_token_here` with the token from your email: [source,bash] ---- -claude mcp add --scope user --transport http redpanda https://docs.redpanda.com/mcp +claude mcp add --scope user --transport http redpanda https://docs.redpanda.com/mcp --header "Authorization: Bearer rp_mcp_your_token_here" ---- This command: @@ -119,7 +150,7 @@ For more information about Claude Code, see the https://code.claude.com/docs/en/ Cursor:: + -- -Add the following to your `.cursor/mcp.json` file: +Add the following to your `.cursor/mcp.json` file, replacing `rp_mcp_your_token_here` with the token from your email: [source,json] ---- @@ -127,7 +158,10 @@ Add the following to your `.cursor/mcp.json` file: "mcpServers": { "redpanda": { "type": "http", - "url": "https://docs.redpanda.com/mcp" + "url": "https://docs.redpanda.com/mcp", + "headers": { + "Authorization": "Bearer rp_mcp_your_token_here" + } } } } @@ -140,7 +174,7 @@ VS Code:: -- *Prerequisites:* VS Code 1.102+ with GitHub Copilot enabled. -Create an `mcp.json` file in your workspace `.vscode` folder: +Create an `mcp.json` file in your workspace `.vscode` folder, replacing `rp_mcp_your_token_here` with the token from your email: .`.vscode/mcp.json` [source,json] @@ -149,7 +183,10 @@ Create an `mcp.json` file in your workspace `.vscode` folder: "servers": { "redpanda": { "type": "http", - "url": "https://docs.redpanda.com/mcp" + "url": "https://docs.redpanda.com/mcp", + "headers": { + "Authorization": "Bearer rp_mcp_your_token_here" + } } } } @@ -182,8 +219,9 @@ ChatGPT Desktop supports MCP servers in developer mode. To enable: . Click *Add Server* and enter: + - *Name*: `redpanda` -- *URL*: `https://docs.redpanda.com/mcp` +- *URL*: `https://docs.redpanda.com/mcp?token=rp_mcp_your_token_here` +TIP: ChatGPT Desktop doesn't let you set a custom `Authorization` header, so append your token to the URL using `?token=` as shown above. Replace `rp_mcp_your_token_here` with the token from your email. For more information, see the https://platform.openai.com/docs/guides/developer-mode[ChatGPT Desktop MCP documentation^]. -- @@ -199,8 +237,7 @@ This method is available for Pro, Max, Team, or Enterprise plans and works acros . Open Claude Desktop. . Navigate to Settings > Connectors. . Click *Add custom connector*. -. Enter the URL: `https://docs.redpanda.com/mcp` -. Follow any authentication prompts if required. +. Enter the URL with your token appended: `https://docs.redpanda.com/mcp?token=rp_mcp_your_token_here`. The Connectors interface doesn't expose a custom header field, so append the token to the URL using `?token=`. NOTE: When using Connectors, Claude connects to your remote MCP server from Anthropic's cloud infrastructure. @@ -221,13 +258,13 @@ Add the following configuration to your `claude_desktop_config.json` file: "mcpServers": { "redpanda": { "command": "npx", - "args": ["-y", "mcp-remote", "https://docs.redpanda.com/mcp"] + "args": ["-y", "mcp-remote", "https://docs.redpanda.com/mcp", "--header", "Authorization: Bearer rp_mcp_your_token_here"] } } } ---- -This configuration uses the `mcp-remote` bridge to connect to Redpanda's remote MCP server. Claude Desktop supports only `stdio` and `sse` transports, so `mcp-remote` converts the HTTP endpoint to a compatible format. +Replace `rp_mcp_your_token_here` with the token from your email. This configuration uses the `mcp-remote` bridge to connect to Redpanda's remote MCP server. Claude Desktop supports only `stdio` and `sse` transports, so `mcp-remote` converts the HTTP endpoint to a compatible format. Restart Claude Desktop for changes to take effect. @@ -239,14 +276,14 @@ For more details, see the https://support.anthropic.com/en/articles/9487310-desk ==== Other AI tools -Any tool that supports MCP servers can connect using the following URL: +Any tool that supports MCP servers can connect using the following URL. Send your token in the `Authorization` header, or append it to the URL with `?token=` if the tool can't set headers: [source,text] ---- https://docs.redpanda.com/mcp ---- -NOTE: MCP support varies by tool and version. Check the specific tool's documentation for MCP setup instructions. +NOTE: MCP support varies by tool and version. Check the specific tool's documentation for MCP setup instructions. See <> for how to get a token. ==== What you can do @@ -261,11 +298,13 @@ Once connected, you can ask context-aware questions about Redpanda from within y ==== Usage limits -To ensure fair use and performance for all users, the public MCP endpoint enforces the following rate limits per user: +To ensure fair use and performance for all users, the MCP endpoint enforces the following rate limits: * *60 requests per 15 minutes* -As well as this global limit, the `ask_redpanda_question` tool proxies to an MCP server hosted by Kapa.ai, which enforces its own limits. See the link:https://docs.kapa.ai/integrations/mcp/overview#authentication[Kapa documentation^] for details. +When you authenticate with a token, this limit is applied per token. Unauthenticated requests (during the rollout period) are limited per IP address. + +As well as this limit, the `ask_redpanda_question` tool proxies to an MCP server hosted by Kapa.ai, which enforces its own limits. See the link:https://docs.kapa.ai/integrations/mcp/overview#authentication[Kapa documentation^] for details. These limits are suitable for: @@ -286,6 +325,15 @@ RateLimit-Reset: ==== Troubleshooting +===== Authentication required (HTTP 401) + +If you receive an `HTTP 401` response with an `authentication_required` error: + +* Make sure you've registered for a token at https://docs.redpanda.com/mcp/register[`docs.redpanda.com/mcp/register`^] and used the token from the confirmation email. +* Verify the token is sent as `Authorization: Bearer rp_mcp_...`, or appended to the URL as `?token=rp_mcp_...` for clients that can't set headers. +* Tokens are tied to a verified work email. If you registered with a free or disposable address, the registration is rejected. Use your work email. +* If your token was revoked, register again for a new one. + ===== Server not connecting * Verify the URL is exactly: `https://docs.redpanda.com/mcp`. diff --git a/netlify/edge-functions/mcp-server-card.ts b/netlify/edge-functions/mcp-server-card.ts index c191fbca..ac42bb02 100644 --- a/netlify/edge-functions/mcp-server-card.ts +++ b/netlify/edge-functions/mcp-server-card.ts @@ -7,7 +7,7 @@ export default async (request: Request) => { $schema: "https://modelcontextprotocol.io/schemas/server-card.json", serverInfo: { name: "redpanda-doc-tools-assistant", - version: "1.0.0", + version: "1.2.0", description: "MCP server for searching Redpanda documentation and querying API references" }, transport: { @@ -19,9 +19,16 @@ export default async (request: Request) => { tools: true, prompts: false }, + authentication: { + type: "bearer", + required: false, + registration_url: "https://docs.redpanda.com/mcp/register", + description: "Register a free token with your work email, then send Authorization: Bearer (or ?token= for clients that can't set headers)." + }, metadata: { homepage: `${siteUrl}`, documentation: `${siteUrl}/current/home/`, + authDocumentation: `${siteUrl}/data-platform/how-to-use-these-docs#authentication`, repository: "https://github.com/redpanda-data/docs-site", support: "https://support.redpanda.com", tags: ["documentation", "redpanda", "kafka", "streaming", "agentic data plane"] diff --git a/netlify/functions/lib/auth.mjs b/netlify/functions/lib/auth.mjs new file mode 100644 index 00000000..aaf85ba8 --- /dev/null +++ b/netlify/functions/lib/auth.mjs @@ -0,0 +1,168 @@ +// Auth core for the Redpanda Docs MCP server. +// ------------------------------------------- +// Pure, dependency-light helpers so they can be unit-tested without the Netlify +// runtime (no Netlify Blobs, no DNS, no email here). All side-effecting code +// lives in store.mjs (Blobs) and email.mjs (DNS + provider). + +import { createHash, randomBytes } from 'node:crypto' + +// -------------------- Email classification -------------------- + +// Free consumer providers. We want *work* emails, so these are rejected. +export const FREE_EMAIL_DOMAINS = new Set([ + 'gmail.com', + 'googlemail.com', + 'yahoo.com', + 'yahoo.co.uk', + 'yahoo.co.in', + 'ymail.com', + 'outlook.com', + 'hotmail.com', + 'hotmail.co.uk', + 'live.com', + 'msn.com', + 'icloud.com', + 'me.com', + 'mac.com', + 'proton.me', + 'protonmail.com', + 'pm.me', + 'aol.com', + 'gmx.com', + 'gmx.net', + 'yandex.com', + 'yandex.ru', + 'mail.com', + 'zoho.com', + 'qq.com', + '163.com', + '126.com', +]) + +// Disposable / throwaway providers. Best-effort seed list, not exhaustive. +export const DISPOSABLE_DOMAINS = new Set([ + 'mailinator.com', + '10minutemail.com', + 'guerrillamail.com', + 'guerrillamail.info', + 'sharklasers.com', + 'getnada.com', + 'nada.email', + 'yopmail.com', + 'trashmail.com', + 'tempmail.com', + 'temp-mail.org', + 'throwawaymail.com', + 'maildrop.cc', + 'dispostable.com', + 'fakeinbox.com', + 'mintemail.com', + 'mohmal.com', +]) + +const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +// Layer 1: format. Returns { email, domain } or throws a typed error. +export function normalizeEmail(raw) { + const email = String(raw || '').trim().toLowerCase() + if (!email || !EMAIL_SHAPE.test(email)) { + const err = new Error('invalid email format') + err.code = 'invalid_format' + throw err + } + const domain = email.slice(email.lastIndexOf('@') + 1) + return { email, domain } +} + +// Layer 2: work-domain filter. Returns { ok, reason? }. +export function isWorkEmail(domain) { + const d = String(domain || '').trim().toLowerCase() + if (!d) return { ok: false, reason: 'invalid_format' } + if (FREE_EMAIL_DOMAINS.has(d)) return { ok: false, reason: 'free_provider' } + if (DISPOSABLE_DOMAINS.has(d)) return { ok: false, reason: 'disposable' } + return { ok: true } +} + +// -------------------- Tokens -------------------- + +const TOKEN_PREFIX = 'rp_mcp_' + +// Opaque, high-entropy bearer token. The prefix makes tokens greppable in logs +// and configs and lets us fast-reject obviously malformed values. +export function generateToken() { + return TOKEN_PREFIX + randomBytes(32).toString('base64url') +} + +export function looksLikeToken(token) { + return typeof token === 'string' && token.startsWith(TOKEN_PREFIX) && token.length > TOKEN_PREFIX.length + 20 +} + +// We store only the hash. Tokens are 256-bit random, so a plain SHA-256 is +// sufficient (no salt/KDF needed — they aren't guessable like passwords). +export function hashToken(token) { + return createHash('sha256').update(String(token)).digest('hex') +} + +// -------------------- Request parsing -------------------- + +// Extract a bearer token from the Authorization header, falling back to a +// ?token= / ?key= query param for MCP clients that can't set static headers +// (e.g. some ChatGPT/Connectors configurations). +export function extractBearerToken(authHeader, urlQueryToken) { + const header = String(authHeader || '') + const match = header.match(/^\s*Bearer\s+(.+)\s*$/i) + if (match) return match[1].trim() + if (urlQueryToken) return String(urlQueryToken).trim() + return null +} + +// -------------------- Responses -------------------- + +const DOCS_URL = 'https://docs.redpanda.com/data-platform/how-to-use-these-docs#authentication' + +// Framework-agnostic 401 ({ status, headers, body }) so it can be returned from +// either the Hono layer or a raw handler, and asserted in unit tests. +export function buildUnauthorizedResponse({ registrationUrl, reason } = {}) { + const regUrl = registrationUrl || 'https://docs.redpanda.com/mcp/register' + const description = + 'Register a free token with your work email, then send Authorization: Bearer .' + return { + status: 401, + headers: { + 'WWW-Authenticate': `Bearer realm="redpanda-docs-mcp", error="${reason || 'invalid_token'}", error_description="${description}", resource_metadata="${regUrl}"`, + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + body: { + error: 'authentication_required', + message: `${description} Get one at ${regUrl}`, + registration_url: regUrl, + docs_url: DOCS_URL, + }, + } +} + +// -------------------- Enforcement -------------------- + +// Auth is enforced only when REQUIRE_AUTH is exactly 'true'. Anything else +// (unset, 'false', etc.) is the grace period: requests pass through. +export function isAuthEnforced() { + return process.env.REQUIRE_AUTH === 'true' +} + +// Pure decision helper so the middleware's branching logic is unit-testable +// without Hono or Blobs. `record` is the stored token record (or null). +export function decideAuth({ record, enforced }) { + if (record && !record.revoked) { + return { + allow: true, + userContext: { email: record.email, domain: record.domain }, + unauthorized: null, + } + } + if (enforced) { + return { allow: false, userContext: null, unauthorized: buildUnauthorizedResponse({}) } + } + // Grace period: allow through with no attribution. + return { allow: true, userContext: null, unauthorized: null } +} diff --git a/netlify/functions/lib/email.mjs b/netlify/functions/lib/email.mjs new file mode 100644 index 00000000..b6786d95 --- /dev/null +++ b/netlify/functions/lib/email.mjs @@ -0,0 +1,111 @@ +// Email side effects for MCP registration: MX validation + token delivery. +// ------------------------------------------------------------------------ +// Impure (DNS + network). Kept separate from auth.mjs so the pure logic stays +// unit-testable; these functions are mocked in tests. + +import { promises as dns } from 'node:dns' + +const MX_TIMEOUT_MS = 4_000 + +function withTimeout(promise, ms, label) { + let t + const timeout = new Promise((_, reject) => { + t = setTimeout(() => reject(new Error(`${label}_timeout`)), ms) + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(t)) +} + +// Layer 3: reject domains that can't receive mail (typos, fake domains) before +// we attempt to send. Accept if the domain has MX records, or (fallback) an A +// record. Any lookup failure is treated as invalid. +export async function hasValidMx(domain) { + const d = String(domain || '').trim().toLowerCase() + if (!d) return false + try { + const mx = await withTimeout(dns.resolveMx(d), MX_TIMEOUT_MS, 'mx') + if (Array.isArray(mx) && mx.length > 0) return true + } catch { + // fall through to A-record fallback + } + try { + const a = await withTimeout(dns.resolve(d), MX_TIMEOUT_MS, 'a') + return Array.isArray(a) && a.length > 0 + } catch { + return false + } +} + +const RESEND_API_KEY = process.env.RESEND_API_KEY +const FROM_EMAIL = process.env.MCP_FROM_EMAIL || 'Redpanda Docs ' + +function tokenEmailBody(token) { + const text = `Thanks for registering for the Redpanda Docs MCP server. + +Your access token: + + ${token} + +Add it to your MCP client as an Authorization header: + + Authorization: Bearer ${token} + +Or, for clients that can't set headers, append it to the URL: + + https://docs.redpanda.com/mcp?token=${token} + +Setup instructions: https://docs.redpanda.com/data-platform/how-to-use-these-docs#authentication + +Keep this token private. If you need it revoked, reply to this email.` + + const html = `

Thanks for registering for the Redpanda Docs MCP server.

+

Your access token:

+
${token}
+

Add it to your MCP client as an Authorization header:

+
Authorization: Bearer ${token}
+

Or, for clients that can't set headers, append it to the URL:

+
https://docs.redpanda.com/mcp?token=${token}
+

Setup instructions

+

Keep this token private. If you need it revoked, reply to this email.

` + + return { text, html } +} + +// Layer 4: deliver the token to the address. Possession of a working token is +// the proof the email is real and owned, so the token is NEVER returned in the +// HTTP response — only here. +// +// Dev bypass: outside production with no RESEND_API_KEY, log the token to the +// console so `netlify dev` works without a provider. In production a missing +// key is a hard error. +export async function sendTokenEmail({ to, token }) { + if (!RESEND_API_KEY) { + if (process.env.NODE_ENV === 'production') { + throw new Error('RESEND_API_KEY is required in production to deliver MCP tokens') + } + console.log(`[mcp-register][dev-bypass] token for ${to}: ${token}`) + return { ok: true, devBypass: true } + } + + const { text, html } = tokenEmailBody(token) + const res = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${RESEND_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: FROM_EMAIL, + to: [to], + subject: 'Your Redpanda Docs MCP access token', + text, + html, + }), + }) + + if (!res.ok) { + const detail = await res.text().catch(() => '') + console.error('[mcp-register] Resend send failed', { status: res.status, detail: detail.slice(0, 300) }) + return { ok: false, status: res.status } + } + return { ok: true } +} diff --git a/netlify/functions/lib/store.mjs b/netlify/functions/lib/store.mjs new file mode 100644 index 00000000..eb573de9 --- /dev/null +++ b/netlify/functions/lib/store.mjs @@ -0,0 +1,65 @@ +// Token storage for the MCP server, backed by Netlify Blobs. +// ----------------------------------------------------------- +// Sole consumer of @netlify/blobs so the rest of the auth code stays runtime- +// agnostic and unit-testable. Records are keyed by the SHA-256 hash of the +// token (see auth.mjs:hashToken) so a store leak never exposes usable tokens. + +import { getStore as netlifyGetStore } from '@netlify/blobs' + +const STORE_NAME = 'mcp-tokens' + +// Only persist lastUsedAt/requestCount roughly every N requests to avoid a +// Blobs write on every single query (writes are best-effort and off the hot path). +const TOUCH_EVERY = 10 + +function store() { + return netlifyGetStore(STORE_NAME) +} + +export async function saveRegistration({ tokenHash, email, domain }) { + const record = { + email, + domain, + createdAt: new Date().toISOString(), + lastUsedAt: null, + requestCount: 0, + revoked: false, + } + await store().setJSON(tokenHash, record) + return record +} + +export async function lookupToken(tokenHash) { + if (!tokenHash) return null + return store().get(tokenHash, { type: 'json' }) +} + +// Fire-and-forget usage bump. Never awaited on the hot path, never throws. +// Throttled so we don't write on every request. +export function touchToken(tokenHash, record) { + try { + const count = (record?.requestCount || 0) + 1 + // Always update the in-memory count for the throttle check, but only persist + // periodically (and on the very first use). + if (count === 1 || count % TOUCH_EVERY === 0) { + const updated = { + ...record, + lastUsedAt: new Date().toISOString(), + requestCount: count, + } + // Intentionally not awaited. + store().setJSON(tokenHash, updated).catch((e) => { + console.warn('[store] touchToken write failed', { error: e?.message }) + }) + } + } catch (e) { + console.warn('[store] touchToken error', { error: e?.message }) + } +} + +export async function revokeToken(tokenHash) { + const record = await lookupToken(tokenHash) + if (!record) return false + await store().setJSON(tokenHash, { ...record, revoked: true, revokedAt: new Date().toISOString() }) + return true +} diff --git a/netlify/functions/mcp-register.mjs b/netlify/functions/mcp-register.mjs new file mode 100644 index 00000000..bd3a2f60 --- /dev/null +++ b/netlify/functions/mcp-register.mjs @@ -0,0 +1,223 @@ +// Self-service registration for the Redpanda Docs MCP server. +// ----------------------------------------------------------- +// A user submits their work email; after validation we generate a bearer token, +// store it (hashed), and DELIVER IT BY EMAIL ONLY. Receiving the token is the +// proof the address is real and owned, which is what prevents fake/non-emails. +// The token is never returned in the HTTP response body. + +import { normalizeEmail, isWorkEmail, generateToken, hashToken } from './lib/auth.mjs' +import { hasValidMx, sendTokenEmail } from './lib/email.mjs' +import { saveRegistration } from './lib/store.mjs' + +const REGISTRATION_URL = 'https://docs.redpanda.com/mcp/register' +const DOCS_URL = 'https://docs.redpanda.com/data-platform/how-to-use-these-docs#authentication' + +// -------------------- Lightweight per-IP rate limit -------------------- +// Best-effort, in-memory (resets on cold start). Blunts scripted abuse without +// a dependency. Not a security control. +const WINDOW_MS = 15 * 60 * 1000 +const MAX_PER_WINDOW = Number(process.env.MCP_REGISTER_RATE_LIMIT || 5) +const hits = new Map() + +function clientIp(request) { + return ( + request.headers.get('x-nf-client-connection-ip') || + request.headers.get('cf-connecting-ip') || + (request.headers.get('x-forwarded-for') || '').split(',')[0]?.trim() || + request.headers.get('x-real-ip') || + 'unknown' + ) +} + +function rateLimited(ip) { + const now = Date.now() + const entry = hits.get(ip) + if (!entry || now - entry.start > WINDOW_MS) { + hits.set(ip, { start: now, count: 1 }) + return false + } + entry.count += 1 + return entry.count > MAX_PER_WINDOW +} + +const CORS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Accept', +} + +function json(body, status = 200, extraHeaders = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...CORS, ...extraHeaders }, + }) +} + +const REASON_MESSAGES = { + invalid_format: 'That doesn\'t look like a valid email address.', + free_provider: 'Please use your work email. Free providers (Gmail, Outlook, etc.) aren\'t accepted.', + disposable: 'Disposable email addresses aren\'t accepted. Please use your work email.', + no_mx: 'We couldn\'t verify that email domain can receive mail. Check for typos.', +} + +function formPage() { + return ` + + + + +Redpanda Docs MCP — Get an access token + + + +

Get a Redpanda Docs MCP token

+

Enter your work email to receive a free access token for the documentation MCP server. We'll email the token to you.

+
+ + +
+
+

+ We collect your work email address, its domain, and request counts to track usage and + attribute it to your organization. This may be shared with our CRM and passed to our + documentation search provider (Kapa) for usage attribution. We don't store the content of + your queries. To revoke your token or delete your data, reply to the token email. +

+ + +` +} + +async function parseEmail(request) { + const ct = request.headers.get('content-type') || '' + if (ct.includes('application/json')) { + const body = await request.json().catch(() => ({})) + return body?.email + } + if (ct.includes('application/x-www-form-urlencoded') || ct.includes('multipart/form-data')) { + const form = await request.formData().catch(() => null) + return form?.get('email') + } + // Fallback: try JSON anyway. + const body = await request.json().catch(() => ({})) + return body?.email +} + +export default async (request) => { + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: { ...CORS, 'Access-Control-Max-Age': '86400' } }) + } + + if (request.method === 'GET') { + const url = new URL(request.url) + if (url.searchParams.get('format') === 'json') { + return json({ + endpoint: REGISTRATION_URL, + method: 'POST', + body: { email: 'you@yourcompany.com' }, + note: 'On success, the token is emailed to you (202). It is never returned in the response.', + docs: DOCS_URL, + }) + } + return new Response(formPage(), { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...CORS }, + }) + } + + if (request.method !== 'POST') { + return json({ error: 'method_not_allowed' }, 405, { Allow: 'GET, POST, OPTIONS' }) + } + + const ip = clientIp(request) + if (rateLimited(ip)) { + return json({ error: 'rate_limited', message: 'Too many registration attempts. Try again later.' }, 429) + } + + // --- Validation pipeline (stop at first failure) --- + let email, domain + try { + ;({ email, domain } = normalizeEmail(await parseEmail(request))) // Layer 1: format + } catch { + return json({ error: 'invalid_email', reason: 'invalid_format', message: REASON_MESSAGES.invalid_format }, 400) + } + + const work = isWorkEmail(domain) // Layer 2: work-domain + if (!work.ok) { + return json({ error: 'invalid_email', reason: work.reason, message: REASON_MESSAGES[work.reason] }, 400) + } + + if (!(await hasValidMx(domain))) { // Layer 3: MX + return json({ error: 'invalid_email', reason: 'no_mx', message: REASON_MESSAGES.no_mx }, 400) + } + + // --- Issue + store token --- + const token = generateToken() + const tokenHash = hashToken(token) + try { + await saveRegistration({ tokenHash, email, domain }) + } catch (e) { + console.error('[mcp-register] store write failed', { error: e?.message }) + return json({ error: 'storage_error', message: 'Could not create your token. Please try again.' }, 500) + } + + // --- Layer 4: deliver token by email (ownership proof). Token NOT in response. --- + let sent + try { + sent = await sendTokenEmail({ to: email, token }) + } catch (e) { + console.error('[mcp-register] email send error', { error: e?.message, domain }) + return json({ error: 'email_send_failed', message: 'Could not send the token email. Please try again.' }, 502) + } + if (!sent?.ok) { + return json({ error: 'email_send_failed', message: 'Could not send the token email. Please try again.' }, 502) + } + + // --- Lead-capture side effects (best-effort; never block the response) --- + const emailHash = tokenHash.slice(0, 12) // non-reversible reference for logs + console.log(JSON.stringify({ event: 'mcp_registration', domain, emailHash, ts: new Date().toISOString() })) + + if (process.env.CRM_WEBHOOK_URL) { + fetch(process.env.CRM_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, domain, source: 'mcp', timestamp: new Date().toISOString() }), + }).catch((e) => console.warn('[mcp-register] CRM webhook failed', { error: e?.message })) + } + + return json( + { status: 'token_sent', email, message: `Token sent to ${email}. Check your inbox (and spam).` }, + 202 + ) +} + +export const config = { + path: '/mcp/register', + preferStatic: false, +} diff --git a/netlify/functions/mcp.mjs b/netlify/functions/mcp.mjs index 3d98ec10..6c54d85b 100644 --- a/netlify/functions/mcp.mjs +++ b/netlify/functions/mcp.mjs @@ -17,6 +17,9 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { z } from 'zod' import handle from '@modelfetch/netlify' +import { extractBearerToken, hashToken, decideAuth, isAuthEnforced } from './lib/auth.mjs' +import { lookupToken, touchToken } from './lib/store.mjs' + import rateLimiterModule from 'hono-rate-limiter' const makeRateLimiter = rateLimiterModule.rateLimiter || @@ -25,7 +28,7 @@ const makeRateLimiter = // -------------------- Config -------------------- -const SERVER_VERSION = '1.1.2' +const SERVER_VERSION = '1.2.0' // Hardcoded upstream const KAPA_MCP_SERVER_URL = 'https://redpanda.mcp.kapa.ai' @@ -69,6 +72,10 @@ function withTimeout(promise, ms, label) { const computeLimiterKey = (c) => { const h = (name) => c.req.header(name) || '' + // Authenticated requests get per-token limits (set by the auth middleware). + const auth = c.get('auth') + if (auth?.tokenHash) return `tok:${auth.tokenHash}` + const clientKey = h('x-client-key') if (clientKey) return `ck:${clientKey}` @@ -144,12 +151,22 @@ function ensureKapaConnected() { return kapaConnectPromise } -// Kapa Hosted MCP search tool only accepts `query` -function callKapaSearch(query) { - return kapaClient.callTool({ +// Kapa Hosted MCP search tool accepts `query`. When we have an authenticated +// user we also attach `_meta.user` for Kapa-side usage attribution. +function callKapaSearch(query, user = null) { + const toolCall = { name: KAPA_TOOL_NAME, arguments: { query }, - }) + } + if (user?.email) { + toolCall._meta = { + user: { + email: user.email, + company_name: user.domain || undefined, + }, + } + } + return kapaClient.callTool(toolCall) } // -------------------- Bump.sh API Docs MCP client -------------------- @@ -224,9 +241,12 @@ server.registerTool( top_k: z.number().optional(), }, }, - async (args) => { + async (args, extra) => { const start = Date.now() + // Authenticated user context, attached by the auth middleware via c.set('auth'). + const user = extra?.authInfo || null + const q = String(args?.question || '').trim() if (!q) { return { @@ -264,7 +284,7 @@ server.registerTool( ) return await withTimeout( - callKapaSearch(q), + callKapaSearch(q, user), CALL_TIMEOUT_MS, 'kapa_callTool' ) @@ -276,6 +296,15 @@ server.registerTool( upstream: 'kapa-mcp', }) + // If Kapa rejected the request because of our user metadata, retry once + // without it (attribution is best-effort; never block the answer). + if (/_meta|metadata/i.test(msg) && user) { + try { + return await withTimeout(callKapaSearch(q, null), CALL_TIMEOUT_MS, 'kapa_callTool_no_meta') + } catch { + // fall through to the normal transient-retry path below + } + } if (isTransientError(msg)) { // retry once @@ -287,7 +316,7 @@ server.registerTool( 'kapa_reconnect' ) return await withTimeout( - callKapaSearch(q), + callKapaSearch(q, user), CALL_TIMEOUT_MS, 'kapa_callTool_retry' ) @@ -613,6 +642,48 @@ const baseHandler = handle({ legacyHeaders: true, // also send X-RateLimit-* headers }) + // Auth middleware. Runs BEFORE the limiter so authenticated requests can be + // keyed per-token. Never gates OPTIONS or the GET/SSE stream. When auth is + // not enforced (grace period) unauthenticated requests pass through; when + // enforced they get a 401 pointing to the registration page. + app.use('/mcp', async (c, next) => { + const method = c.req.method + if (method === 'OPTIONS' || method === 'GET') return next() + + const raw = extractBearerToken( + c.req.header('authorization'), + new URL(c.req.url).searchParams.get('token') + ) + + let record = null + let tokenHash = null + if (raw) { + tokenHash = hashToken(raw) + record = await lookupToken(tokenHash).catch((e) => { + console.warn('[auth] token lookup failed', { error: e?.message }) + return null + }) + } + + const { allow, userContext, unauthorized } = decideAuth({ record, enforced: isAuthEnforced() }) + + if (userContext) { + c.set('auth', { ...userContext, tokenHash }) + touchToken(tokenHash, record) // fire-and-forget + } else if (!allow && unauthorized) { + const regUrl = new URL('/mcp/register', c.req.url).toString() + const u = { ...unauthorized } + u.headers = { ...u.headers, 'WWW-Authenticate': u.headers['WWW-Authenticate'].replace(/resource_metadata="[^"]*"/, `resource_metadata="${regUrl}"`) } + u.body = { ...u.body, registration_url: regUrl, message: `Register a free token with your work email at ${regUrl}, then send Authorization: Bearer .` } + return c.json(u.body, u.status, u.headers) + } else { + // Grace period, unauthenticated: log for adoption tracking. + console.log(JSON.stringify({ event: 'mcp_unauthenticated', enforced: false, ua: c.req.header('user-agent') || '' })) + } + + return next() + }) + app.use('/mcp', async (c, next) => { const method = c.req.method if (method === 'GET') { diff --git a/package-lock.json b/package-lock.json index 30e00c14..58299089 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@asciidoctor/tabs": "^1.0.0-beta.5", "@modelcontextprotocol/sdk": "1.17.0", "@modelfetch/netlify": "0.15.2", + "@netlify/blobs": "^9.1.6", "@redpanda-data/docs-extensions-and-macros": "^5.0.0", "@sntke/antora-mermaid-extension": "^0.0.6", "algoliasearch": "^5.35.0", @@ -1022,6 +1023,19 @@ "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", "license": "MIT" }, + "node_modules/@envelop/instrumentation": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz", + "integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -1056,6 +1070,12 @@ "npm": ">=6.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@hono/mcp": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/@hono/mcp/-/mcp-0.1.5.tgz", @@ -1219,6 +1239,118 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@netlify/blobs": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-9.1.6.tgz", + "integrity": "sha512-RR3t5fv7CkGVJiylOTLf/YaHPyrcWmhHW3zX3EK/9UQsnTi8jPxP7B2nyjgRjAx5S4YTzJQP+FmbQlGKdogALQ==", + "license": "MIT", + "dependencies": { + "@netlify/dev-utils": "3.2.0", + "@netlify/runtime-utils": "2.1.0" + }, + "engines": { + "node": "^14.16.0 || >=16.0.0" + } + }, + "node_modules/@netlify/dev-utils": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-3.2.0.tgz", + "integrity": "sha512-5QPVts2j7RHMNVPVB7E28TC564TarS2JDTfMzKGzCrAY35bvOcfJ60Hhp8DOVjI13+BJgN37srUJP4OBDIXCfg==", + "license": "MIT", + "dependencies": { + "@whatwg-node/server": "^0.10.0", + "ansis": "^4.1.0", + "chokidar": "^4.0.1", + "decache": "^4.6.2", + "dot-prop": "9.0.0", + "env-paths": "^3.0.0", + "find-up": "7.0.0", + "image-size": "^2.0.2", + "js-image-generator": "^1.0.4", + "lodash.debounce": "^4.0.8", + "parse-gitignore": "^2.0.0", + "uuid": "^11.1.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || >=20" + } + }, + "node_modules/@netlify/dev-utils/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@netlify/dev-utils/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@netlify/dev-utils/node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@netlify/dev-utils/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@netlify/dev-utils/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@netlify/runtime-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.1.0.tgz", + "integrity": "sha512-z1h+wjB7IVYUsFZsuIYyNxiw5WWuylseY+eXaUDHBxNeLTlqziy+lz03QkR67CUR4Y790xGIhaHV00aOR2KAtw==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || >=20" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -3153,6 +3285,75 @@ "node": ">=18.0.0" } }, + "node_modules/@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/fetch": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.8.3", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/node-fetch": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz", + "integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@whatwg-node/server": { + "version": "0.10.18", + "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.10.18.tgz", + "integrity": "sha512-kMwLlxUbduttIgaPdSkmEarFpP+mSY8FEm+QWMBRJwxOHWkri+cxd8KZHO9EMrB9vgUuz+5WEaCawaL5wGVoXg==", + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -3403,6 +3604,15 @@ "node": ">=0.10.0" } }, + "node_modules/ansis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", + "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -4358,6 +4568,14 @@ "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", "license": "MIT" }, + "node_modules/callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==", + "engines": { + "node": "*" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5333,6 +5551,15 @@ } } }, + "node_modules/decache": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/decache/-/decache-4.6.2.tgz", + "integrity": "sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==", + "license": "MIT", + "dependencies": { + "callsite": "^1.0.0" + } + }, "node_modules/decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -5657,6 +5884,21 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -8706,6 +8948,18 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8722,6 +8976,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -9277,6 +9540,21 @@ "node": ">=10" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, + "node_modules/js-image-generator": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/js-image-generator/-/js-image-generator-1.0.4.tgz", + "integrity": "sha512-ckb7kyVojGAnArouVR+5lBIuwU1fcrn7E/YYSd0FK7oIngAkMmRvHASLro9Zt5SQdWToaI66NybG+OGxPw/HlQ==", + "license": "ISC", + "dependencies": { + "jpeg-js": "^0.4.2" + } + }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -10245,6 +10523,21 @@ "node": ">=0.10.0" } }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -10264,6 +10557,12 @@ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -11612,6 +11911,36 @@ "integrity": "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==", "license": "MIT" }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pa11y": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/pa11y/-/pa11y-9.1.1.tgz", @@ -11895,6 +12224,15 @@ "node": ">=0.8" } }, + "node_modules/parse-gitignore": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-gitignore/-/parse-gitignore-2.0.0.tgz", + "integrity": "sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -15335,6 +15673,18 @@ "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", "license": "ISC" }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -15503,6 +15853,18 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/union-value": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", @@ -15657,6 +16019,12 @@ "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", "license": "BSD" }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "license": "MIT" + }, "node_modules/use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -15690,6 +16058,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8flags": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz", @@ -16235,6 +16616,19 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", @@ -16435,6 +16829,18 @@ "node": ">= 4.0.0" } }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.22.4", "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", diff --git a/package.json b/package.json index ffbb3b93..3843b1cf 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "afdocs:verbose": "vitest run agent-docs.test.ts --reporter=verbose", "afdocs:cli": "afdocs check \"${DEPLOY_URL:-https://docs.redpanda.com}\" -v", "test:redirects": "vitest run tests/redirects.test.ts", - "test:redirects:verbose": "vitest run tests/redirects.test.ts --reporter=verbose" + "test:redirects:verbose": "vitest run tests/redirects.test.ts --reporter=verbose", + "test:mcp": "vitest run tests/mcp-auth.test.ts" }, "dependencies": { "@antora/cli": "^3.1.14", @@ -19,6 +20,7 @@ "@asciidoctor/tabs": "^1.0.0-beta.5", "@modelcontextprotocol/sdk": "1.17.0", "@modelfetch/netlify": "0.15.2", + "@netlify/blobs": "^9.1.6", "@redpanda-data/docs-extensions-and-macros": "^5.0.0", "@sntke/antora-mermaid-extension": "^0.0.6", "algoliasearch": "^5.35.0", diff --git a/server.json b/server.json index d68f3c62..c36813f6 100644 --- a/server.json +++ b/server.json @@ -1,13 +1,13 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", "name": "com.redpanda/docs-mcp", - "description": "Get authoritative answers about Redpanda documentation and search API references.", + "description": "Get authoritative answers about Redpanda documentation and search API references. Requires a free access token: register your work email at https://docs.redpanda.com/mcp/register and send it as Authorization: Bearer .", "repository": { "url": "https://github.com/redpanda-data/docs-site", "source": "github", "subfolder": "netlify" }, - "version": "2026.06.13+pr180-328acad", + "version": "2026.06.15+auth", "remotes": [ { "type": "streamable-http", diff --git a/tests/mcp-auth.test.ts b/tests/mcp-auth.test.ts new file mode 100644 index 00000000..6372da75 --- /dev/null +++ b/tests/mcp-auth.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi } from 'vitest' +import { + normalizeEmail, + isWorkEmail, + generateToken, + looksLikeToken, + hashToken, + extractBearerToken, + buildUnauthorizedResponse, + isAuthEnforced, + decideAuth, +} from '../netlify/functions/lib/auth.mjs' + +describe('normalizeEmail (Layer 1: format)', () => { + it('lowercases and trims, returns domain', () => { + expect(normalizeEmail(' Jake@Redpanda.COM ')).toEqual({ email: 'jake@redpanda.com', domain: 'redpanda.com' }) + }) + it('throws on malformed input', () => { + for (const bad of ['', 'nope', 'a@b', 'no domain.com', '@redpanda.com', 'jake@']) { + expect(() => normalizeEmail(bad)).toThrowError() + } + }) +}) + +describe('isWorkEmail (Layer 2: work-domain filter)', () => { + it('accepts work domains', () => { + expect(isWorkEmail('redpanda.com')).toEqual({ ok: true }) + expect(isWorkEmail('acme-corp.io')).toEqual({ ok: true }) + }) + it('rejects free providers', () => { + for (const d of ['gmail.com', 'outlook.com', 'hotmail.com', 'proton.me', 'yahoo.com', 'icloud.com']) { + expect(isWorkEmail(d)).toEqual({ ok: false, reason: 'free_provider' }) + } + }) + it('rejects disposable providers', () => { + expect(isWorkEmail('mailinator.com')).toEqual({ ok: false, reason: 'disposable' }) + expect(isWorkEmail('yopmail.com')).toEqual({ ok: false, reason: 'disposable' }) + }) +}) + +describe('tokens', () => { + it('generateToken has the rp_mcp_ prefix and is unique', () => { + const tokens = new Set(Array.from({ length: 500 }, () => generateToken())) + expect(tokens.size).toBe(500) + for (const t of tokens) { + expect(t.startsWith('rp_mcp_')).toBe(true) + expect(looksLikeToken(t)).toBe(true) + } + }) + it('hashToken is deterministic 64-hex', () => { + const t = generateToken() + const h = hashToken(t) + expect(h).toMatch(/^[0-9a-f]{64}$/) + expect(hashToken(t)).toBe(h) + expect(hashToken(generateToken())).not.toBe(h) + }) +}) + +describe('extractBearerToken', () => { + it('parses the Authorization header (case-insensitive)', () => { + expect(extractBearerToken('Bearer abc123', null)).toBe('abc123') + expect(extractBearerToken('bearer xyz ', null)).toBe('xyz') + }) + it('falls back to the query token', () => { + expect(extractBearerToken('', 'qtok')).toBe('qtok') + expect(extractBearerToken(null, 'qtok')).toBe('qtok') + }) + it('returns null when absent', () => { + expect(extractBearerToken('', null)).toBeNull() + expect(extractBearerToken('Basic abc', null)).toBeNull() + }) +}) + +describe('buildUnauthorizedResponse', () => { + it('returns a 401 with WWW-Authenticate and registration_url', () => { + const r = buildUnauthorizedResponse({ registrationUrl: 'https://x.test/mcp/register' }) + expect(r.status).toBe(401) + expect(r.headers['WWW-Authenticate']).toContain('Bearer') + expect(r.headers['WWW-Authenticate']).toContain('https://x.test/mcp/register') + expect(r.body.error).toBe('authentication_required') + expect(r.body.registration_url).toBe('https://x.test/mcp/register') + }) +}) + +describe('isAuthEnforced', () => { + it('only true when REQUIRE_AUTH === "true"', () => { + const orig = process.env.REQUIRE_AUTH + process.env.REQUIRE_AUTH = 'true' + expect(isAuthEnforced()).toBe(true) + for (const v of ['false', '', '1', 'yes']) { + process.env.REQUIRE_AUTH = v + expect(isAuthEnforced()).toBe(false) + } + delete process.env.REQUIRE_AUTH + expect(isAuthEnforced()).toBe(false) + if (orig !== undefined) process.env.REQUIRE_AUTH = orig + }) +}) + +describe('decideAuth matrix', () => { + const record = { email: 'jake@redpanda.com', domain: 'redpanda.com', revoked: false } + + it('valid record -> allow + context', () => { + const r = decideAuth({ record, enforced: true }) + expect(r.allow).toBe(true) + expect(r.userContext).toEqual({ email: 'jake@redpanda.com', domain: 'redpanda.com' }) + expect(r.unauthorized).toBeNull() + }) + it('revoked record + enforced -> 401', () => { + const r = decideAuth({ record: { ...record, revoked: true }, enforced: true }) + expect(r.allow).toBe(false) + expect(r.unauthorized.status).toBe(401) + }) + it('no token + grace -> allow with null context', () => { + const r = decideAuth({ record: null, enforced: false }) + expect(r.allow).toBe(true) + expect(r.userContext).toBeNull() + expect(r.unauthorized).toBeNull() + }) + it('no token + enforced -> 401', () => { + const r = decideAuth({ record: null, enforced: true }) + expect(r.allow).toBe(false) + expect(r.unauthorized.status).toBe(401) + }) +}) + +describe('hasValidMx (Layer 3: MX) with mocked resolver', () => { + it('accepts a domain with MX records, rejects NXDOMAIN', async () => { + vi.resetModules() + vi.doMock('node:dns', () => ({ + promises: { + resolveMx: vi.fn(async (d: string) => { + if (d === 'redpanda.com') return [{ exchange: 'mx.redpanda.com', priority: 10 }] + throw new Error('ENOTFOUND') + }), + resolve: vi.fn(async () => { + throw new Error('ENOTFOUND') + }), + }, + })) + const { hasValidMx } = await import('../netlify/functions/lib/email.mjs') + expect(await hasValidMx('redpanda.com')).toBe(true) + expect(await hasValidMx('nope-not-a-real-domain-xyz.com')).toBe(false) + vi.doUnmock('node:dns') + }) +}) From 14e57d0dcd1d2d4eecd95c2543b9c4957732b0cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:56:41 +0000 Subject: [PATCH 02/61] chore: bump server.json version to 2026.06.15+pr181-8d11b9b (PR #181) --- server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.json b/server.json index c36813f6..c23d748a 100644 --- a/server.json +++ b/server.json @@ -7,7 +7,7 @@ "source": "github", "subfolder": "netlify" }, - "version": "2026.06.15+auth", + "version": "2026.06.15+pr181-8d11b9b", "remotes": [ { "type": "streamable-http", From 64dff557e32fe3b3bd185016cd72cdc76382c700 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:06:50 +0000 Subject: [PATCH 03/61] chore: bump server.json version to 2026.06.15+pr181-ff0868b (PR #181) --- server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.json b/server.json index c23d748a..1d0c280c 100644 --- a/server.json +++ b/server.json @@ -7,7 +7,7 @@ "source": "github", "subfolder": "netlify" }, - "version": "2026.06.15+pr181-8d11b9b", + "version": "2026.06.15+pr181-ff0868b", "remotes": [ { "type": "streamable-http", From 2ff8bc5588eff99cbfa204cb5f00c2ee2c4a8508 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 15 Jun 2026 12:21:58 +0100 Subject: [PATCH 04/61] Harden token-email dev bypass to NETLIFY_DEV only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netlify Functions don't reliably set NODE_ENV=production at runtime, so the previous NODE_ENV-based dev bypass could fire in deployed environments — silently logging tokens instead of emailing them and not failing when RESEND_API_KEY is missing. Gate the bypass on NETLIFY_DEV (set only by `netlify dev`/`functions:serve`) so any deployed env without a key errors loudly. Co-Authored-By: Claude Opus 4.8 --- netlify/functions/lib/email.mjs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/netlify/functions/lib/email.mjs b/netlify/functions/lib/email.mjs index b6786d95..62d0b7ae 100644 --- a/netlify/functions/lib/email.mjs +++ b/netlify/functions/lib/email.mjs @@ -74,16 +74,17 @@ Keep this token private. If you need it revoked, reply to this email.` // the proof the email is real and owned, so the token is NEVER returned in the // HTTP response — only here. // -// Dev bypass: outside production with no RESEND_API_KEY, log the token to the -// console so `netlify dev` works without a provider. In production a missing -// key is a hard error. +// Dev bypass: ONLY under `netlify dev`/`functions:serve` (which set +// NETLIFY_DEV=true) with no RESEND_API_KEY, log the token to the console so +// local testing works without a provider. In any deployed environment a missing +// key is a hard error — we never silently log tokens instead of emailing them. export async function sendTokenEmail({ to, token }) { if (!RESEND_API_KEY) { - if (process.env.NODE_ENV === 'production') { - throw new Error('RESEND_API_KEY is required in production to deliver MCP tokens') + if (process.env.NETLIFY_DEV === 'true') { + console.log(`[mcp-register][dev-bypass] token for ${to}: ${token}`) + return { ok: true, devBypass: true } } - console.log(`[mcp-register][dev-bypass] token for ${to}: ${token}`) - return { ok: true, devBypass: true } + throw new Error('RESEND_API_KEY is required to deliver MCP tokens') } const { text, html } = tokenEmailBody(token) From e71d339a878e3f3b05de92c446c7d5a06f166108 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:22:16 +0000 Subject: [PATCH 05/61] chore: bump server.json version to 2026.06.15+pr181-75928f7 (PR #181) --- server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.json b/server.json index 1d0c280c..35731076 100644 --- a/server.json +++ b/server.json @@ -7,7 +7,7 @@ "source": "github", "subfolder": "netlify" }, - "version": "2026.06.15+pr181-ff0868b", + "version": "2026.06.15+pr181-75928f7", "remotes": [ { "type": "streamable-http", From c0475dbd6ba98bf3cf38795220d3924a548bfed5 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 15 Jun 2026 12:55:00 +0100 Subject: [PATCH 06/61] Switch MCP auth to OAuth 2.1 via Redpanda Cloud IdP Replace the custom email->token gate with a standard MCP OAuth 2.1 resource server delegating to the Redpanda Cloud IdP (auth.prd.cloud.redpanda.com). This is required so ChatGPT can authenticate (ChatGPT only supports spec OAuth, not static tokens), while still capturing users' verified work emails. Verified the Cloud IdP supports everything needed (open Dynamic Client Registration, CIMD, PKCE S256, public clients, email scope, userinfo). - /.well-known/oauth-protected-resource (RFC 9728) edge function advertises the Cloud IdP as the authorization server; clients self-register via DCR/CIMD. - mcp.mjs auth middleware validates the bearer token against the IdP /userinfo endpoint, extracts the verified email/org, captures it (Blobs + log + optional CRM_WEBHOOK_URL), and threads it to Kapa via _meta.user. - Optional work-email enforcement (REQUIRE_WORK_EMAIL, default on) returns 403 for personal providers; REQUIRE_AUTH keeps the grace->enforce rollout. - Remove the email->token registration endpoint and email-sending module. - Docs updated: clients prompt for Redpanda Cloud sign-in (no token to paste). - Unit tests rewritten for the OAuth logic (16 tests). Production hardening (needs identity team): register an Auth0 API for the MCP resource so tokens are audience-bound JWTs, and add email as an access-token claim. Until then we validate via /userinfo (no audience binding). Co-Authored-By: Claude Opus 4.8 --- .../ROOT/pages/how-to-use-these-docs.adoc | 80 +++---- netlify/edge-functions/mcp-oauth-metadata.ts | 34 +++ netlify/edge-functions/mcp-server-card.ts | 7 +- netlify/functions/lib/auth.mjs | 211 ++++++++--------- netlify/functions/lib/email.mjs | 112 --------- netlify/functions/lib/idp.mjs | 67 ++++++ netlify/functions/lib/store.mjs | 90 +++---- netlify/functions/mcp-register.mjs | 223 ------------------ netlify/functions/mcp.mjs | 59 +++-- server.json | 2 +- tests/mcp-auth.test.ts | 146 +++++------- 11 files changed, 363 insertions(+), 668 deletions(-) create mode 100644 netlify/edge-functions/mcp-oauth-metadata.ts delete mode 100644 netlify/functions/lib/email.mjs create mode 100644 netlify/functions/lib/idp.mjs delete mode 100644 netlify/functions/mcp-register.mjs diff --git a/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc b/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc index db28ca9f..c1e30117 100644 --- a/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc +++ b/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc @@ -87,48 +87,33 @@ The MCP server is hosted at: `https://docs.redpanda.com/mcp`. You can add this endpoint to any AI agent that supports MCP. [#authentication] -==== Get an access token +==== Authentication -The MCP server requires a free access token tied to your work email. We use this to understand who relies on the documentation tools and to attribute usage to your organization. +The MCP server uses OAuth. The first time you connect, your MCP client opens a browser and prompts you to sign in with your link:https://cloud.redpanda.com[Redpanda Cloud account^] (free to create). Your client then obtains an access token automatically and reuses it on future requests, so you only sign in once. -. Go to https://docs.redpanda.com/mcp/register[`docs.redpanda.com/mcp/register`^]. -. Enter your *work email address*. Free providers (such as Gmail or Outlook) and disposable addresses aren't accepted. -. Check your inbox. We email your token to confirm the address is valid and belongs to you. The token isn't shown in the browser. -. Add the token to your MCP client as shown in the configuration for your tool below. - -Send the token in the `Authorization` header on every request: - -[source,text] ----- -Authorization: Bearer rp_mcp_your_token_here ----- - -For clients that can't set a custom header, append the token to the URL instead: - -[source,text] ----- -https://docs.redpanda.com/mcp?token=rp_mcp_your_token_here ----- +There's no token to copy or paste. Any MCP client that supports the standard MCP OAuth flow (including ChatGPT, Claude, Cursor, and VS Code) handles sign-in for you. [NOTE] ==== -*What we collect and why.* When you register, we store your work email address, its domain, and request counts to track usage and attribute it to your organization. This information may be shared with our customer systems and passed to our documentation search provider (Kapa) for usage attribution. We don't store the content of your queries. To revoke your token or request deletion of your data, reply to the token email. +*What we collect and why.* When you sign in, we receive your verified email address and organization from Redpanda Cloud, which we use to attribute documentation usage to your organization. This may be shared with our customer systems and passed to our documentation search provider (Kapa) for usage attribution. We don't store the content of your queries. ==== -During the initial rollout, authentication is optional and existing connections continue to work. After a transition period, a token will be required. Register now to avoid interruption. +During the initial rollout, authentication is optional and existing connections continue to work. After a transition period, sign-in will be required. [tabs] ==== Claude Code:: + -- -Run the following command to add the Redpanda MCP server to Claude Code, replacing `rp_mcp_your_token_here` with the token from your email: +Run the following command to add the Redpanda MCP server to Claude Code: [source,bash] ---- -claude mcp add --scope user --transport http redpanda https://docs.redpanda.com/mcp --header "Authorization: Bearer rp_mcp_your_token_here" +claude mcp add --scope user --transport http redpanda https://docs.redpanda.com/mcp ---- +The first time you use the server, Claude Code prompts you to authenticate with your Redpanda Cloud account in the browser. + This command: * Adds the MCP server with the name `redpanda`. @@ -150,7 +135,7 @@ For more information about Claude Code, see the https://code.claude.com/docs/en/ Cursor:: + -- -Add the following to your `.cursor/mcp.json` file, replacing `rp_mcp_your_token_here` with the token from your email: +Add the following to your `.cursor/mcp.json` file: [source,json] ---- @@ -158,15 +143,14 @@ Add the following to your `.cursor/mcp.json` file, replacing `rp_mcp_your_token_ "mcpServers": { "redpanda": { "type": "http", - "url": "https://docs.redpanda.com/mcp", - "headers": { - "Authorization": "Bearer rp_mcp_your_token_here" - } + "url": "https://docs.redpanda.com/mcp" } } } ---- +The first time you use the server, Cursor prompts you to sign in with your Redpanda Cloud account. + For more information about MCP in Cursor, see the https://docs.cursor.com/context/model-context-protocol[Cursor documentation^]. -- VS Code:: @@ -174,7 +158,7 @@ VS Code:: -- *Prerequisites:* VS Code 1.102+ with GitHub Copilot enabled. -Create an `mcp.json` file in your workspace `.vscode` folder, replacing `rp_mcp_your_token_here` with the token from your email: +Create an `mcp.json` file in your workspace `.vscode` folder: .`.vscode/mcp.json` [source,json] @@ -183,15 +167,14 @@ Create an `mcp.json` file in your workspace `.vscode` folder, replacing `rp_mcp_ "servers": { "redpanda": { "type": "http", - "url": "https://docs.redpanda.com/mcp", - "headers": { - "Authorization": "Bearer rp_mcp_your_token_here" - } + "url": "https://docs.redpanda.com/mcp" } } } ---- +The first time you use the server, VS Code prompts you to sign in with your Redpanda Cloud account. + To configure globally for all workspaces: . Open Command Palette (kbd:[Cmd+Shift+P] / kbd:[Ctrl+Shift+P]) @@ -219,9 +202,10 @@ ChatGPT Desktop supports MCP servers in developer mode. To enable: . Click *Add Server* and enter: + - *Name*: `redpanda` -- *URL*: `https://docs.redpanda.com/mcp?token=rp_mcp_your_token_here` +- *URL*: `https://docs.redpanda.com/mcp` +- *Authentication*: *OAuth* -TIP: ChatGPT Desktop doesn't let you set a custom `Authorization` header, so append your token to the URL using `?token=` as shown above. Replace `rp_mcp_your_token_here` with the token from your email. +ChatGPT discovers the OAuth flow automatically and prompts you to sign in with your Redpanda Cloud account. For more information, see the https://platform.openai.com/docs/guides/developer-mode[ChatGPT Desktop MCP documentation^]. -- @@ -237,7 +221,8 @@ This method is available for Pro, Max, Team, or Enterprise plans and works acros . Open Claude Desktop. . Navigate to Settings > Connectors. . Click *Add custom connector*. -. Enter the URL with your token appended: `https://docs.redpanda.com/mcp?token=rp_mcp_your_token_here`. The Connectors interface doesn't expose a custom header field, so append the token to the URL using `?token=`. +. Enter the URL: `https://docs.redpanda.com/mcp` +. When prompted, sign in with your Redpanda Cloud account. The connector completes the OAuth flow automatically. NOTE: When using Connectors, Claude connects to your remote MCP server from Anthropic's cloud infrastructure. @@ -258,13 +243,13 @@ Add the following configuration to your `claude_desktop_config.json` file: "mcpServers": { "redpanda": { "command": "npx", - "args": ["-y", "mcp-remote", "https://docs.redpanda.com/mcp", "--header", "Authorization: Bearer rp_mcp_your_token_here"] + "args": ["-y", "mcp-remote", "https://docs.redpanda.com/mcp"] } } } ---- -Replace `rp_mcp_your_token_here` with the token from your email. This configuration uses the `mcp-remote` bridge to connect to Redpanda's remote MCP server. Claude Desktop supports only `stdio` and `sse` transports, so `mcp-remote` converts the HTTP endpoint to a compatible format. +This configuration uses the `mcp-remote` bridge to connect to Redpanda's remote MCP server. On first use, `mcp-remote` opens a browser for you to sign in with your Redpanda Cloud account. Claude Desktop supports only `stdio` and `sse` transports, so `mcp-remote` converts the HTTP endpoint to a compatible format. Restart Claude Desktop for changes to take effect. @@ -276,14 +261,14 @@ For more details, see the https://support.anthropic.com/en/articles/9487310-desk ==== Other AI tools -Any tool that supports MCP servers can connect using the following URL. Send your token in the `Authorization` header, or append it to the URL with `?token=` if the tool can't set headers: +Any tool that supports the MCP OAuth flow can connect using the following URL and will prompt you to sign in with your Redpanda Cloud account on first use: [source,text] ---- https://docs.redpanda.com/mcp ---- -NOTE: MCP support varies by tool and version. Check the specific tool's documentation for MCP setup instructions. See <> for how to get a token. +NOTE: MCP support varies by tool and version. Check the specific tool's documentation for MCP setup instructions. See <> for details on sign-in. ==== What you can do @@ -302,7 +287,7 @@ To ensure fair use and performance for all users, the MCP endpoint enforces the * *60 requests per 15 minutes* -When you authenticate with a token, this limit is applied per token. Unauthenticated requests (during the rollout period) are limited per IP address. +When you're signed in, this limit is applied per user. Unauthenticated requests (during the rollout period) are limited per IP address. As well as this limit, the `ask_redpanda_question` tool proxies to an MCP server hosted by Kapa.ai, which enforces its own limits. See the link:https://docs.kapa.ai/integrations/mcp/overview#authentication[Kapa documentation^] for details. @@ -329,10 +314,13 @@ RateLimit-Reset: If you receive an `HTTP 401` response with an `authentication_required` error: -* Make sure you've registered for a token at https://docs.redpanda.com/mcp/register[`docs.redpanda.com/mcp/register`^] and used the token from the confirmation email. -* Verify the token is sent as `Authorization: Bearer rp_mcp_...`, or appended to the URL as `?token=rp_mcp_...` for clients that can't set headers. -* Tokens are tied to a verified work email. If you registered with a free or disposable address, the registration is rejected. Use your work email. -* If your token was revoked, register again for a new one. +* Your MCP client should open a browser to sign in with your Redpanda Cloud account. If it doesn't, check that your client supports the MCP OAuth flow (most recent versions do). +* If you don't have a Redpanda Cloud account, create a free one at https://cloud.redpanda.com[`cloud.redpanda.com`^]. +* If sign-in succeeded previously but you now see `401`, your client's token may have expired. Reconnect or restart the client to trigger a fresh sign-in. + +===== Work account required (HTTP 403) + +If you receive an `HTTP 403` with a `work_email_required` error, you signed in with a personal email provider (such as Gmail). Sign in with your work account instead. ===== Server not connecting diff --git a/netlify/edge-functions/mcp-oauth-metadata.ts b/netlify/edge-functions/mcp-oauth-metadata.ts new file mode 100644 index 00000000..6d19afb1 --- /dev/null +++ b/netlify/edge-functions/mcp-oauth-metadata.ts @@ -0,0 +1,34 @@ +// OAuth 2.0 Protected Resource Metadata (RFC 9728) for the MCP server. +// MCP clients (ChatGPT, Claude, Cursor, …) fetch this to discover the +// authorization server, then run the OAuth login against Redpanda Cloud. +// https://datatracker.ietf.org/doc/html/rfc9728 + +const AUTH_SERVER = + Deno.env.get("REDPANDA_OAUTH_ISSUER") || "https://auth.prd.cloud.redpanda.com/"; + +export default async (request: Request) => { + const origin = new URL(request.url).origin; + + const metadata = { + resource: `${origin}/mcp`, + authorization_servers: [AUTH_SERVER], + bearer_methods_supported: ["header"], + scopes_supported: ["openid", "email", "profile"], + resource_documentation: `${origin}/data-platform/how-to-use-these-docs#authentication`, + }; + + return new Response(JSON.stringify(metadata, null, 2), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + }); +}; + +export const config = { + path: "/.well-known/oauth-protected-resource", +}; diff --git a/netlify/edge-functions/mcp-server-card.ts b/netlify/edge-functions/mcp-server-card.ts index ac42bb02..2453bd9d 100644 --- a/netlify/edge-functions/mcp-server-card.ts +++ b/netlify/edge-functions/mcp-server-card.ts @@ -20,10 +20,11 @@ export default async (request: Request) => { prompts: false }, authentication: { - type: "bearer", + type: "oauth2", required: false, - registration_url: "https://docs.redpanda.com/mcp/register", - description: "Register a free token with your work email, then send Authorization: Bearer (or ?token= for clients that can't set headers)." + protected_resource_metadata: `${siteUrl}/.well-known/oauth-protected-resource`, + authorization_servers: ["https://auth.prd.cloud.redpanda.com/"], + description: "Sign in with your Redpanda Cloud account. MCP clients discover the OAuth flow via the protected-resource metadata and obtain a token automatically." }, metadata: { homepage: `${siteUrl}`, diff --git a/netlify/functions/lib/auth.mjs b/netlify/functions/lib/auth.mjs index aaf85ba8..d099ad96 100644 --- a/netlify/functions/lib/auth.mjs +++ b/netlify/functions/lib/auth.mjs @@ -1,80 +1,43 @@ -// Auth core for the Redpanda Docs MCP server. -// ------------------------------------------- -// Pure, dependency-light helpers so they can be unit-tested without the Netlify -// runtime (no Netlify Blobs, no DNS, no email here). All side-effecting code -// lives in store.mjs (Blobs) and email.mjs (DNS + provider). - -import { createHash, randomBytes } from 'node:crypto' +// OAuth resource-server core for the Redpanda Docs MCP server. +// ------------------------------------------------------------ +// Pure, dependency-light helpers (no network, no Blobs) so they can be +// unit-tested without the Netlify runtime. Token validation against the +// Redpanda Cloud IdP lives in idp.mjs; user capture lives in store.mjs. +// +// The MCP server acts as an OAuth 2.1 Resource Server (per the MCP authorization +// spec). MCP clients (ChatGPT, Claude, Cursor, …) discover the authorization +// server via /.well-known/oauth-protected-resource (RFC 9728), sign in with a +// Redpanda Cloud account, and send `Authorization: Bearer `. We validate +// the token and capture the user's verified work email. + +import { createHash } from 'node:crypto' // -------------------- Email classification -------------------- -// Free consumer providers. We want *work* emails, so these are rejected. +// Free consumer providers — rejected when a work email is required. export const FREE_EMAIL_DOMAINS = new Set([ - 'gmail.com', - 'googlemail.com', - 'yahoo.com', - 'yahoo.co.uk', - 'yahoo.co.in', - 'ymail.com', - 'outlook.com', - 'hotmail.com', - 'hotmail.co.uk', - 'live.com', - 'msn.com', - 'icloud.com', - 'me.com', - 'mac.com', - 'proton.me', - 'protonmail.com', - 'pm.me', - 'aol.com', - 'gmx.com', - 'gmx.net', - 'yandex.com', - 'yandex.ru', - 'mail.com', - 'zoho.com', - 'qq.com', - '163.com', - '126.com', + 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk', 'yahoo.co.in', + 'ymail.com', 'outlook.com', 'hotmail.com', 'hotmail.co.uk', 'live.com', + 'msn.com', 'icloud.com', 'me.com', 'mac.com', 'proton.me', 'protonmail.com', + 'pm.me', 'aol.com', 'gmx.com', 'gmx.net', 'yandex.com', 'yandex.ru', + 'mail.com', 'zoho.com', 'qq.com', '163.com', '126.com', ]) // Disposable / throwaway providers. Best-effort seed list, not exhaustive. export const DISPOSABLE_DOMAINS = new Set([ - 'mailinator.com', - '10minutemail.com', - 'guerrillamail.com', - 'guerrillamail.info', - 'sharklasers.com', - 'getnada.com', - 'nada.email', - 'yopmail.com', - 'trashmail.com', - 'tempmail.com', - 'temp-mail.org', - 'throwawaymail.com', - 'maildrop.cc', - 'dispostable.com', - 'fakeinbox.com', - 'mintemail.com', - 'mohmal.com', + 'mailinator.com', '10minutemail.com', 'guerrillamail.com', 'guerrillamail.info', + 'sharklasers.com', 'getnada.com', 'nada.email', 'yopmail.com', 'trashmail.com', + 'tempmail.com', 'temp-mail.org', 'throwawaymail.com', 'maildrop.cc', + 'dispostable.com', 'fakeinbox.com', 'mintemail.com', 'mohmal.com', ]) -const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - -// Layer 1: format. Returns { email, domain } or throws a typed error. -export function normalizeEmail(raw) { - const email = String(raw || '').trim().toLowerCase() - if (!email || !EMAIL_SHAPE.test(email)) { - const err = new Error('invalid email format') - err.code = 'invalid_format' - throw err - } - const domain = email.slice(email.lastIndexOf('@') + 1) - return { email, domain } +export function emailDomain(email) { + const e = String(email || '').trim().toLowerCase() + const at = e.lastIndexOf('@') + return at === -1 ? '' : e.slice(at + 1) } -// Layer 2: work-domain filter. Returns { ok, reason? }. +// Returns { ok, reason? }. Rejects free + disposable providers. export function isWorkEmail(domain) { const d = String(domain || '').trim().toLowerCase() if (!d) return { ok: false, reason: 'invalid_format' } @@ -83,86 +46,104 @@ export function isWorkEmail(domain) { return { ok: true } } -// -------------------- Tokens -------------------- - -const TOKEN_PREFIX = 'rp_mcp_' - -// Opaque, high-entropy bearer token. The prefix makes tokens greppable in logs -// and configs and lets us fast-reject obviously malformed values. -export function generateToken() { - return TOKEN_PREFIX + randomBytes(32).toString('base64url') -} +// -------------------- Request parsing -------------------- -export function looksLikeToken(token) { - return typeof token === 'string' && token.startsWith(TOKEN_PREFIX) && token.length > TOKEN_PREFIX.length + 20 +// OAuth bearer tokens travel in the Authorization header only. (The MCP/OAuth +// spec forbids tokens in the query string, and ChatGPT only sends them as a +// header, so there is no ?token= fallback here.) +export function extractBearerToken(authHeader) { + const m = String(authHeader || '').match(/^\s*Bearer\s+(.+?)\s*$/i) + return m ? m[1] : null } -// We store only the hash. Tokens are 256-bit random, so a plain SHA-256 is -// sufficient (no salt/KDF needed — they aren't guessable like passwords). +// Used as a cache key / opaque reference. Tokens are never logged in the clear. export function hashToken(token) { return createHash('sha256').update(String(token)).digest('hex') } -// -------------------- Request parsing -------------------- +// -------------------- Config flags -------------------- + +// Auth is enforced only when REQUIRE_AUTH === 'true' (default = grace period). +export function isAuthEnforced() { + return process.env.REQUIRE_AUTH === 'true' +} -// Extract a bearer token from the Authorization header, falling back to a -// ?token= / ?key= query param for MCP clients that can't set static headers -// (e.g. some ChatGPT/Connectors configurations). -export function extractBearerToken(authHeader, urlQueryToken) { - const header = String(authHeader || '') - const match = header.match(/^\s*Bearer\s+(.+)\s*$/i) - if (match) return match[1].trim() - if (urlQueryToken) return String(urlQueryToken).trim() - return null +// Require a work email (reject free/disposable). Default true; set +// REQUIRE_WORK_EMAIL=false to accept any verified Cloud email. +export function isWorkEmailRequired() { + return process.env.REQUIRE_WORK_EMAIL !== 'false' } // -------------------- Responses -------------------- const DOCS_URL = 'https://docs.redpanda.com/data-platform/how-to-use-these-docs#authentication' -// Framework-agnostic 401 ({ status, headers, body }) so it can be returned from -// either the Hono layer or a raw handler, and asserted in unit tests. -export function buildUnauthorizedResponse({ registrationUrl, reason } = {}) { - const regUrl = registrationUrl || 'https://docs.redpanda.com/mcp/register' - const description = - 'Register a free token with your work email, then send Authorization: Bearer .' +// RFC 6750 / RFC 9728 compliant 401. The `resource_metadata` parameter points +// MCP clients at our protected-resource-metadata document so they can discover +// the Redpanda Cloud authorization server and start the OAuth flow. +export function buildUnauthorizedResponse({ resourceMetadataUrl, error = 'invalid_token', description } = {}) { + const meta = resourceMetadataUrl || 'https://docs.redpanda.com/.well-known/oauth-protected-resource' + const desc = description || 'Sign in with your Redpanda Cloud account to use the Redpanda Docs MCP server.' return { status: 401, headers: { - 'WWW-Authenticate': `Bearer realm="redpanda-docs-mcp", error="${reason || 'invalid_token'}", error_description="${description}", resource_metadata="${regUrl}"`, + 'WWW-Authenticate': `Bearer realm="redpanda-docs-mcp", error="${error}", error_description="${desc}", resource_metadata="${meta}"`, 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }, body: { error: 'authentication_required', - message: `${description} Get one at ${regUrl}`, - registration_url: regUrl, + message: desc, + resource_metadata: meta, docs_url: DOCS_URL, }, } } -// -------------------- Enforcement -------------------- - -// Auth is enforced only when REQUIRE_AUTH is exactly 'true'. Anything else -// (unset, 'false', etc.) is the grace period: requests pass through. -export function isAuthEnforced() { - return process.env.REQUIRE_AUTH === 'true' +function buildForbiddenWorkEmail(reason) { + const message = + reason === 'disposable' + ? 'Disposable email addresses are not accepted. Sign in with your work account.' + : 'Please sign in with your work account. Personal email providers (Gmail, Outlook, etc.) are not accepted.' + return { + status: 403, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + body: { error: 'work_email_required', reason, message, docs_url: DOCS_URL }, + } } -// Pure decision helper so the middleware's branching logic is unit-testable -// without Hono or Blobs. `record` is the stored token record (or null). -export function decideAuth({ record, enforced }) { - if (record && !record.revoked) { - return { - allow: true, - userContext: { email: record.email, domain: record.domain }, - unauthorized: null, +// -------------------- Decision logic (pure, unit-tested) -------------------- +// +// `claims` is the validated token's userinfo (or null if no/invalid token). +// Returns { allow, userContext, response } where `response` is a framework- +// agnostic { status, headers, body } to return on rejection. +export function decideAuth({ claims, enforced, workEmailRequired, resourceMetadataUrl }) { + if (!claims) { + if (enforced) { + return { allow: false, userContext: null, response: buildUnauthorizedResponse({ resourceMetadataUrl }) } + } + // Grace period: allow through with no attribution. + return { allow: true, userContext: null, response: null } + } + + const email = String(claims.email || '').trim().toLowerCase() + const domain = emailDomain(email) + + if (workEmailRequired && email) { + const work = isWorkEmail(domain) + if (!work.ok) { + return { allow: false, userContext: null, response: buildForbiddenWorkEmail(work.reason) } } } - if (enforced) { - return { allow: false, userContext: null, unauthorized: buildUnauthorizedResponse({}) } + + return { + allow: true, + userContext: { + sub: claims.sub || null, + email: email || null, + domain: domain || null, + emailVerified: claims.email_verified === true, + }, + response: null, } - // Grace period: allow through with no attribution. - return { allow: true, userContext: null, unauthorized: null } } diff --git a/netlify/functions/lib/email.mjs b/netlify/functions/lib/email.mjs deleted file mode 100644 index 62d0b7ae..00000000 --- a/netlify/functions/lib/email.mjs +++ /dev/null @@ -1,112 +0,0 @@ -// Email side effects for MCP registration: MX validation + token delivery. -// ------------------------------------------------------------------------ -// Impure (DNS + network). Kept separate from auth.mjs so the pure logic stays -// unit-testable; these functions are mocked in tests. - -import { promises as dns } from 'node:dns' - -const MX_TIMEOUT_MS = 4_000 - -function withTimeout(promise, ms, label) { - let t - const timeout = new Promise((_, reject) => { - t = setTimeout(() => reject(new Error(`${label}_timeout`)), ms) - }) - return Promise.race([promise, timeout]).finally(() => clearTimeout(t)) -} - -// Layer 3: reject domains that can't receive mail (typos, fake domains) before -// we attempt to send. Accept if the domain has MX records, or (fallback) an A -// record. Any lookup failure is treated as invalid. -export async function hasValidMx(domain) { - const d = String(domain || '').trim().toLowerCase() - if (!d) return false - try { - const mx = await withTimeout(dns.resolveMx(d), MX_TIMEOUT_MS, 'mx') - if (Array.isArray(mx) && mx.length > 0) return true - } catch { - // fall through to A-record fallback - } - try { - const a = await withTimeout(dns.resolve(d), MX_TIMEOUT_MS, 'a') - return Array.isArray(a) && a.length > 0 - } catch { - return false - } -} - -const RESEND_API_KEY = process.env.RESEND_API_KEY -const FROM_EMAIL = process.env.MCP_FROM_EMAIL || 'Redpanda Docs ' - -function tokenEmailBody(token) { - const text = `Thanks for registering for the Redpanda Docs MCP server. - -Your access token: - - ${token} - -Add it to your MCP client as an Authorization header: - - Authorization: Bearer ${token} - -Or, for clients that can't set headers, append it to the URL: - - https://docs.redpanda.com/mcp?token=${token} - -Setup instructions: https://docs.redpanda.com/data-platform/how-to-use-these-docs#authentication - -Keep this token private. If you need it revoked, reply to this email.` - - const html = `

Thanks for registering for the Redpanda Docs MCP server.

-

Your access token:

-
${token}
-

Add it to your MCP client as an Authorization header:

-
Authorization: Bearer ${token}
-

Or, for clients that can't set headers, append it to the URL:

-
https://docs.redpanda.com/mcp?token=${token}
-

Setup instructions

-

Keep this token private. If you need it revoked, reply to this email.

` - - return { text, html } -} - -// Layer 4: deliver the token to the address. Possession of a working token is -// the proof the email is real and owned, so the token is NEVER returned in the -// HTTP response — only here. -// -// Dev bypass: ONLY under `netlify dev`/`functions:serve` (which set -// NETLIFY_DEV=true) with no RESEND_API_KEY, log the token to the console so -// local testing works without a provider. In any deployed environment a missing -// key is a hard error — we never silently log tokens instead of emailing them. -export async function sendTokenEmail({ to, token }) { - if (!RESEND_API_KEY) { - if (process.env.NETLIFY_DEV === 'true') { - console.log(`[mcp-register][dev-bypass] token for ${to}: ${token}`) - return { ok: true, devBypass: true } - } - throw new Error('RESEND_API_KEY is required to deliver MCP tokens') - } - - const { text, html } = tokenEmailBody(token) - const res = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - Authorization: `Bearer ${RESEND_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - from: FROM_EMAIL, - to: [to], - subject: 'Your Redpanda Docs MCP access token', - text, - html, - }), - }) - - if (!res.ok) { - const detail = await res.text().catch(() => '') - console.error('[mcp-register] Resend send failed', { status: res.status, detail: detail.slice(0, 300) }) - return { ok: false, status: res.status } - } - return { ok: true } -} diff --git a/netlify/functions/lib/idp.mjs b/netlify/functions/lib/idp.mjs new file mode 100644 index 00000000..459de9c9 --- /dev/null +++ b/netlify/functions/lib/idp.mjs @@ -0,0 +1,67 @@ +// Redpanda Cloud IdP token validation for the MCP server. +// -------------------------------------------------------- +// Impure (network). Validates an incoming OAuth access token by calling the +// Cloud IdP's /userinfo endpoint and returns the user's claims (sub, email, +// email_verified, …). This works with the opaque access tokens Auth0 issues +// when no custom API/audience is registered. +// +// Production hardening (needs the identity team): register an Auth0 API for the +// MCP resource so access tokens are audience-bound JWTs, then validate via JWKS +// for audience binding instead of (or in addition to) /userinfo. + +import { hashToken } from './auth.mjs' + +const ISSUER = process.env.REDPANDA_OAUTH_ISSUER || 'https://auth.prd.cloud.redpanda.com/' +const USERINFO_URL = + process.env.REDPANDA_OAUTH_USERINFO || new URL('/userinfo', ISSUER).toString() + +const USERINFO_TIMEOUT_MS = 6_000 +const CACHE_TTL_MS = 5 * 60 * 1000 + +// Per-token cache, reused across warm invocations, to avoid hitting /userinfo on +// every request. Keyed by token hash; never stores the raw token. +const cache = new Map() + +function cacheGet(key) { + const hit = cache.get(key) + if (!hit) return undefined + if (hit.exp <= Date.now()) { + cache.delete(key) + return undefined + } + return hit.claims +} + +// Validate the bearer token. Returns the claims object on success, or null if +// the token is missing/invalid/expired or the IdP is unreachable. +export async function validateToken(token) { + if (!token) return null + const key = hashToken(token) + + const cached = cacheGet(key) + if (cached !== undefined) return cached + + const controller = new AbortController() + const t = setTimeout(() => controller.abort(), USERINFO_TIMEOUT_MS) + try { + const res = await fetch(USERINFO_URL, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + signal: controller.signal, + }) + if (!res.ok) { + // Negatively cache invalid tokens briefly to blunt repeated bad calls. + cache.set(key, { claims: null, exp: Date.now() + 30_000 }) + return null + } + const claims = await res.json() + cache.set(key, { claims, exp: Date.now() + CACHE_TTL_MS }) + return claims + } catch (e) { + console.warn('[oauth] userinfo validation failed', { error: e?.message }) + return null + } finally { + clearTimeout(t) + } +} + +export const OAUTH_ISSUER = ISSUER diff --git a/netlify/functions/lib/store.mjs b/netlify/functions/lib/store.mjs index eb573de9..b3b3e413 100644 --- a/netlify/functions/lib/store.mjs +++ b/netlify/functions/lib/store.mjs @@ -1,65 +1,43 @@ -// Token storage for the MCP server, backed by Netlify Blobs. -// ----------------------------------------------------------- -// Sole consumer of @netlify/blobs so the rest of the auth code stays runtime- -// agnostic and unit-testable. Records are keyed by the SHA-256 hash of the -// token (see auth.mjs:hashToken) so a store leak never exposes usable tokens. +// User capture for the MCP server, backed by Netlify Blobs. +// ---------------------------------------------------------- +// Sole consumer of @netlify/blobs. Records each authenticated user (their +// verified work email + org/domain) for lead capture and usage attribution. +// On first sight of a user, optionally forwards the lead to a CRM webhook. -import { getStore as netlifyGetStore } from '@netlify/blobs' +import { getStore } from '@netlify/blobs' -const STORE_NAME = 'mcp-tokens' - -// Only persist lastUsedAt/requestCount roughly every N requests to avoid a -// Blobs write on every single query (writes are best-effort and off the hot path). -const TOUCH_EVERY = 10 +const STORE_NAME = 'mcp-users' function store() { - return netlifyGetStore(STORE_NAME) -} - -export async function saveRegistration({ tokenHash, email, domain }) { - const record = { - email, - domain, - createdAt: new Date().toISOString(), - lastUsedAt: null, - requestCount: 0, - revoked: false, - } - await store().setJSON(tokenHash, record) - return record + return getStore(STORE_NAME) } -export async function lookupToken(tokenHash) { - if (!tokenHash) return null - return store().get(tokenHash, { type: 'json' }) -} - -// Fire-and-forget usage bump. Never awaited on the hot path, never throws. -// Throttled so we don't write on every request. -export function touchToken(tokenHash, record) { - try { - const count = (record?.requestCount || 0) + 1 - // Always update the in-memory count for the throttle check, but only persist - // periodically (and on the very first use). - if (count === 1 || count % TOUCH_EVERY === 0) { - const updated = { - ...record, - lastUsedAt: new Date().toISOString(), - requestCount: count, - } - // Intentionally not awaited. - store().setJSON(tokenHash, updated).catch((e) => { - console.warn('[store] touchToken write failed', { error: e?.message }) - }) +// Record an authenticated user. Best-effort and idempotent: dedupes by `sub` +// (falling back to email). Call fire-and-forget — never block the request. +export async function recordUser({ sub, email, domain }) { + const key = sub || email + if (!key) return + + const now = new Date().toISOString() + const existing = await store().get(key, { type: 'json' }).catch(() => null) + const isNew = !existing + + const record = isNew + ? { sub, email, domain, firstSeenAt: now, lastSeenAt: now, requestCount: 1 } + : { ...existing, email, domain, lastSeenAt: now, requestCount: (existing.requestCount || 0) + 1 } + + await store().setJSON(key, record).catch((e) => + console.warn('[store] recordUser write failed', { error: e?.message }) + ) + + if (isNew) { + console.log(JSON.stringify({ event: 'mcp_user_captured', domain, sub, ts: now })) + if (process.env.CRM_WEBHOOK_URL) { + fetch(process.env.CRM_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, domain, sub, source: 'mcp', timestamp: now }), + }).catch((e) => console.warn('[store] CRM webhook failed', { error: e?.message })) } - } catch (e) { - console.warn('[store] touchToken error', { error: e?.message }) } } - -export async function revokeToken(tokenHash) { - const record = await lookupToken(tokenHash) - if (!record) return false - await store().setJSON(tokenHash, { ...record, revoked: true, revokedAt: new Date().toISOString() }) - return true -} diff --git a/netlify/functions/mcp-register.mjs b/netlify/functions/mcp-register.mjs deleted file mode 100644 index bd3a2f60..00000000 --- a/netlify/functions/mcp-register.mjs +++ /dev/null @@ -1,223 +0,0 @@ -// Self-service registration for the Redpanda Docs MCP server. -// ----------------------------------------------------------- -// A user submits their work email; after validation we generate a bearer token, -// store it (hashed), and DELIVER IT BY EMAIL ONLY. Receiving the token is the -// proof the address is real and owned, which is what prevents fake/non-emails. -// The token is never returned in the HTTP response body. - -import { normalizeEmail, isWorkEmail, generateToken, hashToken } from './lib/auth.mjs' -import { hasValidMx, sendTokenEmail } from './lib/email.mjs' -import { saveRegistration } from './lib/store.mjs' - -const REGISTRATION_URL = 'https://docs.redpanda.com/mcp/register' -const DOCS_URL = 'https://docs.redpanda.com/data-platform/how-to-use-these-docs#authentication' - -// -------------------- Lightweight per-IP rate limit -------------------- -// Best-effort, in-memory (resets on cold start). Blunts scripted abuse without -// a dependency. Not a security control. -const WINDOW_MS = 15 * 60 * 1000 -const MAX_PER_WINDOW = Number(process.env.MCP_REGISTER_RATE_LIMIT || 5) -const hits = new Map() - -function clientIp(request) { - return ( - request.headers.get('x-nf-client-connection-ip') || - request.headers.get('cf-connecting-ip') || - (request.headers.get('x-forwarded-for') || '').split(',')[0]?.trim() || - request.headers.get('x-real-ip') || - 'unknown' - ) -} - -function rateLimited(ip) { - const now = Date.now() - const entry = hits.get(ip) - if (!entry || now - entry.start > WINDOW_MS) { - hits.set(ip, { start: now, count: 1 }) - return false - } - entry.count += 1 - return entry.count > MAX_PER_WINDOW -} - -const CORS = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Accept', -} - -function json(body, status = 200, extraHeaders = {}) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...CORS, ...extraHeaders }, - }) -} - -const REASON_MESSAGES = { - invalid_format: 'That doesn\'t look like a valid email address.', - free_provider: 'Please use your work email. Free providers (Gmail, Outlook, etc.) aren\'t accepted.', - disposable: 'Disposable email addresses aren\'t accepted. Please use your work email.', - no_mx: 'We couldn\'t verify that email domain can receive mail. Check for typos.', -} - -function formPage() { - return ` - - - - -Redpanda Docs MCP — Get an access token - - - -

Get a Redpanda Docs MCP token

-

Enter your work email to receive a free access token for the documentation MCP server. We'll email the token to you.

-
- - -
-
-

- We collect your work email address, its domain, and request counts to track usage and - attribute it to your organization. This may be shared with our CRM and passed to our - documentation search provider (Kapa) for usage attribution. We don't store the content of - your queries. To revoke your token or delete your data, reply to the token email. -

- - -` -} - -async function parseEmail(request) { - const ct = request.headers.get('content-type') || '' - if (ct.includes('application/json')) { - const body = await request.json().catch(() => ({})) - return body?.email - } - if (ct.includes('application/x-www-form-urlencoded') || ct.includes('multipart/form-data')) { - const form = await request.formData().catch(() => null) - return form?.get('email') - } - // Fallback: try JSON anyway. - const body = await request.json().catch(() => ({})) - return body?.email -} - -export default async (request) => { - if (request.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: { ...CORS, 'Access-Control-Max-Age': '86400' } }) - } - - if (request.method === 'GET') { - const url = new URL(request.url) - if (url.searchParams.get('format') === 'json') { - return json({ - endpoint: REGISTRATION_URL, - method: 'POST', - body: { email: 'you@yourcompany.com' }, - note: 'On success, the token is emailed to you (202). It is never returned in the response.', - docs: DOCS_URL, - }) - } - return new Response(formPage(), { - status: 200, - headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...CORS }, - }) - } - - if (request.method !== 'POST') { - return json({ error: 'method_not_allowed' }, 405, { Allow: 'GET, POST, OPTIONS' }) - } - - const ip = clientIp(request) - if (rateLimited(ip)) { - return json({ error: 'rate_limited', message: 'Too many registration attempts. Try again later.' }, 429) - } - - // --- Validation pipeline (stop at first failure) --- - let email, domain - try { - ;({ email, domain } = normalizeEmail(await parseEmail(request))) // Layer 1: format - } catch { - return json({ error: 'invalid_email', reason: 'invalid_format', message: REASON_MESSAGES.invalid_format }, 400) - } - - const work = isWorkEmail(domain) // Layer 2: work-domain - if (!work.ok) { - return json({ error: 'invalid_email', reason: work.reason, message: REASON_MESSAGES[work.reason] }, 400) - } - - if (!(await hasValidMx(domain))) { // Layer 3: MX - return json({ error: 'invalid_email', reason: 'no_mx', message: REASON_MESSAGES.no_mx }, 400) - } - - // --- Issue + store token --- - const token = generateToken() - const tokenHash = hashToken(token) - try { - await saveRegistration({ tokenHash, email, domain }) - } catch (e) { - console.error('[mcp-register] store write failed', { error: e?.message }) - return json({ error: 'storage_error', message: 'Could not create your token. Please try again.' }, 500) - } - - // --- Layer 4: deliver token by email (ownership proof). Token NOT in response. --- - let sent - try { - sent = await sendTokenEmail({ to: email, token }) - } catch (e) { - console.error('[mcp-register] email send error', { error: e?.message, domain }) - return json({ error: 'email_send_failed', message: 'Could not send the token email. Please try again.' }, 502) - } - if (!sent?.ok) { - return json({ error: 'email_send_failed', message: 'Could not send the token email. Please try again.' }, 502) - } - - // --- Lead-capture side effects (best-effort; never block the response) --- - const emailHash = tokenHash.slice(0, 12) // non-reversible reference for logs - console.log(JSON.stringify({ event: 'mcp_registration', domain, emailHash, ts: new Date().toISOString() })) - - if (process.env.CRM_WEBHOOK_URL) { - fetch(process.env.CRM_WEBHOOK_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, domain, source: 'mcp', timestamp: new Date().toISOString() }), - }).catch((e) => console.warn('[mcp-register] CRM webhook failed', { error: e?.message })) - } - - return json( - { status: 'token_sent', email, message: `Token sent to ${email}. Check your inbox (and spam).` }, - 202 - ) -} - -export const config = { - path: '/mcp/register', - preferStatic: false, -} diff --git a/netlify/functions/mcp.mjs b/netlify/functions/mcp.mjs index 6c54d85b..48cc27ed 100644 --- a/netlify/functions/mcp.mjs +++ b/netlify/functions/mcp.mjs @@ -17,8 +17,9 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { z } from 'zod' import handle from '@modelfetch/netlify' -import { extractBearerToken, hashToken, decideAuth, isAuthEnforced } from './lib/auth.mjs' -import { lookupToken, touchToken } from './lib/store.mjs' +import { extractBearerToken, decideAuth, isAuthEnforced, isWorkEmailRequired } from './lib/auth.mjs' +import { validateToken } from './lib/idp.mjs' +import { recordUser } from './lib/store.mjs' import rateLimiterModule from 'hono-rate-limiter' const makeRateLimiter = @@ -72,9 +73,10 @@ function withTimeout(promise, ms, label) { const computeLimiterKey = (c) => { const h = (name) => c.req.header(name) || '' - // Authenticated requests get per-token limits (set by the auth middleware). + // Authenticated requests get per-user limits (set by the auth middleware). const auth = c.get('auth') - if (auth?.tokenHash) return `tok:${auth.tokenHash}` + if (auth?.sub) return `sub:${auth.sub}` + if (auth?.email) return `email:${auth.email}` const clientKey = h('x-client-key') if (clientKey) return `ck:${clientKey}` @@ -642,41 +644,36 @@ const baseHandler = handle({ legacyHeaders: true, // also send X-RateLimit-* headers }) - // Auth middleware. Runs BEFORE the limiter so authenticated requests can be - // keyed per-token. Never gates OPTIONS or the GET/SSE stream. When auth is - // not enforced (grace period) unauthenticated requests pass through; when - // enforced they get a 401 pointing to the registration page. + // OAuth resource-server middleware. Runs BEFORE the limiter so authenticated + // requests can be keyed per-user. Never gates OPTIONS or the GET/SSE stream. + // Grace period (REQUIRE_AUTH != 'true'): unauthenticated requests pass + // through. Enforced: a 401 points MCP clients at our protected-resource + // metadata so they can sign in with a Redpanda Cloud account. app.use('/mcp', async (c, next) => { const method = c.req.method if (method === 'OPTIONS' || method === 'GET') return next() - const raw = extractBearerToken( - c.req.header('authorization'), - new URL(c.req.url).searchParams.get('token') - ) + const token = extractBearerToken(c.req.header('authorization')) + const claims = token ? await validateToken(token) : null + + const resourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', c.req.url).toString() + const { allow, userContext, response } = decideAuth({ + claims, + enforced: isAuthEnforced(), + workEmailRequired: isWorkEmailRequired(), + resourceMetadataUrl, + }) - let record = null - let tokenHash = null - if (raw) { - tokenHash = hashToken(raw) - record = await lookupToken(tokenHash).catch((e) => { - console.warn('[auth] token lookup failed', { error: e?.message }) - return null - }) + if (userContext) { + c.set('auth', userContext) + recordUser(userContext).catch(() => {}) // fire-and-forget lead capture } - const { allow, userContext, unauthorized } = decideAuth({ record, enforced: isAuthEnforced() }) + if (!allow && response) { + return c.json(response.body, response.status, response.headers) + } - if (userContext) { - c.set('auth', { ...userContext, tokenHash }) - touchToken(tokenHash, record) // fire-and-forget - } else if (!allow && unauthorized) { - const regUrl = new URL('/mcp/register', c.req.url).toString() - const u = { ...unauthorized } - u.headers = { ...u.headers, 'WWW-Authenticate': u.headers['WWW-Authenticate'].replace(/resource_metadata="[^"]*"/, `resource_metadata="${regUrl}"`) } - u.body = { ...u.body, registration_url: regUrl, message: `Register a free token with your work email at ${regUrl}, then send Authorization: Bearer .` } - return c.json(u.body, u.status, u.headers) - } else { + if (!userContext) { // Grace period, unauthenticated: log for adoption tracking. console.log(JSON.stringify({ event: 'mcp_unauthenticated', enforced: false, ua: c.req.header('user-agent') || '' })) } diff --git a/server.json b/server.json index 35731076..749799f5 100644 --- a/server.json +++ b/server.json @@ -1,7 +1,7 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", "name": "com.redpanda/docs-mcp", - "description": "Get authoritative answers about Redpanda documentation and search API references. Requires a free access token: register your work email at https://docs.redpanda.com/mcp/register and send it as Authorization: Bearer .", + "description": "Get authoritative answers about Redpanda documentation and search API references. Sign in with your Redpanda Cloud account (OAuth) when prompted by your MCP client.", "repository": { "url": "https://github.com/redpanda-data/docs-site", "source": "github", diff --git a/tests/mcp-auth.test.ts b/tests/mcp-auth.test.ts index 6372da75..0ec4d012 100644 --- a/tests/mcp-auth.test.ts +++ b/tests/mcp-auth.test.ts @@ -1,28 +1,24 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect } from 'vitest' import { - normalizeEmail, + emailDomain, isWorkEmail, - generateToken, - looksLikeToken, - hashToken, extractBearerToken, + hashToken, buildUnauthorizedResponse, isAuthEnforced, + isWorkEmailRequired, decideAuth, } from '../netlify/functions/lib/auth.mjs' -describe('normalizeEmail (Layer 1: format)', () => { - it('lowercases and trims, returns domain', () => { - expect(normalizeEmail(' Jake@Redpanda.COM ')).toEqual({ email: 'jake@redpanda.com', domain: 'redpanda.com' }) - }) - it('throws on malformed input', () => { - for (const bad of ['', 'nope', 'a@b', 'no domain.com', '@redpanda.com', 'jake@']) { - expect(() => normalizeEmail(bad)).toThrowError() - } +describe('emailDomain', () => { + it('extracts and lowercases the domain', () => { + expect(emailDomain('Jake@Redpanda.COM')).toBe('redpanda.com') + expect(emailDomain('no-at')).toBe('') + expect(emailDomain('')).toBe('') }) }) -describe('isWorkEmail (Layer 2: work-domain filter)', () => { +describe('isWorkEmail', () => { it('accepts work domains', () => { expect(isWorkEmail('redpanda.com')).toEqual({ ok: true }) expect(isWorkEmail('acme-corp.io')).toEqual({ ok: true }) @@ -38,52 +34,44 @@ describe('isWorkEmail (Layer 2: work-domain filter)', () => { }) }) -describe('tokens', () => { - it('generateToken has the rp_mcp_ prefix and is unique', () => { - const tokens = new Set(Array.from({ length: 500 }, () => generateToken())) - expect(tokens.size).toBe(500) - for (const t of tokens) { - expect(t.startsWith('rp_mcp_')).toBe(true) - expect(looksLikeToken(t)).toBe(true) - } - }) - it('hashToken is deterministic 64-hex', () => { - const t = generateToken() - const h = hashToken(t) - expect(h).toMatch(/^[0-9a-f]{64}$/) - expect(hashToken(t)).toBe(h) - expect(hashToken(generateToken())).not.toBe(h) - }) -}) - describe('extractBearerToken', () => { it('parses the Authorization header (case-insensitive)', () => { - expect(extractBearerToken('Bearer abc123', null)).toBe('abc123') - expect(extractBearerToken('bearer xyz ', null)).toBe('xyz') + expect(extractBearerToken('Bearer abc123')).toBe('abc123') + expect(extractBearerToken('bearer xyz ')).toBe('xyz') + }) + it('returns null when absent or non-bearer', () => { + expect(extractBearerToken('')).toBeNull() + expect(extractBearerToken(null)).toBeNull() + expect(extractBearerToken('Basic abc')).toBeNull() }) - it('falls back to the query token', () => { - expect(extractBearerToken('', 'qtok')).toBe('qtok') - expect(extractBearerToken(null, 'qtok')).toBe('qtok') + it('does NOT accept a query token (spec forbids tokens in the URL)', () => { + // single-arg signature only — no query fallback + expect(extractBearerToken(undefined)).toBeNull() }) - it('returns null when absent', () => { - expect(extractBearerToken('', null)).toBeNull() - expect(extractBearerToken('Basic abc', null)).toBeNull() +}) + +describe('hashToken', () => { + it('is deterministic 64-hex and never returns the raw token', () => { + const h = hashToken('rp-secret-token') + expect(h).toMatch(/^[0-9a-f]{64}$/) + expect(hashToken('rp-secret-token')).toBe(h) + expect(h).not.toContain('rp-secret-token') }) }) describe('buildUnauthorizedResponse', () => { - it('returns a 401 with WWW-Authenticate and registration_url', () => { - const r = buildUnauthorizedResponse({ registrationUrl: 'https://x.test/mcp/register' }) + it('returns a 401 with WWW-Authenticate + resource_metadata', () => { + const r = buildUnauthorizedResponse({ resourceMetadataUrl: 'https://x.test/.well-known/oauth-protected-resource' }) expect(r.status).toBe(401) expect(r.headers['WWW-Authenticate']).toContain('Bearer') - expect(r.headers['WWW-Authenticate']).toContain('https://x.test/mcp/register') + expect(r.headers['WWW-Authenticate']).toContain('resource_metadata="https://x.test/.well-known/oauth-protected-resource"') expect(r.body.error).toBe('authentication_required') - expect(r.body.registration_url).toBe('https://x.test/mcp/register') + expect(r.body.resource_metadata).toBe('https://x.test/.well-known/oauth-protected-resource') }) }) -describe('isAuthEnforced', () => { - it('only true when REQUIRE_AUTH === "true"', () => { +describe('config flags', () => { + it('isAuthEnforced only true when REQUIRE_AUTH === "true"', () => { const orig = process.env.REQUIRE_AUTH process.env.REQUIRE_AUTH = 'true' expect(isAuthEnforced()).toBe(true) @@ -95,52 +83,48 @@ describe('isAuthEnforced', () => { expect(isAuthEnforced()).toBe(false) if (orig !== undefined) process.env.REQUIRE_AUTH = orig }) + it('isWorkEmailRequired defaults true, false only when explicitly "false"', () => { + const orig = process.env.REQUIRE_WORK_EMAIL + delete process.env.REQUIRE_WORK_EMAIL + expect(isWorkEmailRequired()).toBe(true) + process.env.REQUIRE_WORK_EMAIL = 'false' + expect(isWorkEmailRequired()).toBe(false) + process.env.REQUIRE_WORK_EMAIL = 'true' + expect(isWorkEmailRequired()).toBe(true) + if (orig === undefined) delete process.env.REQUIRE_WORK_EMAIL + else process.env.REQUIRE_WORK_EMAIL = orig + }) }) describe('decideAuth matrix', () => { - const record = { email: 'jake@redpanda.com', domain: 'redpanda.com', revoked: false } + const workClaims = { sub: 'auth0|123', email: 'jake@redpanda.com', email_verified: true } + const freeClaims = { sub: 'auth0|456', email: 'someone@gmail.com', email_verified: true } - it('valid record -> allow + context', () => { - const r = decideAuth({ record, enforced: true }) + it('no token + grace -> allow with null context', () => { + const r = decideAuth({ claims: null, enforced: false, workEmailRequired: true }) expect(r.allow).toBe(true) - expect(r.userContext).toEqual({ email: 'jake@redpanda.com', domain: 'redpanda.com' }) - expect(r.unauthorized).toBeNull() + expect(r.userContext).toBeNull() + expect(r.response).toBeNull() }) - it('revoked record + enforced -> 401', () => { - const r = decideAuth({ record: { ...record, revoked: true }, enforced: true }) + it('no token + enforced -> 401', () => { + const r = decideAuth({ claims: null, enforced: true, workEmailRequired: true }) expect(r.allow).toBe(false) - expect(r.unauthorized.status).toBe(401) + expect(r.response.status).toBe(401) }) - it('no token + grace -> allow with null context', () => { - const r = decideAuth({ record: null, enforced: false }) + it('valid work email -> allow + context', () => { + const r = decideAuth({ claims: workClaims, enforced: true, workEmailRequired: true }) expect(r.allow).toBe(true) - expect(r.userContext).toBeNull() - expect(r.unauthorized).toBeNull() + expect(r.userContext).toEqual({ sub: 'auth0|123', email: 'jake@redpanda.com', domain: 'redpanda.com', emailVerified: true }) }) - it('no token + enforced -> 401', () => { - const r = decideAuth({ record: null, enforced: true }) + it('free email + work required -> 403 forbidden', () => { + const r = decideAuth({ claims: freeClaims, enforced: true, workEmailRequired: true }) expect(r.allow).toBe(false) - expect(r.unauthorized.status).toBe(401) + expect(r.response.status).toBe(403) + expect(r.response.body.error).toBe('work_email_required') }) -}) - -describe('hasValidMx (Layer 3: MX) with mocked resolver', () => { - it('accepts a domain with MX records, rejects NXDOMAIN', async () => { - vi.resetModules() - vi.doMock('node:dns', () => ({ - promises: { - resolveMx: vi.fn(async (d: string) => { - if (d === 'redpanda.com') return [{ exchange: 'mx.redpanda.com', priority: 10 }] - throw new Error('ENOTFOUND') - }), - resolve: vi.fn(async () => { - throw new Error('ENOTFOUND') - }), - }, - })) - const { hasValidMx } = await import('../netlify/functions/lib/email.mjs') - expect(await hasValidMx('redpanda.com')).toBe(true) - expect(await hasValidMx('nope-not-a-real-domain-xyz.com')).toBe(false) - vi.doUnmock('node:dns') + it('free email + work NOT required -> allow + context', () => { + const r = decideAuth({ claims: freeClaims, enforced: true, workEmailRequired: false }) + expect(r.allow).toBe(true) + expect(r.userContext.email).toBe('someone@gmail.com') }) }) From a669b2cce3ed37a2650d87becc38b5ea90b9b088 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:55:39 +0000 Subject: [PATCH 07/61] chore: bump server.json version to 2026.06.15+pr181-852c563 (PR #181) --- server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.json b/server.json index 749799f5..e603fd9b 100644 --- a/server.json +++ b/server.json @@ -7,7 +7,7 @@ "source": "github", "subfolder": "netlify" }, - "version": "2026.06.15+pr181-75928f7", + "version": "2026.06.15+pr181-852c563", "remotes": [ { "type": "streamable-http", From 3e0d7ca786f15af787abef02e60d6de76ca86617 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 15 Jun 2026 13:27:50 +0100 Subject: [PATCH 08/61] chore: trigger preview redeploy to pick up REQUIRE_AUTH Co-Authored-By: Claude Opus 4.8 From dc655b18700235458372ee8c2c512b41d450f718 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 15 Jun 2026 13:28:14 +0100 Subject: [PATCH 09/61] chore: trigger preview redeploy to pick up REQUIRE_AUTH Co-Authored-By: Claude Opus 4.8 From e751010c88c6d1df93044a079801867b39f9614a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:28:31 +0000 Subject: [PATCH 10/61] chore: bump server.json version to 2026.06.15+pr181-a59f207 (PR #181) --- server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.json b/server.json index e603fd9b..fc86cfe0 100644 --- a/server.json +++ b/server.json @@ -7,7 +7,7 @@ "source": "github", "subfolder": "netlify" }, - "version": "2026.06.15+pr181-852c563", + "version": "2026.06.15+pr181-a59f207", "remotes": [ { "type": "streamable-http", From c0b9280ac8a5ffad10ed46f7f311874ae1ab7afa Mon Sep 17 00:00:00 2001 From: Jake Cahill <45230295+JakeSCahill@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:20:50 +0100 Subject: [PATCH 11/61] Apply suggestion from @JakeSCahill --- data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc b/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc index c1e30117..f67a6aba 100644 --- a/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc +++ b/data-platform/modules/ROOT/pages/how-to-use-these-docs.adoc @@ -95,7 +95,7 @@ There's no token to copy or paste. Any MCP client that supports the standard MCP [NOTE] ==== -*What we collect and why.* When you sign in, we receive your verified email address and organization from Redpanda Cloud, which we use to attribute documentation usage to your organization. This may be shared with our customer systems and passed to our documentation search provider (Kapa) for usage attribution. We don't store the content of your queries. +*What we collect and why.* When you sign in, we receive your verified email address and organization from Redpanda Cloud, which we use to attribute documentation usage to your organization. This may be shared with our customer systems and passed to our documentation search provider (Kapa) for usage attribution. ==== During the initial rollout, authentication is optional and existing connections continue to work. After a transition period, sign-in will be required. From 70623c0cf382befb3a4865cc616e7a4a12edd240 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:21:05 +0000 Subject: [PATCH 12/61] chore: bump server.json version to 2026.06.15+pr181-27a3294 (PR #181) --- server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.json b/server.json index fc86cfe0..47814d72 100644 --- a/server.json +++ b/server.json @@ -7,7 +7,7 @@ "source": "github", "subfolder": "netlify" }, - "version": "2026.06.15+pr181-a59f207", + "version": "2026.06.15+pr181-27a3294", "remotes": [ { "type": "streamable-http", From e8aac4cf125379830bbd21a60d519f94471ec02b Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 15 Jun 2026 14:25:59 +0100 Subject: [PATCH 13/61] Remove unused exports from MCP auth modules Drop the unused OAUTH_ISSUER export from idp.mjs and de-export the FREE_EMAIL_DOMAINS / DISPOSABLE_DOMAINS sets (used only internally). No behavior change. Co-Authored-By: Claude Opus 4.8 --- netlify/functions/lib/auth.mjs | 4 ++-- netlify/functions/lib/idp.mjs | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/netlify/functions/lib/auth.mjs b/netlify/functions/lib/auth.mjs index d099ad96..246636b6 100644 --- a/netlify/functions/lib/auth.mjs +++ b/netlify/functions/lib/auth.mjs @@ -15,7 +15,7 @@ import { createHash } from 'node:crypto' // -------------------- Email classification -------------------- // Free consumer providers — rejected when a work email is required. -export const FREE_EMAIL_DOMAINS = new Set([ +const FREE_EMAIL_DOMAINS = new Set([ 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk', 'yahoo.co.in', 'ymail.com', 'outlook.com', 'hotmail.com', 'hotmail.co.uk', 'live.com', 'msn.com', 'icloud.com', 'me.com', 'mac.com', 'proton.me', 'protonmail.com', @@ -24,7 +24,7 @@ export const FREE_EMAIL_DOMAINS = new Set([ ]) // Disposable / throwaway providers. Best-effort seed list, not exhaustive. -export const DISPOSABLE_DOMAINS = new Set([ +const DISPOSABLE_DOMAINS = new Set([ 'mailinator.com', '10minutemail.com', 'guerrillamail.com', 'guerrillamail.info', 'sharklasers.com', 'getnada.com', 'nada.email', 'yopmail.com', 'trashmail.com', 'tempmail.com', 'temp-mail.org', 'throwawaymail.com', 'maildrop.cc', diff --git a/netlify/functions/lib/idp.mjs b/netlify/functions/lib/idp.mjs index 459de9c9..8a9c3e14 100644 --- a/netlify/functions/lib/idp.mjs +++ b/netlify/functions/lib/idp.mjs @@ -63,5 +63,3 @@ export async function validateToken(token) { clearTimeout(t) } } - -export const OAUTH_ISSUER = ISSUER From a5cf8b9ce2a8f7fb963d2f65bd4aa30dc3a08b83 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:26:18 +0000 Subject: [PATCH 14/61] chore: bump server.json version to 2026.06.15+pr181-20381aa (PR #181) --- server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.json b/server.json index 47814d72..992da28c 100644 --- a/server.json +++ b/server.json @@ -7,7 +7,7 @@ "source": "github", "subfolder": "netlify" }, - "version": "2026.06.15+pr181-27a3294", + "version": "2026.06.15+pr181-20381aa", "remotes": [ { "type": "streamable-http", From 59be719b5257e750869828cf20b1adfe6d36db50 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 15 Jun 2026 14:56:15 +0100 Subject: [PATCH 15/61] TEMP: add CIMD probe client metadata document (to be removed) Co-Authored-By: Claude Opus 4.8 --- netlify/edge-functions/mcp-test-client.ts | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 netlify/edge-functions/mcp-test-client.ts diff --git a/netlify/edge-functions/mcp-test-client.ts b/netlify/edge-functions/mcp-test-client.ts new file mode 100644 index 00000000..f8e9aa62 --- /dev/null +++ b/netlify/edge-functions/mcp-test-client.ts @@ -0,0 +1,27 @@ +// TEMPORARY — CIMD probe. Serves a valid OAuth client metadata document +// (RFC 7591 / SEP-991) whose client_id equals its own URL, so we can test +// whether the Cloud IdP fetches & honors Client ID Metadata Documents. +// Remove after the test. +export default async (request: Request) => { + const url = new URL(request.url); + const clientId = `${url.origin}/mcp-test-client.json`; + const doc = { + client_id: clientId, + client_name: "Redpanda Docs MCP CIMD probe", + redirect_uris: ["https://example.com/cb"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "openid email profile", + }; + return new Response(JSON.stringify(doc, null, 2), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": "*", + }, + }); +}; + +export const config = { path: "/mcp-test-client.json" }; From b3927cdf808595b0aa624927a22593ed96f5d8a1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:56:43 +0000 Subject: [PATCH 16/61] chore: bump server.json version to 2026.06.15+pr181-00193c2 (PR #181) --- server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.json b/server.json index 992da28c..be8907e7 100644 --- a/server.json +++ b/server.json @@ -7,7 +7,7 @@ "source": "github", "subfolder": "netlify" }, - "version": "2026.06.15+pr181-20381aa", + "version": "2026.06.15+pr181-00193c2", "remotes": [ { "type": "streamable-http", From b2def9e4cc190260fb97cd91b4ce75defd9aa36a Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 15 Jun 2026 15:10:25 +0100 Subject: [PATCH 17/61] Remove temporary CIMD probe client doc The probe confirmed CIMD is not enabled on the Cloud IdP (a valid client metadata document used as client_id still returns 'Unknown client'). Co-Authored-By: Claude Opus 4.8 --- netlify/edge-functions/mcp-test-client.ts | 27 ----------------------- 1 file changed, 27 deletions(-) delete mode 100644 netlify/edge-functions/mcp-test-client.ts diff --git a/netlify/edge-functions/mcp-test-client.ts b/netlify/edge-functions/mcp-test-client.ts deleted file mode 100644 index f8e9aa62..00000000 --- a/netlify/edge-functions/mcp-test-client.ts +++ /dev/null @@ -1,27 +0,0 @@ -// TEMPORARY — CIMD probe. Serves a valid OAuth client metadata document -// (RFC 7591 / SEP-991) whose client_id equals its own URL, so we can test -// whether the Cloud IdP fetches & honors Client ID Metadata Documents. -// Remove after the test. -export default async (request: Request) => { - const url = new URL(request.url); - const clientId = `${url.origin}/mcp-test-client.json`; - const doc = { - client_id: clientId, - client_name: "Redpanda Docs MCP CIMD probe", - redirect_uris: ["https://example.com/cb"], - token_endpoint_auth_method: "none", - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "openid email profile", - }; - return new Response(JSON.stringify(doc, null, 2), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-store", - "Access-Control-Allow-Origin": "*", - }, - }); -}; - -export const config = { path: "/mcp-test-client.json" }; From f3212e066a96304e986b6f22f171d49bd6590c36 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:10:47 +0000 Subject: [PATCH 18/61] chore: bump server.json version to 2026.06.15+pr181-1fe3d6c (PR #181) --- server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.json b/server.json index be8907e7..ed52274a 100644 --- a/server.json +++ b/server.json @@ -7,7 +7,7 @@ "source": "github", "subfolder": "netlify" }, - "version": "2026.06.15+pr181-00193c2", + "version": "2026.06.15+pr181-1fe3d6c", "remotes": [ { "type": "streamable-http", From fcee882fa4a5a7d13606b82a070bc9814255fb0e Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 16 Jun 2026 16:24:53 +0100 Subject: [PATCH 19/61] Start production AS scaffold: jose + storage layer, federate to Auth0 (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the superseded resource-server-pointing-at-Cloud approach with the agreed broker architecture: our service is the OAuth 2.1 Authorization Server, federating the human login upstream to Auth0 and issuing/validating its own tokens. Ports the validated spike to production shape. Added (Milestone 1 — AS core): - lib/oauth/keys.mjs — jose RS256 sign/verify + JWKS; key from env (MCP_OAUTH_SIGNING_JWK) or dev-generated + persisted in Blobs (the spike proved an in-memory key breaks the flow) - lib/oauth/store.mjs — auth requests + auth codes on Netlify Blobs (interface is the seam for a Netlify DB/Neon backend when relational queries are needed) - lib/oauth/pkce.mjs, config.mjs, upstream.mjs (Auth0 + dev mock federation, id_token validated against Auth0 JWKS) - mcp-oauth.mjs — AS endpoints: discovery (RFC 8414), JWKS, /authorize, /mcp/callback, /token (authorization_code + PKCE) Changed: - mcp.mjs resource server now validates OUR OWN access tokens (jose) instead of calling the upstream /userinfo - protected-resource metadata + server card point authorization_servers at us - removed lib/idp.mjs (superseded /userinfo validation) Deferred (clearly marked): DCR/CIMD client registration (M2), refresh_token grant + rotation (M3), consent UI, revocation. Neon backend is a documented swap behind the store interface (needs Netlify DB provisioning). Auth0 mode needs Santi's client_id; defaults to a dev mock until then. Tests: 22 pass (PKCE incl. RFC 7636 vector; JWT issue/verify; JWKS leaks no private key; wrong-audience/tampered rejected). Co-Authored-By: Claude Opus 4.8 --- netlify/edge-functions/mcp-oauth-metadata.ts | 9 +- netlify/edge-functions/mcp-server-card.ts | 4 +- netlify/functions/lib/idp.mjs | 65 -------- netlify/functions/lib/oauth/config.mjs | 39 +++++ netlify/functions/lib/oauth/keys.mjs | 82 ++++++++++ netlify/functions/lib/oauth/pkce.mjs | 15 ++ netlify/functions/lib/oauth/store.mjs | 51 ++++++ netlify/functions/lib/oauth/upstream.mjs | 67 ++++++++ netlify/functions/mcp-oauth.mjs | 155 +++++++++++++++++++ netlify/functions/mcp.mjs | 9 +- package.json | 1 + tests/mcp-oauth.test.ts | 61 ++++++++ 12 files changed, 484 insertions(+), 74 deletions(-) delete mode 100644 netlify/functions/lib/idp.mjs create mode 100644 netlify/functions/lib/oauth/config.mjs create mode 100644 netlify/functions/lib/oauth/keys.mjs create mode 100644 netlify/functions/lib/oauth/pkce.mjs create mode 100644 netlify/functions/lib/oauth/store.mjs create mode 100644 netlify/functions/lib/oauth/upstream.mjs create mode 100644 netlify/functions/mcp-oauth.mjs create mode 100644 tests/mcp-oauth.test.ts diff --git a/netlify/edge-functions/mcp-oauth-metadata.ts b/netlify/edge-functions/mcp-oauth-metadata.ts index 6d19afb1..b5409fd8 100644 --- a/netlify/edge-functions/mcp-oauth-metadata.ts +++ b/netlify/edge-functions/mcp-oauth-metadata.ts @@ -1,17 +1,16 @@ // OAuth 2.0 Protected Resource Metadata (RFC 9728) for the MCP server. // MCP clients (ChatGPT, Claude, Cursor, …) fetch this to discover the -// authorization server, then run the OAuth login against Redpanda Cloud. +// authorization server. That AS is OUR OWN service (which federates the human +// login to the Redpanda Cloud IdP), so authorization_servers points back at +// this origin, where /.well-known/oauth-authorization-server lives. // https://datatracker.ietf.org/doc/html/rfc9728 -const AUTH_SERVER = - Deno.env.get("REDPANDA_OAUTH_ISSUER") || "https://auth.prd.cloud.redpanda.com/"; - export default async (request: Request) => { const origin = new URL(request.url).origin; const metadata = { resource: `${origin}/mcp`, - authorization_servers: [AUTH_SERVER], + authorization_servers: [origin], bearer_methods_supported: ["header"], scopes_supported: ["openid", "email", "profile"], resource_documentation: `${origin}/data-platform/how-to-use-these-docs#authentication`, diff --git a/netlify/edge-functions/mcp-server-card.ts b/netlify/edge-functions/mcp-server-card.ts index 2453bd9d..1bf387d9 100644 --- a/netlify/edge-functions/mcp-server-card.ts +++ b/netlify/edge-functions/mcp-server-card.ts @@ -23,8 +23,8 @@ export default async (request: Request) => { type: "oauth2", required: false, protected_resource_metadata: `${siteUrl}/.well-known/oauth-protected-resource`, - authorization_servers: ["https://auth.prd.cloud.redpanda.com/"], - description: "Sign in with your Redpanda Cloud account. MCP clients discover the OAuth flow via the protected-resource metadata and obtain a token automatically." + authorization_servers: [siteUrl], + description: "Sign in with your Redpanda Cloud account. MCP clients discover the OAuth flow via the protected-resource metadata and obtain a token automatically (this site is the authorization server; it federates login to Redpanda Cloud)." }, metadata: { homepage: `${siteUrl}`, diff --git a/netlify/functions/lib/idp.mjs b/netlify/functions/lib/idp.mjs deleted file mode 100644 index 8a9c3e14..00000000 --- a/netlify/functions/lib/idp.mjs +++ /dev/null @@ -1,65 +0,0 @@ -// Redpanda Cloud IdP token validation for the MCP server. -// -------------------------------------------------------- -// Impure (network). Validates an incoming OAuth access token by calling the -// Cloud IdP's /userinfo endpoint and returns the user's claims (sub, email, -// email_verified, …). This works with the opaque access tokens Auth0 issues -// when no custom API/audience is registered. -// -// Production hardening (needs the identity team): register an Auth0 API for the -// MCP resource so access tokens are audience-bound JWTs, then validate via JWKS -// for audience binding instead of (or in addition to) /userinfo. - -import { hashToken } from './auth.mjs' - -const ISSUER = process.env.REDPANDA_OAUTH_ISSUER || 'https://auth.prd.cloud.redpanda.com/' -const USERINFO_URL = - process.env.REDPANDA_OAUTH_USERINFO || new URL('/userinfo', ISSUER).toString() - -const USERINFO_TIMEOUT_MS = 6_000 -const CACHE_TTL_MS = 5 * 60 * 1000 - -// Per-token cache, reused across warm invocations, to avoid hitting /userinfo on -// every request. Keyed by token hash; never stores the raw token. -const cache = new Map() - -function cacheGet(key) { - const hit = cache.get(key) - if (!hit) return undefined - if (hit.exp <= Date.now()) { - cache.delete(key) - return undefined - } - return hit.claims -} - -// Validate the bearer token. Returns the claims object on success, or null if -// the token is missing/invalid/expired or the IdP is unreachable. -export async function validateToken(token) { - if (!token) return null - const key = hashToken(token) - - const cached = cacheGet(key) - if (cached !== undefined) return cached - - const controller = new AbortController() - const t = setTimeout(() => controller.abort(), USERINFO_TIMEOUT_MS) - try { - const res = await fetch(USERINFO_URL, { - headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, - signal: controller.signal, - }) - if (!res.ok) { - // Negatively cache invalid tokens briefly to blunt repeated bad calls. - cache.set(key, { claims: null, exp: Date.now() + 30_000 }) - return null - } - const claims = await res.json() - cache.set(key, { claims, exp: Date.now() + CACHE_TTL_MS }) - return claims - } catch (e) { - console.warn('[oauth] userinfo validation failed', { error: e?.message }) - return null - } finally { - clearTimeout(t) - } -} diff --git a/netlify/functions/lib/oauth/config.mjs b/netlify/functions/lib/oauth/config.mjs new file mode 100644 index 00000000..9616f2f3 --- /dev/null +++ b/netlify/functions/lib/oauth/config.mjs @@ -0,0 +1,39 @@ +// Central config for the docs MCP OAuth 2.1 Authorization Server. +// The issuer is derived per-request from the origin (works across prod + +// deploy previews); everything else is env-tunable. + +export const PATHS = { + metadata: '/.well-known/oauth-authorization-server', + jwks: '/.well-known/jwks.json', + authorize: '/oauth/authorize', + token: '/oauth/token', + callback: '/mcp/callback', // matches the redirect URI registered in Auth0 + mockIdp: '/oauth/mock-idp/authorize', // dev only +} + +export const SCOPES = ['openid', 'email', 'profile'] +export const ACCESS_TOKEN_TTL_SEC = Number(process.env.MCP_OAUTH_ACCESS_TTL || 3600) +export const AUTH_REQUEST_TTL_SEC = 600 +export const AUTH_CODE_TTL_SEC = 60 + +// Upstream IdP (Redpanda Cloud Auth0). Defaults to 'mock' for local dev until a +// real client_id is configured. +export const UPSTREAM_MODE = + process.env.MCP_OAUTH_UPSTREAM || (process.env.REDPANDA_OAUTH_CLIENT_ID ? 'auth0' : 'mock') +export const AUTH0_ISSUER = process.env.REDPANDA_OAUTH_ISSUER || 'https://auth.prd.cloud.redpanda.com/' +export const AUTH0_CLIENT_ID = process.env.REDPANDA_OAUTH_CLIENT_ID // public client, no secret +export const REQUIRE_WORK_EMAIL = process.env.REQUIRE_WORK_EMAIL !== 'false' + +// AS issuer = the public origin of the request (e.g. https://docs.redpanda.com). +export function issuerFor(origin) { + return origin +} +export function endpoints(origin) { + return { + issuer: origin, + authorization_endpoint: `${origin}${PATHS.authorize}`, + token_endpoint: `${origin}${PATHS.token}`, + jwks_uri: `${origin}${PATHS.jwks}`, + callback_uri: `${origin}${PATHS.callback}`, + } +} diff --git a/netlify/functions/lib/oauth/keys.mjs b/netlify/functions/lib/oauth/keys.mjs new file mode 100644 index 00000000..853796a2 --- /dev/null +++ b/netlify/functions/lib/oauth/keys.mjs @@ -0,0 +1,82 @@ +// Signing-key management + JWT issue/verify for our AS, using `jose`. +// +// Key source: +// - Production: load the RS256 keypair from env `MCP_OAUTH_SIGNING_JWK` +// (a JSON object {privateJwk, publicJwk, kid}). +// - Dev/no-env: generate once and persist to Netlify Blobs, so the key is +// stable across invocations (the spike proved an in-memory key breaks the +// flow — tokens signed at /token wouldn't verify at /mcp). +// +// PRODUCTION TODO: key rotation (publish multiple JWKS entries; sign with newest). + +import { SignJWT, jwtVerify, importJWK, exportJWK, generateKeyPair, calculateJwkThumbprint } from 'jose' +import { getStore } from '@netlify/blobs' + +const ALG = 'RS256' +const KEY_STORE = 'mcp-oauth-keys' +const KEY_NAME = 'active' + +let cache = null // { privateKey, publicJwk, kid } + +async function materialize({ privateJwk, publicJwk, kid }) { + const privateKey = await importJWK(privateJwk, ALG) + return { privateKey, publicJwk: { ...publicJwk, kid, alg: ALG, use: 'sig' }, kid } +} + +async function generate() { + const { publicKey, privateKey } = await generateKeyPair(ALG, { extractable: true }) + const publicJwk = await exportJWK(publicKey) + const privateJwk = await exportJWK(privateKey) + const kid = await calculateJwkThumbprint(publicJwk) + return { privateJwk, publicJwk, kid } +} + +async function loadKeys() { + if (cache) return cache + + const fromEnv = process.env.MCP_OAUTH_SIGNING_JWK + if (fromEnv) { + cache = await materialize(JSON.parse(fromEnv)) + return cache + } + + // Dev: persist a generated key in Blobs so it survives warm invocations. + const store = getStore(KEY_STORE) + let stored = await store.get(KEY_NAME, { type: 'json' }).catch(() => null) + if (!stored) { + stored = await generate() + await store.setJSON(KEY_NAME, stored).catch(() => {}) + } + cache = await materialize(stored) + return cache +} + +export async function getJwks() { + const { publicJwk } = await loadKeys() + return { keys: [publicJwk] } +} + +export async function signAccessToken(claims, { issuer, audience, ttlSec }) { + const { privateKey, kid } = await loadKeys() + return new SignJWT(claims) + .setProtectedHeader({ alg: ALG, kid, typ: 'JWT' }) + .setIssuedAt() + .setIssuer(issuer) + .setAudience(audience) + .setExpirationTime(`${ttlSec}s`) + .sign(privateKey) +} + +export async function verifyAccessToken(token, { issuer, audience }) { + const { privateKey } = await loadKeys() + // For a single local key we can verify with the private key's public half; + // importing the public JWK keeps it explicit. + const { publicJwk } = await loadKeys() + const publicKey = await importJWK(publicJwk, ALG) + try { + const { payload } = await jwtVerify(token, publicKey, { issuer, audience }) + return { valid: true, claims: payload } + } catch (e) { + return { valid: false, error: e?.code || e?.message || 'invalid_token' } + } +} diff --git a/netlify/functions/lib/oauth/pkce.mjs b/netlify/functions/lib/oauth/pkce.mjs new file mode 100644 index 00000000..cf1fee20 --- /dev/null +++ b/netlify/functions/lib/oauth/pkce.mjs @@ -0,0 +1,15 @@ +// PKCE helpers (RFC 7636), S256 only. +import { createHash, randomBytes } from 'node:crypto' + +export const s256 = (verifier) => createHash('sha256').update(verifier).digest('base64url') + +export function verifyChallenge(verifier, challenge) { + if (!verifier || !challenge) return false + return s256(verifier) === challenge +} + +// Generate a verifier/challenge pair for our own (upstream) leg of the flow. +export function generatePair() { + const verifier = randomBytes(32).toString('base64url') + return { verifier, challenge: s256(verifier) } +} diff --git a/netlify/functions/lib/oauth/store.mjs b/netlify/functions/lib/oauth/store.mjs new file mode 100644 index 00000000..54f4a00e --- /dev/null +++ b/netlify/functions/lib/oauth/store.mjs @@ -0,0 +1,51 @@ +// AS state storage — auth requests (in-flight) + authorization codes. +// +// Backed by Netlify Blobs (available today; key-value fits the AS state, which +// is all keyed lookups by id/hash). The interface below is the seam for a +// Netlify DB (Neon Postgres) backend when relational queries/analytics are +// needed — swap the four functions; callers don't change. +// +// NOTE: refresh tokens (Milestone 3) and registered clients/DCR (Milestone 2) +// will add tables/namespaces here. + +import { getStore } from '@netlify/blobs' +import { randomUUID, randomBytes } from 'node:crypto' +import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from './config.mjs' + +const STORE = 'mcp-oauth' +const AR = (id) => `ar:${id}` // auth request +const AC = (code) => `ac:${code}` // authorization code + +function store() { + return getStore(STORE) +} +const expired = (rec) => !rec || Date.now() > rec.expiresAt + +export async function putAuthRequest(data) { + const id = randomUUID() + await store().setJSON(AR(id), { ...data, expiresAt: Date.now() + AUTH_REQUEST_TTL_SEC * 1000 }) + return id +} + +export async function takeAuthRequest(id) { + if (!id) return null + const key = AR(id) + const rec = await store().get(key, { type: 'json' }).catch(() => null) + await store().delete(key).catch(() => {}) // one-time + return expired(rec) ? null : rec +} + +export async function putAuthCode(data) { + const code = randomBytes(32).toString('base64url') + await store().setJSON(AC(code), { ...data, used: false, expiresAt: Date.now() + AUTH_CODE_TTL_SEC * 1000 }) + return code +} + +export async function takeAuthCode(code) { + if (!code) return null + const key = AC(code) + const rec = await store().get(key, { type: 'json' }).catch(() => null) + if (expired(rec) || rec.used) return null + await store().delete(key).catch(() => {}) // one-time use + return rec +} diff --git a/netlify/functions/lib/oauth/upstream.mjs b/netlify/functions/lib/oauth/upstream.mjs new file mode 100644 index 00000000..6d3fd60c --- /dev/null +++ b/netlify/functions/lib/oauth/upstream.mjs @@ -0,0 +1,67 @@ +// Upstream IdP federation (the human login leg). +// mode 'auth0' — real Redpanda Cloud Auth0 (public client + PKCE). +// mode 'mock' — dev stand-in so the flow runs with no client_id yet. + +import { jwtVerify, createRemoteJWKSet, decodeJwt } from 'jose' +import { UPSTREAM_MODE, AUTH0_ISSUER, AUTH0_CLIENT_ID, PATHS } from './config.mjs' + +let jwksCache = null +function auth0Jwks() { + if (!jwksCache) jwksCache = createRemoteJWKSet(new URL('.well-known/jwks.json', AUTH0_ISSUER)) + return jwksCache +} + +// URL we redirect the user to in order to authenticate upstream. +export function buildAuthorizeUrl({ origin, state, redirectUri, codeChallenge }) { + if (UPSTREAM_MODE === 'auth0') { + if (!AUTH0_CLIENT_ID) throw new Error('REDPANDA_OAUTH_CLIENT_ID required for auth0 mode') + const u = new URL('authorize', AUTH0_ISSUER) + u.searchParams.set('response_type', 'code') + u.searchParams.set('client_id', AUTH0_CLIENT_ID) + u.searchParams.set('redirect_uri', redirectUri) + u.searchParams.set('scope', 'openid email profile') + u.searchParams.set('state', state) + u.searchParams.set('code_challenge', codeChallenge) + u.searchParams.set('code_challenge_method', 'S256') + return u.toString() + } + const u = new URL(PATHS.mockIdp, origin) + u.searchParams.set('state', state) + u.searchParams.set('redirect_uri', redirectUri) + return u.toString() +} + +// Exchange the upstream code for the user's verified identity claims. +export async function exchangeCode({ code, codeVerifier, redirectUri }) { + if (UPSTREAM_MODE === 'auth0') { + const res = await fetch(new URL('oauth/token', AUTH0_ISSUER), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: AUTH0_CLIENT_ID, + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri, + }), + }) + if (!res.ok) throw new Error(`upstream token exchange failed: ${res.status}`) + const tok = await res.json() + // Validate the ID token against Auth0's JWKS (sig/iss/aud/exp). + const { payload } = await jwtVerify(tok.id_token, auth0Jwks(), { + issuer: AUTH0_ISSUER, + audience: AUTH0_CLIENT_ID, + }) + return { + sub: payload.sub, + email: payload.email, + email_verified: payload.email_verified === true, + org_id: payload.org_id || null, + org_name: payload.org_name || null, + } + } + // mock: canned verified identity + return { sub: 'mock|123', email: 'spike@redpanda.com', email_verified: true, org_id: 'org_mock', org_name: 'Mock Org' } +} + +export { UPSTREAM_MODE } diff --git a/netlify/functions/mcp-oauth.mjs b/netlify/functions/mcp-oauth.mjs new file mode 100644 index 00000000..7558f624 --- /dev/null +++ b/netlify/functions/mcp-oauth.mjs @@ -0,0 +1,155 @@ +// Docs MCP OAuth 2.1 Authorization Server. +// ---------------------------------------- +// Our service is the AS for MCP clients (ChatGPT, Claude, …) and federates the +// human login upstream to the Redpanda Cloud IdP (Auth0). It issues our own +// signed access tokens; the /mcp resource server validates them. +// +// Implemented (Milestone 1): discovery, JWKS, /authorize (PKCE), /callback +// (federation), /token (authorization_code + PKCE). +// Deferred: DCR/CIMD client registration (M2), refresh_token grant (M3), +// consent screen, revocation. + +import { getJwks, signAccessToken } from './lib/oauth/keys.mjs' +import { putAuthRequest, takeAuthRequest, putAuthCode, takeAuthCode } from './lib/oauth/store.mjs' +import { buildAuthorizeUrl, exchangeCode, UPSTREAM_MODE } from './lib/oauth/upstream.mjs' +import { verifyChallenge, generatePair } from './lib/oauth/pkce.mjs' +import { PATHS, SCOPES, ACCESS_TOKEN_TTL_SEC, REQUIRE_WORK_EMAIL, endpoints } from './lib/oauth/config.mjs' +import { isWorkEmail, emailDomain } from './lib/auth.mjs' +import { recordUser } from './lib/store.mjs' + +const json = (body, status = 200) => + new Response(JSON.stringify(body, null, 2), { + status, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + }) +const redirect = (location) => new Response(null, { status: 302, headers: { Location: location } }) + +// Send an OAuth error back to the downstream client's redirect_uri. +function clientError(redirectUri, state, error, description) { + const u = new URL(redirectUri) + u.searchParams.set('error', error) + if (description) u.searchParams.set('error_description', description) + if (state) u.searchParams.set('state', state) + return redirect(u.toString()) +} + +export default async (request) => { + const url = new URL(request.url) + const origin = url.origin + const path = url.pathname + const q = url.searchParams + const ep = endpoints(origin) + + // -------- Discovery (RFC 8414) -------- + if (path === PATHS.metadata) { + return json({ + issuer: ep.issuer, + authorization_endpoint: ep.authorization_endpoint, + token_endpoint: ep.token_endpoint, + jwks_uri: ep.jwks_uri, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], // + refresh_token (M3) + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + scopes_supported: SCOPES, + }) + } + + if (path === PATHS.jwks) return json(await getJwks()) + + // -------- /authorize: downstream client starts the flow -------- + if (path === PATHS.authorize) { + const clientId = q.get('client_id') + const redirectUri = q.get('redirect_uri') + const codeChallenge = q.get('code_challenge') + if (!clientId || !redirectUri) return json({ error: 'invalid_request', error_description: 'client_id and redirect_uri required' }, 400) + if (!codeChallenge || q.get('code_challenge_method') !== 'S256') { + return clientError(redirectUri, q.get('state'), 'invalid_request', 'PKCE S256 required') + } + + const upstream = generatePair() // our PKCE for the upstream leg + const reqId = await putAuthRequest({ + clientId, + clientRedirectUri: redirectUri, + clientState: q.get('state') || '', + clientCodeChallenge: codeChallenge, + upstreamVerifier: upstream.verifier, + }) + + return redirect( + buildAuthorizeUrl({ origin, state: reqId, redirectUri: ep.callback_uri, codeChallenge: upstream.challenge }) + ) + } + + // -------- Dev-only mock upstream -------- + if (path === PATHS.mockIdp) { + if (UPSTREAM_MODE !== 'mock') return json({ error: 'not_found' }, 404) + const back = new URL(q.get('redirect_uri')) + back.searchParams.set('code', 'mock-upstream-code') + back.searchParams.set('state', q.get('state')) + return redirect(back.toString()) + } + + // -------- /callback: upstream returns; we mint our own code -------- + if (path === PATHS.callback) { + const authReq = await takeAuthRequest(q.get('state')) + if (!q.get('code') || !authReq) return json({ error: 'invalid_request', error_description: 'unknown or expired state' }, 400) + + let user + try { + user = await exchangeCode({ code: q.get('code'), codeVerifier: authReq.upstreamVerifier, redirectUri: ep.callback_uri }) + } catch (e) { + return clientError(authReq.clientRedirectUri, authReq.clientState, 'server_error', 'upstream login failed') + } + + const domain = emailDomain(user.email) + if (REQUIRE_WORK_EMAIL && user.email && !isWorkEmail(domain).ok) { + return clientError(authReq.clientRedirectUri, authReq.clientState, 'access_denied', 'A work account is required') + } + + // Lead capture (best-effort, non-blocking). + recordUser({ sub: user.sub, email: user.email, domain }).catch(() => {}) + + const code = await putAuthCode({ + clientId: authReq.clientId, + clientRedirectUri: authReq.clientRedirectUri, + clientCodeChallenge: authReq.clientCodeChallenge, + user: { sub: user.sub, email: user.email, email_verified: user.email_verified, org_id: user.org_id, org_name: user.org_name, domain }, + }) + + const back = new URL(authReq.clientRedirectUri) + back.searchParams.set('code', code) + if (authReq.clientState) back.searchParams.set('state', authReq.clientState) + return redirect(back.toString()) + } + + // -------- /token: exchange our code (+ PKCE) for an access token -------- + if (path === PATHS.token && request.method === 'POST') { + const ct = request.headers.get('content-type') || '' + const body = ct.includes('application/json') + ? await request.json().catch(() => ({})) + : Object.fromEntries(new URLSearchParams(await request.text())) + + if (body.grant_type !== 'authorization_code') return json({ error: 'unsupported_grant_type' }, 400) + const rec = await takeAuthCode(body.code) + if (!rec) return json({ error: 'invalid_grant', error_description: 'invalid or used code' }, 400) + if (body.redirect_uri !== rec.clientRedirectUri) return json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' }, 400) + if (!verifyChallenge(body.code_verifier, rec.clientCodeChallenge)) { + return json({ error: 'invalid_grant', error_description: 'PKCE verification failed' }, 400) + } + + const u = rec.user + const access_token = await signAccessToken( + { sub: u.sub, email: u.email, email_verified: u.email_verified, org_id: u.org_id, org_name: u.org_name, scope: SCOPES.join(' ') }, + { issuer: ep.issuer, audience: `${origin}/mcp`, ttlSec: ACCESS_TOKEN_TTL_SEC } + ) + return json({ access_token, token_type: 'Bearer', expires_in: ACCESS_TOKEN_TTL_SEC, scope: SCOPES.join(' ') }) + } + + return json({ error: 'not_found', path }, 404) +} + +export const config = { + path: [PATHS.metadata, PATHS.jwks, PATHS.authorize, PATHS.token, PATHS.callback, PATHS.mockIdp], + preferStatic: false, +} diff --git a/netlify/functions/mcp.mjs b/netlify/functions/mcp.mjs index 48cc27ed..5d1272c3 100644 --- a/netlify/functions/mcp.mjs +++ b/netlify/functions/mcp.mjs @@ -18,7 +18,7 @@ import { z } from 'zod' import handle from '@modelfetch/netlify' import { extractBearerToken, decideAuth, isAuthEnforced, isWorkEmailRequired } from './lib/auth.mjs' -import { validateToken } from './lib/idp.mjs' +import { verifyAccessToken } from './lib/oauth/keys.mjs' import { recordUser } from './lib/store.mjs' import rateLimiterModule from 'hono-rate-limiter' @@ -653,8 +653,13 @@ const baseHandler = handle({ const method = c.req.method if (method === 'OPTIONS' || method === 'GET') return next() + // Validate OUR OWN access token (issued by our AS), not the upstream IdP. + const origin = new URL(c.req.url).origin const token = extractBearerToken(c.req.header('authorization')) - const claims = token ? await validateToken(token) : null + const verified = token + ? await verifyAccessToken(token, { issuer: origin, audience: `${origin}/mcp` }) + : { valid: false } + const claims = verified.valid ? verified.claims : null const resourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', c.req.url).toString() const { allow, userContext, response } = decideAuth({ diff --git a/package.json b/package.json index 3843b1cf..375ed06f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@modelcontextprotocol/sdk": "1.17.0", "@modelfetch/netlify": "0.15.2", "@netlify/blobs": "^9.1.6", + "jose": "^5.9.6", "@redpanda-data/docs-extensions-and-macros": "^5.0.0", "@sntke/antora-mermaid-extension": "^0.0.6", "algoliasearch": "^5.35.0", diff --git a/tests/mcp-oauth.test.ts b/tests/mcp-oauth.test.ts new file mode 100644 index 00000000..eaae2e8d --- /dev/null +++ b/tests/mcp-oauth.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { generateKeyPair, exportJWK, calculateJwkThumbprint } from 'jose' +import { verifyChallenge, s256, generatePair } from '../netlify/functions/lib/oauth/pkce.mjs' + +// --- PKCE --- +describe('PKCE (S256)', () => { + it('verifies a matching verifier/challenge', () => { + const { verifier, challenge } = generatePair() + expect(verifyChallenge(verifier, challenge)).toBe(true) + }) + it('rejects a wrong verifier and empty inputs', () => { + const { challenge } = generatePair() + expect(verifyChallenge('wrong', challenge)).toBe(false) + expect(verifyChallenge('', challenge)).toBe(false) + expect(verifyChallenge('x', '')).toBe(false) + }) + it('s256 is base64url SHA-256', () => { + // RFC 7636 Appendix B test vector + expect(s256('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + }) +}) + +// --- JWT issue/verify via our keys module (env-provided key, no Blobs) --- +describe('access token issue + verify (jose)', () => { + let keys: typeof import('../netlify/functions/lib/oauth/keys.mjs') + const ISS = 'https://docs.test' + const AUD = 'https://docs.test/mcp' + + beforeAll(async () => { + const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true }) + const publicJwk = await exportJWK(publicKey) + const privateJwk = await exportJWK(privateKey) + const kid = await calculateJwkThumbprint(publicJwk) + process.env.MCP_OAUTH_SIGNING_JWK = JSON.stringify({ privateJwk, publicJwk, kid }) + keys = await import('../netlify/functions/lib/oauth/keys.mjs') + }) + + it('JWKS exposes a public key (no private material)', async () => { + const jwks = await keys.getJwks() + expect(jwks.keys).toHaveLength(1) + expect(jwks.keys[0].use).toBe('sig') + expect(jwks.keys[0].kid).toBeTruthy() + expect(jwks.keys[0].d).toBeUndefined() // never leak the private exponent + }) + + it('round-trips a signed token', async () => { + const t = await keys.signAccessToken({ sub: 'auth0|1', email: 'jake@redpanda.com' }, { issuer: ISS, audience: AUD, ttlSec: 60 }) + const r = await keys.verifyAccessToken(t, { issuer: ISS, audience: AUD }) + expect(r.valid).toBe(true) + expect(r.claims.email).toBe('jake@redpanda.com') + expect(r.claims.iss).toBe(ISS) + expect(r.claims.aud).toBe(AUD) + }) + + it('rejects wrong audience and tampered tokens', async () => { + const t = await keys.signAccessToken({ sub: 'auth0|1' }, { issuer: ISS, audience: AUD, ttlSec: 60 }) + expect((await keys.verifyAccessToken(t, { issuer: ISS, audience: 'https://evil/mcp' })).valid).toBe(false) + expect((await keys.verifyAccessToken(t + 'x', { issuer: ISS, audience: AUD })).valid).toBe(false) + expect((await keys.verifyAccessToken('not.a.jwt', { issuer: ISS, audience: AUD })).valid).toBe(false) + }) +}) From c81d44f86e5b33b9a8644fb214b6204fefd8ecb4 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 16 Jun 2026 17:18:06 +0100 Subject: [PATCH 20/61] Make dev-mock upstream fail-closed The dev mock issues canned identities, so it must never be reachable by accident in a deployed environment. Resolve the upstream mode fail-closed: mock is only allowed under an explicit dev signal (NETLIFY_DEV or MCP_OAUTH_ALLOW_MOCK=true). Anything that would otherwise silently fall back to mock (e.g. a prod deploy missing REDPANDA_OAUTH_CLIENT_ID) resolves to null, and the AS returns 503 on the flow endpoints instead of handing out mock tokens. Discovery + JWKS stay up. - config.mjs: resolveUpstreamMode() (pure, tested) + UPSTREAM_MISCONFIGURED - upstream.mjs: throw if neither auth0 nor mock is active - mcp-oauth.mjs: 503 on /authorize, /callback, /token, mock-idp when misconfigured - tests: 6 cases covering the resolution matrix (28 total pass) Co-Authored-By: Claude Opus 4.8 --- netlify/functions/lib/oauth/config.mjs | 25 ++++++++++++++++++++---- netlify/functions/lib/oauth/upstream.mjs | 18 +++++++++++------ netlify/functions/mcp-oauth.mjs | 9 ++++++++- tests/mcp-oauth.test.ts | 24 +++++++++++++++++++++++ 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/netlify/functions/lib/oauth/config.mjs b/netlify/functions/lib/oauth/config.mjs index 9616f2f3..26c262ba 100644 --- a/netlify/functions/lib/oauth/config.mjs +++ b/netlify/functions/lib/oauth/config.mjs @@ -16,10 +16,27 @@ export const ACCESS_TOKEN_TTL_SEC = Number(process.env.MCP_OAUTH_ACCESS_TTL || 3 export const AUTH_REQUEST_TTL_SEC = 600 export const AUTH_CODE_TTL_SEC = 60 -// Upstream IdP (Redpanda Cloud Auth0). Defaults to 'mock' for local dev until a -// real client_id is configured. -export const UPSTREAM_MODE = - process.env.MCP_OAUTH_UPSTREAM || (process.env.REDPANDA_OAUTH_CLIENT_ID ? 'auth0' : 'mock') +// Upstream IdP (Redpanda Cloud Auth0) mode resolution — FAIL-CLOSED. +// +// The dev mock issues canned identities, so it must NEVER be reachable in a +// real deployment by accident. Mock is only allowed under an explicit dev +// signal (NETLIFY_DEV, or MCP_OAUTH_ALLOW_MOCK=true). Anything that would +// otherwise fall back to mock (e.g. a prod deploy missing the client_id) +// resolves to `null` = misconfigured, and the AS refuses the flow rather than +// handing out mock tokens. +export function resolveUpstreamMode(env = process.env) { + const allowMock = env.NETLIFY_DEV === 'true' || env.MCP_OAUTH_ALLOW_MOCK === 'true' + const hasClientId = !!env.REDPANDA_OAUTH_CLIENT_ID + const explicit = env.MCP_OAUTH_UPSTREAM + if (explicit === 'auth0') return hasClientId ? 'auth0' : null // explicit auth0 needs a client_id + if (explicit === 'mock') return allowMock ? 'mock' : null // mock only when explicitly allowed + if (hasClientId) return 'auth0' + if (allowMock) return 'mock' + return null // unconfigured (e.g. prod without client_id) → fail closed +} + +export const UPSTREAM_MODE = resolveUpstreamMode() +export const UPSTREAM_MISCONFIGURED = UPSTREAM_MODE === null export const AUTH0_ISSUER = process.env.REDPANDA_OAUTH_ISSUER || 'https://auth.prd.cloud.redpanda.com/' export const AUTH0_CLIENT_ID = process.env.REDPANDA_OAUTH_CLIENT_ID // public client, no secret export const REQUIRE_WORK_EMAIL = process.env.REQUIRE_WORK_EMAIL !== 'false' diff --git a/netlify/functions/lib/oauth/upstream.mjs b/netlify/functions/lib/oauth/upstream.mjs index 6d3fd60c..0c7f8295 100644 --- a/netlify/functions/lib/oauth/upstream.mjs +++ b/netlify/functions/lib/oauth/upstream.mjs @@ -25,10 +25,13 @@ export function buildAuthorizeUrl({ origin, state, redirectUri, codeChallenge }) u.searchParams.set('code_challenge_method', 'S256') return u.toString() } - const u = new URL(PATHS.mockIdp, origin) - u.searchParams.set('state', state) - u.searchParams.set('redirect_uri', redirectUri) - return u.toString() + if (UPSTREAM_MODE === 'mock') { + const u = new URL(PATHS.mockIdp, origin) + u.searchParams.set('state', state) + u.searchParams.set('redirect_uri', redirectUri) + return u.toString() + } + throw new Error('upstream IdP not configured') // fail-closed (see config.resolveUpstreamMode) } // Exchange the upstream code for the user's verified identity claims. @@ -60,8 +63,11 @@ export async function exchangeCode({ code, codeVerifier, redirectUri }) { org_name: payload.org_name || null, } } - // mock: canned verified identity - return { sub: 'mock|123', email: 'spike@redpanda.com', email_verified: true, org_id: 'org_mock', org_name: 'Mock Org' } + if (UPSTREAM_MODE === 'mock') { + // canned verified identity (dev only) + return { sub: 'mock|123', email: 'spike@redpanda.com', email_verified: true, org_id: 'org_mock', org_name: 'Mock Org' } + } + throw new Error('upstream IdP not configured') // fail-closed } export { UPSTREAM_MODE } diff --git a/netlify/functions/mcp-oauth.mjs b/netlify/functions/mcp-oauth.mjs index 7558f624..8877615f 100644 --- a/netlify/functions/mcp-oauth.mjs +++ b/netlify/functions/mcp-oauth.mjs @@ -13,7 +13,7 @@ import { getJwks, signAccessToken } from './lib/oauth/keys.mjs' import { putAuthRequest, takeAuthRequest, putAuthCode, takeAuthCode } from './lib/oauth/store.mjs' import { buildAuthorizeUrl, exchangeCode, UPSTREAM_MODE } from './lib/oauth/upstream.mjs' import { verifyChallenge, generatePair } from './lib/oauth/pkce.mjs' -import { PATHS, SCOPES, ACCESS_TOKEN_TTL_SEC, REQUIRE_WORK_EMAIL, endpoints } from './lib/oauth/config.mjs' +import { PATHS, SCOPES, ACCESS_TOKEN_TTL_SEC, REQUIRE_WORK_EMAIL, UPSTREAM_MISCONFIGURED, endpoints } from './lib/oauth/config.mjs' import { isWorkEmail, emailDomain } from './lib/auth.mjs' import { recordUser } from './lib/store.mjs' @@ -57,6 +57,13 @@ export default async (request) => { if (path === PATHS.jwks) return json(await getJwks()) + // Fail closed: if no real upstream is configured and dev-mock isn't explicitly + // allowed (e.g. a prod deploy missing the client_id), refuse the flow rather + // than issuing mock identities. Discovery + JWKS above stay available. + if (UPSTREAM_MISCONFIGURED) { + return json({ error: 'server_error', error_description: 'authorization server upstream not configured' }, 503) + } + // -------- /authorize: downstream client starts the flow -------- if (path === PATHS.authorize) { const clientId = q.get('client_id') diff --git a/tests/mcp-oauth.test.ts b/tests/mcp-oauth.test.ts index eaae2e8d..657c65dc 100644 --- a/tests/mcp-oauth.test.ts +++ b/tests/mcp-oauth.test.ts @@ -1,6 +1,30 @@ import { describe, it, expect, beforeAll } from 'vitest' import { generateKeyPair, exportJWK, calculateJwkThumbprint } from 'jose' import { verifyChallenge, s256, generatePair } from '../netlify/functions/lib/oauth/pkce.mjs' +import { resolveUpstreamMode } from '../netlify/functions/lib/oauth/config.mjs' + +// --- Fail-closed upstream mode resolution --- +describe('resolveUpstreamMode (fail-closed)', () => { + it('unconfigured (prod, no client_id, no dev flag) -> null (refuse)', () => { + expect(resolveUpstreamMode({})).toBeNull() + }) + it('client_id present -> auth0', () => { + expect(resolveUpstreamMode({ REDPANDA_OAUTH_CLIENT_ID: 'abc' })).toBe('auth0') + }) + it('mock only under an explicit dev signal', () => { + expect(resolveUpstreamMode({ NETLIFY_DEV: 'true' })).toBe('mock') + expect(resolveUpstreamMode({ MCP_OAUTH_ALLOW_MOCK: 'true' })).toBe('mock') + }) + it('explicit mock without the dev signal -> null (no silent prod mock)', () => { + expect(resolveUpstreamMode({ MCP_OAUTH_UPSTREAM: 'mock' })).toBeNull() + }) + it('explicit auth0 without a client_id -> null', () => { + expect(resolveUpstreamMode({ MCP_OAUTH_UPSTREAM: 'auth0' })).toBeNull() + }) + it('client_id wins even with dev flag set', () => { + expect(resolveUpstreamMode({ REDPANDA_OAUTH_CLIENT_ID: 'abc', NETLIFY_DEV: 'true' })).toBe('auth0') + }) +}) // --- PKCE --- describe('PKCE (S256)', () => { From 2819de6155f10bb4803994babb4a5115bc873337 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Wed, 17 Jun 2026 09:32:55 +0100 Subject: [PATCH 21/61] Fix mcp-oauth bundling: config.path must be literal strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netlify statically analyzes config.path at bundle time, so it can't be an array of imported constants (PATHS.*) — that failed bundling (and the PR preview build) with 'path: Must be a string or array of strings'. Use literal paths. Verified the full M1 flow live (functions:serve, mock upstream): authorize -> mock-idp -> /mcp/callback -> /token -> AS-issued JWT, then /mcp accepts that token (200) and rejects no-token / garbage (401). Confirms cross-function token validation via the Blobs-shared signing key. Co-Authored-By: Claude Opus 4.8 --- netlify/functions/mcp-oauth.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/netlify/functions/mcp-oauth.mjs b/netlify/functions/mcp-oauth.mjs index 8877615f..b658efa0 100644 --- a/netlify/functions/mcp-oauth.mjs +++ b/netlify/functions/mcp-oauth.mjs @@ -156,7 +156,17 @@ export default async (request) => { return json({ error: 'not_found', path }, 404) } +// NOTE: Netlify statically analyzes `config.path` at bundle time, so these MUST +// be literal strings (not imported constants). Keep in sync with PATHS in +// lib/oauth/config.mjs. export const config = { - path: [PATHS.metadata, PATHS.jwks, PATHS.authorize, PATHS.token, PATHS.callback, PATHS.mockIdp], + path: [ + '/.well-known/oauth-authorization-server', + '/.well-known/jwks.json', + '/oauth/authorize', + '/oauth/token', + '/mcp/callback', + '/oauth/mock-idp/authorize', + ], preferStatic: false, } From e22fca0059727a86bea997d75b3e6a0b071b7dd8 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Wed, 17 Jun 2026 09:51:18 +0100 Subject: [PATCH 22/61] Use strong consistency for the OAuth Blobs stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netlify Blobs defaults to eventual consistency (deletes/updates propagate up to 60s). For one-time-use auth codes and refresh-token rotation/reuse-detection that window would let a consumed code/token be replayed, so the auth store now uses { consistency: 'strong' }. The dev signing-key store does too, so the resource server reads the key the AS just wrote rather than regenerating. Verified live (functions:serve): full flow issues a token, /mcp accepts it (200), and replaying a consumed auth code is rejected (400). Note: Blobs still has no atomic CAS, so a sub-second concurrent replay remains theoretically possible — negligible at our volume; a relational DB is the only full fix (documented as the future swap behind the store interface). Co-Authored-By: Claude Opus 4.8 --- netlify/functions/lib/oauth/keys.mjs | 4 +++- netlify/functions/lib/oauth/store.mjs | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/netlify/functions/lib/oauth/keys.mjs b/netlify/functions/lib/oauth/keys.mjs index 853796a2..cba487a0 100644 --- a/netlify/functions/lib/oauth/keys.mjs +++ b/netlify/functions/lib/oauth/keys.mjs @@ -41,7 +41,9 @@ async function loadKeys() { } // Dev: persist a generated key in Blobs so it survives warm invocations. - const store = getStore(KEY_STORE) + // Strong consistency so a second function (e.g. the resource server reading + // the key the AS just wrote) sees it immediately rather than regenerating. + const store = getStore({ name: KEY_STORE, consistency: 'strong' }) let stored = await store.get(KEY_NAME, { type: 'json' }).catch(() => null) if (!stored) { stored = await generate() diff --git a/netlify/functions/lib/oauth/store.mjs b/netlify/functions/lib/oauth/store.mjs index 54f4a00e..781dbb30 100644 --- a/netlify/functions/lib/oauth/store.mjs +++ b/netlify/functions/lib/oauth/store.mjs @@ -17,7 +17,11 @@ const AR = (id) => `ar:${id}` // auth request const AC = (code) => `ac:${code}` // authorization code function store() { - return getStore(STORE) + // STRONG consistency is required: auth codes and refresh tokens are one-time + // use, and Blobs' default eventual consistency propagates deletes/updates over + // up to 60s — long enough for a consumed code/token to be replayed in that + // window. Strong reads come from the origin region (fine at our volume). + return getStore({ name: STORE, consistency: 'strong' }) } const expired = (rec) => !rec || Date.now() > rec.expiresAt From dc8895b1d2981af965380bd9e80a35e8e66cd584 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Wed, 17 Jun 2026 09:59:16 +0100 Subject: [PATCH 23/61] M2: client registration (DCR + CIMD) + redirect_uri validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP clients can now identify themselves to our AS, so real clients (ChatGPT, Claude, …) can connect: - lib/oauth/clients.mjs: DCR (RFC 7591) registerClient; CIMD getClient that fetches+validates a URL client_id's metadata document (https-only + loopback/ private-host SSRF guard, timeout, size cap); redirect_uri matching (exact, with loopback port-flexibility for native clients like Claude Code) - store.mjs: putClient/getStoredClient (Blobs, strong consistency; resilient to store errors) - mcp-oauth.mjs: POST /oauth/register; /authorize now resolves the client and validates redirect_uri BEFORE any redirect (open-redirect guard) and rejects unknown clients; /token binds the code to its client_id; metadata advertises registration_endpoint + client_id_metadata_document_supported - config.mjs: register path + registration_endpoint Verified live (functions:serve): register -> authorize -> token -> /mcp (200); unknown client -> 400 invalid_client; bad redirect_uri -> 400 invalid_request. 37 unit tests pass (incl. CIMD URL detection, doc validation, SSRF guard, loopback redirect matching). CIMD is wired + unit-tested but not yet live-exercised (needs a hosted client doc). Deferred: refresh-token grant + rotation (M3), consent UI, /register rate-limit. Co-Authored-By: Claude Opus 4.8 --- netlify/functions/lib/oauth/clients.mjs | 114 ++++++++++++++++++++++++ netlify/functions/lib/oauth/config.mjs | 2 + netlify/functions/lib/oauth/store.mjs | 18 ++++ netlify/functions/mcp-oauth.mjs | 30 +++++++ tests/mcp-oauth-clients.test.ts | 57 ++++++++++++ 5 files changed, 221 insertions(+) create mode 100644 netlify/functions/lib/oauth/clients.mjs create mode 100644 tests/mcp-oauth-clients.test.ts diff --git a/netlify/functions/lib/oauth/clients.mjs b/netlify/functions/lib/oauth/clients.mjs new file mode 100644 index 00000000..36481743 --- /dev/null +++ b/netlify/functions/lib/oauth/clients.mjs @@ -0,0 +1,114 @@ +// OAuth client identity for the AS: Dynamic Client Registration (RFC 7591) + +// Client ID Metadata Documents (CIMD). MCP clients (ChatGPT, Claude, …) are +// third-party apps that identify themselves at runtime — either by registering +// (DCR) and getting a client_id, or by presenting a URL client_id whose +// metadata document we fetch (CIMD). + +import { randomBytes } from 'node:crypto' +import { putClient, getStoredClient } from './store.mjs' + +const CIMD_FETCH_TIMEOUT_MS = 5_000 +const CIMD_MAX_BYTES = 32_000 + +export function isCimdClientId(clientId) { + return typeof clientId === 'string' && clientId.startsWith('https://') +} + +// --- redirect_uri matching (OAuth 2.1: exact match, with loopback flexibility) --- +// Native clients (e.g. Claude Code) use http://127.0.0.1:/cb or +// http://localhost:/cb, so for loopback we match everything except the port. +export function redirectUriAllowed(client, redirectUri) { + const allowed = client?.redirect_uris || [] + if (allowed.includes(redirectUri)) return true + let u + try { u = new URL(redirectUri) } catch { return false } + const isLoopback = u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1') + if (!isLoopback) return false + return allowed.some((a) => { + let r + try { r = new URL(a) } catch { return false } + return r.protocol === u.protocol && r.hostname === u.hostname && r.pathname === u.pathname // port may differ + }) +} + +// --- validation of submitted/ fetched client metadata --- +function normalizeClientMetadata(meta) { + const redirect_uris = Array.isArray(meta?.redirect_uris) ? meta.redirect_uris : [] + if (redirect_uris.length === 0) { + const err = new Error('redirect_uris is required') + err.code = 'invalid_redirect_uri' + throw err + } + for (const uri of redirect_uris) { + try { new URL(uri) } catch { + const err = new Error(`invalid redirect_uri: ${uri}`) + err.code = 'invalid_redirect_uri' + throw err + } + } + return { + redirect_uris, + client_name: typeof meta.client_name === 'string' ? meta.client_name.slice(0, 200) : undefined, + token_endpoint_auth_method: 'none', // public clients only + grant_types: ['authorization_code'], + response_types: ['code'], + scope: 'openid email profile', + } +} + +// --- DCR (RFC 7591) --- +export async function registerClient(meta) { + const normalized = normalizeClientMetadata(meta) + const client_id = `mcp_${randomBytes(24).toString('base64url')}` + const record = { client_id, ...normalized, client_id_issued_at: Math.floor(Date.now() / 1000) } + await putClient(record) + return record // RFC 7591 registration response (public client → no secret) +} + +// --- CIMD: fetch + validate a URL client_id's metadata document --- +function assertSafeCimdUrl(clientId) { + let u + try { u = new URL(clientId) } catch { throw new Error('client_id is not a valid URL') } + if (u.protocol !== 'https:') throw new Error('CIMD client_id must be https') + const host = u.hostname + // Best-effort SSRF guard: block loopback/private literals. (Residual: DNS + // rebinding — a hardened deploy would also resolve+range-check the IP.) + if ( + host === 'localhost' || host === '::1' || host.endsWith('.local') || + /^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || + /^169\.254\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host) + ) { + throw new Error('CIMD client_id host not allowed') + } + return u +} + +export function validateCimdDocument(clientId, doc) { + if (!doc || typeof doc !== 'object') throw new Error('CIMD metadata not an object') + if (doc.client_id !== clientId) throw new Error('CIMD client_id must equal the document URL') + return normalizeClientMetadata(doc) +} + +async function fetchCimdClient(clientId, fetchImpl = fetch) { + assertSafeCimdUrl(clientId) + const controller = new AbortController() + const t = setTimeout(() => controller.abort(), CIMD_FETCH_TIMEOUT_MS) + try { + const res = await fetchImpl(clientId, { headers: { Accept: 'application/json' }, signal: controller.signal }) + if (!res.ok) throw new Error(`CIMD fetch failed: ${res.status}`) + const text = (await res.text()).slice(0, CIMD_MAX_BYTES) + const doc = JSON.parse(text) + return { client_id: clientId, ...validateCimdDocument(clientId, doc) } + } finally { + clearTimeout(t) + } +} + +// Resolve a client_id to a client record (DCR-stored or CIMD-fetched), or null. +export async function getClient(clientId, { fetchImpl } = {}) { + if (!clientId) return null + if (isCimdClientId(clientId)) { + try { return await fetchCimdClient(clientId, fetchImpl) } catch { return null } + } + return getStoredClient(clientId) +} diff --git a/netlify/functions/lib/oauth/config.mjs b/netlify/functions/lib/oauth/config.mjs index 26c262ba..beab5277 100644 --- a/netlify/functions/lib/oauth/config.mjs +++ b/netlify/functions/lib/oauth/config.mjs @@ -7,6 +7,7 @@ export const PATHS = { jwks: '/.well-known/jwks.json', authorize: '/oauth/authorize', token: '/oauth/token', + register: '/oauth/register', // RFC 7591 Dynamic Client Registration callback: '/mcp/callback', // matches the redirect URI registered in Auth0 mockIdp: '/oauth/mock-idp/authorize', // dev only } @@ -50,6 +51,7 @@ export function endpoints(origin) { issuer: origin, authorization_endpoint: `${origin}${PATHS.authorize}`, token_endpoint: `${origin}${PATHS.token}`, + registration_endpoint: `${origin}${PATHS.register}`, jwks_uri: `${origin}${PATHS.jwks}`, callback_uri: `${origin}${PATHS.callback}`, } diff --git a/netlify/functions/lib/oauth/store.mjs b/netlify/functions/lib/oauth/store.mjs index 781dbb30..b700f6df 100644 --- a/netlify/functions/lib/oauth/store.mjs +++ b/netlify/functions/lib/oauth/store.mjs @@ -15,6 +15,7 @@ import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from './config.mjs' const STORE = 'mcp-oauth' const AR = (id) => `ar:${id}` // auth request const AC = (code) => `ac:${code}` // authorization code +const CL = (id) => `client:${id}` // DCR-registered client function store() { // STRONG consistency is required: auth codes and refresh tokens are one-time @@ -53,3 +54,20 @@ export async function takeAuthCode(code) { await store().delete(key).catch(() => {}) // one-time use return rec } + +// --- DCR-registered clients (persistent; no TTL) --- +export async function putClient(client) { + await store().setJSON(CL(client.client_id), client) + return client +} + +export async function getStoredClient(clientId) { + if (!clientId) return null + // Resilient: a store error (e.g. Blobs unavailable) resolves to "unknown + // client" rather than crashing the /authorize handler. + try { + return await store().get(CL(clientId), { type: 'json' }) + } catch { + return null + } +} diff --git a/netlify/functions/mcp-oauth.mjs b/netlify/functions/mcp-oauth.mjs index b658efa0..3e5d847e 100644 --- a/netlify/functions/mcp-oauth.mjs +++ b/netlify/functions/mcp-oauth.mjs @@ -13,6 +13,7 @@ import { getJwks, signAccessToken } from './lib/oauth/keys.mjs' import { putAuthRequest, takeAuthRequest, putAuthCode, takeAuthCode } from './lib/oauth/store.mjs' import { buildAuthorizeUrl, exchangeCode, UPSTREAM_MODE } from './lib/oauth/upstream.mjs' import { verifyChallenge, generatePair } from './lib/oauth/pkce.mjs' +import { registerClient, getClient, redirectUriAllowed } from './lib/oauth/clients.mjs' import { PATHS, SCOPES, ACCESS_TOKEN_TTL_SEC, REQUIRE_WORK_EMAIL, UPSTREAM_MISCONFIGURED, endpoints } from './lib/oauth/config.mjs' import { isWorkEmail, emailDomain } from './lib/auth.mjs' import { recordUser } from './lib/store.mjs' @@ -47,16 +48,29 @@ export default async (request) => { authorization_endpoint: ep.authorization_endpoint, token_endpoint: ep.token_endpoint, jwks_uri: ep.jwks_uri, + registration_endpoint: ep.registration_endpoint, response_types_supported: ['code'], grant_types_supported: ['authorization_code'], // + refresh_token (M3) code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'], scopes_supported: SCOPES, + client_id_metadata_document_supported: true, // CIMD (clients may use a URL client_id) }) } if (path === PATHS.jwks) return json(await getJwks()) + // -------- /register: Dynamic Client Registration (RFC 7591) -------- + if (path === PATHS.register && request.method === 'POST') { + const meta = await request.json().catch(() => null) + if (!meta) return json({ error: 'invalid_client_metadata', error_description: 'JSON body required' }, 400) + try { + return json(await registerClient(meta), 201) + } catch (e) { + return json({ error: e.code || 'invalid_client_metadata', error_description: e.message }, 400) + } + } + // Fail closed: if no real upstream is configured and dev-mock isn't explicitly // allowed (e.g. a prod deploy missing the client_id), refuse the flow rather // than issuing mock identities. Discovery + JWKS above stay available. @@ -70,6 +84,16 @@ export default async (request) => { const redirectUri = q.get('redirect_uri') const codeChallenge = q.get('code_challenge') if (!clientId || !redirectUri) return json({ error: 'invalid_request', error_description: 'client_id and redirect_uri required' }, 400) + + // Resolve + validate the client and redirect_uri BEFORE any redirect — never + // redirect to an unvalidated URI (open-redirect / code-injection guard). + const client = await getClient(clientId) + if (!client) return json({ error: 'invalid_client', error_description: 'unknown client_id (register via DCR or use a CIMD URL)' }, 400) + if (!redirectUriAllowed(client, redirectUri)) { + return json({ error: 'invalid_request', error_description: 'redirect_uri not registered for this client' }, 400) + } + + // redirect_uri is now trusted, so PKCE errors may be returned to it. if (!codeChallenge || q.get('code_challenge_method') !== 'S256') { return clientError(redirectUri, q.get('state'), 'invalid_request', 'PKCE S256 required') } @@ -140,6 +164,11 @@ export default async (request) => { if (body.grant_type !== 'authorization_code') return json({ error: 'unsupported_grant_type' }, 400) const rec = await takeAuthCode(body.code) if (!rec) return json({ error: 'invalid_grant', error_description: 'invalid or used code' }, 400) + // Bind the code to the client it was issued to (public clients don't + // authenticate, so this prevents a code being redeemed by another client_id). + if (body.client_id && body.client_id !== rec.clientId) { + return json({ error: 'invalid_grant', error_description: 'client_id mismatch' }, 400) + } if (body.redirect_uri !== rec.clientRedirectUri) return json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' }, 400) if (!verifyChallenge(body.code_verifier, rec.clientCodeChallenge)) { return json({ error: 'invalid_grant', error_description: 'PKCE verification failed' }, 400) @@ -165,6 +194,7 @@ export const config = { '/.well-known/jwks.json', '/oauth/authorize', '/oauth/token', + '/oauth/register', '/mcp/callback', '/oauth/mock-idp/authorize', ], diff --git a/tests/mcp-oauth-clients.test.ts b/tests/mcp-oauth-clients.test.ts new file mode 100644 index 00000000..4248a5fd --- /dev/null +++ b/tests/mcp-oauth-clients.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest' +import { isCimdClientId, redirectUriAllowed, validateCimdDocument, getClient } from '../netlify/functions/lib/oauth/clients.mjs' + +describe('isCimdClientId', () => { + it('treats https URLs as CIMD client_ids', () => { + expect(isCimdClientId('https://claude.ai/.well-known/oauth-client')).toBe(true) + expect(isCimdClientId('mcp_abc123')).toBe(false) + expect(isCimdClientId('http://insecure/doc')).toBe(false) // not https + }) +}) + +describe('redirectUriAllowed', () => { + const client = { redirect_uris: ['https://chatgpt.com/cb', 'http://127.0.0.1:0/callback'] } + it('exact match', () => { + expect(redirectUriAllowed(client, 'https://chatgpt.com/cb')).toBe(true) + expect(redirectUriAllowed(client, 'https://evil.com/cb')).toBe(false) + }) + it('loopback matches ignoring the port (native clients)', () => { + expect(redirectUriAllowed(client, 'http://127.0.0.1:52345/callback')).toBe(true) + expect(redirectUriAllowed(client, 'http://127.0.0.1:9999/other')).toBe(false) // path must match + }) + it('non-loopback http is not port-flexible', () => { + expect(redirectUriAllowed({ redirect_uris: ['http://example.com:1/cb'] }, 'http://example.com:2/cb')).toBe(false) + }) +}) + +describe('validateCimdDocument', () => { + const url = 'https://claude.ai/oauth-client.json' + it('accepts a doc whose client_id equals its URL', () => { + const c = validateCimdDocument(url, { client_id: url, redirect_uris: ['https://claude.ai/cb'] }) + expect(c.redirect_uris).toEqual(['https://claude.ai/cb']) + expect(c.token_endpoint_auth_method).toBe('none') + }) + it('rejects client_id != URL, or missing redirect_uris', () => { + expect(() => validateCimdDocument(url, { client_id: 'https://x/', redirect_uris: ['https://x/cb'] })).toThrow() + expect(() => validateCimdDocument(url, { client_id: url })).toThrow() + }) +}) + +describe('getClient (CIMD fetch with injected fetch)', () => { + it('fetches + validates a CIMD URL client_id', async () => { + const url = 'https://claude.ai/oauth-client.json' + const fetchImpl = vi.fn(async () => ({ ok: true, text: async () => JSON.stringify({ client_id: url, redirect_uris: ['https://claude.ai/cb'] }) })) + const c = await getClient(url, { fetchImpl }) + expect(c.client_id).toBe(url) + expect(fetchImpl).toHaveBeenCalledOnce() + }) + it('unknown DCR client_id -> null (store miss, no crash)', async () => { + expect(await getClient('mcp_unknown', { fetchImpl: vi.fn() })).toBeNull() + }) + it('blocks loopback/private CIMD hosts (SSRF guard) -> null', async () => { + const spy = vi.fn() + expect(await getClient('https://127.0.0.1/doc', { fetchImpl: spy })).toBeNull() + expect(await getClient('https://localhost/doc', { fetchImpl: spy })).toBeNull() + expect(spy).not.toHaveBeenCalled() // never even fetched + }) +}) From 4cdd956af521b66f6f20f418508941f96a62618b Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Wed, 17 Jun 2026 10:05:26 +0100 Subject: [PATCH 24/61] M3: refresh-token grant with rotation + reuse detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code grant now also issues a refresh token, and /token handles grant_type=refresh_token so clients renew access tokens without re-login. - lib/oauth/refresh.mjs: token gen (hashed at rest), newFamilyId, and a pure decideRefresh() (rotate / reuse / invalid) — unit-tested - store.mjs: refresh-token + family ops (Blobs, strong consistency) - mcp-oauth.mjs: issue first refresh in a new family on the code grant; on refresh, rotate (supersede old, issue new) and detect reuse — replaying a superseded token revokes the whole family (theft signal -> forces re-auth); client-id binding enforced; metadata advertises refresh_token grant - config.mjs: REFRESH_TOKEN_TTL_SEC (default 30d) Verified live (functions:serve): code grant -> refresh; rotate -> new tokens, new access works at /mcp (200); replaying the old token -> 400 reuse + family revoked; the latest token then also fails (family_revoked). 44 unit tests pass. Note: refresh re-issues from stored claims (doesn't re-check Auth0 each time) — standard; periodic re-validation can be added later. Co-Authored-By: Claude Opus 4.8 --- netlify/functions/lib/oauth/config.mjs | 1 + netlify/functions/lib/oauth/refresh.mjs | 31 ++++++++++ netlify/functions/lib/oauth/store.mjs | 26 ++++++++ netlify/functions/mcp-oauth.mjs | 81 +++++++++++++++++++------ tests/mcp-oauth-refresh.test.ts | 38 ++++++++++++ 5 files changed, 158 insertions(+), 19 deletions(-) create mode 100644 netlify/functions/lib/oauth/refresh.mjs create mode 100644 tests/mcp-oauth-refresh.test.ts diff --git a/netlify/functions/lib/oauth/config.mjs b/netlify/functions/lib/oauth/config.mjs index beab5277..7ccec711 100644 --- a/netlify/functions/lib/oauth/config.mjs +++ b/netlify/functions/lib/oauth/config.mjs @@ -14,6 +14,7 @@ export const PATHS = { export const SCOPES = ['openid', 'email', 'profile'] export const ACCESS_TOKEN_TTL_SEC = Number(process.env.MCP_OAUTH_ACCESS_TTL || 3600) +export const REFRESH_TOKEN_TTL_SEC = Number(process.env.MCP_OAUTH_REFRESH_TTL || 30 * 24 * 3600) // 30d export const AUTH_REQUEST_TTL_SEC = 600 export const AUTH_CODE_TTL_SEC = 60 diff --git a/netlify/functions/lib/oauth/refresh.mjs b/netlify/functions/lib/oauth/refresh.mjs new file mode 100644 index 00000000..adb1af6e --- /dev/null +++ b/netlify/functions/lib/oauth/refresh.mjs @@ -0,0 +1,31 @@ +// Refresh-token rotation + reuse detection (OAuth 2.1 / RFC 6819 BCP). +// +// Each refresh issues a NEW refresh token and supersedes the old one (rotation). +// Tokens belong to a "family"; if a *superseded* (already-used) token is ever +// replayed, the whole family is revoked (reuse detection) — a theft signal that +// forces re-authentication. We only store hashes of refresh tokens. + +import { randomBytes, createHash, randomUUID } from 'node:crypto' + +export function hashRefresh(token) { + return createHash('sha256').update(String(token)).digest('hex') +} + +export function newRefreshToken() { + const token = `rt_${randomBytes(32).toString('base64url')}` + return { token, hash: hashRefresh(token) } +} + +export function newFamilyId() { + return randomUUID() +} + +// Pure decision (unit-tested). `record` = the refresh token's stored record, +// `family` = its family doc, both possibly null. Times in ms. +export function decideRefresh({ record, family, nowMs }) { + if (!record) return { action: 'invalid', reason: 'unknown_token' } + if (!family || family.revoked) return { action: 'invalid', reason: 'family_revoked' } + if (record.expiresAt && record.expiresAt < nowMs) return { action: 'invalid', reason: 'expired' } + if (record.used) return { action: 'reuse', reason: 'token_reuse' } // caller revokes the family + return { action: 'rotate' } +} diff --git a/netlify/functions/lib/oauth/store.mjs b/netlify/functions/lib/oauth/store.mjs index b700f6df..a888c78f 100644 --- a/netlify/functions/lib/oauth/store.mjs +++ b/netlify/functions/lib/oauth/store.mjs @@ -16,6 +16,8 @@ const STORE = 'mcp-oauth' const AR = (id) => `ar:${id}` // auth request const AC = (code) => `ac:${code}` // authorization code const CL = (id) => `client:${id}` // DCR-registered client +const RT = (h) => `rt:${h}` // refresh token (by hash) +const RTF = (id) => `rtf:${id}` // refresh-token family function store() { // STRONG consistency is required: auth codes and refresh tokens are one-time @@ -71,3 +73,27 @@ export async function getStoredClient(clientId) { return null } } + +// --- refresh tokens (by hash) + families --- +export async function putRefresh(hash, rec) { + await store().setJSON(RT(hash), rec) +} +export async function getRefresh(hash) { + if (!hash) return null + return store().get(RT(hash), { type: 'json' }).catch(() => null) +} +export async function markRefreshUsed(hash) { + const rec = await getRefresh(hash) + if (rec) await store().setJSON(RT(hash), { ...rec, used: true }) +} +export async function putFamily(id, rec) { + await store().setJSON(RTF(id), rec) +} +export async function getFamily(id) { + if (!id) return null + return store().get(RTF(id), { type: 'json' }).catch(() => null) +} +export async function revokeFamily(id) { + const fam = (await getFamily(id)) || {} + await store().setJSON(RTF(id), { ...fam, revoked: true, revokedAt: Date.now() }) +} diff --git a/netlify/functions/mcp-oauth.mjs b/netlify/functions/mcp-oauth.mjs index 3e5d847e..558cdc9e 100644 --- a/netlify/functions/mcp-oauth.mjs +++ b/netlify/functions/mcp-oauth.mjs @@ -10,11 +10,15 @@ // consent screen, revocation. import { getJwks, signAccessToken } from './lib/oauth/keys.mjs' -import { putAuthRequest, takeAuthRequest, putAuthCode, takeAuthCode } from './lib/oauth/store.mjs' +import { + putAuthRequest, takeAuthRequest, putAuthCode, takeAuthCode, + putRefresh, getRefresh, markRefreshUsed, putFamily, getFamily, revokeFamily, +} from './lib/oauth/store.mjs' import { buildAuthorizeUrl, exchangeCode, UPSTREAM_MODE } from './lib/oauth/upstream.mjs' import { verifyChallenge, generatePair } from './lib/oauth/pkce.mjs' import { registerClient, getClient, redirectUriAllowed } from './lib/oauth/clients.mjs' -import { PATHS, SCOPES, ACCESS_TOKEN_TTL_SEC, REQUIRE_WORK_EMAIL, UPSTREAM_MISCONFIGURED, endpoints } from './lib/oauth/config.mjs' +import { hashRefresh, newRefreshToken, newFamilyId, decideRefresh } from './lib/oauth/refresh.mjs' +import { PATHS, SCOPES, ACCESS_TOKEN_TTL_SEC, REFRESH_TOKEN_TTL_SEC, REQUIRE_WORK_EMAIL, UPSTREAM_MISCONFIGURED, endpoints } from './lib/oauth/config.mjs' import { isWorkEmail, emailDomain } from './lib/auth.mjs' import { recordUser } from './lib/store.mjs' @@ -50,7 +54,7 @@ export default async (request) => { jwks_uri: ep.jwks_uri, registration_endpoint: ep.registration_endpoint, response_types_supported: ['code'], - grant_types_supported: ['authorization_code'], // + refresh_token (M3) + grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'], scopes_supported: SCOPES, @@ -161,25 +165,64 @@ export default async (request) => { ? await request.json().catch(() => ({})) : Object.fromEntries(new URLSearchParams(await request.text())) - if (body.grant_type !== 'authorization_code') return json({ error: 'unsupported_grant_type' }, 400) - const rec = await takeAuthCode(body.code) - if (!rec) return json({ error: 'invalid_grant', error_description: 'invalid or used code' }, 400) - // Bind the code to the client it was issued to (public clients don't - // authenticate, so this prevents a code being redeemed by another client_id). - if (body.client_id && body.client_id !== rec.clientId) { - return json({ error: 'invalid_grant', error_description: 'client_id mismatch' }, 400) + const audience = `${origin}/mcp` + const mintAccess = (u, scope) => + signAccessToken( + { sub: u.sub, email: u.email, email_verified: u.email_verified, org_id: u.org_id, org_name: u.org_name, scope }, + { issuer: ep.issuer, audience, ttlSec: ACCESS_TOKEN_TTL_SEC } + ) + const issueRefresh = async (familyId, clientId, user, scope) => { + const { token, hash } = newRefreshToken() + await putRefresh(hash, { familyId, clientId, user, scope, used: false, expiresAt: Date.now() + REFRESH_TOKEN_TTL_SEC * 1000 }) + return token } - if (body.redirect_uri !== rec.clientRedirectUri) return json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' }, 400) - if (!verifyChallenge(body.code_verifier, rec.clientCodeChallenge)) { - return json({ error: 'invalid_grant', error_description: 'PKCE verification failed' }, 400) + + // ---- authorization_code grant ---- + if (body.grant_type === 'authorization_code') { + const rec = await takeAuthCode(body.code) + if (!rec) return json({ error: 'invalid_grant', error_description: 'invalid or used code' }, 400) + // Bind the code to the client it was issued to (public clients don't + // authenticate, so this prevents a code being redeemed by another client_id). + if (body.client_id && body.client_id !== rec.clientId) { + return json({ error: 'invalid_grant', error_description: 'client_id mismatch' }, 400) + } + if (body.redirect_uri !== rec.clientRedirectUri) return json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' }, 400) + if (!verifyChallenge(body.code_verifier, rec.clientCodeChallenge)) { + return json({ error: 'invalid_grant', error_description: 'PKCE verification failed' }, 400) + } + + const scope = SCOPES.join(' ') + const access_token = await mintAccess(rec.user, scope) + const familyId = newFamilyId() + await putFamily(familyId, { revoked: false, clientId: rec.clientId, createdAt: Date.now() }) + const refresh_token = await issueRefresh(familyId, rec.clientId, rec.user, scope) + return json({ access_token, token_type: 'Bearer', expires_in: ACCESS_TOKEN_TTL_SEC, scope, refresh_token }) } - const u = rec.user - const access_token = await signAccessToken( - { sub: u.sub, email: u.email, email_verified: u.email_verified, org_id: u.org_id, org_name: u.org_name, scope: SCOPES.join(' ') }, - { issuer: ep.issuer, audience: `${origin}/mcp`, ttlSec: ACCESS_TOKEN_TTL_SEC } - ) - return json({ access_token, token_type: 'Bearer', expires_in: ACCESS_TOKEN_TTL_SEC, scope: SCOPES.join(' ') }) + // ---- refresh_token grant (rotation + reuse detection) ---- + if (body.grant_type === 'refresh_token') { + if (!body.refresh_token) return json({ error: 'invalid_request', error_description: 'refresh_token required' }, 400) + const oldHash = hashRefresh(body.refresh_token) + const record = await getRefresh(oldHash) + const family = record ? await getFamily(record.familyId) : null + const decision = decideRefresh({ record, family, nowMs: Date.now() }) + + if (decision.action === 'reuse') { + await revokeFamily(record.familyId) // theft signal — kill the whole session + return json({ error: 'invalid_grant', error_description: 'refresh token reuse detected; session revoked' }, 400) + } + if (decision.action === 'invalid') return json({ error: 'invalid_grant', error_description: decision.reason }, 400) + if (body.client_id && body.client_id !== record.clientId) { + return json({ error: 'invalid_grant', error_description: 'client_id mismatch' }, 400) + } + + await markRefreshUsed(oldHash) // rotate: supersede the presented token + const refresh_token = await issueRefresh(record.familyId, record.clientId, record.user, record.scope) + const access_token = await mintAccess(record.user, record.scope) + return json({ access_token, token_type: 'Bearer', expires_in: ACCESS_TOKEN_TTL_SEC, scope: record.scope, refresh_token }) + } + + return json({ error: 'unsupported_grant_type' }, 400) } return json({ error: 'not_found', path }, 404) diff --git a/tests/mcp-oauth-refresh.test.ts b/tests/mcp-oauth-refresh.test.ts new file mode 100644 index 00000000..75afab24 --- /dev/null +++ b/tests/mcp-oauth-refresh.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { hashRefresh, newRefreshToken, newFamilyId, decideRefresh } from '../netlify/functions/lib/oauth/refresh.mjs' + +describe('refresh token primitives', () => { + it('newRefreshToken has rp prefix and matching hash; hashRefresh deterministic', () => { + const { token, hash } = newRefreshToken() + expect(token.startsWith('rt_')).toBe(true) + expect(hash).toBe(hashRefresh(token)) + expect(hash).toMatch(/^[0-9a-f]{64}$/) + expect(hash).not.toContain(token) + }) + it('family ids are unique', () => { + expect(newFamilyId()).not.toBe(newFamilyId()) + }) +}) + +describe('decideRefresh (rotation + reuse detection)', () => { + const now = 1_000_000 + const fam = { revoked: false } + const fresh = { used: false, expiresAt: now + 10_000, familyId: 'f1' } + + it('valid unused token -> rotate', () => { + expect(decideRefresh({ record: fresh, family: fam, nowMs: now }).action).toBe('rotate') + }) + it('already-used (superseded) token -> reuse', () => { + expect(decideRefresh({ record: { ...fresh, used: true }, family: fam, nowMs: now }).action).toBe('reuse') + }) + it('revoked family -> invalid', () => { + expect(decideRefresh({ record: fresh, family: { revoked: true }, nowMs: now })).toEqual({ action: 'invalid', reason: 'family_revoked' }) + }) + it('expired token -> invalid', () => { + expect(decideRefresh({ record: { ...fresh, expiresAt: now - 1 }, family: fam, nowMs: now })).toEqual({ action: 'invalid', reason: 'expired' }) + }) + it('unknown token / missing family -> invalid', () => { + expect(decideRefresh({ record: null, family: null, nowMs: now }).action).toBe('invalid') + expect(decideRefresh({ record: fresh, family: null, nowMs: now }).action).toBe('invalid') + }) +}) From f76519ebffa2aac39d6d399e50b95708a7984bdb Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Wed, 17 Jun 2026 12:45:51 +0100 Subject: [PATCH 25/61] Add login interstitial at /authorize with a Cloud sign-up link (Option B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the product call: docs MCP auth is login-only for now (no inline account creation / org provisioning). To onboard prospects without a Redpanda Cloud account, /authorize now renders a small interstitial — "Continue with Redpanda Cloud" + "Sign up at cloud.redpanda.com" — before redirecting to the IdP. - lib/oauth/pages.mjs: loginInterstitialHtml() (pure, attribute-escaped — unit-tested) - config.mjs: SIGNUP_URL (default https://cloud.redpanda.com) + LOGIN_INTERSTITIAL (set MCP_OAUTH_INTERSTITIAL=off to redirect straight through, e.g. if the signup link later lives on the Auth0 login page) - mcp-oauth.mjs: /authorize returns the interstitial (200 HTML) instead of an immediate 302; the Continue link carries the upstream authorize URL Verified live (functions:serve): /authorize returns the interstitial; following Continue completes mock-idp -> /callback -> client redirect with a code. 82 tests pass (incl. HTML escaping / attribute-breakout guard). Co-Authored-By: Claude Opus 4.8 --- netlify/functions/lib/oauth/config.mjs | 7 ++++ netlify/functions/lib/oauth/pages.mjs | 50 ++++++++++++++++++++++++++ netlify/functions/mcp-oauth.mjs | 17 ++++++--- tests/mcp-oauth-pages.test.ts | 23 ++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 netlify/functions/lib/oauth/pages.mjs create mode 100644 tests/mcp-oauth-pages.test.ts diff --git a/netlify/functions/lib/oauth/config.mjs b/netlify/functions/lib/oauth/config.mjs index 7ccec711..46fee5a4 100644 --- a/netlify/functions/lib/oauth/config.mjs +++ b/netlify/functions/lib/oauth/config.mjs @@ -43,6 +43,13 @@ export const AUTH0_ISSUER = process.env.REDPANDA_OAUTH_ISSUER || 'https://auth.p export const AUTH0_CLIENT_ID = process.env.REDPANDA_OAUTH_CLIENT_ID // public client, no secret export const REQUIRE_WORK_EMAIL = process.env.REQUIRE_WORK_EMAIL !== 'false' +// Login interstitial (shown at /authorize before redirecting to the IdP). It +// carries the "Sign up at cloud.redpanda.com" link for users without an account. +// Set MCP_OAUTH_INTERSTITIAL=off to redirect straight to the IdP (e.g. once the +// signup link lives on the Auth0 login page instead). +export const SIGNUP_URL = process.env.MCP_OAUTH_SIGNUP_URL || 'https://cloud.redpanda.com' +export const LOGIN_INTERSTITIAL = process.env.MCP_OAUTH_INTERSTITIAL !== 'off' + // AS issuer = the public origin of the request (e.g. https://docs.redpanda.com). export function issuerFor(origin) { return origin diff --git a/netlify/functions/lib/oauth/pages.mjs b/netlify/functions/lib/oauth/pages.mjs new file mode 100644 index 00000000..829114a4 --- /dev/null +++ b/netlify/functions/lib/oauth/pages.mjs @@ -0,0 +1,50 @@ +// HTML for the login interstitial shown at /authorize before redirecting to the +// upstream IdP. Pure (returns a string) so it's unit-testable. + +function escapeAttr(s) { + return String(s) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +// `continueUrl` is the upstream (Auth0) authorize URL; `signupUrl` points users +// without a Redpanda Cloud account at the Cloud signup page. +export function loginInterstitialHtml({ continueUrl, signupUrl }) { + const c = escapeAttr(continueUrl) + const s = escapeAttr(signupUrl) + return ` + + + + +Sign in — Redpanda Docs MCP + + + +
+

Connect to Redpanda Docs

+

Sign in with your Redpanda Cloud account to use the documentation tools in your AI client.

+ Continue with Redpanda Cloud + +

We use your verified work email to track documentation usage and attribute it to your organization. We don't store the content of your queries.

+
+ +` +} diff --git a/netlify/functions/mcp-oauth.mjs b/netlify/functions/mcp-oauth.mjs index 558cdc9e..5caecaea 100644 --- a/netlify/functions/mcp-oauth.mjs +++ b/netlify/functions/mcp-oauth.mjs @@ -18,7 +18,8 @@ import { buildAuthorizeUrl, exchangeCode, UPSTREAM_MODE } from './lib/oauth/upst import { verifyChallenge, generatePair } from './lib/oauth/pkce.mjs' import { registerClient, getClient, redirectUriAllowed } from './lib/oauth/clients.mjs' import { hashRefresh, newRefreshToken, newFamilyId, decideRefresh } from './lib/oauth/refresh.mjs' -import { PATHS, SCOPES, ACCESS_TOKEN_TTL_SEC, REFRESH_TOKEN_TTL_SEC, REQUIRE_WORK_EMAIL, UPSTREAM_MISCONFIGURED, endpoints } from './lib/oauth/config.mjs' +import { loginInterstitialHtml } from './lib/oauth/pages.mjs' +import { PATHS, SCOPES, ACCESS_TOKEN_TTL_SEC, REFRESH_TOKEN_TTL_SEC, REQUIRE_WORK_EMAIL, UPSTREAM_MISCONFIGURED, SIGNUP_URL, LOGIN_INTERSTITIAL, endpoints } from './lib/oauth/config.mjs' import { isWorkEmail, emailDomain } from './lib/auth.mjs' import { recordUser } from './lib/store.mjs' @@ -27,6 +28,8 @@ const json = (body, status = 200) => status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, }) +const html = (body, status = 200) => + new Response(body, { status, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' } }) const redirect = (location) => new Response(null, { status: 302, headers: { Location: location } }) // Send an OAuth error back to the downstream client's redirect_uri. @@ -111,9 +114,15 @@ export default async (request) => { upstreamVerifier: upstream.verifier, }) - return redirect( - buildAuthorizeUrl({ origin, state: reqId, redirectUri: ep.callback_uri, codeChallenge: upstream.challenge }) - ) + const upstreamUrl = buildAuthorizeUrl({ origin, state: reqId, redirectUri: ep.callback_uri, codeChallenge: upstream.challenge }) + + // Interstitial: show a "Continue / Sign up" page (so users without a Cloud + // account get a signup link) before bouncing to the IdP. Disable with + // MCP_OAUTH_INTERSTITIAL=off to redirect straight through. + if (LOGIN_INTERSTITIAL) { + return html(loginInterstitialHtml({ continueUrl: upstreamUrl, signupUrl: SIGNUP_URL })) + } + return redirect(upstreamUrl) } // -------- Dev-only mock upstream -------- diff --git a/tests/mcp-oauth-pages.test.ts b/tests/mcp-oauth-pages.test.ts new file mode 100644 index 00000000..d1e1b2a8 --- /dev/null +++ b/tests/mcp-oauth-pages.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import { loginInterstitialHtml } from '../netlify/functions/lib/oauth/pages.mjs' + +describe('loginInterstitialHtml', () => { + const continueUrl = 'https://auth.prd.cloud.redpanda.com/authorize?client_id=docs&state=abc&scope=openid+email' + const signupUrl = 'https://cloud.redpanda.com' + const out = loginInterstitialHtml({ continueUrl, signupUrl }) + + it('renders a Continue link to the upstream URL and a signup link', () => { + expect(out).toContain('Continue with Redpanda Cloud') + expect(out).toContain('Sign up at cloud.redpanda.com') + expect(out).toContain('href="https://cloud.redpanda.com"') + }) + it('escapes & in the continue URL href (valid HTML attribute)', () => { + expect(out).toContain('client_id=docs&state=abc') // & -> & + expect(out).not.toContain('client_id=docs&state=abc') // raw & should not appear + }) + it('escapes quotes to prevent attribute breakout', () => { + const evil = loginInterstitialHtml({ continueUrl: 'https://x/?a=">