- Add formatError() helper in CLI to safely handle unknown error types - Add uniqueTestId() helper for generating unique test identifiers - Replace String(id) with id.toString() for database ID conversions - Replace String(n) with n.toLocaleString() for user-facing number formatting - Fix TypeScript errors in test files (undefined checks, unused variables) - Update lint commands to include ast-grep scanning Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
336 lines
9.7 KiB
TypeScript
336 lines
9.7 KiB
TypeScript
/**
|
|
* WebAuthn utility functions for passkey registration and authentication
|
|
*/
|
|
|
|
import type { Database } from "@reviq/db-schema";
|
|
import type { VerifiedRegistrationResponse } from "@simplewebauthn/server";
|
|
import type {
|
|
AuthenticationResponseJSON,
|
|
PublicKeyCredentialCreationOptionsJSON,
|
|
PublicKeyCredentialRequestOptionsJSON,
|
|
RegistrationResponseJSON,
|
|
} from "@simplewebauthn/types";
|
|
import type { Kysely } from "kysely";
|
|
import type { ParsedPasskey, PasskeyRow } from "./passkey-helpers.js";
|
|
import {
|
|
generateAuthenticationOptions,
|
|
generateRegistrationOptions,
|
|
verifyAuthenticationResponse,
|
|
verifyRegistrationResponse,
|
|
} from "@simplewebauthn/server";
|
|
import { formatPasskeyDate, parsePasskeyRow } from "./passkey-helpers.js";
|
|
|
|
/**
|
|
* Known authenticator AAGUIDs mapped to friendly names
|
|
*/
|
|
export const KNOWN_AAGUIDS: Record<string, string> = {
|
|
"ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4": "Google Password Manager",
|
|
"adce0002-35bc-c60a-648b-0b25f1f05503": "Chrome on Mac",
|
|
"08987058-cadc-4b81-b6e1-30de50dcbe96": "Windows Hello",
|
|
"9ddd1817-af5a-4672-a2b9-3e3dd95000a9": "Windows Hello",
|
|
"6028b017-b1d4-4c02-b4b3-afcdafc96bb2": "Windows Hello",
|
|
"dd4ec289-e01d-41c9-bb89-70fa845d4bf2": "iCloud Keychain (Managed)",
|
|
"531126d6-e717-415c-9320-3d9aa6981239": "Dashlane",
|
|
"bada5566-a7aa-401f-bd96-45619a55120d": "1Password",
|
|
"b84e4048-15dc-4dd0-8640-f4f60813c8af": "NordPass",
|
|
"0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6": "Keeper",
|
|
"891494da-2c90-4d31-a9cd-4eab0aed1309": "Sésame",
|
|
"f3809540-7f14-49c1-a8b3-8f813b225541": "Enpass",
|
|
"b5397666-4885-aa6b-cebf-e52262a439a2": "Chromium Browser",
|
|
"771b48fd-d3d4-4f74-9232-fc157ab0507a": "Edge on Mac",
|
|
"39a5647e-1853-446c-a1f6-a79bae9f5bc7": "IDmelon",
|
|
"d548826e-79b4-db40-a3d8-11116f7e8349": "Bitwarden",
|
|
"fbfc3007-154e-4ecc-8c0b-6e020557d7bd": "iCloud Keychain",
|
|
"53414d53-554e-4700-0000-000000000000": "Samsung Pass",
|
|
"66a0ccb3-bd6a-191f-ee06-e375c50b9846": "Thales Bio iOS SDK",
|
|
"8836336a-f590-0921-301d-46427531eee6": "Thales Bio Android SDK",
|
|
"cd69adb5-3c7a-deb9-3177-6800ea6cb72a": "Thales PIN Android SDK",
|
|
"17290f1e-c212-34d0-1423-365d729f09d9": "Thales PIN iOS SDK",
|
|
"50726f74-6f6e-5061-7373-50726f746f6e": "Proton Pass",
|
|
"fdb141b2-5d84-443e-8a35-4698c205a502": "KeePassXC",
|
|
"cc45f64e-52a2-451b-831a-4edd8022a202": "ToothPic Passkey Provider",
|
|
"bfc748bb-3429-4faa-b9f9-7cfa9f3b76d0": "iPasswords",
|
|
"b35a26b2-8f6e-4697-ab1d-d44db4da28c6": "Zoho Vault",
|
|
"b78a0a55-6ef8-d246-a042-ba0f6d55050c": "LastPass",
|
|
"de503f9c-21a4-4f76-b4b7-558eb55c6f89": "Devolutions",
|
|
};
|
|
|
|
/**
|
|
* Relying Party information for WebAuthn
|
|
*/
|
|
export interface RPInfo {
|
|
rpName: string;
|
|
rpID: string;
|
|
origins: string[];
|
|
}
|
|
|
|
/**
|
|
* Get Relying Party information from the origin
|
|
*/
|
|
export const getRPInfo = (
|
|
origin: string,
|
|
allowedOrigins: string[],
|
|
rpName: string,
|
|
): RPInfo => {
|
|
// Extract RP ID (domain only, no port)
|
|
const rpID = new URL(origin).hostname;
|
|
|
|
// Parse allowed origins to their origin format
|
|
const origins = allowedOrigins.map((o) => new URL(o).origin);
|
|
|
|
return {
|
|
rpName,
|
|
rpID,
|
|
origins,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Get all passkeys for a user
|
|
*/
|
|
export const getUserPasskeys = async (
|
|
db: Kysely<Database>,
|
|
userId: number,
|
|
): Promise<ParsedPasskey[]> => {
|
|
const rows = await db
|
|
.selectFrom("passkeys")
|
|
.selectAll()
|
|
.where("user_id", "=", userId)
|
|
.execute();
|
|
|
|
return rows.map((row) => parsePasskeyRow(row as unknown as PasskeyRow));
|
|
};
|
|
|
|
/**
|
|
* Create registration options for a new passkey
|
|
*/
|
|
export const createRegistrationOptions = async (
|
|
db: Kysely<Database>,
|
|
rpInfo: RPInfo,
|
|
user: { id?: number; email: string; displayName?: string | null },
|
|
): Promise<{
|
|
options: PublicKeyCredentialCreationOptionsJSON;
|
|
challengeId: number;
|
|
}> => {
|
|
// Get existing passkeys to exclude (if user exists)
|
|
const existingPasskeys = user.id ? await getUserPasskeys(db, user.id) : [];
|
|
|
|
const options = await generateRegistrationOptions({
|
|
rpName: rpInfo.rpName,
|
|
rpID: rpInfo.rpID,
|
|
userName: user.displayName ?? user.email,
|
|
// Don't prompt users for additional information about the authenticator
|
|
attestationType: "direct",
|
|
// Prevent users from re-registering existing authenticators
|
|
excludeCredentials: existingPasskeys.map((passkey) => ({
|
|
id: passkey.credentialId,
|
|
transports: passkey.transports ?? undefined,
|
|
})),
|
|
authenticatorSelection: {
|
|
residentKey: "preferred",
|
|
userVerification: "preferred",
|
|
authenticatorAttachment: "platform",
|
|
},
|
|
});
|
|
|
|
// Store challenge in database
|
|
const { id: challengeId } = await db
|
|
.insertInto("webauthn_challenges")
|
|
.values({
|
|
options: JSON.stringify(options),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return {
|
|
options,
|
|
challengeId: Number(challengeId),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Verify a registration response and store the passkey
|
|
*/
|
|
export const verifyRegistration = async (
|
|
db: Kysely<Database>,
|
|
rpInfo: RPInfo,
|
|
userId: number,
|
|
challengeId: number,
|
|
response: RegistrationResponseJSON,
|
|
): Promise<{ passkeyId: number }> => {
|
|
// Fetch the challenge
|
|
const challengeRow = await db
|
|
.selectFrom("webauthn_challenges")
|
|
.select("options")
|
|
.where("id", "=", challengeId.toString())
|
|
.executeTakeFirst();
|
|
|
|
if (!challengeRow) {
|
|
throw new Error("Registration timed out. Please try again.");
|
|
}
|
|
|
|
const options =
|
|
challengeRow.options as unknown as PublicKeyCredentialCreationOptionsJSON;
|
|
|
|
let verification: VerifiedRegistrationResponse;
|
|
try {
|
|
verification = await verifyRegistrationResponse({
|
|
response,
|
|
expectedChallenge: options.challenge,
|
|
expectedOrigin: rpInfo.origins,
|
|
expectedRPID: rpInfo.rpID,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
throw new Error(
|
|
`Invalid registration response. Please try again. ${message}`,
|
|
);
|
|
} finally {
|
|
// Always delete the challenge
|
|
await db
|
|
.deleteFrom("webauthn_challenges")
|
|
.where("id", "=", challengeId.toString())
|
|
.execute();
|
|
}
|
|
|
|
const { verified, registrationInfo } = verification;
|
|
if (!verified) {
|
|
throw new Error("Unable to verify your device.");
|
|
}
|
|
|
|
const { credential, credentialDeviceType, credentialBackedUp } =
|
|
registrationInfo;
|
|
|
|
// Get friendly name from AAGUID
|
|
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
|
const passKeyName =
|
|
guidName ?? `Key registered at ${formatPasskeyDate(new Date())}`;
|
|
|
|
// Store the passkey
|
|
const { id: passkeyId } = await db
|
|
.insertInto("passkeys")
|
|
.values({
|
|
user_id: userId,
|
|
credential_id: Buffer.from(credential.id, "base64url"),
|
|
public_key: Buffer.from(credential.publicKey),
|
|
webauthn_user_id: options.user.id,
|
|
counter: BigInt(credential.counter),
|
|
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
|
|
backup_eligible: registrationInfo.credentialBackedUp,
|
|
backup_status: credentialBackedUp,
|
|
transports: JSON.stringify(response.response.transports ?? []),
|
|
rpid: rpInfo.rpID,
|
|
name: passKeyName,
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return { passkeyId: Number(passkeyId) };
|
|
};
|
|
|
|
/**
|
|
* Create authentication options for passkey login
|
|
*/
|
|
export const createAuthenticationOptions = async (
|
|
db: Kysely<Database>,
|
|
rpInfo: RPInfo,
|
|
userId: number,
|
|
): Promise<{
|
|
options: PublicKeyCredentialRequestOptionsJSON;
|
|
challengeId: number;
|
|
}> => {
|
|
const userPasskeys = await getUserPasskeys(db, userId);
|
|
|
|
const options = await generateAuthenticationOptions({
|
|
rpID: rpInfo.rpID,
|
|
allowCredentials: userPasskeys.map((passkey) => ({
|
|
id: passkey.credentialId,
|
|
transports: passkey.transports ?? undefined,
|
|
})),
|
|
});
|
|
|
|
// Store challenge in database
|
|
const { id: challengeId } = await db
|
|
.insertInto("webauthn_challenges")
|
|
.values({
|
|
options: JSON.stringify(options),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return {
|
|
options,
|
|
challengeId: Number(challengeId),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Verify an authentication response
|
|
*/
|
|
export const verifyAuthentication = async (
|
|
db: Kysely<Database>,
|
|
rpInfo: RPInfo,
|
|
userId: number,
|
|
challengeId: number,
|
|
response: AuthenticationResponseJSON,
|
|
): Promise<boolean> => {
|
|
// Fetch the challenge
|
|
const challengeRow = await db
|
|
.selectFrom("webauthn_challenges")
|
|
.select("options")
|
|
.where("id", "=", challengeId.toString())
|
|
.executeTakeFirst();
|
|
|
|
if (!challengeRow) {
|
|
throw new Error("Authentication timed out. Please try again.");
|
|
}
|
|
|
|
const options =
|
|
challengeRow.options as unknown as PublicKeyCredentialRequestOptionsJSON;
|
|
|
|
try {
|
|
const userPasskeys = await getUserPasskeys(db, userId);
|
|
const passkey = userPasskeys.find((p) => p.credentialId === response.id);
|
|
|
|
if (!passkey) {
|
|
throw new Error("Unknown passkey.");
|
|
}
|
|
|
|
const verification = await verifyAuthenticationResponse({
|
|
response,
|
|
expectedChallenge: options.challenge,
|
|
expectedOrigin: rpInfo.origins,
|
|
expectedRPID: rpInfo.rpID,
|
|
credential: {
|
|
id: passkey.credentialId,
|
|
// Cast to expected type - the Uint8Array is compatible
|
|
publicKey: passkey.publicKey as Uint8Array<ArrayBuffer>,
|
|
counter: passkey.counter,
|
|
transports: passkey.transports ?? undefined,
|
|
},
|
|
});
|
|
|
|
if (!verification.verified) {
|
|
return false;
|
|
}
|
|
|
|
// Update passkey counter and last_used_at
|
|
await db
|
|
.updateTable("passkeys")
|
|
.set({
|
|
counter: verification.authenticationInfo.newCounter.toString(),
|
|
last_used_at: new Date(),
|
|
})
|
|
.where("id", "=", passkey.id.toString())
|
|
.execute();
|
|
|
|
return true;
|
|
} finally {
|
|
// Always delete the challenge
|
|
await db
|
|
.deleteFrom("webauthn_challenges")
|
|
.where("id", "=", challengeId.toString())
|
|
.execute();
|
|
}
|
|
};
|