Files
HealthLog/src/lib/__tests__/require-auth-bearer.test.ts
T
Marc-André Bombeck c38a2c8a2a fix(auth): allow narrow-scope tokens on unscoped routes — unblocks v1.5 iOS endpoints
The previous `requireAuth()` policy 403'd every narrow-scope Bearer token
on routes that did not declare a required permission. The intent was to
keep wildcard tokens from acting as session-equivalents on sensitive
routes that never gated on scope, but the side effect blocked four
W8d routes (`/api/measurements/by-external-ids`, `/api/personal-records`,
`/api/medications/[id]/glp1`, `/api/dashboard/glp1`) for the v1.5 iOS app.

Restate the contract: a route that does not declare a `requiredPermission`
is content with any authenticated token; a route that declares one gates
on it. Narrow-scope tokens still 403 against scoped routes they don't
list. Add four cases to the Bearer-auth test suite covering all token-
× route-scope combinations.
2026-05-14 16:46:35 +02:00

292 lines
9.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Mocks must be hoisted before importing the module under test. ---
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: {
findUnique: vi.fn(),
update: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
const headersGet = vi.fn<(name: string) => string | null>();
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: headersGet })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// --- Imports use the mocked modules above. ---
import { requireAuth, HttpError } from "../api-handler";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { hashToken } from "@/lib/auth/hmac";
import { auditLog } from "@/lib/auth/audit";
const FAKE_HASH = "deadbeefcafef00d";
const RAW_TOKEN = "hlk_" + "a".repeat(64);
const FAKE_USER = {
id: "user-1",
role: "USER" as const,
username: "marc",
email: "marc@example.com",
};
function setBearerHeader(value: string | null): void {
headersGet.mockReset();
headersGet.mockImplementation((name: string) =>
name.toLowerCase() === "authorization" ? value : null,
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(hashToken).mockReturnValue(FAKE_HASH);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
});
describe("requireAuth — Bearer token path", () => {
it("authenticates a valid Bearer token and returns AuthContext", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-1",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const ctx = await requireAuth();
expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN);
// V3 audit: pin the where-clause shape — a regression that switches
// back to raw-token comparison (where: { tokenHash: RAW_TOKEN }) would
// be a CRITICAL leak of stored hashes. Tests must enforce the hash.
expect(prisma.apiToken.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { tokenHash: FAKE_HASH } }),
);
expect(ctx.user.id).toBe("user-1");
expect(ctx.session.id).toBe("token-1");
expect(ctx.session.expiresAt).toEqual(expiresAt);
// lastUsedAt refresh is fire-and-forget but should still have been triggered.
expect(prisma.apiToken.update).toHaveBeenCalledWith({
where: { id: "token-1" },
data: { lastUsedAt: expect.any(Date) },
});
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.success",
expect.objectContaining({ userId: "user-1" }),
);
});
it("rejects a revoked token with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-2",
userId: "user-1",
permissions: [],
revoked: true,
expiresAt: null,
} as never);
await expect(requireAuth()).rejects.toMatchObject({
statusCode: 401,
} satisfies Partial<HttpError>);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "revoked" }),
}),
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("rejects an expired token with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
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(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "expired" }),
}),
);
});
it("rejects an unknown token with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null);
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "unknown_token" }),
}),
);
});
it("rejects a Bearer token missing the requested permission with 403", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-4",
userId: "user-1",
permissions: ["something:else"],
revoked: false,
expiresAt: null,
} as never);
await expect(requireAuth("medication:ingest")).rejects.toMatchObject({
statusCode: 403,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({
reason: "insufficient_permissions",
required: "medication:ingest",
}),
}),
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
// v1.4.25 W10 reconcile (code-review H2): a route that does NOT declare
// a scope is content with any authenticated token. The previous policy
// 403'd every narrow-scope token here and broke `/api/measurements/by-
// external-ids`, `/api/personal-records`, `/api/medications/[id]/glp1`,
// and `/api/dashboard/glp1` — all four were called without a scope.
it("accepts a narrow-scope Bearer token when the route did not declare a scope", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-5a",
userId: "user-1",
permissions: ["medication:ingest"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const ctx = await requireAuth();
expect(ctx.user.id).toBe("user-1");
expect(ctx.session.id).toBe("token-5a");
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.success",
expect.objectContaining({ userId: "user-1" }),
);
});
it("accepts a wildcard Bearer token when the route did not declare a scope", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-5b",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const ctx = await requireAuth();
expect(ctx.user.id).toBe("user-1");
expect(ctx.session.id).toBe("token-5b");
});
it("accepts a narrow-scope Bearer token when the route's required scope matches", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-5c",
userId: "user-1",
permissions: ["medication:ingest"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const ctx = await requireAuth("medication:ingest");
expect(ctx.user.id).toBe("user-1");
expect(ctx.session.id).toBe("token-5c");
});
it("returns 401 when neither cookie nor Bearer is present", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(null);
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
});
});
describe("requireAuth — cookie path remains intact", () => {
it("returns the session payload without consulting the Bearer header", async () => {
const cookieSession = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
user: { ...FAKE_USER, role: "USER" as const },
};
vi.mocked(getSession).mockResolvedValue(cookieSession as never);
// Even if a Bearer header is present, the cookie wins (existing behaviour).
setBearerHeader(`Bearer ${RAW_TOKEN}`);
const ctx = await requireAuth("medication:ingest");
expect(ctx).toEqual(cookieSession);
// Cookie short-circuits before any token logic runs.
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
expect(hashToken).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
});