90 lines
3.8 KiB
TypeScript
90 lines
3.8 KiB
TypeScript
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
|
|
/**
|
|
* Single-purpose access tokens for guest (no linked User) appointments —
|
|
* closes the Phase 4 gap where a guest's only credential was the
|
|
* appointment's own id (see docs/phase-4-summary.md, "Security", and
|
|
* GuestAppointmentAccessToken in schema.prisma). A registered customer's
|
|
* access is still session-based and untouched by this file — see
|
|
* src/features/booking/authorization.ts.
|
|
*
|
|
* Only the SHA-256 hash of the token is ever persisted. The raw token
|
|
* exists solely in this module's return values, for a caller to embed in
|
|
* a link (the booking confirmation page today; a future notification
|
|
* email) and never log or store elsewhere.
|
|
*/
|
|
|
|
const TOKEN_BYTES = 32;
|
|
const VALID_FOR_DAYS_AFTER_APPOINTMENT = 90;
|
|
|
|
function hashToken(rawToken: string): string {
|
|
return createHash("sha256").update(rawToken).digest("hex");
|
|
}
|
|
|
|
/**
|
|
* Issues this appointment's guest access token, generating one if none
|
|
* exists or overwriting the previous hash if one does — deliberately a
|
|
* single row per appointment (see the model's `appointmentId @unique`),
|
|
* so re-issuing invalidates any earlier copy of the raw token rather than
|
|
* leaving multiple valid links outstanding. Reschedule/cancel do not
|
|
* currently re-issue — the same link a guest already has keeps working
|
|
* across those, which is the more useful default until Phase 7's
|
|
* notification emails (not yet built) give a concrete reason to rotate on
|
|
* a specific event.
|
|
*/
|
|
export async function issueGuestAccessToken(
|
|
prisma: PrismaClient,
|
|
params: { appointmentId: string; appointmentEndAt: Date },
|
|
): Promise<string> {
|
|
const rawToken = randomBytes(TOKEN_BYTES).toString("base64url");
|
|
const expiresAt = new Date(
|
|
params.appointmentEndAt.getTime() + VALID_FOR_DAYS_AFTER_APPOINTMENT * 24 * 60 * 60 * 1000,
|
|
);
|
|
|
|
await prisma.guestAppointmentAccessToken.upsert({
|
|
where: { appointmentId: params.appointmentId },
|
|
create: { appointmentId: params.appointmentId, tokenHash: hashToken(rawToken), expiresAt },
|
|
update: { tokenHash: hashToken(rawToken), expiresAt, revokedAt: null, lastUsedAt: null },
|
|
});
|
|
|
|
return rawToken;
|
|
}
|
|
|
|
/**
|
|
* True only if `rawToken` is the CURRENT token for exactly this
|
|
* appointment, not expired, and not revoked. Compares hashes with
|
|
* `timingSafeEqual` (matching the convention in
|
|
* src/lib/booking-draft/sign.ts) since, unlike a lookup keyed by the hash
|
|
* itself, this comparison happens in application code after fetching the
|
|
* row by appointmentId.
|
|
*/
|
|
export async function validateGuestAccessToken(
|
|
prisma: PrismaClient,
|
|
params: { appointmentId: string; rawToken: string },
|
|
): Promise<boolean> {
|
|
const record = await prisma.guestAppointmentAccessToken.findUnique({
|
|
where: { appointmentId: params.appointmentId },
|
|
});
|
|
if (!record || record.revokedAt || record.expiresAt < new Date()) return false;
|
|
|
|
const provided = Buffer.from(hashToken(params.rawToken), "hex");
|
|
const stored = Buffer.from(record.tokenHash, "hex");
|
|
if (provided.length !== stored.length || !timingSafeEqual(provided, stored)) return false;
|
|
|
|
// Best-effort — a stale lastUsedAt from a lost race isn't a security concern.
|
|
await prisma.guestAppointmentAccessToken
|
|
.update({ where: { appointmentId: params.appointmentId }, data: { lastUsedAt: new Date() } })
|
|
.catch(() => {});
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Immediately invalidates a guest appointment's current access link — e.g. if it was sent to the wrong address. */
|
|
export async function revokeGuestAccessToken(prisma: PrismaClient, appointmentId: string): Promise<void> {
|
|
await prisma.guestAppointmentAccessToken.updateMany({
|
|
where: { appointmentId, revokedAt: null },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
}
|