Files
HealthLog/src/lib/auth/passkey.ts
T
Marc-André Bombeck 38309932f3 Initial release v1.0.0
HealthLog — personal health-tracking PWA with weight, blood pressure,
pulse, mood tracking, medication compliance, Withings integration,
doctor report PDF export, and Dracula-themed UI.
2026-03-10 20:14:33 +01:00

223 lines
5.8 KiB
TypeScript

import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
type VerifiedRegistrationResponse,
type VerifiedAuthenticationResponse,
} from "@simplewebauthn/server";
import { prisma } from "@/lib/db";
type Transport =
| "ble"
| "cable"
| "hybrid"
| "internal"
| "nfc"
| "smart-card"
| "usb";
const rpName = "HealthLog";
async function cleanupExpiredChallenges() {
await prisma.authChallenge.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
}
function getConfiguredOrigins(): string[] {
const candidates = [
process.env.APP_URL,
process.env.NEXT_PUBLIC_APP_URL,
"http://localhost:3000",
]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
const validOrigins = candidates
.map((value) => {
try {
return new URL(value).origin;
} catch {
return null;
}
})
.filter((value): value is string => Boolean(value));
return Array.from(new Set(validOrigins));
}
function getRpId(): string {
const origins = getConfiguredOrigins();
return new URL(origins[0]).hostname;
}
function getExpectedOrigin(): string | string[] {
const origins = getConfiguredOrigins();
return origins.length === 1 ? origins[0] : origins;
}
// ── Registration ─────────────────────────────────────────
export async function createRegistrationOptions(
userId: string,
username: string,
) {
await cleanupExpiredChallenges();
const existingPasskeys = await prisma.passkey.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options = await generateRegistrationOptions({
rpName,
rpID: getRpId(),
userName: username,
attestationType: "none",
excludeCredentials: existingPasskeys.map((pk) => ({
id: pk.credentialId,
transports: pk.transports as Transport[],
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
// Store challenge with 5-min TTL
const challenge = await prisma.authChallenge.create({
data: {
userId,
challenge: options.challenge,
type: "registration",
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
},
});
return { options, challengeId: challenge.id };
}
export async function verifyRegistration(
challengeId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
response: any,
): Promise<VerifiedRegistrationResponse> {
const challenge = await prisma.authChallenge.findUnique({
where: { id: challengeId },
});
if (!challenge || challenge.expiresAt < new Date()) {
throw new Error("Challenge expired or not found");
}
try {
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
});
return verification;
} finally {
// Invalidate challenge after first verification attempt (success or failure)
await prisma.authChallenge
.delete({ where: { id: challengeId } })
.catch(() => {});
}
}
// ── Authentication ───────────────────────────────────────
export async function createAuthenticationOptions(userId?: string) {
await cleanupExpiredChallenges();
let allowCredentials: { id: string; transports?: Transport[] }[] | undefined;
if (userId) {
const passkeys = await prisma.passkey.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
allowCredentials = passkeys.map((pk) => ({
id: pk.credentialId,
transports: pk.transports as Transport[],
}));
}
const options = await generateAuthenticationOptions({
rpID: getRpId(),
userVerification: "preferred",
allowCredentials,
});
const challenge = await prisma.authChallenge.create({
data: {
userId: userId ?? null,
challenge: options.challenge,
type: "authentication",
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
},
});
return { options, challengeId: challenge.id };
}
export async function verifyAuthentication(
challengeId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
response: any,
): Promise<{
verification: VerifiedAuthenticationResponse;
passkey: { userId: string };
}> {
const challenge = await prisma.authChallenge.findUnique({
where: { id: challengeId },
});
if (!challenge || challenge.expiresAt < new Date()) {
throw new Error("Challenge expired or not found");
}
// Find the passkey by credential ID
const credentialId = response.id;
const passkey = await prisma.passkey.findUnique({
where: { credentialId },
});
if (!passkey) {
throw new Error("Passkey not found");
}
try {
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
credential: {
id: passkey.credentialId,
publicKey: passkey.credentialPublicKey,
counter: Number(passkey.counter),
transports: passkey.transports as Transport[],
},
});
if (verification.verified) {
// Update counter
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
});
}
return { verification, passkey: { userId: passkey.userId } };
} finally {
// Invalidate challenge after first verification attempt (success or failure)
await prisma.authChallenge
.delete({ where: { id: challengeId } })
.catch(() => {});
}
}