mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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_/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user