Merge wave-sharing-harden: passphrase + QR second factor for share links

Gate the clinician share view behind an auto-generated passphrase carried
in the QR URL fragment and stored only as an HMAC hash. The public view
verifies it in constant time behind a rate limit and sets a short-lived,
path-scoped cookie before rendering; links minted before this change keep
working ungated.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 22:05:49 +02:00
commit aa6196a789
26 files changed
+1509 -90

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 });
},
);
@@ -75,6 +75,7 @@ function storedRow(overrides: Record<string, unknown> = {}) {
rangeEnd: null,
resourceTypes: ["Observation"],
allowFhirApi: true,
passphraseHash: "hash(STORED)",
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
revokedAt: null,
@@ -119,6 +120,44 @@ describe("POST /api/share-links — create", () => {
expect(createArg.data.userId).toBe("user-1");
});
it("returns the raw passphrase once and persists ONLY its hash", async () => {
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
storedRow({ passphraseHash: "hash(STORED)" }) as never,
);
const res = await POST(postReq(validBody()));
expect(res.status).toBe(201);
const body = (await res.json()) as {
data: {
passphrase: string;
token: string;
shareUrl: string;
qrUrl: string;
protected: boolean;
};
};
// The raw passphrase is returned exactly once in the grouped form.
expect(body.data.passphrase).toMatch(
/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/,
);
// Stored as a hash (the mock wraps the NORMALISED bare form), never raw.
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
.calls[0][0];
const bare = body.data.passphrase.replace(/-/g, "");
expect(createArg.data.passphraseHash).toBe(`hash(${bare})`);
expect(createArg.data.passphraseHash).not.toContain(body.data.passphrase);
// The QR payload carries the passphrase ONLY in the URL fragment (`#k=`),
// never the path or query — and the bare share URL never carries it.
expect(body.data.qrUrl).toContain(`#k=${body.data.passphrase}`);
expect(body.data.shareUrl).not.toContain(body.data.passphrase);
expect(body.data.shareUrl).toContain(`/c/${body.data.token}`);
// Summary reports the link as passphrase-protected.
expect(body.data.protected).toBe(true);
});
it("rejects an expiry beyond the cap (422)", async () => {
const tooFar = new Date(
Date.now() + 200 * 24 * 60 * 60 * 1000,
+54 -2
View File
@@ -29,8 +29,31 @@ import {
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
import {
generatePassphrase,
hashPassphrase,
normalisePassphrase,
PASSPHRASE_FRAGMENT_KEY,
} from "@/lib/clinician-share/passphrase";
import type { Prisma } from "@/generated/prisma/client";
/**
* The absolute origin to build the public share URL from. Prefers the
* forwarded host/proto a reverse proxy sets (the documented self-hosting path
* fronts the app with Caddy/Traefik/Nginx), falling back to the request URL's
* own origin. The QR payload carries the passphrase in the FRAGMENT only.
*/
function resolveOrigin(request: NextRequest): string {
const proto =
request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ||
new URL(request.url).protocol.replace(":", "");
const host =
request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ||
request.headers.get("host") ||
new URL(request.url).host;
return `${proto}://${host}`;
}
/** Project a stored row to the safe owner-facing shape (never the token). */
function toSummary(row: {
id: string;
@@ -39,6 +62,7 @@ function toSummary(row: {
rangeEnd: Date | null;
resourceTypes: string[];
allowFhirApi: boolean;
passphraseHash: string | null;
expiresAt: Date;
createdAt: Date;
revokedAt: Date | null;
@@ -52,6 +76,9 @@ function toSummary(row: {
rangeEnd: row.rangeEnd ? row.rangeEnd.toISOString() : null,
resourceTypes: row.resourceTypes,
allowFhirApi: row.allowFhirApi,
// v1.18.7 — surface only WHETHER a passphrase guards the link, never the
// hash itself. Legacy (null-hash) links read `false` and stay ungated.
protected: row.passphraseHash !== null,
expiresAt: row.expiresAt.toISOString(),
createdAt: row.createdAt.toISOString(),
revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null,
@@ -91,12 +118,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
const rawToken = `hls_${randomBytes(24).toString("hex")}`;
const tokenHash = hashToken(rawToken);
// v1.18.7 — always mint a passphrase second factor; store only its HMAC
// hash. The raw passphrase is returned exactly once below. `normalisePassphrase`
// is the canonical store form so the grouped (`XXXX-XXXX-…`) display and the
// bare fragment value collide to one hash.
const rawPassphrase = generatePassphrase();
const passphraseHash = hashPassphrase(normalisePassphrase(rawPassphrase)!);
// Field-by-field build — no mass assignment. Scope columns are written
// exactly once here and never updated.
const created = await prisma.clinicianShareLink.create({
data: {
userId: user.id,
tokenHash,
passphraseHash,
label: input.label,
rangeStart: new Date(input.rangeStart),
rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null,
@@ -112,6 +147,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
rangeEnd: true,
resourceTypes: true,
allowFhirApi: true,
passphraseHash: true,
expiresAt: true,
createdAt: true,
revokedAt: true,
@@ -131,8 +167,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// The raw token is returned exactly once; it is unrecoverable thereafter.
return apiSuccess({ ...toSummary(created), token: rawToken }, 201);
// The raw token AND raw passphrase are returned exactly once; both are
// unrecoverable thereafter. The QR/deep-link payload carries the passphrase
// in the URL FRAGMENT (`#k=`) — never the path or query — so it is never
// sent to the server, kept out of logs, referrers, and the access record.
const origin = resolveOrigin(request);
const shareUrl = `${origin}/c/${rawToken}`;
const qrUrl = `${shareUrl}#${PASSPHRASE_FRAGMENT_KEY}=${rawPassphrase}`;
return apiSuccess(
{
...toSummary(created),
token: rawToken,
passphrase: rawPassphrase,
shareUrl,
qrUrl,
},
201,
);
});
export const GET = apiHandler(async () => {
@@ -149,6 +200,7 @@ export const GET = apiHandler(async () => {
rangeEnd: true,
resourceTypes: true,
allowFhirApi: true,
passphraseHash: true,
expiresAt: true,
createdAt: true,
revokedAt: 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>
);
}
+153 -48
View File
@@ -21,9 +21,10 @@
* the share-links card. Cards paint through `<SettingsCard>` so the surface
* matches every sibling section 1:1.
*/
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Copy, Loader2, Share2, Trash2 } from "lucide-react";
import QRCode from "qrcode";
import { Copy, KeyRound, Loader2, Share2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -71,6 +72,8 @@ interface ShareLinkSummary {
rangeEnd: string | null;
resourceTypes: string[];
allowFhirApi: boolean;
/** v1.18.7 — whether a passphrase second factor guards this link. */
protected: boolean;
expiresAt: string;
createdAt: string;
revokedAt: string | null;
@@ -79,6 +82,18 @@ interface ShareLinkSummary {
active: boolean;
}
/**
* v1.18.7 — the create response. The token, passphrase, and URLs are returned
* EXACTLY ONCE here; the list query never carries them (the server stores only
* hashes). `qrUrl` carries the passphrase in the URL fragment (`#k=`).
*/
interface ShareLinkCreated extends ShareLinkSummary {
token: string;
passphrase: string;
shareUrl: string;
qrUrl: string;
}
export function SharingSection() {
// v1.18.7 — the visible heading + subtitle come from the shared settings
// shell chrome in the route; this body is the share-links card only.
@@ -106,11 +121,34 @@ function ShareLinksCard() {
"Patient",
"Observation",
]);
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copied, setCopied] = useState<"link" | "passphrase" | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [showRevoked, setShowRevoked] = useState(false);
// Render the QR from the `#k=` deep link once a link is created. The fragment
// carries the passphrase, so the QR alone opens the record — it is shown
// exactly once alongside the passphrase text. The QR is keyed off `qrUrl` so
// a re-create swaps it; the effect only WRITES on async completion (never a
// synchronous reset), so it does not trigger a cascading render.
const qrUrl = created?.qrUrl ?? null;
useEffect(() => {
if (!qrUrl) return;
let cancelled = false;
QRCode.toDataURL(qrUrl, { margin: 1, width: 220 })
.then((url) => {
if (!cancelled) setQrDataUrl(url);
})
.catch(() => {
/* clipboard/QR can fail in some contexts; the link + passphrase text
are still shown so the owner can share them by hand */
});
return () => {
cancelled = true;
};
}, [qrUrl]);
const { data: links } = useQuery({
queryKey: queryKeys.shareLinks(),
queryFn: () =>
@@ -128,27 +166,26 @@ function ShareLinksCard() {
if (expiryDays < 1 || expiryDays > MAX_DAYS) {
return Promise.reject(new Error("EXPIRY_RANGE"));
}
return apiPost<ShareLinkSummary & { token: string }>(
"/api/share-links",
{
label: trimmed,
rangeStart: isoDaysFromNow(-rangeDays),
rangeEnd: null,
resourceTypes,
allowFhirApi,
expiresAt: isoDaysFromNow(expiryDays),
},
);
return apiPost<ShareLinkCreated>("/api/share-links", {
label: trimmed,
rangeStart: isoDaysFromNow(-rangeDays),
rangeEnd: null,
resourceTypes,
allowFhirApi,
expiresAt: isoDaysFromNow(expiryDays),
});
},
onSuccess: (created) => {
setNewToken(created.token);
setCopied(false);
onSuccess: (result) => {
// Clear any stale QR before the effect renders the new one.
setQrDataUrl(null);
setCreated(result);
setCopied(null);
setLabel("");
setFormError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
},
onError: (err: Error) => {
setNewToken(null);
setCreated(null);
setFormError(
err.message === "EXPIRY_RANGE"
? t("settings.sharing.expiryInvalid", { max: MAX_DAYS })
@@ -170,15 +207,13 @@ function ShareLinksCard() {
);
}
async function copyToken() {
if (!newToken) return;
async function copyValue(value: string, which: "link" | "passphrase") {
try {
const origin = window.location.origin;
await navigator.clipboard.writeText(`${origin}/c/${newToken}`);
setCopied(true);
await navigator.clipboard.writeText(value);
setCopied(which);
} catch {
// Clipboard can be unavailable (insecure context); the token stays
// visible in the card so the owner can copy it by hand.
// Clipboard can be unavailable (insecure context); the values stay
// visible in the card so the owner can copy them by hand.
}
}
@@ -313,37 +348,97 @@ function ShareLinksCard() {
</Button>
</form>
{newToken && (
{created && (
<div
className="bg-success/10 space-y-2 rounded-lg p-3 text-sm"
className="bg-success/10 space-y-3 rounded-lg p-3 text-sm"
data-testid="share-token-reveal"
>
<p className="text-success font-medium">
{t("settings.sharing.tokenCreated")}
</p>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.tokenOnce")}
{t("settings.sharing.shownOnce")}
</p>
<div className="flex items-center gap-2">
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
{`${typeof window !== "undefined" ? window.location.origin : ""}/c/${newToken}`}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={copyToken}
aria-label={t("settings.sharing.copy")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied && (
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
{/* QR — carries the passphrase in the URL fragment, so scanning it
opens the record. Shown exactly once with the passphrase text. */}
{qrDataUrl && (
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
{t("settings.sharing.qrLabel")}
</p>
{/* The QR is a transient secret render, not stored content. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrDataUrl}
alt={t("settings.sharing.qrAlt")}
width={220}
height={220}
className="bg-background rounded border p-2"
/>
</div>
)}
{/* The link (no passphrase). On its own it cannot open the record. */}
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
{t("settings.sharing.linkLabel")}
</p>
<div className="flex items-center gap-2">
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
{created.shareUrl}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => copyValue(created.shareUrl, "link")}
aria-label={t("settings.sharing.copyLink")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied === "link" && (
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
)}
</div>
{/* The passphrase — the second factor. Shown once; only its hash is
stored, so it cannot be recovered. */}
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
{t("settings.sharing.passphraseLabel")}
</p>
<div className="flex items-center gap-2">
<code
className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all"
data-testid="share-passphrase"
>
{created.passphrase}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => copyValue(created.passphrase, "passphrase")}
aria-label={t("settings.sharing.copyPassphrase")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied === "passphrase" && (
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
)}
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.passphraseHint")}
</p>
</div>
</div>
)}
@@ -373,6 +468,16 @@ function ShareLinksCard() {
<Badge className="bg-success/15 text-success text-[10px]">
{t("settings.sharing.statusActive")}
</Badge>
{link.protected && (
<Badge
variant="outline"
className="gap-1 text-[10px]"
data-testid="share-protected-badge"
>
<KeyRound className="h-2.5 w-2.5" />
{t("settings.sharing.protected")}
</Badge>
)}
{link.allowFhirApi && (
<Badge variant="outline" className="text-[10px]">
FHIR
@@ -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_/);
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* v1.18.7 — clinician share-link passphrase second factor.
*
* A share URL is now possession AND knowledge: a leaked `/c/<token>` link
* without the passphrase cannot open the record. Every new link gets a
* high-entropy passphrase minted here, returned to the owner EXACTLY ONCE,
* and stored only as its HMAC-SHA256 hash (the same `hashToken` scheme as the
* token itself, keyed by `API_TOKEN_HMAC_KEY`). There is no recovery path.
*
* Shape: 16 Crockford-base32 characters grouped `XXXX-XXXX-XXXX-XXXX`. The raw
* 16 chars carry 80 bits of entropy; we draw them from 10 random bytes (80
* bits) so the human-typeable form is the full secret with no truncation. The
* QR payload (built by the create route) carries this in the URL FRAGMENT so it
* never reaches a server log, referrer, or access record.
*/
import { randomBytes, timingSafeEqual } from "node:crypto";
import { hashToken } from "@/lib/auth/hmac";
/**
* Crockford base32 alphabet (no I/L/O/U — removes the characters most often
* misread when typed by hand). 32 symbols → 5 bits each.
*/
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/** Group size for the human-typeable form (`XXXX-XXXX-XXXX-XXXX`). */
const GROUP = 4;
/** Total characters in a raw passphrase (16 × 5 bits = 80 bits). */
const LENGTH = 16;
/** The fragment key the QR / deep-link uses: `#k=<passphrase>`. */
export const PASSPHRASE_FRAGMENT_KEY = "k";
/**
* The canonical regex a submitted passphrase must match once normalised
* (uppercased, separators stripped): 16 Crockford-base32 characters.
*/
export const PASSPHRASE_PATTERN = new RegExp(`^[${ALPHABET}]{${LENGTH}}$`);
/**
* Generate a fresh passphrase in the grouped human-typeable form
* (`XXXX-XXXX-XXXX-XXXX`). Drawn from CSPRNG bytes via rejection-free
* masking — each character maps a 5-bit slice of the random stream onto the
* 32-symbol alphabet, so the distribution is uniform.
*/
export function generatePassphrase(): string {
// 16 chars × 5 bits = 80 bits → 10 bytes of entropy.
const bytes = randomBytes(10);
let bits = 0;
let value = 0;
const chars: string[] = [];
for (let i = 0; i < bytes.length && chars.length < LENGTH; i++) {
value = (value << 8) | bytes[i];
bits += 8;
while (bits >= 5 && chars.length < LENGTH) {
bits -= 5;
chars.push(ALPHABET[(value >>> bits) & 0x1f]);
}
}
const groups: string[] = [];
for (let i = 0; i < chars.length; i += GROUP) {
groups.push(chars.slice(i, i + GROUP).join(""));
}
return groups.join("-");
}
/**
* Normalise a passphrase a human (or a QR fragment) submitted: strip every
* separator / whitespace and uppercase. Lowercase `l`→`L` style confusions are
* left to the alphabet (which already excludes I/L/O/U), so a value that does
* not normalise to the canonical pattern is simply rejected by the caller.
* Returns the bare 16-char form, or `null` when it does not match the pattern.
*/
export function normalisePassphrase(raw: string): string | null {
const stripped = raw.replace(/[\s-]+/g, "").toUpperCase();
return PASSPHRASE_PATTERN.test(stripped) ? stripped : null;
}
/**
* Hash a passphrase for storage / comparison. Normalises first so the grouped
* and bare forms collide to one hash; throws (via `normalisePassphrase` →
* caller) only when the input is malformed. Returns the lowercase hex HMAC.
*/
export function hashPassphrase(normalised: string): string {
return hashToken(normalised);
}
/**
* Constant-time check of a submitted passphrase against a stored hash. Returns
* false for a malformed submission, a null stored hash, or any mismatch — and
* never short-circuits on length so timing leaks nothing about the secret.
*/
export function verifyPassphrase(
submitted: string,
storedHash: string | null,
): boolean {
if (!storedHash) return false;
const normalised = normalisePassphrase(submitted);
if (!normalised) return false;
const candidate = Buffer.from(hashPassphrase(normalised), "hex");
const expected = Buffer.from(storedHash, "hex");
if (candidate.length !== expected.length) return false;
try {
return timingSafeEqual(candidate, expected);
} catch {
return false;
}
}
+32 -1
View File
@@ -27,7 +27,7 @@ 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}$/;
export const SHARE_TOKEN_PATTERN = /^hls_[0-9a-f]{48}$/;
/**
* The scoped read context a resolved share token yields. It is intentionally a
@@ -117,3 +117,34 @@ export async function resolveShareToken(
expiresAt: row.expiresAt,
};
}
/**
* v1.18.7 — the live-gate state for a raw token, WITHOUT bumping access
* counters. Used by the public page (decide gate vs render) and the unlock
* route (verify a passphrase). It deliberately resolves a malformed / unknown /
* revoked / expired token to `null` — the same blunt nothing the view returns —
* so the unlock surface leaks no more than the page does. It carries the stored
* `passphraseHash` and the `tokenHash` (for cookie scoping + rate-limit keying)
* but never the owner scope; the full read still flows through
* {@link resolveShareToken}.
*/
export interface ShareGateState {
tokenHash: string;
/** `null` for a legacy link minted before the passphrase gate. */
passphraseHash: string | null;
}
export async function resolveShareGateState(
rawToken: string | null | undefined,
): Promise<ShareGateState | null> {
if (!rawToken || !SHARE_TOKEN_PATTERN.test(rawToken)) return null;
const tokenHash = hashToken(rawToken);
const row = await prisma.clinicianShareLink.findUnique({
where: { tokenHash },
select: { passphraseHash: true, revokedAt: true, expiresAt: true },
});
if (!row) return null;
if (row.revokedAt !== null) return null;
if (row.expiresAt.getTime() <= Date.now()) return null;
return { tokenHash, passphraseHash: row.passphraseHash };
}
+81
View File
@@ -0,0 +1,81 @@
/**
* v1.18.7 — short-lived unlock cookie for a passphrase-gated share link.
*
* After the public unlock route verifies the passphrase it mints a signed,
* httpOnly, SameSite=Strict, Secure (per `shouldEmitSecureCookie`) cookie
* SCOPED to that one token's view path (`/c/<token>`). The page checks the
* cookie server-side before rendering the record; presenting an unlock for one
* token never unlocks another (the cookie name + path both pin the token hash).
*
* The value is opaque and self-verifying: `<expiryMs>.<hmac>` where the HMAC is
* over `"<tokenHash>.<expiryMs>"` keyed by `API_TOKEN_HMAC_KEY`. No server-side
* session row is needed; a forged or replayed-across-token value fails the
* constant-time HMAC check, and an expired one fails the timestamp check. TTL
* is short (30 min) so a shared device window closes quickly.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
/** Unlock-cookie lifetime: 30 minutes. */
export const UNLOCK_TTL_SECONDS = 30 * 60;
const MIN_HMAC_KEY_LENGTH = 32;
function unlockKey(): string {
const key = process.env.API_TOKEN_HMAC_KEY;
if (!key) {
throw new Error("API_TOKEN_HMAC_KEY env var must be set");
}
if (key.length < MIN_HMAC_KEY_LENGTH) {
throw new Error(
`API_TOKEN_HMAC_KEY must be at least ${MIN_HMAC_KEY_LENGTH} characters, got ${key.length}.`,
);
}
return key;
}
/**
* The per-token cookie name. A short prefix of the token hash keeps cookies for
* different shares distinct without putting the full hash in the header. The
* path scoping (`/c/<token>`) is the real isolation boundary; the name suffix
* only avoids cross-token collisions inside the same browser.
*/
export function unlockCookieName(tokenHash: string): string {
return `hls_unlock_${tokenHash.slice(0, 16)}`;
}
function sign(tokenHash: string, expiryMs: number): string {
return createHmac("sha256", unlockKey())
.update(`${tokenHash}.${expiryMs}`)
.digest("hex");
}
/** Mint a fresh unlock-cookie value bound to this token hash. */
export function mintUnlockValue(tokenHash: string): string {
const expiryMs = Date.now() + UNLOCK_TTL_SECONDS * 1000;
return `${expiryMs}.${sign(tokenHash, expiryMs)}`;
}
/**
* Verify an unlock-cookie value against the token hash. Constant-time on the
* HMAC; returns false for any malformed, forged, cross-token, or expired value.
*/
export function verifyUnlockValue(
value: string | undefined,
tokenHash: string,
): boolean {
if (!value) return false;
const dot = value.indexOf(".");
if (dot <= 0) return false;
const expiryMs = Number(value.slice(0, dot));
if (!Number.isFinite(expiryMs) || expiryMs <= Date.now()) return false;
const received = value.slice(dot + 1);
const expected = sign(tokenHash, expiryMs);
const a = Buffer.from(received, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || a.length === 0) return false;
try {
return timingSafeEqual(a, b);
} catch {
return false;
}
}
+61 -2
View File
@@ -8,7 +8,10 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
import {
createShareLinkSchema,
unlockShareLinkSchema,
} from "@/lib/validations/clinician-share-link";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// v1.11.0 — clinician share-link owner-facing summary (never the raw token).
@@ -19,6 +22,9 @@ const shareLinkSummary = z.object({
rangeEnd: z.string().nullable(),
resourceTypes: z.array(z.string()),
allowFhirApi: z.boolean(),
// v1.18.7 — whether a passphrase second factor guards this link. Always true
// for links created on v1.18.7+; false only for legacy links.
protected: z.boolean(),
expiresAt: z.string(),
createdAt: z.string(),
revokedAt: z.string().nullable(),
@@ -31,6 +37,18 @@ const shareLinkCreatedResponse = shareLinkSummary.extend({
token: z
.string()
.describe("Raw `hls_` token — returned ONCE and unrecoverable thereafter."),
// v1.18.7 — passphrase second factor + QR deep link, all returned ONCE.
passphrase: z
.string()
.describe(
"Raw passphrase second factor — returned ONCE; stored only as an HMAC hash, unrecoverable.",
),
shareUrl: z.string().describe("Absolute `/c/<token>` URL (no passphrase)."),
qrUrl: z
.string()
.describe(
"Absolute share URL with the passphrase in the URL FRAGMENT (`#k=<passphrase>`). Encode this as the QR payload; the fragment is never sent to the server.",
),
});
const shareLinkListResponse = z.object({
@@ -42,6 +60,11 @@ const shareLinkRevokedResponse = z.object({
revoked: z.boolean(),
});
// v1.18.7 — public passphrase-gate verify result.
const shareLinkUnlockResponse = z.object({
unlocked: z.literal(true),
});
export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/export/health-record": {
post: {
@@ -80,7 +103,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Export"],
summary: "Create a clinician share link (v1.11.0)",
description:
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:<userId>`, 20/h). Strict: unknown keys 422.",
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:<userId>`, 20/h). Strict: unknown keys 422.",
requestBody: {
required: true,
content: {
@@ -148,6 +171,42 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/c/{token}/unlock": {
post: {
tags: ["Export"],
summary: "Verify a share-link passphrase (v1.18.7, public)",
description:
"Anonymous, no-session passphrase gate for a protected share link. The raw path token plus the submitted passphrase are the only credentials. On success the server sets a short-lived (30 min), token-scoped, httpOnly cookie so `/c/<token>` renders the record. Rate-limited (`share-unlock:<tokenHash>:<ip>`); every failure class (bad token, no passphrase set, wrong passphrase) answers one blunt 401.",
requestParams: { path: z.object({ token: z.string() }) },
requestBody: {
required: true,
content: {
"application/json": { schema: unlockShareLinkSchema },
},
},
responses: {
"200": {
description:
"Passphrase accepted; short-lived token-scoped unlock cookie set.",
content: {
"application/json": {
schema: dataEnvelope(
shareLinkUnlockResponse,
"ShareLinkUnlock",
),
},
},
},
// Spread the standard responses first, then override 401 with the
// share-specific blunt-reject description.
...stdResponses,
"401": {
description: "Invalid passphrase (blunt — no failure detail leaked).",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/fhir/metadata": {
get: {
tags: ["FHIR"],
@@ -64,3 +64,16 @@ export const createShareLinkSchema = z
);
export type CreateShareLinkInput = z.infer<typeof createShareLinkSchema>;
/**
* v1.18.7 — public unlock payload. A clinician submits the passphrase second
* factor (grouped or bare form). Bounded so a junk body cannot pin the hasher;
* the route normalises + constant-time compares against the stored hash.
*/
export const unlockShareLinkSchema = z
.object({
passphrase: z.string().trim().min(1).max(64),
})
.strict();
export type UnlockShareLinkInput = z.infer<typeof unlockShareLinkSchema>;
+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,