Gate the public share view behind the passphrase

A protected share link no longer renders the record until the passphrase
is verified. The public page checks the gate state first (without bumping
access counters), and for a link with a passphrase hash it serves a gate
island unless a valid, token-scoped unlock cookie is present. A legacy
link with a null passphrase hash renders exactly as before.

The unlock route verifies the submitted passphrase with a constant-time
compare against the stored hash, is rate-limited by share-unlock token
and IP before any compare, and on success sets a short-lived (30 min)
httpOnly, SameSite=Strict, Secure cookie scoped to that one token's view
path. Every failure class answers one blunt 401; no-store and the flat
404 for unknown links are preserved. The gate island auto-submits the
#k= fragment (scrubbing it from history first) or prompts for input.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 22:03:46 +02:00
1 parent 1aeb6f7957
commit 5783acacbb
9 files changed
+755 -24

No files matched your search

@@ -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();
});
});
+106
View File
@@ -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/<token>` 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:<tokenHash>:<ip>` 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 });
},
);
+96 -20
View File
@@ -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<typeof vi.fn>;
const gateState = resolveShareGateState as ReturnType<typeof vi.fn>;
const loadData = loadShareViewData as ReturnType<typeof vi.fn>;
const TOKEN_HASH = "f".repeat(64);
function pageProps(token: string) {
return { params: Promise.resolve({ token }) };
}
/** The page returns a `<ShareUnlockGate>` 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();
});
});
+32 -4
View File
@@ -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 <ShareUnlockGate t={(key, vars) => 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();
@@ -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=<passphrase>` 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/<token>/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, string | number>,
) => 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<void> {
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=<passphrase>` 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 (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
<form
className="space-y-4 rounded-lg border border-border bg-card p-6"
onSubmit={(e) => {
e.preventDefault();
void submit(passphrase);
}}
>
<div className="space-y-1.5">
<h1 className="text-lg font-semibold">
{t("clinicianView.unlock.title")}
</h1>
<p className="text-sm text-muted-foreground">
{t("clinicianView.unlock.description")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="share-passphrase">
{t("clinicianView.unlock.label")}
</Label>
<Input
id="share-passphrase"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder="XXXX-XXXX-XXXX-XXXX"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
aria-invalid={error}
className="font-mono"
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{t("clinicianView.unlock.error")}
</p>
)}
<Button
type="submit"
disabled={pending || !passphrase.trim()}
className="min-h-11 w-full sm:min-h-9"
>
{t("clinicianView.unlock.submit")}
</Button>
</form>
</main>
);
}
@@ -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<string>();
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);
});
});
@@ -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<typeof vi.fn>;
const VALID_TOKEN = `hls_${"a".repeat(48)}`;
function row(overrides: Record<string, unknown> = {}) {
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();
});
});
@@ -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_/);
});
});
+6
View File
@@ -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/<token>/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/<hlv_token>` 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,