mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user