mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The passkey sign-in, security-key sign-in and security-key registration verifiers read the challenge, verified the response, and deleted the challenge afterwards. Two requests carrying the same assertion could both read it before either deleted it, and both be accepted. A challenge is now claimed with one guarded DELETE ... RETURNING that also checks its ceremony, its account and its expiry, before anything is verified; a concurrent second request finds nothing. A passkey sign-in also refuses a challenge that was begun for a different account than the passkey belongs to.
379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
import {
|
|
generateRegistrationOptions,
|
|
verifyRegistrationResponse,
|
|
generateAuthenticationOptions,
|
|
verifyAuthenticationResponse,
|
|
type AuthenticationResponseJSON,
|
|
type RegistrationResponseJSON,
|
|
type VerifiedRegistrationResponse,
|
|
type VerifiedAuthenticationResponse,
|
|
} from "@simplewebauthn/server";
|
|
import { z } from "zod/v4";
|
|
import { prisma } from "@/lib/db";
|
|
import {
|
|
RP_NAME as rpName,
|
|
getRpId,
|
|
getExpectedOrigin,
|
|
REGISTRATION_ACCEPTED_ALGORITHM_IDS,
|
|
REGISTRATION_OFFERED_ALGORITHM_IDS,
|
|
} from "@/lib/auth/webauthn-rp";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
|
|
// v1.4.43 W13 L-3 — explicit Zod narrowing in front of
|
|
// `verifyAuthentication`. Replaces the raw
|
|
// `as AuthenticationResponseJSON` cast; the SimpleWebAuthn verifier
|
|
// still owns the cryptographic validation downstream, but a
|
|
// malformed body now fails fast at the boundary with a structured
|
|
// error rather than crashing on a follow-up `.id` deref.
|
|
//
|
|
// Shape mirrors SimpleWebAuthn's `AuthenticationResponseJSON`:
|
|
// https://w3c.github.io/webauthn/#dictdef-authenticationresponsejson
|
|
// The schema is intentionally permissive (`passthrough` + optionals)
|
|
// so future authenticator-attachment / extension-result additions
|
|
// don't break the boundary; the strict checks remain inside
|
|
// `verifyAuthenticationResponse`.
|
|
const authenticationResponseSchema = z
|
|
.object({
|
|
id: z.string().min(1),
|
|
rawId: z.string().min(1),
|
|
response: z
|
|
.object({
|
|
clientDataJSON: z.string().min(1),
|
|
authenticatorData: z.string().min(1),
|
|
signature: z.string().min(1),
|
|
userHandle: z.string().optional(),
|
|
})
|
|
.loose(),
|
|
authenticatorAttachment: z.string().optional(),
|
|
clientExtensionResults: z.unknown().optional(),
|
|
type: z.literal("public-key"),
|
|
})
|
|
.loose();
|
|
|
|
// v1.4.43 W10 senior-dev L-1 — symmetric Zod narrowing in front of
|
|
// `verifyRegistration`. SimpleWebAuthn's `RegistrationResponseJSON`
|
|
// shape: https://w3c.github.io/webauthn/#dictdef-registrationresponsejson
|
|
// `attestationObject` replaces `authenticatorData + signature` from
|
|
// the authentication shape; everything else mirrors the auth schema.
|
|
const registrationResponseSchema = z
|
|
.object({
|
|
id: z.string().min(1),
|
|
rawId: z.string().min(1),
|
|
response: z
|
|
.object({
|
|
clientDataJSON: z.string().min(1),
|
|
attestationObject: z.string().min(1),
|
|
transports: z.array(z.string()).optional(),
|
|
publicKeyAlgorithm: z.number().optional(),
|
|
publicKey: z.string().optional(),
|
|
authenticatorData: z.string().optional(),
|
|
})
|
|
.loose(),
|
|
authenticatorAttachment: z.string().optional(),
|
|
clientExtensionResults: z.unknown().optional(),
|
|
type: z.literal("public-key"),
|
|
})
|
|
.loose();
|
|
|
|
type Transport =
|
|
"ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb";
|
|
|
|
const PASSKEY_REGISTRATION_CHALLENGE_PREFIX = "passkey-registration:v1:";
|
|
|
|
function registrationChallengeType(sessionId: string): string {
|
|
return `${PASSKEY_REGISTRATION_CHALLENGE_PREFIX}${hashToken(sessionId)}`;
|
|
}
|
|
|
|
// The relying-party config (`rpName` / `getRpId` / `getExpectedOrigin`) is
|
|
// shared with the second-factor security-key path via `@/lib/auth/webauthn-rp`
|
|
// so both ceremonies bind credentials to the identical RP.
|
|
|
|
async function cleanupExpiredChallenges() {
|
|
await prisma.authChallenge.deleteMany({
|
|
where: { expiresAt: { lt: new Date() } },
|
|
});
|
|
}
|
|
|
|
// ── Registration ─────────────────────────────────────────
|
|
|
|
export async function createRegistrationOptions(
|
|
userId: string,
|
|
username: string,
|
|
sessionId: 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",
|
|
supportedAlgorithmIDs: REGISTRATION_OFFERED_ALGORITHM_IDS,
|
|
excludeCredentials: existingPasskeys.map((pk) => ({
|
|
id: pk.credentialId,
|
|
transports: pk.transports as Transport[],
|
|
})),
|
|
authenticatorSelection: {
|
|
residentKey: "preferred",
|
|
// `required` (vs `preferred`) instructs the authenticator that
|
|
// a UV check (biometric / PIN / fingerprint) is mandatory for
|
|
// every registration ceremony. A `preferred` posture lets a
|
|
// device that lacks a UV factor silently downgrade — and the
|
|
// resulting passkey carries the same `userVerified: false` flag
|
|
// forever, blocking any future AAL3-style assertion against it.
|
|
userVerification: "required",
|
|
},
|
|
});
|
|
|
|
// Store challenge with 5-min TTL
|
|
const challenge = await prisma.authChallenge.create({
|
|
data: {
|
|
userId,
|
|
challenge: options.challenge,
|
|
type: registrationChallengeType(sessionId),
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
},
|
|
});
|
|
|
|
return { options, challengeId: challenge.id };
|
|
}
|
|
|
|
export type RegistrationChallengeClaim =
|
|
| { ok: true; expectedChallenge: string; createdAt: Date }
|
|
| { ok: false; reason: "invalid" | "wrong_context" };
|
|
|
|
/**
|
|
* Claim a registration challenge for exactly one cookie session.
|
|
*
|
|
* The preliminary lookup is used only to classify a foreign account/session
|
|
* without consuming its challenge. The guarded DELETE is the actual claim:
|
|
* concurrent requests cannot both receive the challenge material.
|
|
*/
|
|
export async function consumeRegistrationChallenge(input: {
|
|
challengeId: string;
|
|
userId: string;
|
|
sessionId: string;
|
|
}): Promise<RegistrationChallengeClaim> {
|
|
const row = await prisma.authChallenge.findUnique({
|
|
where: { id: input.challengeId },
|
|
select: { userId: true, type: true, expiresAt: true },
|
|
});
|
|
|
|
if (!row) return { ok: false, reason: "invalid" };
|
|
|
|
const expectedType = registrationChallengeType(input.sessionId);
|
|
if (
|
|
row.userId !== input.userId ||
|
|
(row.type.startsWith(PASSKEY_REGISTRATION_CHALLENGE_PREFIX) &&
|
|
row.type !== expectedType)
|
|
) {
|
|
return { ok: false, reason: "wrong_context" };
|
|
}
|
|
|
|
if (row.type !== expectedType || row.expiresAt <= new Date()) {
|
|
return { ok: false, reason: "invalid" };
|
|
}
|
|
|
|
const claimed = await prisma.$queryRaw<
|
|
Array<{ challenge: string; created_at: Date }>
|
|
>`
|
|
DELETE FROM auth_challenges
|
|
WHERE id = ${input.challengeId}
|
|
AND user_id = ${input.userId}
|
|
AND type = ${expectedType}
|
|
AND expires_at > NOW()
|
|
RETURNING challenge, created_at
|
|
`;
|
|
|
|
if (claimed.length !== 1) {
|
|
return { ok: false, reason: "invalid" };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
expectedChallenge: claimed[0].challenge,
|
|
createdAt: claimed[0].created_at,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Claim a WebAuthn challenge: delete it and return its material in one
|
|
* statement, or return null when it is unknown, expired, of another ceremony,
|
|
* or (when `userId` is given) minted for another account.
|
|
*
|
|
* Every ceremony verifier goes through here. They used to read the challenge,
|
|
* verify, and delete it in a `finally`, so two requests carrying the same
|
|
* assertion could both read it before either deleted it and both be
|
|
* accepted. The guarded DELETE ... RETURNING gives exactly one of them the
|
|
* challenge; the other finds nothing.
|
|
*/
|
|
export async function claimAuthChallenge(input: {
|
|
challengeId: string;
|
|
type: string;
|
|
userId?: string;
|
|
}): Promise<{ challenge: string; userId: string | null } | null> {
|
|
const rows = input.userId
|
|
? await prisma.$queryRaw<
|
|
Array<{ challenge: string; user_id: string | null }>
|
|
>`
|
|
DELETE FROM auth_challenges
|
|
WHERE id = ${input.challengeId}
|
|
AND type = ${input.type}
|
|
AND user_id = ${input.userId}
|
|
AND expires_at > NOW()
|
|
RETURNING challenge, user_id
|
|
`
|
|
: await prisma.$queryRaw<
|
|
Array<{ challenge: string; user_id: string | null }>
|
|
>`
|
|
DELETE FROM auth_challenges
|
|
WHERE id = ${input.challengeId}
|
|
AND type = ${input.type}
|
|
AND expires_at > NOW()
|
|
RETURNING challenge, user_id
|
|
`;
|
|
if (rows.length !== 1) return null;
|
|
return { challenge: rows[0].challenge, userId: rows[0].user_id };
|
|
}
|
|
|
|
export async function verifyRegistration(
|
|
expectedChallenge: string,
|
|
response: unknown,
|
|
): Promise<VerifiedRegistrationResponse> {
|
|
// v1.4.43 W10 senior-dev L-1 — Zod narrow at the boundary before
|
|
// delegating to SimpleWebAuthn's full cryptographic validation.
|
|
// A malformed body now fails fast with a structured Zod error
|
|
// rather than crashing on a follow-up `.id` deref deeper in the
|
|
// verifier. Mirrors the v1.4.43 W13 L-3 narrowing on the
|
|
// authentication side.
|
|
const parsed = registrationResponseSchema.safeParse(response);
|
|
if (!parsed.success) {
|
|
throw new Error("Registration response shape invalid");
|
|
}
|
|
return verifyRegistrationResponse({
|
|
response: parsed.data as RegistrationResponseJSON,
|
|
expectedChallenge,
|
|
expectedOrigin: getExpectedOrigin(),
|
|
expectedRPID: getRpId(),
|
|
supportedAlgorithmIDs: REGISTRATION_ACCEPTED_ALGORITHM_IDS,
|
|
});
|
|
}
|
|
|
|
// ── 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(),
|
|
// Match the registration posture — see `createRegistrationOptions`
|
|
// above for the rationale. Refusing to honour a `userVerified:
|
|
// false` assertion keeps the AAL3 floor that the registration
|
|
// policy now enforces.
|
|
userVerification: "required",
|
|
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,
|
|
response: unknown,
|
|
): Promise<{
|
|
verification: VerifiedAuthenticationResponse;
|
|
passkey: { userId: string };
|
|
}> {
|
|
// Claimed before anything else: a challenge is good for one verification,
|
|
// successful or not, and a concurrent second request finds nothing.
|
|
const challenge = await claimAuthChallenge({
|
|
challengeId,
|
|
type: "authentication",
|
|
});
|
|
if (!challenge) {
|
|
throw new Error("Challenge expired or not found");
|
|
}
|
|
|
|
// v1.4.43 W13 L-3 — explicit Zod narrowing instead of the previous
|
|
// raw `as AuthenticationResponseJSON` cast. A malformed body now
|
|
// throws here with a structured error rather than crashing on a
|
|
// follow-up `.id` deref. The SimpleWebAuthn verifier downstream
|
|
// still owns cryptographic validation; this just closes the
|
|
// type-narrowing gap a future refactor could trip on.
|
|
const parsed = authenticationResponseSchema.safeParse(response);
|
|
if (!parsed.success) {
|
|
throw new Error("Malformed passkey authentication response");
|
|
}
|
|
const typedResponse = parsed.data as unknown as AuthenticationResponseJSON;
|
|
|
|
// Find the passkey by credential ID
|
|
const credentialId = typedResponse.id;
|
|
const passkey = await prisma.passkey.findUnique({
|
|
where: { credentialId },
|
|
});
|
|
|
|
if (!passkey) {
|
|
throw new Error("Passkey not found");
|
|
}
|
|
|
|
// A challenge begun for one account (the re-proof arm names the account)
|
|
// is answered only by that account's passkey.
|
|
if (challenge.userId !== null && challenge.userId !== passkey.userId) {
|
|
throw new Error("Challenge expired or not found");
|
|
}
|
|
|
|
const verification = await verifyAuthenticationResponse({
|
|
response: typedResponse,
|
|
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 + stamp last-used so the management UI can surface
|
|
// when each passkey was last exercised (v1.23 passkey QoL).
|
|
await prisma.passkey.update({
|
|
where: { id: passkey.id },
|
|
data: {
|
|
counter: BigInt(verification.authenticationInfo.newCounter),
|
|
lastUsedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
return { verification, passkey: { userId: passkey.userId } };
|
|
}
|