mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
resolveBearerToken took an optional permission, and an unset one admitted any valid token. 324 of the 330 route files pass no permission, so a token minted with a narrow scope reached the full-backup export, the labs and measurement surfaces, the coach, and the bulk deletes — the settings copy tells users to hand exactly that token to third-party automations. Invert the default. The scope argument becomes a required ScopeRequirement union: a route that names no scope now accepts cookie sessions and cookie-equivalent ["*"] tokens only, and refuses a narrow token with 403 and an undeclared_scope audit row. A route that means to accept a narrow token names it, as the FHIR routes already do. No route file changes. The absence of an argument becomes a positive statement rather than an omission, so a newly added route denies a newly minted narrow scope by default — the safe outcome is the one you get by doing nothing. /mcp keeps its own posture through an explicit any-valid-token requirement: it authenticates here and authorises downstream on audience and write scope. That is the single deliberate fail-open call site in the tree, and making the parameter required is what keeps it greppable. An MCP-audience token loses its REST read leg as a result, narrowing it to /mcp only. The isMcpAudienceToken guard stays as defence in depth.
191 lines
6.4 KiB
TypeScript
191 lines
6.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
|
user: { findUnique: vi.fn() },
|
|
},
|
|
}));
|
|
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
|
|
|
|
import { resolveBearerToken, BearerAuthError } from "../bearer";
|
|
import type { ScopeRequirement } from "../bearer";
|
|
import { prisma } from "@/lib/db";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
|
|
const FAKE_HASH = "deadbeefcafef00d";
|
|
const RAW_TOKEN = "hlk_" + "a".repeat(64);
|
|
const FAKE_USER = { id: "user-1", role: "USER" };
|
|
|
|
/** The REST default: cookie sessions and `["*"]` tokens only. */
|
|
const WILDCARD_ONLY: ScopeRequirement = { kind: "wildcard-only" };
|
|
/** The `/mcp` posture: authenticate here, authorise downstream. */
|
|
const ANY_VALID: ScopeRequirement = { kind: "any-valid-token" };
|
|
const scope = (s: string): ScopeRequirement => ({ kind: "scope", scope: s });
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(hashToken).mockReturnValue(FAKE_HASH);
|
|
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
|
|
});
|
|
|
|
describe("resolveBearerToken", () => {
|
|
it("looks the token up by HMAC hash, never the plaintext", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-1",
|
|
userId: "user-1",
|
|
permissions: ["*"],
|
|
revoked: false,
|
|
expiresAt: new Date(Date.now() + 86_400_000),
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
|
|
|
const res = await resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY);
|
|
|
|
expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN);
|
|
expect(prisma.apiToken.findUnique).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { tokenHash: FAKE_HASH } }),
|
|
);
|
|
expect(res.user.id).toBe("user-1");
|
|
expect(res.tokenId).toBe("token-1");
|
|
expect(res.permissions).toEqual(["*"]);
|
|
expect(prisma.apiToken.update).toHaveBeenCalledWith({
|
|
where: { id: "token-1" },
|
|
data: { lastUsedAt: expect.any(Date) },
|
|
});
|
|
});
|
|
|
|
it("rejects an unknown token (401)", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never);
|
|
await expect(
|
|
resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY),
|
|
).rejects.toMatchObject({
|
|
statusCode: 401,
|
|
reason: "unknown_token",
|
|
} satisfies Partial<BearerAuthError>);
|
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a revoked token (401)", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-2",
|
|
userId: "user-1",
|
|
permissions: [],
|
|
revoked: true,
|
|
expiresAt: null,
|
|
} as never);
|
|
await expect(
|
|
resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY),
|
|
).rejects.toMatchObject({
|
|
statusCode: 401,
|
|
reason: "revoked",
|
|
});
|
|
});
|
|
|
|
it("rejects an expired token (401)", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-3",
|
|
userId: "user-1",
|
|
permissions: [],
|
|
revoked: false,
|
|
expiresAt: new Date(Date.now() - 60_000),
|
|
} as never);
|
|
await expect(
|
|
resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY),
|
|
).rejects.toMatchObject({
|
|
statusCode: 401,
|
|
reason: "expired",
|
|
});
|
|
});
|
|
|
|
it("rejects a narrow-scope token missing the required permission (403)", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-4",
|
|
userId: "user-1",
|
|
permissions: ["something:else"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
await expect(
|
|
resolveBearerToken(RAW_TOKEN, scope("health:write")),
|
|
).rejects.toMatchObject({
|
|
statusCode: 403,
|
|
reason: "insufficient_permissions",
|
|
});
|
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// The inversion. A narrow token used to be admitted by every route that
|
|
// named no scope — 324 of them — which is how a token minted for medication
|
|
// intake reached the full-backup export. `wildcard-only` is now the default
|
|
// arm, so the safe outcome is the one a route gets by doing nothing.
|
|
it("refuses a narrow-scope token under the wildcard-only default (403)", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-5",
|
|
userId: "user-1",
|
|
permissions: ["medication:ingest"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
await expect(
|
|
resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY),
|
|
).rejects.toMatchObject({
|
|
statusCode: 403,
|
|
reason: "undeclared_scope",
|
|
});
|
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("admits a narrow-scope token for the scope it lists", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-5b",
|
|
userId: "user-1",
|
|
permissions: ["fhir:read"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
|
const res = await resolveBearerToken(RAW_TOKEN, scope("fhir:read"));
|
|
expect(res.user.id).toBe("user-1");
|
|
});
|
|
|
|
it("admits any valid token under the any-valid-token posture", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-5c",
|
|
userId: "user-1",
|
|
permissions: ["health:read"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
|
const res = await resolveBearerToken(RAW_TOKEN, ANY_VALID);
|
|
expect(res.user.id).toBe("user-1");
|
|
});
|
|
|
|
it("admits a wildcard token under the wildcard-only default", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-5d",
|
|
userId: "user-1",
|
|
permissions: ["*"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
|
const res = await resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY);
|
|
expect(res.user.id).toBe("user-1");
|
|
});
|
|
|
|
it("admits a wildcard token for any required permission", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-6",
|
|
userId: "user-1",
|
|
permissions: ["*"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
|
const res = await resolveBearerToken(RAW_TOKEN, scope("health:write"));
|
|
expect(res.user.id).toBe("user-1");
|
|
});
|
|
});
|