feat(mcp): add an OAuth 2.1 authorization-server bridge for remote connectors

Let a remote MCP client (Claude.ai, ChatGPT) self-register and connect to
the `/mcp` endpoint over OAuth 2.1 + PKCE, bridged onto the existing `hlk_`
Bearer model — no parallel identity store.

- Discovery: RFC 9728 Protected Resource Metadata and RFC 8414 Authorization
  Server Metadata at `/.well-known/oauth-protected-resource` and
  `/.well-known/oauth-authorization-server` (added to the proxy allowlist).
- `/api/mcp/oauth/authorize` — consent gate backed by the existing session;
  a code is only issued on an authenticated POST decision, never on a GET.
- `/api/mcp/oauth/token` — authorization-code and refresh-token grants with
  mandatory PKCE S256, single-use codes, and rotating refresh tokens. The
  access token is an ordinary short-lived `hlk_` token scoped `health:read`.
- `/api/mcp/oauth/register` — RFC 7591 DCR fallback; CIMD (SEP-991) is the
  preferred path and resolves client metadata through `safeFetch`.
- Audience binding (RFC 8707): every token is bound to the canonical `/mcp`
  resource URI; a mismatched `resource` is rejected at authorize and token.
- The grant artifacts (code, refresh, DCR client id) are HMAC-signed,
  self-describing, and domain-separated; the central log redactors scrub
  them plus the PKCE verifier.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 14:27:09 +02:00
1 parent 03d41bc5c0
commit 7ec3bf0dad
18 files changed
+2186 -3

No files matched your search

@@ -0,0 +1,18 @@
/**
* RFC 8414 — OAuth Authorization Server Metadata for the built-in bridge AS.
*
* Public, unauthenticated discovery (added to the proxy `.well-known` allowlist).
* Advertises the authorize / token / register endpoints, `S256`-only PKCE, and
* CIMD support (`client_id_metadata_document_supported: true` + `none` auth) so
* Claude.ai / ChatGPT can self-register and complete the OAuth 2.1 + PKCE flow.
*/
import { authorizationServerMetadata } from "@/lib/mcp/oauth/metadata";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function GET(request: Request): Response {
return Response.json(authorizationServerMetadata(request.url), {
headers: { "Cache-Control": "public, max-age=300" },
});
}
@@ -0,0 +1,19 @@
/**
* RFC 9728 — OAuth Protected Resource Metadata for the `/mcp` resource.
*
* Public, unauthenticated discovery (added to the proxy `.well-known` allowlist).
* A remote MCP client reads this after the `/mcp` 401 to learn which
* Authorization Server backs the resource and what scopes it accepts. The
* `resource` value is the canonical `/mcp` URI and MUST match the URL the user
* pasted into Claude.ai / ChatGPT.
*/
import { protectedResourceMetadata } from "@/lib/mcp/oauth/metadata";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function GET(request: Request): Response {
return Response.json(protectedResourceMetadata(request.url), {
headers: { "Cache-Control": "public, max-age=300" },
});
}
@@ -0,0 +1,218 @@
/**
* Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
process.env.APP_URL = "https://health.example";
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
import { GET, POST } from "../authorize/route";
import { getSession } from "@/lib/auth/session";
import { registerDcrClient } from "@/lib/mcp/oauth/clients";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
const VERIFIER = "a".repeat(64);
const CHALLENGE = s256Challenge(VERIFIER);
const RESOURCE = "https://health.example/mcp";
const CLIENT = registerDcrClient({
clientName: "Claude",
redirectUris: [REDIRECT],
});
function authorizeUrl(
overrides: Record<string, string | undefined> = {},
): string {
const params: Record<string, string | undefined> = {
response_type: "code",
client_id: CLIENT.clientId,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
code_challenge_method: "S256",
scope: "health:read",
state: "xyz",
resource: RESOURCE,
...overrides,
};
const u = new URL("https://health.example/api/mcp/oauth/authorize");
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) u.searchParams.set(k, v);
}
return u.toString();
}
function getReq(url: string): Request {
return new Request(url, { method: "GET" });
}
function postReq(fields: Record<string, string>): Request {
const body = new URLSearchParams(fields);
return new Request("https://health.example/api/mcp/oauth/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
}
function signedIn() {
vi.mocked(getSession).mockResolvedValue({
session: { id: "s1", expiresAt: new Date(Date.now() + 1e6) },
user: { id: "user-1" } as never,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(null);
});
describe("GET /authorize — consent gate", () => {
it("renders a consent screen for an authenticated user (no code issued)", async () => {
signedIn();
const res = await GET(getReq(authorizeUrl()) as never);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toMatch(/Authorize access/);
expect(body).toMatch(/Claude/);
expect(body).toMatch(/decision/); // the Allow/Deny form
// A GET never mints a code.
expect(body).not.toMatch(/hlac_/);
});
it("prompts sign-in (with a return link) when there is no session", async () => {
const res = await GET(getReq(authorizeUrl()) as never);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toMatch(/\/auth\/login/);
expect(body).not.toMatch(/hlac_/);
});
});
describe("GET /authorize — rejected requests", () => {
it("rejects an audience mismatch with 400 invalid_target", async () => {
signedIn();
const res = await GET(
getReq(authorizeUrl({ resource: "https://evil.example/mcp" })) as never,
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe("invalid_target");
});
it("rejects a missing PKCE challenge with 400", async () => {
signedIn();
const res = await GET(
getReq(
authorizeUrl({
code_challenge: undefined,
code_challenge_method: undefined,
}),
) as never,
);
expect(res.status).toBe(400);
});
it("rejects a non-S256 PKCE method with 400", async () => {
signedIn();
const res = await GET(
getReq(authorizeUrl({ code_challenge_method: "plain" })) as never,
);
expect(res.status).toBe(400);
});
it("rejects an unknown client with 400 invalid_client", async () => {
signedIn();
const res = await GET(
getReq(authorizeUrl({ client_id: "bogus" })) as never,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_client");
});
it("rejects a redirect_uri not registered to the client", async () => {
signedIn();
const res = await GET(
getReq(
authorizeUrl({ redirect_uri: "https://evil.example/cb" }),
) as never,
);
expect(res.status).toBe(400);
});
});
describe("POST /authorize — decision", () => {
it("requires a session (401 when unauthenticated)", async () => {
const res = await POST(
postReq({
response_type: "code",
client_id: CLIENT.clientId,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
code_challenge_method: "S256",
scope: "health:read",
resource: RESOURCE,
decision: "allow",
}) as never,
);
expect(res.status).toBe(401);
});
it("mints a code and 302s back on allow", async () => {
signedIn();
const res = await POST(
postReq({
response_type: "code",
client_id: CLIENT.clientId,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
code_challenge_method: "S256",
scope: "health:read",
state: "xyz",
resource: RESOURCE,
decision: "allow",
}) as never,
);
expect(res.status).toBe(302);
const loc = res.headers.get("location") ?? "";
expect(loc.startsWith(REDIRECT)).toBe(true);
const code = new URL(loc).searchParams.get("code");
expect(code).toMatch(/^hlac_/);
expect(new URL(loc).searchParams.get("state")).toBe("xyz");
});
it("302s with access_denied on deny", async () => {
signedIn();
const res = await POST(
postReq({
response_type: "code",
client_id: CLIENT.clientId,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
code_challenge_method: "S256",
scope: "health:read",
resource: RESOURCE,
decision: "deny",
}) as never,
);
expect(res.status).toBe(302);
const loc = res.headers.get("location") ?? "";
expect(new URL(loc).searchParams.get("error")).toBe("access_denied");
});
});
@@ -0,0 +1,72 @@
/**
* DCR registration (RFC 7591) — stateless `hlc_` client-id issuance.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 19,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
import { POST } from "../register/route";
function jsonReq(body: unknown): Request {
return new Request("https://health.example/api/mcp/oauth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => vi.clearAllMocks());
describe("POST /register", () => {
it("registers a public client and returns an hlc_ client_id", async () => {
const res = await POST(
jsonReq({
client_name: "ChatGPT",
redirect_uris: [
"https://chatgpt.com/connector_platform_oauth_redirect",
],
}) as never,
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.client_id).toMatch(/^hlc_/);
expect(body.token_endpoint_auth_method).toBe("none");
expect(body.redirect_uris).toEqual([
"https://chatgpt.com/connector_platform_oauth_redirect",
]);
});
it("accepts a loopback redirect (Claude Code)", async () => {
const res = await POST(
jsonReq({ redirect_uris: ["http://127.0.0.1:8976/callback"] }) as never,
);
expect(res.status).toBe(201);
});
it("rejects a missing redirect_uris with invalid_client_metadata", async () => {
const res = await POST(jsonReq({ client_name: "x" }) as never);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_client_metadata");
});
it("rejects a non-https, non-loopback redirect_uri", async () => {
const res = await POST(
jsonReq({ redirect_uris: ["http://evil.example/cb"] }) as never,
);
expect(res.status).toBe(400);
});
});
@@ -0,0 +1,204 @@
/**
* Token endpoint — PKCE verification, audience binding, single-use codes,
* refresh-token rotation.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
process.env.APP_URL = "https://health.example";
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/auth/issue-token", () => ({
issueApiToken: vi.fn(async () => ({
token: "hlk_" + "a".repeat(64),
expiresAt: new Date(Date.now() + 3_600_000),
tokenId: "tok-1",
name: "MCP connector",
})),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 100,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
})),
checkRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 0,
resetAt: Date.now() + 60_000,
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
import { randomUUID } from "node:crypto";
import { POST } from "../token/route";
import { issueApiToken } from "@/lib/auth/issue-token";
import { checkRateLimit } from "@/lib/rate-limit";
import { signArtifact } from "@/lib/mcp/oauth/artifacts";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
const CLIENT_ID = "https://app.example/client.json";
const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
const VERIFIER = "a".repeat(64);
const CHALLENGE = s256Challenge(VERIFIER);
const RESOURCE = "https://health.example/mcp";
function authCode(overrides: Record<string, unknown> = {}): string {
return signArtifact(
"authCode",
{
jti: randomUUID(),
sub: "user-1",
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
scope: "health:read offline_access",
resource: RESOURCE,
...overrides,
},
120_000,
);
}
function tokenReq(fields: Record<string, string>): Request {
return new Request("https://health.example/api/mcp/oauth/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fields).toString(),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 0,
resetAt: Date.now() + 60_000,
});
});
describe("authorization_code grant", () => {
it("exchanges a valid code + verifier for an access token (+ refresh)", async () => {
const res = await POST(
tokenReq({
grant_type: "authorization_code",
code: authCode(),
code_verifier: VERIFIER,
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
}) as never,
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.token_type).toBe("Bearer");
expect(body.access_token).toMatch(/^hlk_/);
expect(body.refresh_token).toMatch(/^hlrt_/);
expect(body.scope).toContain("health:read");
expect(issueApiToken).toHaveBeenCalledWith(
expect.objectContaining({ permissions: ["health:read"] }),
);
});
it("rejects a wrong PKCE verifier with invalid_grant", async () => {
const res = await POST(
tokenReq({
grant_type: "authorization_code",
code: authCode(),
code_verifier: "b".repeat(64),
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
}) as never,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
expect(issueApiToken).not.toHaveBeenCalled();
});
it("rejects a code bound to a different audience", async () => {
const res = await POST(
tokenReq({
grant_type: "authorization_code",
code: authCode({ resource: "https://evil.example/mcp" }),
code_verifier: VERIFIER,
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
}) as never,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_target");
});
it("rejects a code presented by a different client", async () => {
const res = await POST(
tokenReq({
grant_type: "authorization_code",
code: authCode(),
code_verifier: VERIFIER,
client_id: "https://other.example/client.json",
redirect_uri: REDIRECT,
}) as never,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
});
it("rejects a replayed code (single-use jti already claimed)", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
tokenReq({
grant_type: "authorization_code",
code: authCode(),
code_verifier: VERIFIER,
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
}) as never,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
expect(issueApiToken).not.toHaveBeenCalled();
});
});
describe("refresh_token grant", () => {
it("rotates: issues a new access + refresh pair", async () => {
const refresh = signArtifact(
"refreshToken",
{
jti: randomUUID(),
sub: "user-1",
client_id: CLIENT_ID,
scope: "health:read offline_access",
resource: RESOURCE,
},
1_000_000,
);
const res = await POST(
tokenReq({
grant_type: "refresh_token",
refresh_token: refresh,
client_id: CLIENT_ID,
}) as never,
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.access_token).toMatch(/^hlk_/);
expect(body.refresh_token).toMatch(/^hlrt_/);
});
});
describe("grant validation", () => {
it("rejects an unsupported grant_type", async () => {
const res = await POST(tokenReq({ grant_type: "password" }) as never);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("unsupported_grant_type");
});
});
+363
View File
@@ -0,0 +1,363 @@
/**
* Authorization endpoint (OAuth 2.1 authorization-code flow + PKCE S256).
*
* This is the consent gate of the bridge AS. It is backed by HealthLog's
* existing Postgres session (passkey / password login) — there is NO parallel
* user store (ADR-006). The flow:
*
* GET — validate the request; if the caller has no session, prompt them to
* sign in (returning here); if they do, render a per-client CONSENT
* screen naming the client + the exact scope. NO code is ever issued on
* a GET (confused-deputy mitigation: a third party cannot silently
* obtain a grant — an authenticated human must click "Allow").
* POST — the consent decision. Requires a session, re-validates every
* parameter, and on "Allow" mints a single-use, short-lived,
* self-describing authorization code bound to this user + client +
* redirect URI + PKCE challenge + scope + audience, then 302s back.
*
* Mandatory gates, every request: PKCE `S256` (a missing / non-S256 challenge is
* rejected), audience binding (`resource` MUST equal the canonical `/mcp` URI —
* RFC 8707), and an exact redirect-URI match against the resolved client (with
* port-agnostic loopback tolerance). Errors are returned directly (never
* redirected to an unvalidated URI) so the endpoint is not an open redirector.
*/
import { randomUUID } from "node:crypto";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
import { withBackgroundEvent } from "@/lib/logging/background";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
audienceMatches,
AUTH_CODE_TTL_SECONDS,
SCOPE_HEALTH_READ,
} from "@/lib/mcp/oauth/config";
import { redirectUriAllowed, resolveClient } from "@/lib/mcp/oauth/clients";
import { isValidChallenge } from "@/lib/mcp/oauth/pkce";
import { signArtifact } from "@/lib/mcp/oauth/artifacts";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const AUTHORIZE_LIMIT = 60;
const AUTHORIZE_WINDOW_MS = 15 * 60 * 1000;
const baseParams = {
response_type: z.string(),
client_id: z.string().min(1).max(4096),
redirect_uri: z.string().min(1).max(2048),
code_challenge: z.string().min(1).max(256).optional(),
code_challenge_method: z.string().optional(),
scope: z.string().max(256).optional(),
state: z.string().max(2048).optional(),
resource: z.string().max(2048).optional(),
};
function htmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function badRequest(error: string, description: string): Response {
return Response.json(
{ error, error_description: description },
{ status: 400 },
);
}
function html(body: string, status = 200): Response {
return new Response(
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HealthLog — Authorize access</title></head><body>${body}</body></html>`,
{
status,
headers: { "content-type": "text/html; charset=utf-8" },
},
);
}
/** The single scope this read surface grants, plus optional refresh continuity. */
function grantScope(requested: string | undefined): string {
const wants = new Set(
(requested ?? SCOPE_HEALTH_READ).split(/\s+/).filter(Boolean),
);
const granted = [SCOPE_HEALTH_READ];
if (wants.has("offline_access")) granted.push("offline_access");
return granted.join(" ");
}
interface ValidatedRequest {
clientId: string;
clientName: string;
redirectUri: string;
codeChallenge: string;
scope: string;
state?: string;
resource: string;
}
/** Shared validation for GET + POST. Returns a ready error Response on failure. */
async function validate(
params: Record<string, string | undefined>,
requestUrl: string,
): Promise<{ ok: true; v: ValidatedRequest } | { ok: false; res: Response }> {
const parsed = z.object(baseParams).safeParse(params);
if (!parsed.success) {
return {
ok: false,
res: badRequest("invalid_request", "Missing or malformed parameters"),
};
}
const p = parsed.data;
// 1. Client must resolve (CIMD URL or stateless DCR id). Unknown → cannot
// safely redirect anywhere, so error directly.
const resolved = await resolveClient(p.client_id);
if (!resolved.ok) {
return {
ok: false,
res: badRequest("invalid_client", "Unknown or unresolvable client"),
};
}
// 2. Redirect URI MUST match the registration before we trust it.
if (!redirectUriAllowed(p.redirect_uri, resolved.client.redirectUris)) {
return {
ok: false,
res: badRequest(
"invalid_request",
"redirect_uri does not match the registered client",
),
};
}
// 3. response_type — code only.
if (p.response_type !== "code") {
return {
ok: false,
res: badRequest(
"unsupported_response_type",
"Only response_type=code is supported",
),
};
}
// 4. PKCE S256 is MANDATORY.
if (
p.code_challenge_method !== "S256" ||
!isValidChallenge(p.code_challenge)
) {
return {
ok: false,
res: badRequest(
"invalid_request",
"PKCE with code_challenge_method=S256 is required",
),
};
}
// 5. Audience binding (RFC 8707) — resource MUST equal the canonical /mcp URI.
if (!audienceMatches(p.resource, requestUrl)) {
return {
ok: false,
res: badRequest(
"invalid_target",
"resource must equal this server's MCP endpoint",
),
};
}
return {
ok: true,
v: {
clientId: p.client_id,
clientName: resolved.client.clientName,
redirectUri: p.redirect_uri,
codeChallenge: p.code_challenge as string,
scope: grantScope(p.scope),
state: p.state,
resource: p.resource as string,
},
};
}
function redirectBack(
redirectUri: string,
query: Record<string, string>,
): Response {
const url = new URL(redirectUri);
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
return Response.redirect(url.toString(), 302);
}
export async function GET(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.authorize", async () => {
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:authorize",
AUTHORIZE_LIMIT,
AUTHORIZE_WINDOW_MS,
);
if (!rl.allowed) {
return Response.json(
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
},
);
}
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries());
const result = await validate(params, request.url);
if (!result.ok) {
annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
return result.res;
}
const { v } = result;
const session = await getSession();
if (!session) {
// No identity yet — send the human to sign in, returning to this exact
// request. NO grant is issued without an authenticated decision.
const next = url.pathname + url.search;
const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
annotate({ action: { name: "mcp.oauth.authorize.login_required" } });
return html(
`<main><h1>Sign in to HealthLog</h1><p>To connect <strong>${htmlEscape(v.clientName)}</strong> you must sign in first.</p><p><a href="${htmlEscape(loginHref)}">Sign in and continue</a></p></main>`,
);
}
annotate({ action: { name: "mcp.oauth.authorize.consent_shown" } });
// Consent screen — a plain POST form (no inline script/style, CSP-safe).
const hidden = (name: string, value: string) =>
`<input type="hidden" name="${name}" value="${htmlEscape(value)}">`;
return html(
`<main>
<h1>Authorize access</h1>
<p><strong>${htmlEscape(v.clientName)}</strong> is requesting access to your HealthLog data.</p>
<p>Scope: <code>${htmlEscape(v.scope)}</code> — read-only access to your own health records.</p>
<form method="POST" action="/api/mcp/oauth/authorize">
${hidden("response_type", "code")}
${hidden("client_id", v.clientId)}
${hidden("redirect_uri", v.redirectUri)}
${hidden("code_challenge", v.codeChallenge)}
${hidden("code_challenge_method", "S256")}
${hidden("scope", v.scope)}
${hidden("resource", v.resource)}
${v.state !== undefined ? hidden("state", v.state) : ""}
<button type="submit" name="decision" value="allow">Allow</button>
<button type="submit" name="decision" value="deny">Deny</button>
</form>
</main>`,
);
});
}
export async function POST(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.authorize", async () => {
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:authorize",
AUTHORIZE_LIMIT,
AUTHORIZE_WINDOW_MS,
);
if (!rl.allowed) {
return Response.json(
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
},
);
}
// The consent decision MUST come from an authenticated human.
const session = await getSession();
if (!session) {
annotate({
action: { name: "mcp.oauth.authorize.decision_unauthenticated" },
});
return Response.json(
{ error: "access_denied", error_description: "Sign in required" },
{ status: 401 },
);
}
const form = await request.formData();
const params: Record<string, string | undefined> = {};
for (const key of [
"response_type",
"client_id",
"redirect_uri",
"code_challenge",
"code_challenge_method",
"scope",
"state",
"resource",
"decision",
]) {
const value = form.get(key);
params[key] = typeof value === "string" ? value : undefined;
}
const result = await validate(params, request.url);
if (!result.ok) {
annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
return result.res;
}
const { v } = result;
if (params.decision !== "allow") {
annotate({ action: { name: "mcp.oauth.authorize.denied" } });
return redirectBack(v.redirectUri, {
error: "access_denied",
...(v.state !== undefined ? { state: v.state } : {}),
});
}
const code = signArtifact(
"authCode",
{
// `jti` makes the code single-use: the token endpoint claims it once
// through the atomic rate-limit upsert, so a replay within the TTL is
// rejected.
jti: randomUUID(),
sub: session.user.id,
client_id: v.clientId,
redirect_uri: v.redirectUri,
code_challenge: v.codeChallenge,
scope: v.scope,
resource: v.resource,
},
AUTH_CODE_TTL_SECONDS * 1000,
);
await auditLog("mcp.oauth.authorize.granted", {
userId: session.user.id,
details: { client_id: v.clientId, scope: v.scope },
});
annotate({ action: { name: "mcp.oauth.authorize.granted" } });
return redirectBack(v.redirectUri, {
code,
...(v.state !== undefined ? { state: v.state } : {}),
});
});
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Dynamic Client Registration (RFC 7591) — the DCR fallback for clients that
* do not present a CIMD `client_id` URL.
*
* Registration is STATELESS: the issued `client_id` is a signed, self-describing
* `hlc_` artifact carrying the registered `redirect_uris` + name (see
* `clients.ts` / `artifacts.ts`), so no client row is written. The endpoint only
* validates the metadata, enforces a per-IP rate limit (it is unauthenticated),
* and returns the RFC 7591 client-information response for a PUBLIC client
* (`token_endpoint_auth_method: "none"` — PKCE is the proof, there is no secret).
*
* Redirect URIs are shape-validated here; the AUTHORITATIVE redirect-URI match
* happens at `/authorize` against the decoded registration.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
import { withBackgroundEvent } from "@/lib/logging/background";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { registerDcrClient } from "@/lib/mcp/oauth/clients";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const REGISTER_LIMIT = 20;
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
const MAX_BODY_BYTES = 16 * 1024;
/** A redirect URI must be an absolute HTTPS URL or an http loopback URL. */
function isAllowableRedirectUri(value: string): boolean {
let url: URL;
try {
url = new URL(value);
} catch {
return false;
}
if (url.protocol === "https:") return true;
if (
url.protocol === "http:" &&
["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname)
) {
return true;
}
return false;
}
const registrationSchema = z.object({
redirect_uris: z
.array(z.string().min(1).max(2048))
.min(1)
.max(12)
.refine((uris) => uris.every(isAllowableRedirectUri), {
message: "redirect_uris must be HTTPS or http loopback URLs",
}),
client_name: z.string().min(1).max(200).optional(),
// Accepted-and-ignored RFC 7591 fields (we never read them but tolerate them).
grant_types: z.array(z.string()).optional(),
response_types: z.array(z.string()).optional(),
token_endpoint_auth_method: z.string().optional(),
scope: z.string().optional(),
});
function oauthError(
error: string,
description: string,
status: number,
extraHeaders: Record<string, string> = {},
): Response {
return Response.json(
{ error, error_description: description },
{ status, headers: extraHeaders },
);
}
export async function POST(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.register", async () => {
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:register",
REGISTER_LIMIT,
REGISTER_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "mcp.oauth.register.rate_limited" } });
return Response.json(
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
},
);
}
const raw = await request.text();
if (raw.length > MAX_BODY_BYTES) {
return oauthError("invalid_client_metadata", "Payload too large", 400);
}
let body: unknown;
try {
body = JSON.parse(raw);
} catch {
return oauthError("invalid_client_metadata", "Invalid JSON", 400);
}
const parsed = registrationSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "mcp.oauth.register.invalid" } });
return oauthError(
"invalid_client_metadata",
"redirect_uris is required and must contain HTTPS or loopback URLs",
400,
);
}
const client = registerDcrClient({
clientName: parsed.data.client_name ?? "MCP client",
redirectUris: parsed.data.redirect_uris,
});
annotate({
action: { name: "mcp.oauth.register.issued" },
meta: { source: "dcr", redirect_uri_count: client.redirectUris.length },
});
// RFC 7591 client-information response for a public client.
return Response.json(
{
client_id: client.clientId,
client_id_issued_at: Math.floor(Date.now() / 1000),
redirect_uris: client.redirectUris,
client_name: client.clientName,
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
},
{ status: 201 },
);
});
}
+316
View File
@@ -0,0 +1,316 @@
/**
* Token endpoint (OAuth 2.1) — `authorization_code` + `refresh_token` grants.
*
* Exchanges the bridge AS's self-describing artifacts for a REAL `hlk_`
* `ApiToken` (the access token) minted through `issue-token.ts` with the narrow
* `health:read` scope (ADR-006). The access token is therefore an ordinary
* Bearer the `/mcp` resolver already understands — bound to `<userId>:<tokenId>`
* (REQ-SEC-11), revocable, and short-lived. There is no token passthrough: the
* MCP layer never forwards a client-presented upstream credential.
*
* Gates:
* - PKCE — the presented `code_verifier` MUST hash (S256) to the
* `code_challenge` bound into the authorization code.
* - Single use — the code's `jti` is claimed once through the atomic
* rate-limit upsert; a replay within the TTL is rejected.
* - Audience — the code's bound `resource` MUST equal the canonical `/mcp`
* URI (RFC 8707); a `resource` parameter, when sent, must match too.
* - Client binding — the `client_id` presented at `/token` MUST equal the one
* the code was issued to.
*
* Refresh tokens rotate on every use (OAuth 2.1 public-client rotation): a fresh
* access + refresh pair is returned and the presented refresh `jti` is claimed
* so it cannot be reused.
*/
import { randomUUID } from "node:crypto";
import { NextRequest } from "next/server";
import { annotate } from "@/lib/logging/context";
import { withBackgroundEvent } from "@/lib/logging/background";
import { auditLog } from "@/lib/auth/audit";
import { issueApiToken } from "@/lib/auth/issue-token";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import {
ACCESS_TOKEN_TTL_MINUTES,
audienceMatches,
REFRESH_TOKEN_TTL_DAYS,
SCOPE_HEALTH_READ,
} from "@/lib/mcp/oauth/config";
import { signArtifact, verifyArtifact } from "@/lib/mcp/oauth/artifacts";
import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const TOKEN_LIMIT = 120;
const TOKEN_WINDOW_MS = 15 * 60 * 1000;
function oauthError(
error: string,
description: string,
status = 400,
): Response {
return Response.json(
{ error, error_description: description },
{ status, headers: { "Cache-Control": "no-store", Pragma: "no-cache" } },
);
}
function tokenSuccess(body: Record<string, unknown>): Response {
return Response.json(body, {
headers: { "Cache-Control": "no-store", Pragma: "no-cache" },
});
}
/** Claim a one-time-use jti through the atomic limiter (limit 1 ⇒ single use). */
async function claimJti(
kind: string,
jti: string,
ttlMs: number,
): Promise<boolean> {
if (typeof jti !== "string" || jti.length === 0) return false;
const rl = await checkRateLimit(`oauth:jti:${kind}:${jti}`, 1, ttlMs);
return rl.allowed;
}
interface AuthCodeClaims {
jti: string;
sub: string;
client_id: string;
redirect_uri: string;
code_challenge: string;
scope: string;
resource: string;
}
interface RefreshClaims {
jti: string;
sub: string;
client_id: string;
scope: string;
resource: string;
}
async function mintTokenPair(args: {
userId: string;
clientId: string;
scope: string;
resource: string;
grant: "authorization_code" | "refresh_token";
}): Promise<Response> {
const access = await issueApiToken({
userId: args.userId,
name: "MCP connector",
permissions: [SCOPE_HEALTH_READ],
expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES,
});
const includeRefresh = args.scope.split(/\s+/).includes("offline_access");
const refresh = includeRefresh
? signArtifact(
"refreshToken",
{
jti: randomUUID(),
sub: args.userId,
client_id: args.clientId,
scope: args.scope,
resource: args.resource,
},
REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000,
)
: undefined;
await auditLog("mcp.oauth.token.issued", {
userId: args.userId,
details: { client_id: args.clientId, grant: args.grant, scope: args.scope },
});
annotate({
action: { name: "mcp.oauth.token.issued" },
meta: { grant: args.grant },
});
return tokenSuccess({
access_token: access.token,
token_type: "Bearer",
expires_in: ACCESS_TOKEN_TTL_MINUTES * 60,
scope: args.scope,
...(refresh ? { refresh_token: refresh } : {}),
});
}
export async function POST(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.token", async () => {
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:token",
TOKEN_LIMIT,
TOKEN_WINDOW_MS,
);
if (!rl.allowed) {
return Response.json(
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
},
);
}
let form: FormData;
try {
form = await request.formData();
} catch {
return oauthError(
"invalid_request",
"Expected application/x-www-form-urlencoded body",
);
}
const get = (k: string): string | undefined => {
const v = form.get(k);
return typeof v === "string" ? v : undefined;
};
const grantType = get("grant_type");
if (grantType === "authorization_code") {
const code = get("code");
const verifier = get("code_verifier");
const clientId = get("client_id");
const redirectUri = get("redirect_uri");
if (!code || !verifier || !clientId || !redirectUri) {
return oauthError(
"invalid_request",
"Missing code, code_verifier, client_id, or redirect_uri",
);
}
const verified = verifyArtifact<AuthCodeClaims>("authCode", code);
if (!verified.ok) {
annotate({ action: { name: "mcp.oauth.token.code_rejected" } });
return oauthError(
"invalid_grant",
"Authorization code is invalid or expired",
);
}
const claims = verified.claims;
// Client + redirect binding.
if (
claims.client_id !== clientId ||
claims.redirect_uri !== redirectUri
) {
return oauthError(
"invalid_grant",
"Authorization code was issued to a different client/redirect",
);
}
// Audience: a resource parameter, if present, must also match.
const resourceParam = get("resource");
if (resourceParam && !audienceMatches(resourceParam, request.url)) {
return oauthError(
"invalid_target",
"resource does not match the bound audience",
);
}
if (!audienceMatches(claims.resource, request.url)) {
return oauthError(
"invalid_target",
"Authorization code is bound to a different audience",
);
}
// PKCE.
if (!verifyPkceS256(verifier, claims.code_challenge)) {
annotate({ action: { name: "mcp.oauth.token.pkce_failed" } });
return oauthError("invalid_grant", "PKCE verification failed");
}
// Single use — claim the code's jti.
if (!(await claimJti("code", claims.jti, 5 * 60 * 1000))) {
annotate({ action: { name: "mcp.oauth.token.code_replayed" } });
return oauthError(
"invalid_grant",
"Authorization code has already been used",
);
}
return mintTokenPair({
userId: claims.sub,
clientId: claims.client_id,
scope: claims.scope,
resource: claims.resource,
grant: "authorization_code",
});
}
if (grantType === "refresh_token") {
const refreshToken = get("refresh_token");
const clientId = get("client_id");
if (!refreshToken || !clientId) {
return oauthError(
"invalid_request",
"Missing refresh_token or client_id",
);
}
const verified = verifyArtifact<RefreshClaims>(
"refreshToken",
refreshToken,
);
if (!verified.ok) {
return oauthError(
"invalid_grant",
"Refresh token is invalid or expired",
);
}
const claims = verified.claims;
if (claims.client_id !== clientId) {
return oauthError(
"invalid_grant",
"Refresh token was issued to a different client",
);
}
if (!audienceMatches(claims.resource, request.url)) {
return oauthError(
"invalid_target",
"Refresh token is bound to a different audience",
);
}
// Rotation: claim the presented refresh jti so it cannot be reused.
if (
!(await claimJti(
"refresh",
claims.jti,
REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000,
))
) {
annotate({ action: { name: "mcp.oauth.token.refresh_replayed" } });
return oauthError(
"invalid_grant",
"Refresh token has already been used",
);
}
return mintTokenPair({
userId: claims.sub,
clientId: claims.client_id,
scope: claims.scope,
resource: claims.resource,
grant: "refresh_token",
});
}
return oauthError(
"unsupported_grant_type",
"Only authorization_code and refresh_token are supported",
);
});
}
+10 -3
View File
@@ -23,6 +23,12 @@ export interface IssueTokenOptions {
permissions?: string[];
/** Lifetime in days. */
expiresInDays?: number;
/**
* Lifetime in minutes. When set it WINS over `expiresInDays` — used for the
* short-lived OAuth access tokens the MCP bridge mints (the refresh token
* carries continuity). Ignored when `expiresInDays` alone is supplied.
*/
expiresInMinutes?: number;
}
/**
@@ -35,9 +41,10 @@ export async function issueApiToken(
): Promise<IssuedToken> {
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
const tokenHashValue = hashToken(rawToken);
const expiresAt = new Date(
Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000,
);
const expiresAt =
opts.expiresInMinutes !== undefined
? new Date(Date.now() + opts.expiresInMinutes * 60 * 1000)
: new Date(Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000);
const created = await prisma.apiToken.create({
data: {
+8
View File
@@ -89,6 +89,14 @@ export function redactSecrets(input: string): string {
// these prefixes (CLAUDE.md headless-client-API patterns); this
// is the matching egress guard for log/error surfaces.
.replace(/(^|[^A-Za-z0-9])hl[kr]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
// v1.22.0 — the MCP OAuth bridge artifacts: the authorization code
// (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All
// are signed, base64url-bodied, and credential-bearing, so they are
// scrubbed at the egress boundary like the native token formats above.
.replace(
/(^|[^A-Za-z0-9])hl(?:ac|rt|c)_[A-Za-z0-9_.-]{16,}/g,
"$1[REDACTED]",
)
// v1.7.0 — `insurance`/`insuranceNumber`/`kvnr` (German statutory-
// insurance number) added as defence-in-depth: the value is
// encrypted at rest and never deliberately logged, but a stray
@@ -0,0 +1,216 @@
/**
* OAuth bridge — library-level unit tests (artifacts, PKCE, clients, metadata,
* audience). These pin the security-load-bearing primitives without a DB.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// A fixed signing key so artifact MACs are deterministic across the run.
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
process.env.APP_URL = "https://health.example";
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: vi.fn(),
SafeFetchError: class SafeFetchError extends Error {},
}));
import { safeFetch } from "@/lib/safe-fetch";
import { signArtifact, verifyArtifact } from "../artifacts";
import { s256Challenge, verifyPkceS256, isValidVerifier } from "../pkce";
import {
registerDcrClient,
resolveClient,
redirectUriAllowed,
} from "../clients";
import {
protectedResourceMetadata,
authorizationServerMetadata,
wwwAuthenticateChallenge,
} from "../metadata";
import { audienceMatches, canonicalResource } from "../config";
describe("artifacts — sign / verify / tamper / expiry / domain separation", () => {
it("round-trips claims and enforces expiry", () => {
const token = signArtifact("authCode", { sub: "u1" }, 60_000);
const ok = verifyArtifact<{ sub: string }>("authCode", token);
expect(ok.ok).toBe(true);
if (ok.ok) expect(ok.claims.sub).toBe("u1");
});
it("rejects an expired artifact", () => {
const token = signArtifact("authCode", { sub: "u1" }, -1);
const res = verifyArtifact("authCode", token);
expect(res).toEqual({ ok: false, reason: "expired" });
});
it("rejects a tampered payload (bad signature)", () => {
const token = signArtifact("authCode", { sub: "u1" }, 60_000);
// Flip a character in the payload segment.
const [prefixAndPayload, sig] = [
token.slice(0, token.lastIndexOf(".")),
token.slice(token.lastIndexOf(".") + 1),
];
const tampered = `${prefixAndPayload}A.${sig}`;
const res = verifyArtifact("authCode", tampered);
expect(res.ok).toBe(false);
});
it("domain-separates the three artifact classes", () => {
const code = signArtifact("authCode", { sub: "u1" }, 60_000);
// An auth code must not verify as a refresh token or a client id.
expect(verifyArtifact("refreshToken", code).ok).toBe(false);
expect(verifyArtifact("clientId", code).ok).toBe(false);
});
});
describe("PKCE — S256 only", () => {
it("verifies a correct verifier against its challenge", () => {
const verifier = "a".repeat(64);
const challenge = s256Challenge(verifier);
expect(verifyPkceS256(verifier, challenge)).toBe(true);
});
it("rejects a wrong verifier", () => {
const challenge = s256Challenge("a".repeat(64));
expect(verifyPkceS256("b".repeat(64), challenge)).toBe(false);
});
it("rejects a too-short verifier (RFC 7636 §4.1)", () => {
expect(isValidVerifier("short")).toBe(false);
expect(verifyPkceS256("short", s256Challenge("short"))).toBe(false);
});
});
describe("clients — DCR (stateless) round-trip", () => {
it("registers and resolves a DCR client with its redirect URIs", async () => {
const reg = registerDcrClient({
clientName: "Claude",
redirectUris: ["https://claude.ai/api/mcp/auth_callback"],
});
expect(reg.clientId.startsWith("hlc_")).toBe(true);
const resolved = await resolveClient(reg.clientId);
expect(resolved.ok).toBe(true);
if (resolved.ok) {
expect(resolved.client.source).toBe("dcr");
expect(resolved.client.redirectUris).toEqual([
"https://claude.ai/api/mcp/auth_callback",
]);
}
});
it("rejects an unknown client id", async () => {
const res = await resolveClient("not-a-client");
expect(res.ok).toBe(false);
});
});
describe("clients — CIMD via safeFetch (SSRF-safe)", () => {
beforeEach(() => vi.clearAllMocks());
it("fetches a CIMD document through safeFetch with requirePublicHost", async () => {
const clientId = "https://app.example/mcp-client.json";
vi.mocked(safeFetch).mockResolvedValue(
new Response(
JSON.stringify({
client_id: clientId,
client_name: "ChatGPT",
redirect_uris: [
"https://chatgpt.com/connector_platform_oauth_redirect",
],
}),
{ status: 200 },
),
);
const res = await resolveClient(clientId);
expect(res.ok).toBe(true);
// The SSRF guard is requested.
const opts = vi.mocked(safeFetch).mock.calls[0][2];
expect(opts?.requirePublicHost).toBe(true);
});
it("rejects a CIMD doc whose client_id does not match its URL (SEP-991)", async () => {
const clientId = "https://app.example/mcp-client.json";
vi.mocked(safeFetch).mockResolvedValue(
new Response(
JSON.stringify({
client_id: "https://attacker.example/other.json",
redirect_uris: ["https://app.example/cb"],
}),
{ status: 200 },
),
);
const res = await resolveClient(clientId);
expect(res).toEqual({ ok: false, reason: "invalid_metadata" });
});
it("surfaces an SSRF block as ssrf_blocked", async () => {
vi.mocked(safeFetch).mockRejectedValue(new Error("private host"));
const res = await resolveClient("https://169.254.169.254/meta.json");
expect(res).toEqual({ ok: false, reason: "ssrf_blocked" });
});
});
describe("clients — redirect URI matching", () => {
it("matches an exact registered URI (claude.ai callback)", () => {
const registered = ["https://claude.ai/api/mcp/auth_callback"];
expect(
redirectUriAllowed("https://claude.ai/api/mcp/auth_callback", registered),
).toBe(true);
});
it("matches loopback port-agnostically", () => {
const registered = ["http://localhost:1234/callback"];
expect(
redirectUriAllowed("http://localhost:55999/callback", registered),
).toBe(true);
expect(
redirectUriAllowed("http://127.0.0.1:1/callback", [
"http://127.0.0.1:9/callback",
]),
).toBe(true);
});
it("rejects a non-loopback mismatch and a path mismatch", () => {
expect(
redirectUriAllowed("https://evil.example/cb", [
"https://claude.ai/api/mcp/auth_callback",
]),
).toBe(false);
expect(
redirectUriAllowed("http://localhost:1/evil", [
"http://localhost:2/callback",
]),
).toBe(false);
});
});
describe("metadata + audience", () => {
it("PRM names the canonical /mcp resource", () => {
const prm = protectedResourceMetadata();
expect(prm.resource).toBe("https://health.example/mcp");
expect(prm.authorization_servers).toEqual(["https://health.example"]);
});
it("AS metadata advertises S256-only PKCE + CIMD + none auth", () => {
const as = authorizationServerMetadata();
expect(as.code_challenge_methods_supported).toEqual(["S256"]);
expect(as.token_endpoint_auth_methods_supported).toEqual(["none"]);
expect(as.client_id_metadata_document_supported).toBe(true);
expect(as.grant_types_supported).toContain("authorization_code");
expect(as.grant_types_supported).toContain("refresh_token");
});
it("the WWW-Authenticate challenge points at the PRM", () => {
expect(wwwAuthenticateChallenge()).toMatch(
/resource_metadata="https:\/\/health\.example\/\.well-known\/oauth-protected-resource"/,
);
});
it("audience binding accepts the canonical resource and rejects look-alikes", () => {
expect(audienceMatches(canonicalResource())).toBe(true);
expect(audienceMatches("https://health.example/mcp/")).toBe(true);
expect(audienceMatches("https://evil.example/mcp")).toBe(false);
expect(audienceMatches(undefined)).toBe(false);
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* Signed, self-describing OAuth artifacts (stateless bridge).
*
* The minimal AS keeps NO new identity tables (ADR-006). The three short-lived
* OAuth artifacts it mints — the authorization code, the refresh token, and the
* Dynamic-Client-Registration client id — are instead HMAC-signed, tamper-proof,
* self-describing strings. Each carries its own claims and is verified by
* recomputing the signature in constant time; nothing is looked up in a table.
*
* Only the ACCESS token is a real persisted row: it is an ordinary `hlk_`
* `ApiToken` minted through `issue-token.ts`, so the existing revoke / expiry /
* `lastUsedAt` machinery and the `<userId>:<tokenId>` binding all apply to it
* unchanged. The artifacts here exist only to carry grant state BETWEEN the
* authorize and token steps without inventing a parallel store.
*
* Signing key: `API_TOKEN_HMAC_KEY` (the same fail-closed key the Bearer hasher
* uses — `hashToken` validates length ≥ 32). Each artifact class is domain-
* separated by a context label folded into the MAC input, so a code can never be
* presented as a refresh token or a client id, and vice-versa.
*
* The signed value is `<prefix>.<base64url(payload)>.<base64url(mac)>`. The
* payload always carries an `exp` (epoch ms) which `verifyArtifact` enforces.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
/** Artifact classes, each with a stable prefix + domain-separation label. */
export const ARTIFACT_KINDS = {
authCode: { prefix: "hlac_", context: "mcp.oauth.auth_code.v1" },
refreshToken: { prefix: "hlrt_", context: "mcp.oauth.refresh.v1" },
clientId: { prefix: "hlc_", context: "mcp.oauth.client.v1" },
} as const;
export type ArtifactKind = keyof typeof ARTIFACT_KINDS;
function signingKey(): string {
const key = process.env.API_TOKEN_HMAC_KEY;
if (!key || key.length < 32) {
// Fail closed — never sign an OAuth artifact off weak / missing key
// material (mirrors `hashToken`).
throw new Error(
"API_TOKEN_HMAC_KEY must be set and at least 32 characters",
);
}
return key;
}
function b64url(input: Buffer | string): string {
return Buffer.from(input)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function b64urlDecode(input: string): Buffer {
return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}
function mac(context: string, payloadB64: string): string {
return createHmac("sha256", signingKey())
.update(`${context}.${payloadB64}`)
.digest("hex");
}
/**
* Mint a signed artifact. `claims` is merged with `exp` (now + `ttlMs`) and
* serialised; the caller never sets `exp` itself.
*/
export function signArtifact(
kind: ArtifactKind,
claims: Record<string, unknown>,
ttlMs: number,
): string {
const { prefix, context } = ARTIFACT_KINDS[kind];
const payload = { ...claims, exp: Date.now() + ttlMs };
const payloadB64 = b64url(JSON.stringify(payload));
// `mac` returns hex; base64url the RAW bytes so `verifyArtifact` can compare
// against `Buffer.from(expectedSig, "hex")` byte-for-byte.
const sig = b64url(Buffer.from(mac(context, payloadB64), "hex"));
return `${prefix}${payloadB64}.${sig}`;
}
export type VerifyResult<T> =
| { ok: true; claims: T }
| { ok: false; reason: "malformed" | "bad_signature" | "expired" };
/**
* Verify + decode a signed artifact. Returns the claims on success, or a stable
* machine reason on failure. The signature is checked in constant time and the
* `exp` claim is enforced. A wrong-kind artifact fails on `malformed` (prefix
* mismatch) or `bad_signature` (domain-separation label mismatch).
*/
export function verifyArtifact<T = Record<string, unknown>>(
kind: ArtifactKind,
value: string,
): VerifyResult<T> {
const { prefix, context } = ARTIFACT_KINDS[kind];
if (typeof value !== "string" || !value.startsWith(prefix)) {
return { ok: false, reason: "malformed" };
}
const body = value.slice(prefix.length);
const dot = body.lastIndexOf(".");
if (dot <= 0) return { ok: false, reason: "malformed" };
const payloadB64 = body.slice(0, dot);
const sigB64 = body.slice(dot + 1);
const expectedSig = mac(context, payloadB64);
const presented = b64urlDecode(sigB64);
const expected = Buffer.from(expectedSig, "hex");
if (
presented.length !== expected.length ||
!timingSafeEqual(presented, expected)
) {
return { ok: false, reason: "bad_signature" };
}
let claims: T & { exp?: number };
try {
claims = JSON.parse(b64urlDecode(payloadB64).toString("utf8"));
} catch {
return { ok: false, reason: "malformed" };
}
if (typeof claims.exp !== "number" || claims.exp <= Date.now()) {
return { ok: false, reason: "expired" };
}
return { ok: true, claims };
}
+222
View File
@@ -0,0 +1,222 @@
/**
* OAuth client resolution — CIMD-first, DCR fallback (Phase 3 of the MCP
* milestone; changes B's DCR-first plan per the deep-value research).
*
* Both Claude.ai and ChatGPT now prefer **CIMD** (Client ID Metadata Documents,
* SEP-991): the `client_id` IS an HTTPS URL that resolves to a JSON metadata
* document describing the client. CIMD removes the per-connection client-row
* explosion that DCR causes — and, here, removes the need for ANY client store.
* A CIMD client is resolved by fetching its document THROUGH `safeFetch`
* (SSRF-safe: `requirePublicHost`, manual redirects, bounded timeout) so a
* malicious `client_id` cannot point the AS at an internal host (R-SEC-6).
*
* For clients that still use **DCR** (RFC 7591) we register them statelessly:
* the issued `client_id` is itself a signed, self-describing `hlc_` artifact
* (see `artifacts.ts`) carrying the registered `redirect_uris` + name. Decoding
* it locally recovers the registration with no table — and tampering with it
* fails the HMAC.
*
* Redirect-URI matching is exact against the registered set, with ONE tolerated
* relaxation: loopback redirect URIs match port-agnostically (Claude Code /
* Desktop bind an ephemeral localhost port), per the connector spec. The
* `https://claude.ai/api/mcp/auth_callback` hosted callback is matched as an
* ordinary registered entry.
*/
import { safeFetch } from "@/lib/safe-fetch";
import { ARTIFACT_KINDS, signArtifact, verifyArtifact } from "./artifacts";
export interface ResolvedClient {
clientId: string;
clientName: string;
redirectUris: string[];
/** CIMD + DCR public clients authenticate with `none` (PKCE is the proof). */
isPublic: true;
source: "cimd" | "dcr";
}
export type ClientResolution =
| { ok: true; client: ResolvedClient }
| {
ok: false;
reason: "unknown_client" | "invalid_metadata" | "ssrf_blocked";
};
const MAX_REDIRECT_URIS = 12;
const CIMD_MAX_BYTES = 16 * 1024;
function asStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const out: string[] = [];
for (const v of value) {
if (typeof v !== "string" || v.length === 0 || v.length > 2048) return null;
out.push(v);
}
if (out.length === 0 || out.length > MAX_REDIRECT_URIS) return null;
return out;
}
/** A `client_id` that is an HTTPS URL is a CIMD client (SEP-991). */
export function isCimdClientId(clientId: string): boolean {
try {
return new URL(clientId).protocol === "https:";
} catch {
return false;
}
}
/** A `client_id` minted by our stateless DCR endpoint. */
export function isDcrClientId(clientId: string): boolean {
return clientId.startsWith(ARTIFACT_KINDS.clientId.prefix);
}
/**
* Register a client via DCR (RFC 7591). The returned `client_id` is a signed
* `hlc_` artifact that encodes the registration — no row is written. DCR clients
* never expire (the artifact has a long horizon) but carry no secret.
*/
export function registerDcrClient(input: {
clientName: string;
redirectUris: string[];
}): ResolvedClient {
const clientId = signArtifact(
"clientId",
{ name: input.clientName, redirect_uris: input.redirectUris },
// Ten-year horizon: a registration is long-lived but still self-expiring.
10 * 365 * 24 * 60 * 60 * 1000,
);
return {
clientId,
clientName: input.clientName,
redirectUris: input.redirectUris,
isPublic: true,
source: "dcr",
};
}
function resolveDcrClient(clientId: string): ClientResolution {
const verified = verifyArtifact<{ name?: string; redirect_uris?: unknown }>(
"clientId",
clientId,
);
if (!verified.ok) return { ok: false, reason: "unknown_client" };
const redirectUris = asStringArray(verified.claims.redirect_uris);
if (!redirectUris) return { ok: false, reason: "invalid_metadata" };
return {
ok: true,
client: {
clientId,
clientName:
typeof verified.claims.name === "string"
? verified.claims.name
: "MCP client",
redirectUris,
isPublic: true,
source: "dcr",
},
};
}
async function resolveCimdClient(clientId: string): Promise<ClientResolution> {
let res: Response;
try {
// SSRF floor: the metadata host is fully user-controlled, so pin the
// connect-time public-host check + manual redirects + bounded timeout.
res = await safeFetch(
clientId,
{ method: "GET", headers: { accept: "application/json" } },
{ requirePublicHost: true, timeoutMs: 8000 },
);
} catch {
return { ok: false, reason: "ssrf_blocked" };
}
if (!res.ok) return { ok: false, reason: "unknown_client" };
const raw = await res.text();
if (raw.length > CIMD_MAX_BYTES) {
return { ok: false, reason: "invalid_metadata" };
}
let doc: Record<string, unknown>;
try {
doc = JSON.parse(raw);
} catch {
return { ok: false, reason: "invalid_metadata" };
}
// SEP-991: the document's `client_id` MUST equal the URL it was fetched from.
if (doc.client_id !== clientId) {
return { ok: false, reason: "invalid_metadata" };
}
const redirectUris = asStringArray(doc.redirect_uris);
if (!redirectUris) return { ok: false, reason: "invalid_metadata" };
return {
ok: true,
client: {
clientId,
clientName:
typeof doc.client_name === "string" ? doc.client_name : "MCP client",
redirectUris,
isPublic: true,
source: "cimd",
},
};
}
/**
* Resolve a `client_id` to its registration. HTTPS URLs are CIMD (fetched
* SSRF-safely); `hlc_` ids are stateless DCR; anything else is unknown.
*/
export async function resolveClient(
clientId: string,
): Promise<ClientResolution> {
if (typeof clientId !== "string" || clientId.length === 0) {
return { ok: false, reason: "unknown_client" };
}
if (isCimdClientId(clientId)) return resolveCimdClient(clientId);
if (isDcrClientId(clientId)) return resolveDcrClient(clientId);
return { ok: false, reason: "unknown_client" };
}
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
function isLoopback(url: URL): boolean {
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
}
/**
* Whether `requested` is an allowed redirect URI for `registered`. Exact match
* by default; loopback URIs match port-agnostically (the AS ignores the port for
* `localhost` / `127.0.0.1` / `::1`, per the connector spec) so Claude Code /
* Desktop's ephemeral callback port still matches its registration.
*/
export function redirectUriAllowed(
requested: string,
registered: readonly string[],
): boolean {
if (registered.includes(requested)) return true;
let req: URL;
try {
req = new URL(requested);
} catch {
return false;
}
if (!isLoopback(req)) return false;
for (const entry of registered) {
let reg: URL;
try {
reg = new URL(entry);
} catch {
continue;
}
if (
isLoopback(reg) &&
reg.hostname === req.hostname &&
reg.pathname === req.pathname
) {
return true; // ignore the port for loopback
}
}
return false;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* OAuth bridge configuration — origin + canonical resource resolution.
*
* The MCP authorization surface is a minimal in-repo Authorization Server that
* BRIDGES onto HealthLog's existing `hlk_` Bearer model (ADR-006) — it keeps no
* parallel identity store. Every metadata document, redirect-URI match, and
* audience check is anchored to ONE canonical origin so a token minted for this
* deployment can never be replayed against another (RFC 8707 audience binding).
*
* Origin precedence mirrors the passkey RP-origin / invite-URL resolution: the
* operator-configured `APP_URL` / `NEXT_PUBLIC_APP_URL` win over the request
* origin (which may be an internal hostname behind the reverse proxy). The
* canonical resource identifier is the `/mcp` endpoint exactly — the same URL a
* user pastes into Claude.ai / ChatGPT — so the Protected Resource Metadata
* `resource` value matches the user-entered URL (a Claude.ai requirement).
*/
/**
* Resolve the public origin of this deployment, e.g. `https://health.example`.
* Operator config wins; the request URL is the last resort.
*/
export function resolveBaseOrigin(requestUrl?: string): string {
const candidates = [
process.env.APP_URL,
process.env.NEXT_PUBLIC_APP_URL,
requestUrl,
]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
for (const candidate of candidates) {
try {
return new URL(candidate).origin;
} catch {
// try the next candidate
}
}
return "http://localhost:3000";
}
/**
* The canonical MCP resource identifier (RFC 8707 audience). This is the value
* the AS binds every issued token to and the value a client MUST send as
* `resource=` — any other value is rejected.
*/
export function canonicalResource(requestUrl?: string): string {
return `${resolveBaseOrigin(requestUrl)}/mcp`;
}
/**
* Whether a client-supplied `resource` value matches our canonical resource.
* Trailing slashes are tolerated (`/mcp` vs `/mcp/`) but the origin + path must
* otherwise be exact — no substring / prefix matching that could admit a
* look-alike host (RFC 8707, R-SEC-5).
*/
export function audienceMatches(
resource: string | null | undefined,
requestUrl?: string,
): boolean {
if (!resource) return false;
const want = canonicalResource(requestUrl).replace(/\/+$/, "");
const got = resource.trim().replace(/\/+$/, "");
return want === got;
}
/** The single scope the read MCP surface issues. */
export const SCOPE_HEALTH_READ = "health:read";
/**
* Scopes the AS advertises + will issue. `health:write` is advertised so a
* future incremental-consent (SEP-835) challenge can request it, but the read
* surface only ever issues `health:read`. `offline_access` enables refresh
* tokens (OAuth 2.1 public-client rotation) — Claude appends it when advertised.
*/
export const SUPPORTED_SCOPES = [
SCOPE_HEALTH_READ,
"health:write",
"offline_access",
] as const;
/** Access-token lifetime (minutes). Short-lived; refresh tokens carry continuity. */
export const ACCESS_TOKEN_TTL_MINUTES = 60;
/** Refresh-token lifetime (days). */
export const REFRESH_TOKEN_TTL_DAYS = 30;
/** Authorization-code lifetime (seconds). Single-redemption, short window. */
export const AUTH_CODE_TTL_SECONDS = 120;
+92
View File
@@ -0,0 +1,92 @@
/**
* OAuth discovery documents — RFC 9728 (Protected Resource Metadata) + RFC 8414
* (Authorization Server Metadata).
*
* These two JSON documents are how a remote MCP client bootstraps the flow from
* nothing but the pasted `/mcp` URL:
*
* 1. The client calls `/mcp`, gets a `401` whose `WWW-Authenticate` points at
* `/.well-known/oauth-protected-resource` (PRM).
* 2. PRM names this `resource` (the canonical `/mcp` URI — it MUST match the
* user-entered URL) and lists its `authorization_servers`.
* 3. The client fetches the AS metadata and learns the `/authorize` + `/token`
* + `/register` endpoints, that **PKCE S256 is mandatory**, and that the AS
* accepts **CIMD** clients (`client_id_metadata_document_supported: true` +
* `none` auth) with **DCR** as a fallback.
*
* Everything is anchored to the canonical origin (`config.ts`) so the documents
* are deterministic and the audience can be validated against them.
*/
import {
canonicalResource,
resolveBaseOrigin,
SUPPORTED_SCOPES,
} from "./config";
export interface ProtectedResourceMetadata {
resource: string;
authorization_servers: string[];
scopes_supported: string[];
bearer_methods_supported: string[];
resource_documentation?: string;
}
export interface AuthorizationServerMetadata {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
registration_endpoint: string;
scopes_supported: string[];
response_types_supported: string[];
grant_types_supported: string[];
token_endpoint_auth_methods_supported: string[];
code_challenge_methods_supported: string[];
client_id_metadata_document_supported: boolean;
}
/** RFC 9728 — the Protected Resource Metadata for the `/mcp` resource. */
export function protectedResourceMetadata(
requestUrl?: string,
): ProtectedResourceMetadata {
const origin = resolveBaseOrigin(requestUrl);
return {
resource: canonicalResource(requestUrl),
authorization_servers: [origin],
scopes_supported: [...SUPPORTED_SCOPES],
bearer_methods_supported: ["header"],
};
}
/** RFC 8414 — the Authorization Server Metadata for the built-in bridge AS. */
export function authorizationServerMetadata(
requestUrl?: string,
): AuthorizationServerMetadata {
const origin = resolveBaseOrigin(requestUrl);
return {
issuer: origin,
authorization_endpoint: `${origin}/api/mcp/oauth/authorize`,
token_endpoint: `${origin}/api/mcp/oauth/token`,
registration_endpoint: `${origin}/api/mcp/oauth/register`,
scopes_supported: [...SUPPORTED_SCOPES],
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
// Public clients only — PKCE is the proof of possession, not a secret.
token_endpoint_auth_methods_supported: ["none"],
// S256 ONLY — `plain` is structurally unsupported (RFC 7636 + connectors).
code_challenge_methods_supported: ["S256"],
// CIMD is the preferred registration path (SEP-991); DCR is the fallback.
client_id_metadata_document_supported: true,
};
}
/** Build the `WWW-Authenticate` value pointing a 401 at the PRM (RFC 9728). */
export function wwwAuthenticateChallenge(
requestUrl?: string,
scope?: string,
): string {
const origin = resolveBaseOrigin(requestUrl);
const prm = `${origin}/.well-known/oauth-protected-resource`;
const parts = ['Bearer realm="healthlog-mcp"', `resource_metadata="${prm}"`];
if (scope) parts.push(`scope="${scope}"`);
return parts.join(", ");
}
+56
View File
@@ -0,0 +1,56 @@
/**
* PKCE (RFC 7636) — S256 only.
*
* Both Claude.ai and ChatGPT send `code_challenge_method=S256` on every
* authorization request, and the AS advertises ONLY `S256` in
* `code_challenge_methods_supported`. The `plain` method is structurally
* unsupported here — a missing or non-S256 challenge is rejected at `/authorize`,
* and a verifier that does not hash to the bound challenge is rejected at
* `/token`. PKCE is therefore mandatory on the whole authorization-code flow.
*/
import { createHash, timingSafeEqual } from "node:crypto";
/** Base64url-encode without padding (the PKCE wire form). */
function b64url(buf: Buffer): string {
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
/** The S256 transform: BASE64URL(SHA256(ASCII(verifier))). */
export function s256Challenge(verifier: string): string {
return b64url(createHash("sha256").update(verifier, "ascii").digest());
}
/** RFC 7636 §4.1 — verifier is 43–128 chars of the unreserved set. */
export function isValidVerifier(verifier: unknown): verifier is string {
return (
typeof verifier === "string" && /^[A-Za-z0-9\-._~]{43,128}$/.test(verifier)
);
}
/** A non-empty challenge string (the exact length is the client's concern). */
export function isValidChallenge(challenge: unknown): challenge is string {
return (
typeof challenge === "string" &&
challenge.length >= 43 &&
challenge.length <= 128
);
}
/**
* Constant-time check that `verifier` hashes (S256) to `challenge`. Returns
* false for any malformed input rather than throwing.
*/
export function verifyPkceS256(verifier: string, challenge: string): boolean {
if (!isValidVerifier(verifier) || !isValidChallenge(challenge)) {
return false;
}
const computed = Buffer.from(s256Challenge(verifier));
const expected = Buffer.from(challenge);
return (
computed.length === expected.length && timingSafeEqual(computed, expected)
);
}
+5
View File
@@ -81,6 +81,11 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
/telegram[_-]?chat[_-]?id/i,
/chatid/i,
/chat[_-]id/i,
// v1.22.0 — the PKCE `code_verifier` (the proof-of-possession secret the MCP
// OAuth token endpoint receives). `access_token` / `refresh_token` /
// `client_secret` are already covered by `/token/i` + `/secret/i`; the
// verifier is the one secret-shaped key those patterns miss.
/verifier/i,
];
const REDACTED = "[redacted]";
+7
View File
@@ -71,6 +71,13 @@ const PUBLIC_PATHS = [
*/
const WELL_KNOWN_PUBLIC_PATHS = new Set<string>([
"/.well-known/apple-app-site-association",
// v1.22.0 — OAuth discovery for the remote MCP connector. A remote client
// (Claude.ai / ChatGPT) reads these unauthenticated to bootstrap the OAuth
// 2.1 + PKCE flow from the pasted `/mcp` URL alone: RFC 9728 Protected
// Resource Metadata + RFC 8414 Authorization Server Metadata. Both are
// public, deterministic, secret-free discovery documents.
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-authorization-server",
]);
/**