Files
HealthLog/src/app/api/mcp/oauth/__tests__/token.test.ts
T
Marc-André Bombeck 22eb9cba0d fix(mcp): close OAuth 2.1 resource-server gaps before the remote surface ships
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.
2026-06-27 15:09:11 +02:00

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");
});
});