mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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.
205 lines
6.0 KiB
TypeScript
205 lines
6.0 KiB
TypeScript
/**
|
|
* 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");
|
|
});
|
|
});
|