mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Encode the admin invite QR as a clean /invite/<hlv_token> universal-link that the iOS app intercepts via the AASA wildcard matcher. Add the matching web route as a thin, shape-validated redirect onto the existing register flow, and open the path on the public allowlist so an unauthenticated scanner reaches it. A malformed token segment is indistinguishable from no invite — no database hit, no enumeration oracle. The token is never logged.
168 lines
5.5 KiB
TypeScript
168 lines
5.5 KiB
TypeScript
/**
|
|
* v1.15.20 — registration invite tokens.
|
|
*
|
|
* When the operator has disabled open registration
|
|
* (`AppSettings.registrationEnabled = false`), an admin can mint an
|
|
* invite link that still admits a signup. The raw token (`hlv_<hex>`)
|
|
* is shown exactly once at mint time; only its HMAC-SHA256 hash under
|
|
* `API_TOKEN_HMAC_KEY` is persisted — the exact `ApiToken` /
|
|
* `ClinicianShareLink` scheme, so a database leak never yields a
|
|
* usable invite. The keyed hash also makes the `tokenHash` unique-index
|
|
* lookup the timing-safe comparison: an attacker without the HMAC key
|
|
* cannot construct hash preimages to probe byte-by-byte.
|
|
*
|
|
* Consumption is an atomic guarded increment (`uses < maxUses` inside
|
|
* the UPDATE's WHERE) so two concurrent signups can never overshoot
|
|
* the budget.
|
|
*/
|
|
import { randomBytes } from "node:crypto";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
|
|
export { INVITE_MAX_TTL_DAYS } from "@/lib/validations/invite";
|
|
|
|
/** Raw-token prefix — distinct from `hlk_` (API) and `hlr_` (refresh). */
|
|
export const INVITE_TOKEN_PREFIX = "hlv_";
|
|
|
|
/** Generate a fresh raw invite token. 32 random bytes → 64 hex chars. */
|
|
export function generateInviteToken(): string {
|
|
return `${INVITE_TOKEN_PREFIX}${randomBytes(32).toString("hex")}`;
|
|
}
|
|
|
|
/** Cheap shape gate before paying the HMAC + DB lookup. */
|
|
export function looksLikeInviteToken(value: string): boolean {
|
|
return /^hlv_[0-9a-f]{64}$/.test(value);
|
|
}
|
|
|
|
/**
|
|
* Compose the invite universal-link for a freshly minted invite.
|
|
*
|
|
* The URL is `https://<host>/invite/<hlv_token>` — a clean path the iOS
|
|
* scene delegate intercepts via the AASA `["*"]` matcher (issue #16): an
|
|
* installed app opens straight onto onboarding registration with the
|
|
* token prefilled; a browser hits the `/invite/<token>` web route, which
|
|
* is a thin redirect onto `/auth/register?invite=<token>`. The token is
|
|
* `hlv_` + 64 hex, so it is already path-safe; `encodeURIComponent`
|
|
* stays as a defensive belt against any future prefix change.
|
|
*
|
|
* Origin precedence mirrors the passkey RP-origin resolution: the
|
|
* operator-configured `APP_URL` / `NEXT_PUBLIC_APP_URL` win over the
|
|
* request origin (which may be an internal hostname behind the proxy).
|
|
*/
|
|
export function buildInviteUrl(rawToken: string, requestUrl: string): string {
|
|
const candidates = [
|
|
process.env.APP_URL,
|
|
process.env.NEXT_PUBLIC_APP_URL,
|
|
requestUrl,
|
|
]
|
|
.map((value) => value?.trim())
|
|
.filter((value): value is string => Boolean(value));
|
|
|
|
let origin = "http://localhost:3000";
|
|
for (const candidate of candidates) {
|
|
try {
|
|
origin = new URL(candidate).origin;
|
|
break;
|
|
} catch {
|
|
// try the next candidate
|
|
}
|
|
}
|
|
return `${origin}/invite/${encodeURIComponent(rawToken)}`;
|
|
}
|
|
|
|
export type InviteConsumeResult =
|
|
| { ok: true; inviteId: string }
|
|
| {
|
|
ok: false;
|
|
reason: "not_found" | "expired" | "exhausted" | "revoked";
|
|
};
|
|
|
|
/**
|
|
* Validate + consume one use of an invite token, atomically. Returns the
|
|
* invite id on success so the caller can stamp `usedBy` once the new
|
|
* account exists. The guarded `updateMany` is the only mutation path —
|
|
* `uses < maxUses` inside the WHERE makes the increment race-safe.
|
|
*/
|
|
export async function consumeInviteToken(
|
|
rawToken: string,
|
|
): Promise<InviteConsumeResult> {
|
|
if (!looksLikeInviteToken(rawToken)) {
|
|
return { ok: false, reason: "not_found" };
|
|
}
|
|
|
|
const tokenHash = hashToken(rawToken);
|
|
const invite = await prisma.inviteToken.findUnique({
|
|
where: { tokenHash },
|
|
select: {
|
|
id: true,
|
|
expiresAt: true,
|
|
uses: true,
|
|
maxUses: true,
|
|
revokedAt: true,
|
|
},
|
|
});
|
|
if (!invite) return { ok: false, reason: "not_found" };
|
|
|
|
const now = new Date();
|
|
// v1.16.0 — a revoked invite is refused like an expired one; the row
|
|
// stays so the admin table keeps its redemption history visible.
|
|
if (invite.revokedAt !== null) {
|
|
return { ok: false, reason: "revoked" };
|
|
}
|
|
if (invite.expiresAt.getTime() <= now.getTime()) {
|
|
return { ok: false, reason: "expired" };
|
|
}
|
|
if (invite.uses >= invite.maxUses) {
|
|
return { ok: false, reason: "exhausted" };
|
|
}
|
|
|
|
const { count } = await prisma.inviteToken.updateMany({
|
|
where: {
|
|
id: invite.id,
|
|
expiresAt: { gt: now },
|
|
revokedAt: null,
|
|
// Guarded increment — Postgres evaluates the predicate and the
|
|
// UPDATE atomically, so a concurrent consumer of the last use
|
|
// makes this a no-op instead of an overshoot.
|
|
uses: { lt: invite.maxUses },
|
|
},
|
|
data: {
|
|
uses: { increment: 1 },
|
|
usedAt: now,
|
|
},
|
|
});
|
|
if (count === 0) {
|
|
return { ok: false, reason: "exhausted" };
|
|
}
|
|
|
|
return { ok: true, inviteId: invite.id };
|
|
}
|
|
|
|
/**
|
|
* Stamp the consumer account onto the invite after the user row exists
|
|
* AND append a row to the per-signup redemption ledger (v1.16.0 —
|
|
* `usedBy` only ever holds the LAST consumer; the ledger holds them
|
|
* all, so multi-use invites keep their full history). Best-effort; a
|
|
* failure here never unwinds the registration.
|
|
*/
|
|
export async function recordInviteConsumer(
|
|
inviteId: string,
|
|
userId: string,
|
|
): Promise<void> {
|
|
try {
|
|
await prisma.$transaction([
|
|
prisma.inviteToken.update({
|
|
where: { id: inviteId },
|
|
data: { usedBy: userId },
|
|
}),
|
|
prisma.inviteRedemption.create({
|
|
data: { inviteId, userId },
|
|
}),
|
|
]);
|
|
} catch {
|
|
// Informational rows only — the use itself is already counted by
|
|
// the guarded increment in `consumeInviteToken`.
|
|
}
|
|
}
|