mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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]";
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user