diff --git a/docs/reference-webauthn.ts b/docs/reference-webauthn.ts deleted file mode 100644 index 8680c80..0000000 --- a/docs/reference-webauthn.ts +++ /dev/null @@ -1,300 +0,0 @@ -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; -};