mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +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
@@ -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]";
|
||||
|
||||
Reference in new issue
Block a user