delete unsued
This commit is contained in:
@@ -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<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",
|
|
||||||
};
|
|
||||||
|
|
||||||
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<void> => {
|
|
||||||
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<boolean> => {
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user