import type { AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, } from "@simplewebauthn/types"; import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { TRPCError } from "@trpc/server"; import { uniq } from "lodash-es"; const KNOWN_AAGUIDS: Record = { "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", }; export const getRPInfo = ( ctx: APIContext, ): { rpName: string; rpID: string; origins: string[]; } => { // RP must always be the frontend URL. const rpID = ctx.origin.includes("oval.ph") ? "oval.ph" : new URL(ctx.origin).hostname; const origins = uniq( ctx.env.ALLOWED_WEBAUTHN_ORIGINS.split(",").map((o) => new URL(o).origin), ); return { rpName: `Oval Business${rpID !== "oval.ph" ? ` (${rpID})` : ""}`, rpID, origins, }; }; export const getUserPasskeys = async (ctx: APIContext, userId: string) => { const userPasskeys = await fetchPasskeyQuery(ctx.db) .where("passkeys.user_id", "=", userId) .execute(); return userPasskeys.map(parsePasskey); }; export const createRegistrationOptions = async ( ctx: ProtectedAPIContext, ): Promise<{ options: PublicKeyCredentialCreationOptionsJSON; challengeId: PublicId<"passkey_challenges">; }> => { const { rpID, rpName } = getRPInfo(ctx); const userPasskeys = await getUserPasskeys(ctx, ctx.user.id); const options: PublicKeyCredentialCreationOptionsJSON = await generateRegistrationOptions({ rpName, rpID, userName: ctx.user.display_name, // Don't prompt users for additional information about the authenticator // (Recommended for smoother UX) attestationType: "direct", // Prevent users from re-registering existing authenticators excludeCredentials: userPasskeys.map((passkey) => ({ id: passkey.credentialId, // Optional transports: passkey.transports ?? undefined, })), // See "Guiding use of authenticators via authenticatorSelection" below authenticatorSelection: { // Defaults residentKey: "preferred", userVerification: "preferred", // Optional authenticatorAttachment: "platform", }, }); const { public_id } = await ctx.db .insertInto("passkey_challenges") .values({ options: JSON.stringify(options), }) .returning("public_id") .executeTakeFirstOrThrow(); return { options, challengeId: public_id, }; }; export const verifyRegistration = async ( ctx: ProtectedAPIContext, { challengeId, response, }: { challengeId: PublicId<"passkey_challenges">; response: RegistrationResponseJSON; }, ): Promise => { const { rpID, origins } = getRPInfo(ctx); const optionsRaw = await ctx.db .selectFrom("passkey_challenges") .where("public_id", "=", challengeId) .select("options") .executeTakeFirst(); if (!optionsRaw) { throw new TRPCError({ code: "TIMEOUT", message: "Registration timed out. Please try again.", }); } const options = optionsRaw.options as unknown as PublicKeyCredentialCreationOptionsJSON; let verification; try { verification = await verifyRegistrationResponse({ response, expectedChallenge: options.challenge, expectedOrigin: origins, expectedRPID: rpID, }); } catch (error) { throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid registration response. Please try again. ${(error as { message?: string }).message ?? ""}`, }); } finally { await ctx.db .deleteFrom("passkey_challenges") .where("public_id", "=", challengeId) .execute(); } const { verified, registrationInfo } = verification; if (!(verified && registrationInfo)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Unable to verify your device.", }); } const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo; const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid]; const insert: PasskeyInsert = { credentialId: credential.id, webAuthnUserId: options.user.id, counter: BigInt(credential.counter), deviceType: credentialDeviceType, backupStatus: credentialBackedUp, transports: response.response.transports ?? null, rpid: rpID, name: `${guidName ?? "Key"} registered at ${formatDateTime(new Date())}`, publicKey: credential.publicKey, }; await ctx.db .insertInto("passkeys") .values( passkeyToInsert(insert, { rawUserId: ctx.user.id, }), ) .execute(); }; export const createAuthenticationOptions = async ( ctx: APIContext, userId: string, ): Promise<{ options: PublicKeyCredentialRequestOptionsJSON; challengeId: PublicId<"passkey_challenges">; }> => { const { rpID } = getRPInfo(ctx); const userPasskeys = await getUserPasskeys(ctx, userId); const options = await generateAuthenticationOptions({ rpID, // Require users to use a previously-registered authenticator allowCredentials: userPasskeys.map((passkey) => ({ id: passkey.credentialId, transports: passkey.transports ?? undefined, })), }); const { public_id: challengeId } = await ctx.db .insertInto("passkey_challenges") .values({ options: JSON.stringify(options), }) .returning("public_id") .executeTakeFirstOrThrow(); return { options, challengeId: challengeId, }; }; export const verifyAuthentication = async ( ctx: APIContext, { userId, challengeId, response, }: { userId: string; challengeId: PublicId<"passkey_challenges">; response: AuthenticationResponseJSON; }, ): Promise => { const { rpID, origins } = getRPInfo(ctx); const optionsRaw = await ctx.db .selectFrom("passkey_challenges") .where("public_id", "=", challengeId) .select("options") .executeTakeFirst(); if (!optionsRaw) { throw new TRPCError({ code: "TIMEOUT", message: "Registration timed out. Please try again.", }); } const options = optionsRaw.options as unknown as PublicKeyCredentialRequestOptionsJSON; try { const userPasskeys = await getUserPasskeys(ctx, userId); const passkey = userPasskeys.find( (passkey) => passkey.credentialId === response.id, ); if (!passkey) { throw new TRPCError({ code: "BAD_REQUEST", message: "Unknown passkey.", }); } const verification = await verifyAuthenticationResponse({ response, expectedChallenge: options.challenge, expectedOrigin: origins, expectedRPID: rpID, credential: { id: passkey.credentialId, publicKey: passkey.publicKey, counter: Number.parseInt(passkey.counter.toString(), 10), transports: passkey.transports ?? undefined, }, }); if (!verification.verified) { return false; } await ctx.db .updateTable("passkeys") .set((eb) => ({ counter: verification.authenticationInfo.newCounter.toString(), last_used_at: eb.fn("NOW"), })) .where("passkeys.id", "=", passkey.id) .execute(); } finally { await ctx.db .deleteFrom("passkey_challenges") .where("public_id", "=", challengeId) .execute(); } return true; };