feat(share-links): public share-token resolver

resolveShareToken hashes a raw hls_ token, looks up the ClinicianShareLink,
enforces not-revoked and not-expired, and returns a ShareContext carrying only
the owner userId plus the frozen scope. It is not an auth primitive: it never
reads a session or sets a cookie, so a share token can authenticate only the
share surface. Unknown / revoked / expired / malformed tokens resolve to null
for a blunt 404. Access counters bump fire-and-forget on a hit.

A token presented on a normal authed route is rejected by construction —
requireAuth reads only the cookie session and Authorization: Bearer (against
ApiToken), never the share header, and an hls_ token has no ApiToken row. The
invariant test locks that boundary.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 01:21:42 +02:00
1 parent ceabf8a54d
commit 9830ae8027
3 files changed
+296

No files matched your search

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