diff --git a/src/app/api/c/[token]/unlock/__tests__/route.test.ts b/src/app/api/c/[token]/unlock/__tests__/route.test.ts new file mode 100644 index 000000000..7c0828da3 --- /dev/null +++ b/src/app/api/c/[token]/unlock/__tests__/route.test.ts @@ -0,0 +1,150 @@ +/** + * v1.18.7 — public passphrase-gate verifier (POST /api/c/{token}/unlock). + * + * Asserts the load-bearing properties: + * - a correct passphrase sets a short-lived, token-scoped httpOnly cookie; + * - a wrong passphrase is rejected with a blunt 401, no cookie set; + * - an unknown / revoked / expired token (gate null) is the same blunt 401; + * - a link with no passphrase set cannot be unlocked; + * - the route is rate-limited BEFORE any compare. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; + +const cookieSet = vi.fn(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: cookieSet, + delete: () => {}, + })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: {}, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/clinician-share/resolve-share-token", () => ({ + resolveShareGateState: vi.fn(), +})); +vi.mock("@/lib/clinician-share/passphrase", () => ({ + verifyPassphrase: vi.fn(), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { POST } from "../route"; +import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token"; +import { verifyPassphrase } from "@/lib/clinician-share/passphrase"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; + +const TOKEN = `hls_${"a".repeat(48)}`; +const TOKEN_HASH = "f".repeat(64); + +function req(body: unknown): NextRequest { + return new NextRequest(`http://localhost/api/c/${TOKEN}/unlock`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function call() { + return POST(req({ passphrase: "ABCD-EFGH-JKMN-PQRS" }), { + params: Promise.resolve({ token: TOKEN }), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 60_000, + ip: "1.2.3.4", + }); +}); + +describe("POST /api/c/[token]/unlock", () => { + it("sets a token-scoped httpOnly cookie on a correct passphrase", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "deadbeef", + }); + vi.mocked(verifyPassphrase).mockReturnValue(true); + + const res = await call(); + expect(res.status).toBe(200); + + expect(cookieSet).toHaveBeenCalledTimes(1); + const [name, , opts] = cookieSet.mock.calls[0]; + expect(name).toContain("hls_unlock_"); + expect(opts.httpOnly).toBe(true); + expect(opts.sameSite).toBe("strict"); + // Scoped to THIS token's view path only. + expect(opts.path).toBe(`/c/${TOKEN}`); + expect(opts.maxAge).toBeGreaterThan(0); + expect(opts.maxAge).toBeLessThanOrEqual(30 * 60); + }); + + it("rejects a wrong passphrase with a blunt 401, no cookie", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "deadbeef", + }); + vi.mocked(verifyPassphrase).mockReturnValue(false); + + const res = await call(); + expect(res.status).toBe(401); + expect(cookieSet).not.toHaveBeenCalled(); + }); + + it("rejects an unknown / revoked / expired token (gate null) with 401", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue(null); + + const res = await call(); + expect(res.status).toBe(401); + expect(verifyPassphrase).not.toHaveBeenCalled(); + expect(cookieSet).not.toHaveBeenCalled(); + }); + + it("cannot unlock a link with no passphrase set", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: null, + }); + // verifyPassphrase returns false for a null stored hash. + vi.mocked(verifyPassphrase).mockReturnValue(false); + + const res = await call(); + expect(res.status).toBe(401); + expect(cookieSet).not.toHaveBeenCalled(); + }); + + it("is rate-limited before any compare", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "deadbeef", + }); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + ip: "1.2.3.4", + }); + + const res = await call(); + expect(res.status).toBe(429); + expect(verifyPassphrase).not.toHaveBeenCalled(); + expect(cookieSet).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/c/[token]/unlock/route.ts b/src/app/api/c/[token]/unlock/route.ts new file mode 100644 index 000000000..8cd027811 --- /dev/null +++ b/src/app/api/c/[token]/unlock/route.ts @@ -0,0 +1,106 @@ +/** + * v1.18.7 — POST /api/c/{token}/unlock — public passphrase gate verifier. + * + * The second factor for a passphrase-protected clinician share link. It is an + * anonymous, no-session surface (like the `/c/` view itself): the raw + * path token plus the submitted passphrase are the only credentials. On a + * correct passphrase it mints a SHORT-LIVED, httpOnly, SameSite=Strict, Secure + * cookie SCOPED to this token's view path so the page renders the record; a + * cookie minted for one token never unlocks another. + * + * Brute-force protection is mandatory here — the passphrase is the only thing + * standing between a leaked URL and the record — so the route is rate-limited + * by `share-unlock::` BEFORE any compare, using the + * anonymous-surface limiter so a broken trust chain collapses to one tight + * bucket rather than a free-for-all. + * + * Failure is blunt: a malformed token, an unknown/revoked/expired link, a link + * with no passphrase set, or a wrong passphrase all answer the SAME flat error. + * `no-store` is preserved end to end. Nothing here leaks why a request failed. + */ +import { cookies } from "next/headers"; +import { NextRequest } from "next/server"; + +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token"; +import { verifyPassphrase } from "@/lib/clinician-share/passphrase"; +import { + mintUnlockValue, + unlockCookieName, + UNLOCK_TTL_SECONDS, +} from "@/lib/clinician-share/unlock-cookie"; +import { unlockShareLinkSchema } from "@/lib/validations/clinician-share-link"; + +/** One blunt failure for every class — a probe distinguishes nothing. */ +function reject() { + return apiError("Invalid passphrase", 401); +} + +export const POST = apiHandler( + async ( + request: NextRequest, + ctx: { params: Promise<{ token: string }> }, + ) => { + const { token } = await ctx.params; + annotate({ action: { name: "share-link.unlock" } }); + + // Resolve the live-gate state first (no counter bump). A malformed / + // unknown / revoked / expired token is `null` → the same blunt reject. + const gate = await resolveShareGateState(token); + + // Rate-limit BEFORE any compare. Key on the resolved tokenHash when we + // have one (so the bucket is per-link), else a fixed sentinel so probing + // unknown tokens still shares one tight bucket. The anonymous-surface + // limiter collapses a broken trust chain to a single bucket. + const rlKey = gate ? gate.tokenHash : "unknown"; + const rl = await checkAuthSurfaceRateLimit( + request, + `share-unlock:${rlKey}`, + 10, + 10 * 60 * 1000, + ); + if (!rl.allowed) { + return apiError("Too many attempts", 429); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = unlockShareLinkSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error); + + // A token with no live gate, or one carrying no passphrase, cannot be + // unlocked. `verifyPassphrase` is constant-time and returns false for a + // null stored hash, so the two branches are indistinguishable in timing. + if (!gate) return reject(); + if (!verifyPassphrase(parsed.data.passphrase, gate.passphraseHash)) { + annotate({ meta: { unlock: "rejected" } }); + return reject(); + } + + // Correct passphrase — mint the short-lived, token-scoped unlock cookie. + const cookieStore = await cookies(); + cookieStore.set(unlockCookieName(gate.tokenHash), mintUnlockValue(gate.tokenHash), { + httpOnly: true, + secure: shouldEmitSecureCookie(), + sameSite: "strict", + // Scope to THIS token's view path only — the cookie unlocks nothing else. + path: `/c/${token}`, + maxAge: UNLOCK_TTL_SECONDS, + }); + + annotate({ meta: { unlock: "granted" } }); + return apiSuccess({ unlocked: true }); + }, +); diff --git a/src/app/c/[token]/__tests__/page.test.tsx b/src/app/c/[token]/__tests__/page.test.tsx index 2403a4a28..e6d201cf3 100644 --- a/src/app/c/[token]/__tests__/page.test.tsx +++ b/src/app/c/[token]/__tests__/page.test.tsx @@ -1,16 +1,25 @@ /** * v1.11.0 (Epic C, C5) — public clinician-view page gating. + * v1.18.7 — passphrase gate: the page checks gate state first, gates a + * protected link until the unlock cookie is valid, and never bumps access + * counters on a gate-blocked hit. * - * Asserts the page answers a flat `notFound()` (404) whenever the resolver - * yields null — the single blunt response for unknown / revoked / expired / - * malformed tokens — and renders the scoped view on a successful resolve. + * Asserts the page answers a flat `notFound()` (404) whenever the gate + * resolver yields null — the single blunt response for unknown / revoked / + * expired / malformed tokens; renders the scoped view for a legacy (no + * passphrase) link and for a protected link with a valid unlock cookie; and + * shows the gate (no record load) for a protected link without the cookie. */ import { describe, it, expect, vi, beforeEach } from "vitest"; +const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; + class NotFoundError extends Error { digest = "NEXT_HTTP_ERROR_FALLBACK;404"; } +let cookieValue: string | undefined; + vi.mock("next/navigation", () => ({ notFound: vi.fn(() => { throw new NotFoundError("NEXT_NOT_FOUND"); @@ -18,10 +27,11 @@ vi.mock("next/navigation", () => ({ })); vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ get: () => undefined })), + cookies: vi.fn(async () => ({ get: () => ({ value: cookieValue }) })), })); vi.mock("@/lib/clinician-share/resolve-share-token", () => ({ resolveShareToken: vi.fn(), + resolveShareGateState: vi.fn(), })); vi.mock("@/lib/clinician-share/share-view-data", () => ({ loadShareViewData: vi.fn(), @@ -29,47 +39,113 @@ vi.mock("@/lib/clinician-share/share-view-data", () => ({ vi.mock("@/components/clinician/clinician-view", () => ({ ClinicianView: () => null, })); +vi.mock("@/components/clinician/share-unlock-gate", () => ({ + ShareUnlockGate: () => "GATE", +})); import ClinicianSharePage from "../page"; -import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token"; +import { + resolveShareToken, + resolveShareGateState, +} from "@/lib/clinician-share/resolve-share-token"; import { loadShareViewData } from "@/lib/clinician-share/share-view-data"; +import { mintUnlockValue } from "@/lib/clinician-share/unlock-cookie"; import { notFound } from "next/navigation"; const resolve = resolveShareToken as ReturnType; +const gateState = resolveShareGateState as ReturnType; const loadData = loadShareViewData as ReturnType; +const TOKEN_HASH = "f".repeat(64); + function pageProps(token: string) { return { params: Promise.resolve({ token }) }; } +/** The page returns a `` element when it gates. */ +function isGate(out: unknown): boolean { + return ( + typeof out === "object" && + out !== null && + "props" in out && + typeof (out as { props?: { token?: unknown } }).props?.token === "string" + ); +} + +function resolvedContext() { + return { + shareLinkId: "link-1", + ownerUserId: "owner-1", + label: "Clinic", + rangeStart: new Date(), + rangeEnd: null, + sectionsJson: {}, + resourceTypes: [], + allowFhirApi: false, + expiresAt: new Date(Date.now() + 86_400_000), + }; +} + describe("clinician share page", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); + cookieValue = undefined; + }); it("404s when the token resolves to null (revoked / expired / unknown)", async () => { - resolve.mockResolvedValue(null); + gateState.mockResolvedValue(null); await expect( ClinicianSharePage(pageProps(`hls_${"a".repeat(48)}`)), ).rejects.toBeInstanceOf(NotFoundError); expect(notFound).toHaveBeenCalledTimes(1); - // No data load is attempted on a failed resolve. + // No data load and no counter-bumping resolve on a failed gate. expect(loadData).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); }); - it("loads the scoped view on a successful resolve", async () => { - resolve.mockResolvedValue({ - shareLinkId: "link-1", - ownerUserId: "owner-1", - label: "Clinic", - rangeStart: new Date(), - rangeEnd: null, - sectionsJson: {}, - resourceTypes: [], - allowFhirApi: false, - expiresAt: new Date(Date.now() + 86_400_000), - }); + it("renders a legacy (no-passphrase) link without a gate", async () => { + gateState.mockResolvedValue({ tokenHash: TOKEN_HASH, passphraseHash: null }); + resolve.mockResolvedValue(resolvedContext()); loadData.mockResolvedValue({ report: {}, sections: {} }); await ClinicianSharePage(pageProps(`hls_${"b".repeat(48)}`)); expect(notFound).not.toHaveBeenCalled(); expect(loadData).toHaveBeenCalledTimes(1); }); + + it("gates a protected link with NO unlock cookie (no record load)", async () => { + gateState.mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "stored-hash", + }); + const out = await ClinicianSharePage(pageProps(`hls_${"c".repeat(48)}`)); + // The gate island is returned; the record is never resolved or loaded. + expect(isGate(out)).toBe(true); + expect(resolve).not.toHaveBeenCalled(); + expect(loadData).not.toHaveBeenCalled(); + }); + + it("renders a protected link WITH a valid unlock cookie", async () => { + gateState.mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "stored-hash", + }); + cookieValue = mintUnlockValue(TOKEN_HASH); + resolve.mockResolvedValue(resolvedContext()); + loadData.mockResolvedValue({ report: {}, sections: {} }); + await ClinicianSharePage(pageProps(`hls_${"d".repeat(48)}`)); + expect(loadData).toHaveBeenCalledTimes(1); + }); + + it("gates a protected link with an INVALID / cross-token cookie", async () => { + gateState.mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "stored-hash", + }); + // A cookie minted for a different token must not unlock this one. + cookieValue = mintUnlockValue("a".repeat(64)); + const out = await ClinicianSharePage(pageProps(`hls_${"e".repeat(48)}`)); + expect(isGate(out)).toBe(true); + expect(loadData).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index f88b8fb2e..acbca502c 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -19,8 +19,15 @@ import type { Metadata } from "next"; import { cookies, headers } from "next/headers"; import { notFound } from "next/navigation"; -import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token"; +import { + resolveShareGateState, + resolveShareToken, +} from "@/lib/clinician-share/resolve-share-token"; import { loadShareViewData } from "@/lib/clinician-share/share-view-data"; +import { + unlockCookieName, + verifyUnlockValue, +} from "@/lib/clinician-share/unlock-cookie"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, @@ -29,6 +36,7 @@ import { } from "@/lib/i18n/config"; import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; import { ClinicianView } from "@/components/clinician/clinician-view"; +import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate"; // Never cache a scoped health view — `no-store` end to end. export const dynamic = "force-dynamic"; @@ -61,9 +69,29 @@ export default async function ClinicianSharePage({ }) { const { token } = await params; - // The ONE trust boundary. No session is read; this proves only that the raw - // path token hashes to a live, in-window share link and yields the owner - // scope. Any failure → null → flat 404. + // v1.18.7 — gate check first, WITHOUT bumping access counters. Resolves a + // malformed / unknown / revoked / expired token to null → the same flat 404 + // the full read would. A legacy link (no passphrase) falls straight through. + const gate = await resolveShareGateState(token); + if (!gate) notFound(); + + if (gate.passphraseHash !== null) { + // Protected link: render nothing of the record until the short-lived, + // token-scoped unlock cookie is present and valid. Verifying the cookie is + // constant-time; an invalid / expired / cross-token value shows the gate. + const cookieStore = await cookies(); + const unlockValue = cookieStore.get(unlockCookieName(gate.tokenHash))?.value; + if (!verifyUnlockValue(unlockValue, gate.tokenHash)) { + const locale = await resolveLocale(); + const { t } = getServerTranslator(locale); + return t(key, vars)} token={token} />; + } + } + + // The ONE trust boundary for the full read. No session is read; this proves + // only that the raw path token hashes to a live, in-window share link and + // yields the owner scope. Any failure → null → flat 404. The access counter + // is bumped here (on a real record render), not on a gate-blocked hit. const context = await resolveShareToken(token); if (!context) notFound(); diff --git a/src/components/clinician/share-unlock-gate.tsx b/src/components/clinician/share-unlock-gate.tsx new file mode 100644 index 000000000..790357525 --- /dev/null +++ b/src/components/clinician/share-unlock-gate.tsx @@ -0,0 +1,130 @@ +"use client"; + +/** + * v1.18.7 — passphrase gate for a protected clinician share link. + * + * Renders ONLY when the server determined the link carries a passphrase and the + * short-lived unlock cookie is absent. The record is never sent to the client + * until the cookie is set — this island is the whole page until then. + * + * Two paths: + * - The owner shares a QR / deep link carrying `#k=` in the URL + * FRAGMENT (never sent to the server). On mount we read `location.hash`, + * auto-submit it, scrub the hash from history, and reload on success so the + * server re-renders the record with the now-set cookie. + * - No fragment → a localised passphrase input the clinician types into. + * + * The submit POSTs to `/api/c//unlock`; on success the server has set an + * httpOnly cookie scoped to this token's path, so a full reload paints the + * record. Failure shows one blunt localised error (the server leaks nothing). + */ +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { apiPost } from "@/lib/api/api-fetch"; + +type Translate = ( + key: string, + vars?: Record, +) => string; + +interface ShareUnlockGateProps { + t: Translate; + /** The raw `hls_` token from the path — used only to build the verify URL. */ + token: string; +} + +export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { + const [passphrase, setPassphrase] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(false); + const autoTried = useRef(false); + + async function submit(value: string): Promise { + const trimmed = value.trim(); + if (!trimmed) return; + setPending(true); + setError(false); + try { + await apiPost(`/api/c/${encodeURIComponent(token)}/unlock`, { + passphrase: trimmed, + }); + // Cookie is set server-side; a full reload re-renders with the record. + window.location.reload(); + } catch { + setError(true); + setPending(false); + } + } + + // Auto-unlock from the `#k=` fragment, once. + useEffect(() => { + if (autoTried.current) return; + autoTried.current = true; + const hash = window.location.hash.replace(/^#/, ""); + const params = new URLSearchParams(hash); + const k = params.get("k"); + if (k) { + // Scrub the secret from the address bar / history before the request. + history.replaceState(null, "", window.location.pathname); + // Defer out of the effect body so the submit's setState does not run + // synchronously inside the effect (no cascading render). + queueMicrotask(() => void submit(k)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
{ + e.preventDefault(); + void submit(passphrase); + }} + > +
+

+ {t("clinicianView.unlock.title")} +

+

+ {t("clinicianView.unlock.description")} +

+
+ +
+ + setPassphrase(e.target.value)} + placeholder="XXXX-XXXX-XXXX-XXXX" + autoComplete="off" + autoCapitalize="characters" + spellCheck={false} + aria-invalid={error} + className="font-mono" + /> +
+ + {error && ( +

+ {t("clinicianView.unlock.error")} +

+ )} + + +
+
+ ); +} diff --git a/src/lib/clinician-share/__tests__/passphrase.test.ts b/src/lib/clinician-share/__tests__/passphrase.test.ts new file mode 100644 index 000000000..1a7fd2f6c --- /dev/null +++ b/src/lib/clinician-share/__tests__/passphrase.test.ts @@ -0,0 +1,87 @@ +/** + * v1.18.7 — share-link passphrase second factor. + * + * Asserts: generated passphrases match the grouped human-typeable form and are + * high-entropy / unique; normalisation collapses grouped and bare forms to one + * canonical hash; verification is correct, rejects a wrong passphrase, and a + * null stored hash; a malformed submission is rejected without throwing. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; + +beforeEach(() => { + vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); +}); + +import { + generatePassphrase, + hashPassphrase, + normalisePassphrase, + verifyPassphrase, + PASSPHRASE_PATTERN, +} from "../passphrase"; + +describe("generatePassphrase", () => { + it("emits the grouped XXXX-XXXX-XXXX-XXXX form", () => { + const p = generatePassphrase(); + expect(p).toMatch(/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/); + // The bare form is 16 Crockford-base32 chars (80 bits). + expect(normalisePassphrase(p)).toMatch(PASSPHRASE_PATTERN); + }); + + it("excludes the easily-confused I/L/O/U letters", () => { + for (let i = 0; i < 50; i++) { + expect(generatePassphrase()).not.toMatch(/[ILOU]/); + } + }); + + it("is unique across a sample (high entropy)", () => { + const seen = new Set(); + for (let i = 0; i < 200; i++) seen.add(generatePassphrase()); + expect(seen.size).toBe(200); + }); +}); + +describe("normalisePassphrase", () => { + it("collapses grouped, spaced, and lowercase forms to one canonical value", () => { + const p = generatePassphrase(); + const bare = normalisePassphrase(p)!; + expect(normalisePassphrase(bare)).toBe(bare); + expect(normalisePassphrase(p.toLowerCase())).toBe(bare); + expect(normalisePassphrase(p.replace(/-/g, " "))).toBe(bare); + }); + + it("rejects a malformed value", () => { + expect(normalisePassphrase("not a passphrase!")).toBeNull(); + expect(normalisePassphrase("")).toBeNull(); + // Contains an excluded letter. + expect(normalisePassphrase("IIII-IIII-IIII-IIII")).toBeNull(); + }); +}); + +describe("verifyPassphrase", () => { + it("accepts the correct passphrase against its stored hash", () => { + const p = generatePassphrase(); + const hash = hashPassphrase(normalisePassphrase(p)!); + expect(verifyPassphrase(p, hash)).toBe(true); + // Grouped vs bare vs lowercase all verify against the one hash. + expect(verifyPassphrase(normalisePassphrase(p)!, hash)).toBe(true); + expect(verifyPassphrase(p.toLowerCase(), hash)).toBe(true); + }); + + it("rejects a wrong passphrase", () => { + const hash = hashPassphrase(normalisePassphrase(generatePassphrase())!); + expect(verifyPassphrase(generatePassphrase(), hash)).toBe(false); + }); + + it("rejects against a null stored hash (legacy / unset)", () => { + expect(verifyPassphrase(generatePassphrase(), null)).toBe(false); + }); + + it("rejects a malformed submission without throwing", () => { + const hash = hashPassphrase(normalisePassphrase(generatePassphrase())!); + expect(verifyPassphrase("garbage", hash)).toBe(false); + expect(verifyPassphrase("", hash)).toBe(false); + }); +}); diff --git a/src/lib/clinician-share/__tests__/resolve-share-gate-state.test.ts b/src/lib/clinician-share/__tests__/resolve-share-gate-state.test.ts new file mode 100644 index 000000000..20d2a7a09 --- /dev/null +++ b/src/lib/clinician-share/__tests__/resolve-share-gate-state.test.ts @@ -0,0 +1,86 @@ +/** + * v1.18.7 — share-link gate state (the page's gate-vs-render decision). + * + * Asserts: a legacy (null passphraseHash) live link resolves WITHOUT a gate; + * a protected link surfaces its passphraseHash for the gate; revoked / expired + * / unknown / malformed tokens all resolve to null (the same blunt 404); the + * gate resolver NEVER bumps access counters (no `update` call). + */ +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 { resolveShareGateState } 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 row(overrides: Record = {}) { + return { + passphraseHash: "stored-hash", + revokedAt: null, + expiresAt: new Date(Date.now() + 86_400_000), + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("resolveShareGateState", () => { + it("returns null for a malformed token (no DB hit)", async () => { + expect(await resolveShareGateState("nope")).toBeNull(); + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("returns null for an unknown token", async () => { + findUnique.mockResolvedValue(null); + expect(await resolveShareGateState(VALID_TOKEN)).toBeNull(); + }); + + it("returns null for a revoked link", async () => { + findUnique.mockResolvedValue(row({ revokedAt: new Date() })); + expect(await resolveShareGateState(VALID_TOKEN)).toBeNull(); + }); + + it("returns null for an expired link", async () => { + findUnique.mockResolvedValue(row({ expiresAt: new Date(Date.now() - 1000) })); + expect(await resolveShareGateState(VALID_TOKEN)).toBeNull(); + }); + + it("surfaces the passphraseHash for a protected live link", async () => { + findUnique.mockResolvedValue(row({ passphraseHash: "stored-hash" })); + const gate = await resolveShareGateState(VALID_TOKEN); + expect(gate).toEqual({ + tokenHash: `hash(${VALID_TOKEN})`, + passphraseHash: "stored-hash", + }); + }); + + it("resolves a legacy (null passphraseHash) live link WITHOUT a gate", async () => { + findUnique.mockResolvedValue(row({ passphraseHash: null })); + const gate = await resolveShareGateState(VALID_TOKEN); + expect(gate).not.toBeNull(); + expect(gate!.passphraseHash).toBeNull(); + }); + + it("never bumps the access counter (no update)", async () => { + findUnique.mockResolvedValue(row()); + await resolveShareGateState(VALID_TOKEN); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/clinician-share/__tests__/unlock-cookie.test.ts b/src/lib/clinician-share/__tests__/unlock-cookie.test.ts new file mode 100644 index 000000000..ccb2f58ae --- /dev/null +++ b/src/lib/clinician-share/__tests__/unlock-cookie.test.ts @@ -0,0 +1,62 @@ +/** + * v1.18.7 — short-lived, token-scoped unlock cookie. + * + * Asserts: a minted value verifies against its own token hash; it does NOT + * verify against a different token hash (cross-token isolation); an expired + * value is rejected; a forged / malformed value is rejected; the cookie name + * is per-token. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; + +beforeEach(() => { + vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +import { + mintUnlockValue, + verifyUnlockValue, + unlockCookieName, + UNLOCK_TTL_SECONDS, +} from "../unlock-cookie"; + +const HASH_A = "a".repeat(64); +const HASH_B = "b".repeat(64); + +describe("unlock-cookie", () => { + it("verifies a freshly-minted value against its own token hash", () => { + const value = mintUnlockValue(HASH_A); + expect(verifyUnlockValue(value, HASH_A)).toBe(true); + }); + + it("does NOT verify against a different token hash (cross-token isolation)", () => { + const value = mintUnlockValue(HASH_A); + expect(verifyUnlockValue(value, HASH_B)).toBe(false); + }); + + it("rejects an expired value", () => { + vi.useFakeTimers(); + const value = mintUnlockValue(HASH_A); + // Advance past the TTL. + vi.advanceTimersByTime((UNLOCK_TTL_SECONDS + 60) * 1000); + expect(verifyUnlockValue(value, HASH_A)).toBe(false); + }); + + it("rejects a forged or malformed value", () => { + expect(verifyUnlockValue(undefined, HASH_A)).toBe(false); + expect(verifyUnlockValue("", HASH_A)).toBe(false); + expect(verifyUnlockValue("nodot", HASH_A)).toBe(false); + expect(verifyUnlockValue(`${Date.now() + 1_000_000}.deadbeef`, HASH_A)).toBe( + false, + ); + }); + + it("derives a per-token cookie name", () => { + expect(unlockCookieName(HASH_A)).not.toBe(unlockCookieName(HASH_B)); + expect(unlockCookieName(HASH_A)).toMatch(/^hls_unlock_/); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index c93c7a666..7cd5119e2 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -42,6 +42,12 @@ const PUBLIC_PATHS = [ // by a session cookie, so it must reach the page without an auth gate. // The page renders a flat 404 for any unknown / revoked / expired token. "/c/", + // v1.18.7 — `/api/c//unlock` is the public passphrase-gate verifier + // for a protected share link. Like the view it carries no session — the raw + // path token plus the submitted passphrase are the only credentials — so it + // must reach the route handler without an auth gate. It is rate-limited and + // answers one blunt error for every failure class. + "/api/c/", // v1.17.0 — `/invite/` is the invite universal-link landing // (iOS #16). It is a thin shape-validated redirect onto // `/auth/register?invite=…`, carries no session, touches no database,