diff --git a/src/lib/clinician-share/__tests__/resolve-share-token.test.ts b/src/lib/clinician-share/__tests__/resolve-share-token.test.ts new file mode 100644 index 000000000..814683dc1 --- /dev/null +++ b/src/lib/clinician-share/__tests__/resolve-share-token.test.ts @@ -0,0 +1,112 @@ +/** + * v1.11.0 (Epic C, C3) — share-token resolver, the security core. + * + * Asserts the load-bearing properties: + * - a valid, live token resolves to a ShareContext carrying ONLY the owner + * scope (no session/AuthContext fields); + * - revoked / expired / unknown / malformed tokens all resolve to `null` + * (the caller's blunt 404); + * - a successful resolve bumps the access counters fire-and-forget; + * - the resolver never reads a session and never sets a cookie. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + clinicianShareLink: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + }, +})); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` })); + +import { resolveShareToken } from "../resolve-share-token"; +import { prisma } from "@/lib/db"; + +const findUnique = prisma.clinicianShareLink.findUnique as ReturnType< + typeof vi.fn +>; +const update = prisma.clinicianShareLink.update as ReturnType; + +const VALID_TOKEN = `hls_${"a".repeat(48)}`; + +function liveRow(overrides: Record = {}) { + return { + id: "link-1", + userId: "owner-1", + label: "Cardiology", + rangeStart: new Date("2026-01-01T00:00:00.000Z"), + rangeEnd: null, + sectionsJson: { bp: true }, + resourceTypes: ["Observation"], + allowFhirApi: false, + expiresAt: new Date(Date.now() + 86_400_000), + revokedAt: null, + ...overrides, + }; +} + +describe("resolveShareToken", () => { + beforeEach(() => { + vi.clearAllMocks(); + update.mockResolvedValue({}); + }); + + it("resolves a live token to an owner-scoped context (no session fields)", async () => { + findUnique.mockResolvedValue(liveRow()); + const ctx = await resolveShareToken(VALID_TOKEN); + expect(ctx).not.toBeNull(); + expect(ctx?.ownerUserId).toBe("owner-1"); + expect(ctx?.shareLinkId).toBe("link-1"); + expect(ctx?.resourceTypes).toEqual(["Observation"]); + // The context must carry ONLY the owner scope — never a session/user/role. + expect(ctx).not.toHaveProperty("session"); + expect(ctx).not.toHaveProperty("user"); + expect(ctx).not.toHaveProperty("role"); + // Looked up by the HMAC hash, never by plaintext. + expect(findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { tokenHash: `hash(${VALID_TOKEN})` } }), + ); + }); + + it("bumps access counters fire-and-forget on a successful resolve", async () => { + findUnique.mockResolvedValue(liveRow()); + await resolveShareToken(VALID_TOKEN); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "link-1" }, + data: expect.objectContaining({ accessCount: { increment: 1 } }), + }), + ); + }); + + it("returns null for an unknown token (no row)", async () => { + findUnique.mockResolvedValue(null); + expect(await resolveShareToken(VALID_TOKEN)).toBeNull(); + expect(update).not.toHaveBeenCalled(); + }); + + it("returns null for a revoked token", async () => { + findUnique.mockResolvedValue(liveRow({ revokedAt: new Date() })); + expect(await resolveShareToken(VALID_TOKEN)).toBeNull(); + expect(update).not.toHaveBeenCalled(); + }); + + it("returns null for an expired token", async () => { + findUnique.mockResolvedValue( + liveRow({ expiresAt: new Date(Date.now() - 1000) }), + ); + expect(await resolveShareToken(VALID_TOKEN)).toBeNull(); + expect(update).not.toHaveBeenCalled(); + }); + + it("returns null for a malformed token without touching the DB", async () => { + expect(await resolveShareToken("bearer-or-garbage")).toBeNull(); + expect(await resolveShareToken("hls_short")).toBeNull(); + expect(await resolveShareToken("")).toBeNull(); + expect(await resolveShareToken(null)).toBeNull(); + expect(await resolveShareToken(undefined)).toBeNull(); + expect(findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/clinician-share/__tests__/share-token-reject-on-authed-route.test.ts b/src/lib/clinician-share/__tests__/share-token-reject-on-authed-route.test.ts new file mode 100644 index 000000000..76d1d55dc --- /dev/null +++ b/src/lib/clinician-share/__tests__/share-token-reject-on-authed-route.test.ts @@ -0,0 +1,65 @@ +/** + * v1.11.0 (Epic C, C3) — CRITICAL INVARIANT. + * + * A share token can authenticate ONLY the public share surface. Presented on a + * normal authenticated route it must be REJECTED: `requireAuth` reads only the + * cookie session and `Authorization: Bearer` (against `ApiToken`); it never + * reads the `X-HealthLog-Share` header, and an `hls_` token has no `ApiToken` + * row, so the Bearer path 401s. + * + * This locks the boundary structurally — if a future change made `requireAuth` + * honour a share token, this test fails. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const headerStore = new Map(); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined) })); +vi.mock("@/lib/db", () => ({ + prisma: { apiToken: { findUnique: vi.fn() } }, +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ + get: (name: string) => headerStore.get(name.toLowerCase()) ?? null, + })), +})); + +import { requireAuth } from "@/lib/api-handler"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SHARE_TOKEN = `hls_${"a".repeat(48)}`; + +describe("share token on a normal authed route", () => { + beforeEach(() => { + vi.clearAllMocks(); + headerStore.clear(); + (getSession as ReturnType).mockResolvedValue(null); + // No ApiToken row exists for an `hls_` token — share tokens live in a + // separate table the Bearer path never queries. + (prisma.apiToken.findUnique as ReturnType).mockResolvedValue( + null, + ); + }); + + it("rejects an hls_ token presented as Authorization: Bearer", async () => { + headerStore.set("authorization", `Bearer ${SHARE_TOKEN}`); + await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 }); + // It was looked up in ApiToken (and found nothing) — never honoured. + expect(prisma.apiToken.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { tokenHash: `hash(${SHARE_TOKEN})` }, + }), + ); + }); + + it("ignores the X-HealthLog-Share header entirely on an authed route", async () => { + // Present ONLY the share header — no cookie, no Bearer. + headerStore.set("x-healthlog-share", SHARE_TOKEN); + await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 }); + // The share header is never consulted: the Bearer lookup never ran. + expect(prisma.apiToken.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/clinician-share/resolve-share-token.ts b/src/lib/clinician-share/resolve-share-token.ts new file mode 100644 index 000000000..dba0a02dd --- /dev/null +++ b/src/lib/clinician-share/resolve-share-token.ts @@ -0,0 +1,119 @@ +/** + * v1.11.0 — clinician share-token resolver (Epic C, C3 — the security core). + * + * The ONE entry that turns a raw `hls_` share token into a scoped read context. + * It is deliberately NOT an authentication primitive: + * + * - It returns a {@link ShareContext} that carries ONLY the owner `userId` + * (plus the frozen scope). It is NOT an `AuthContext`/session and can never + * stand in for one. + * - It never calls `getSession` / `authenticateBearer` and never sets a + * cookie. A share token can authenticate exactly one surface — the public + * clinician view (`/c/[token]`) and, when enabled, its scoped FHIR face — + * and nothing else. + * - The token arrives ONLY via the `X-HealthLog-Share` request header. It is + * never read from `Authorization: Bearer`; presenting it on a normal authed + * route does nothing (that route's `requireAuth` ignores this header). + * + * Resolution is blunt on failure: an unknown / revoked / expired / malformed + * token resolves to `null`, and the caller answers a flat 404 so a probe cannot + * distinguish "no such link" from "revoked" from "expired" — share-link ids are + * unguessable and enumeration buys nothing. + * + * On a successful resolve the access counters (`accessCount`, `lastAccessAt`) + * are bumped fire-and-forget; a counter write never blocks or fails the read. + */ +import { hashToken } from "@/lib/auth/hmac"; +import { prisma } from "@/lib/db"; + +/** The `hls_<48 hex>` shape the lifecycle route mints (192-bit body). */ +const SHARE_TOKEN_PATTERN = /^hls_[0-9a-f]{48}$/; + +/** + * The scoped read context a resolved share token yields. It is intentionally a + * distinct, minimal type — NOT an `AuthContext` — so it cannot be passed where + * a session is expected. It carries only what the data aggregator needs to + * scope a read to the owner, plus the frozen sharing scope the view honours. + */ +export interface ShareContext { + /** The share-link row id (for audit / annotate; never user-facing). */ + shareLinkId: string; + /** The OWNER whose data this link exposes — the only identity it carries. */ + ownerUserId: string; + /** Owner-set label (e.g. a clinic note). Plaintext, bounded. */ + label: string; + /** Frozen reporting-window start (absolute ISO instant). */ + rangeStart: Date; + /** Window end; `null` = rolling ("up to now"). */ + rangeEnd: Date | null; + /** Frozen section toggles (the `DoctorReportPrefs` JSON shape). */ + sectionsJson: unknown; + /** FHIR resource types this link may serve (a subset of the catalogue). */ + resourceTypes: string[]; + /** Whether the scoped FHIR API is reachable via this link at all. */ + allowFhirApi: boolean; + /** Absolute expiry instant. */ + expiresAt: Date; +} + +/** + * Resolve a raw `hls_` token to a {@link ShareContext}, or `null` when the + * token is malformed, unknown, revoked, or expired. + * + * This is the single trust boundary for the share surface. It never reads or + * writes a session and never mints a cookie; it only proves "this raw token + * hashes to a live, in-window share link" and returns the owner scope. + */ +export async function resolveShareToken( + rawToken: string | null | undefined, +): Promise { + if (!rawToken || !SHARE_TOKEN_PATTERN.test(rawToken)) return null; + + // Hash with the same HMAC scheme the lifecycle route stored — never compare + // plaintext. A non-matching hash simply finds no row. + const tokenHash = hashToken(rawToken); + + const row = await prisma.clinicianShareLink.findUnique({ + where: { tokenHash }, + select: { + id: true, + userId: true, + label: true, + rangeStart: true, + rangeEnd: true, + sectionsJson: true, + resourceTypes: true, + allowFhirApi: true, + expiresAt: true, + revokedAt: true, + }, + }); + + if (!row) return null; + // Revoked and expired both collapse to the same blunt null → 404. + if (row.revokedAt !== null) return null; + if (row.expiresAt.getTime() <= Date.now()) return null; + + // Fire-and-forget access bump — a counter write must never block or fail the + // read. (`void` the promise; swallow any error.) + void prisma.clinicianShareLink + .update({ + where: { id: row.id }, + data: { accessCount: { increment: 1 }, lastAccessAt: new Date() }, + }) + .catch(() => { + /* counter is best-effort; never surface a write failure to the reader */ + }); + + return { + shareLinkId: row.id, + ownerUserId: row.userId, + label: row.label, + rangeStart: row.rangeStart, + rangeEnd: row.rangeEnd, + sectionsJson: row.sectionsJson, + resourceTypes: row.resourceTypes, + allowFhirApi: row.allowFhirApi, + expiresAt: row.expiresAt, + }; +}