mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Harden the remote MCP endpoint and the in-repo OAuth bridge against the adversarial review of the milestone branch. The two ship-blockers made the "read-only, revocable" promise false; the rest tightened the surface. Audience binding at the resource server: a token whose only grant is the new `health:read` scope is the MCP read credential. The REST edge now accepts it on safe (read) methods and refuses it on writes/deletes with 403, so a connector token can read over `/mcp` and GET routes but can never POST/DELETE the record despite a "read-only" consent. The `/mcp` resolver is unaffected. Revocable, theft-detectable refresh tokens: refresh artifacts now anchor to a persistent connection row. Revoking a connection stops every future refresh and kills its access tokens; a replayed (already-rotated) refresh is treated as theft and revokes the whole connection. Connections surface in settings as the unit a user can disconnect. The old access token is revoked on rotation. Also: fail closed on the OAuth/`/mcp` surface when no canonical origin is configured and mark the discovery documents non-cacheable; honour the global API kill-switch on `/mcp`, `/token`, `/authorize`, and `/register`; cap the CIMD and registration body reads while streaming instead of after; show the verified client origin on consent; validate CIMD redirect-URI schemes; assert same-origin on the consent POST; and add a daily prune for expired connector tokens. New migration 0195 adds the connection anchor and links access tokens to it.
322 lines
9.6 KiB
TypeScript
322 lines
9.6 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(() => ({})),
|
|
}));
|
|
vi.mock("@/lib/app-settings", () => ({
|
|
isApiGloballyEnabled: vi.fn(async () => true),
|
|
}));
|
|
vi.mock("@/lib/mcp/oauth/connections", () => ({
|
|
createConnection: vi.fn(async () => "conn-1"),
|
|
rotateConnection: vi.fn(async () => ({ ok: true })),
|
|
}));
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { POST } from "../token/route";
|
|
import { issueApiToken } from "@/lib/auth/issue-token";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
|
import {
|
|
createConnection,
|
|
rotateConnection,
|
|
} from "@/lib/mcp/oauth/connections";
|
|
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,
|
|
});
|
|
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
|
vi.mocked(createConnection).mockResolvedValue("conn-1");
|
|
vi.mocked(rotateConnection).mockResolvedValue({ ok: true });
|
|
});
|
|
|
|
function refreshToken(overrides: Record<string, unknown> = {}): string {
|
|
return signArtifact(
|
|
"refreshToken",
|
|
{
|
|
jti: randomUUID(),
|
|
cid: "conn-1",
|
|
sub: "user-1",
|
|
client_id: CLIENT_ID,
|
|
scope: "health:read offline_access",
|
|
resource: RESOURCE,
|
|
...overrides,
|
|
},
|
|
1_000_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("creates a revocable connection anchor and links the access token (H2)", async () => {
|
|
await POST(
|
|
tokenReq({
|
|
grant_type: "authorization_code",
|
|
code: authCode(),
|
|
code_verifier: VERIFIER,
|
|
client_id: CLIENT_ID,
|
|
redirect_uri: REDIRECT,
|
|
}) as never,
|
|
);
|
|
expect(createConnection).toHaveBeenCalledTimes(1);
|
|
expect(issueApiToken).toHaveBeenCalledWith(
|
|
expect.objectContaining({ mcpConnectionId: "conn-1" }),
|
|
);
|
|
});
|
|
|
|
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 against a live connection", async () => {
|
|
const res = await POST(
|
|
tokenReq({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken(),
|
|
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_/);
|
|
expect(rotateConnection).toHaveBeenCalledWith(
|
|
expect.objectContaining({ connectionId: "conn-1", clientId: CLIENT_ID }),
|
|
);
|
|
});
|
|
|
|
it("rejects a refresh whose connection was revoked (H2 — settings revoke)", async () => {
|
|
vi.mocked(rotateConnection).mockResolvedValue({
|
|
ok: false,
|
|
reason: "revoked",
|
|
});
|
|
const res = await POST(
|
|
tokenReq({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken(),
|
|
client_id: CLIENT_ID,
|
|
}) as never,
|
|
);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe("invalid_grant");
|
|
expect(issueApiToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a replayed refresh and reports reuse detection (H2)", async () => {
|
|
vi.mocked(rotateConnection).mockResolvedValue({
|
|
ok: false,
|
|
reason: "reuse_detected",
|
|
});
|
|
const res = await POST(
|
|
tokenReq({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken(),
|
|
client_id: CLIENT_ID,
|
|
}) as never,
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toBe("invalid_grant");
|
|
expect(body.error_description).toMatch(/reuse/i);
|
|
expect(issueApiToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a legacy refresh token with no connection binding", async () => {
|
|
const legacy = 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: legacy,
|
|
client_id: CLIENT_ID,
|
|
}) as never,
|
|
);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe("invalid_grant");
|
|
expect(rotateConnection).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("operator kill-switch + origin config (M1/M4)", () => {
|
|
it("refuses to mint when the API is globally disabled", async () => {
|
|
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
|
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(503);
|
|
expect((await res.json()).error).toBe("temporarily_unavailable");
|
|
expect(issueApiToken).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|