Implement WebAuthn passkey authentication
Add complete WebAuthn support for passkey registration and authentication: - Install @simplewebauthn/server for WebAuthn utilities - Create passkey-helpers.ts with base64url/Uint8Array conversion utilities - Create webauthn.ts with registration/authentication option generation and verification - Create context.ts with API context types - Implement all WebAuthn router handlers (createRegistrationOptions, verifyRegistration, createAuthenticationOptions, verifyAuthentication) - Implement passkey management handlers (listPasskeys, createPasskey, renamePasskey, deletePasskey) - Add WebAuthn configuration constants and environment variables Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,11 @@
|
||||
"dependencies": {
|
||||
"@orpc/server": "^1.13.2",
|
||||
"@reviq/api-contract": "workspace:*",
|
||||
"@reviq/db": "workspace:*"
|
||||
"@reviq/db": "workspace:*",
|
||||
"@reviq/db-schema": "workspace:*",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@simplewebauthn/types": "^12.0.0",
|
||||
"kysely": "^0.28.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@macalinao/eslint-config": "catalog:",
|
||||
|
||||
@@ -1,4 +1,29 @@
|
||||
/**
|
||||
* Default port for the API server
|
||||
* API Server constants
|
||||
*/
|
||||
|
||||
/** Default port for the API server */
|
||||
export const DEFAULT_PORT = 9861;
|
||||
|
||||
/** Default Relying Party name for WebAuthn */
|
||||
export const DEFAULT_RP_NAME = "Reviq Publisher Dashboard";
|
||||
|
||||
/** WebAuthn challenge expiry in milliseconds (5 minutes) */
|
||||
export const WEBAUTHN_CHALLENGE_EXPIRY_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Get allowed WebAuthn origins from environment or defaults
|
||||
*/
|
||||
export const getAllowedOrigins = (): string[] => {
|
||||
const envOrigins = Bun.env.ALLOWED_WEBAUTHN_ORIGINS;
|
||||
if (envOrigins) {
|
||||
return envOrigins.split(",").map((o) => o.trim());
|
||||
}
|
||||
|
||||
// Default to localhost origins for development
|
||||
return [
|
||||
`http://localhost:${String(DEFAULT_PORT)}`,
|
||||
"http://localhost:6827",
|
||||
"http://localhost:6828",
|
||||
];
|
||||
};
|
||||
|
||||
60
apps/api-server/src/context.ts
Normal file
60
apps/api-server/src/context.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* API context types for oRPC handlers
|
||||
*/
|
||||
|
||||
import type { Database } from "@reviq/db-schema";
|
||||
import type { Kysely } from "kysely";
|
||||
|
||||
/**
|
||||
* Base API context available to all handlers
|
||||
*/
|
||||
export interface APIContext {
|
||||
/** Database client */
|
||||
db: Kysely<Database>;
|
||||
/** Request origin (e.g., "http://localhost:6827") */
|
||||
origin: string;
|
||||
/** Allowed WebAuthn origins */
|
||||
allowedOrigins: string[];
|
||||
/** Relying party name for WebAuthn */
|
||||
rpName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User information from the session
|
||||
*/
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
emailVerifiedAt: Date | null;
|
||||
isSuperuser: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session information
|
||||
*/
|
||||
export interface Session {
|
||||
id: number;
|
||||
trustedMode: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated API context for protected handlers
|
||||
*/
|
||||
export interface AuthenticatedContext extends APIContext {
|
||||
/** Current user from session */
|
||||
user: SessionUser;
|
||||
/** Current session */
|
||||
session: Session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login request context (used during login flow)
|
||||
*/
|
||||
export interface LoginRequestContext extends APIContext {
|
||||
/** Login request ID from cookie */
|
||||
loginRequestId: number;
|
||||
/** User associated with the login request */
|
||||
user: SessionUser;
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import type { APIContext } from "./context.js";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { DEFAULT_PORT } from "./constants.js";
|
||||
import { createDb } from "@reviq/db";
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_RP_NAME,
|
||||
getAllowedOrigins,
|
||||
} from "./constants.js";
|
||||
import { router } from "./router.js";
|
||||
|
||||
const db = createDb();
|
||||
const handler = new RPCHandler(router);
|
||||
|
||||
const port = import.meta.env.PORT ?? DEFAULT_PORT;
|
||||
const port = Bun.env.PORT ?? DEFAULT_PORT;
|
||||
const allowedOrigins = getAllowedOrigins();
|
||||
const rpName = Bun.env.RP_NAME ?? DEFAULT_RP_NAME;
|
||||
|
||||
Bun.serve({
|
||||
port,
|
||||
@@ -12,8 +21,20 @@ Bun.serve({
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname.startsWith("/api/v1/rpc")) {
|
||||
// Build context for the request
|
||||
const origin =
|
||||
request.headers.get("origin") ?? `http://localhost:${String(port)}`;
|
||||
|
||||
const context: APIContext = {
|
||||
db,
|
||||
origin,
|
||||
allowedOrigins,
|
||||
rpName,
|
||||
};
|
||||
|
||||
const { response } = await handler.handle(request, {
|
||||
prefix: "/api/v1/rpc",
|
||||
context,
|
||||
});
|
||||
return response ?? new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type {
|
||||
APIContext,
|
||||
AuthenticatedContext,
|
||||
LoginRequestContext,
|
||||
} from "./context.js";
|
||||
import { implement } from "@orpc/server";
|
||||
import { contract } from "@reviq/api-contract";
|
||||
import {
|
||||
createAuthenticationOptions as createAuthOptions,
|
||||
createRegistrationOptions as createRegOptions,
|
||||
getRPInfo,
|
||||
getUserPasskeys,
|
||||
verifyAuthentication as verifyAuth,
|
||||
verifyRegistration as verifyReg,
|
||||
} from "./utils/webauthn.js";
|
||||
|
||||
const os = implement(contract);
|
||||
|
||||
@@ -50,24 +63,56 @@ const logout = os.auth.logout.handler(async () => {
|
||||
|
||||
// WebAuthn procedures
|
||||
const createRegistrationOptions =
|
||||
os.auth.webauthn.createRegistrationOptions.handler(async () => {
|
||||
throw new Error("Not implemented");
|
||||
});
|
||||
os.auth.webauthn.createRegistrationOptions.handler(
|
||||
async ({ input, context }) => {
|
||||
const ctx = context as APIContext;
|
||||
const { email } = input;
|
||||
|
||||
// For signup flow, we don't have a user yet
|
||||
// The user will be created when signup is called with the passkeyInfo
|
||||
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
|
||||
|
||||
const result = await createRegOptions(ctx.db, rpInfo, { email });
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
const verifyRegistration = os.auth.webauthn.verifyRegistration.handler(
|
||||
async () => {
|
||||
throw new Error("Not implemented");
|
||||
async ({ input, context }) => {
|
||||
const ctx = context as AuthenticatedContext;
|
||||
const { challengeId, response } = input;
|
||||
|
||||
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
|
||||
await verifyReg(ctx.db, rpInfo, ctx.user.id, challengeId, response);
|
||||
},
|
||||
);
|
||||
|
||||
const createAuthenticationOptions =
|
||||
os.auth.webauthn.createAuthenticationOptions.handler(async () => {
|
||||
throw new Error("Not implemented");
|
||||
os.auth.webauthn.createAuthenticationOptions.handler(async ({ context }) => {
|
||||
const ctx = context as LoginRequestContext;
|
||||
|
||||
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
|
||||
const result = await createAuthOptions(ctx.db, rpInfo, ctx.user.id);
|
||||
return result;
|
||||
});
|
||||
|
||||
const verifyAuthentication = os.auth.webauthn.verifyAuthentication.handler(
|
||||
async () => {
|
||||
throw new Error("Not implemented");
|
||||
async ({ input, context }) => {
|
||||
const ctx = context as LoginRequestContext;
|
||||
const { challengeId, response } = input;
|
||||
|
||||
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
|
||||
const verified = await verifyAuth(
|
||||
ctx.db,
|
||||
rpInfo,
|
||||
ctx.user.id,
|
||||
challengeId,
|
||||
response,
|
||||
);
|
||||
|
||||
if (!verified) {
|
||||
throw new Error("Authentication failed");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -92,21 +137,80 @@ const setPassword = os.me.setPassword.handler(async () => {
|
||||
throw new Error("Not implemented");
|
||||
});
|
||||
|
||||
const listPasskeys = os.me.listPasskeys.handler(async () => {
|
||||
throw new Error("Not implemented");
|
||||
const listPasskeys = os.me.listPasskeys.handler(async ({ context }) => {
|
||||
const ctx = context as AuthenticatedContext;
|
||||
|
||||
const passkeys = await getUserPasskeys(ctx.db, ctx.user.id);
|
||||
|
||||
return passkeys.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
createdAt: p.createdAt,
|
||||
lastUsedAt: p.lastUsedAt,
|
||||
}));
|
||||
});
|
||||
|
||||
const createPasskey = os.me.createPasskey.handler(async () => {
|
||||
throw new Error("Not implemented");
|
||||
});
|
||||
const createPasskey = os.me.createPasskey.handler(
|
||||
async ({ input, context }) => {
|
||||
const ctx = context as AuthenticatedContext;
|
||||
const { name: _name } = input;
|
||||
|
||||
const renamePasskey = os.me.renamePasskey.handler(async () => {
|
||||
throw new Error("Not implemented");
|
||||
});
|
||||
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
|
||||
const result = await createRegOptions(ctx.db, rpInfo, {
|
||||
id: ctx.user.id,
|
||||
email: ctx.user.email,
|
||||
displayName: ctx.user.displayName,
|
||||
});
|
||||
|
||||
const deletePasskey = os.me.deletePasskey.handler(async () => {
|
||||
throw new Error("Not implemented");
|
||||
});
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
const renamePasskey = os.me.renamePasskey.handler(
|
||||
async ({ input, context }) => {
|
||||
const ctx = context as AuthenticatedContext;
|
||||
const { passkeyId, name } = input;
|
||||
|
||||
await ctx.db
|
||||
.updateTable("passkeys")
|
||||
.set({ name })
|
||||
.where("id", "=", String(passkeyId))
|
||||
.where("user_id", "=", ctx.user.id)
|
||||
.execute();
|
||||
},
|
||||
);
|
||||
|
||||
const deletePasskey = os.me.deletePasskey.handler(
|
||||
async ({ input, context }) => {
|
||||
const ctx = context as AuthenticatedContext;
|
||||
const { passkeyId } = input;
|
||||
|
||||
// Check if this is the last passkey and user has no password
|
||||
const user = await ctx.db
|
||||
.selectFrom("users")
|
||||
.select(["password_hash"])
|
||||
.where("id", "=", ctx.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
const passkeyCount = await ctx.db
|
||||
.selectFrom("passkeys")
|
||||
.select(ctx.db.fn.countAll().as("count"))
|
||||
.where("user_id", "=", ctx.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user?.password_hash && Number(passkeyCount?.count ?? 0) <= 1) {
|
||||
throw new Error(
|
||||
"Cannot delete the last passkey when you have no password set",
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.deleteFrom("passkeys")
|
||||
.where("id", "=", String(passkeyId))
|
||||
.where("user_id", "=", ctx.user.id)
|
||||
.execute();
|
||||
},
|
||||
);
|
||||
|
||||
const listSessions = os.me.listSessions.handler(async () => {
|
||||
throw new Error("Not implemented");
|
||||
|
||||
93
apps/api-server/src/utils/passkey-helpers.ts
Normal file
93
apps/api-server/src/utils/passkey-helpers.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Passkey data helpers for converting between database and WebAuthn formats
|
||||
*/
|
||||
|
||||
import type { AuthenticatorTransportFuture } from "@simplewebauthn/types";
|
||||
|
||||
/**
|
||||
* Convert a base64url string to a Uint8Array for BYTEA storage
|
||||
*/
|
||||
export const base64urlToUint8Array = (base64url: string): Uint8Array => {
|
||||
return Uint8Array.from(Buffer.from(base64url, "base64url"));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a Uint8Array (from BYTEA) to a base64url string
|
||||
*/
|
||||
export const uint8ArrayToBase64url = (uint8Array: Uint8Array): string => {
|
||||
return Buffer.from(uint8Array).toString("base64url");
|
||||
};
|
||||
|
||||
/**
|
||||
* Parsed passkey data for use with simplewebauthn
|
||||
*/
|
||||
export interface ParsedPasskey {
|
||||
id: number;
|
||||
credentialId: string;
|
||||
publicKey: Uint8Array;
|
||||
counter: number;
|
||||
transports: AuthenticatorTransportFuture[] | null;
|
||||
deviceType: "singleDevice" | "multiDevice";
|
||||
backupEligible: boolean;
|
||||
backupStatus: boolean;
|
||||
rpid: string;
|
||||
name: string;
|
||||
lastUsedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw passkey row from database
|
||||
*/
|
||||
export interface PasskeyRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
credential_id: Uint8Array;
|
||||
public_key: Uint8Array;
|
||||
webauthn_user_id: string;
|
||||
counter: string | number | bigint;
|
||||
device_type: "singleDevice" | "multiDevice";
|
||||
backup_eligible: boolean;
|
||||
backup_status: boolean;
|
||||
transports: unknown;
|
||||
rpid: string;
|
||||
name: string;
|
||||
last_used_at: Date | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a passkey row from the database into a usable format
|
||||
*/
|
||||
export const parsePasskeyRow = (row: PasskeyRow): ParsedPasskey => {
|
||||
// Create a new Uint8Array to ensure proper ArrayBuffer type
|
||||
const publicKeyBytes = new Uint8Array(row.public_key);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
credentialId: uint8ArrayToBase64url(row.credential_id),
|
||||
publicKey: publicKeyBytes,
|
||||
counter: Number(row.counter),
|
||||
transports: row.transports as AuthenticatorTransportFuture[] | null,
|
||||
deviceType: row.device_type,
|
||||
backupEligible: row.backup_eligible,
|
||||
backupStatus: row.backup_status,
|
||||
rpid: row.rpid,
|
||||
name: row.name,
|
||||
lastUsedAt: row.last_used_at,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a date for passkey name
|
||||
*/
|
||||
export const formatPasskeyDate = (date: Date): string => {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
332
apps/api-server/src/utils/webauthn.ts
Normal file
332
apps/api-server/src/utils/webauthn.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* WebAuthn utility functions for passkey registration and authentication
|
||||
*/
|
||||
|
||||
import type { Database } from "@reviq/db-schema";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/types";
|
||||
import type { Kysely } from "kysely";
|
||||
import type { ParsedPasskey, PasskeyRow } from "./passkey-helpers.js";
|
||||
import type { VerifiedRegistrationResponse } from "@simplewebauthn/server";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { formatPasskeyDate, parsePasskeyRow } from "./passkey-helpers.js";
|
||||
|
||||
/**
|
||||
* Known authenticator AAGUIDs mapped to friendly names
|
||||
*/
|
||||
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<void> => {
|
||||
// Fetch the challenge
|
||||
const challengeRow = await db
|
||||
.selectFrom("webauthn_challenges")
|
||||
.select("options")
|
||||
.where("id", "=", String(challengeId))
|
||||
.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", "=", String(challengeId))
|
||||
.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
|
||||
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,
|
||||
})
|
||||
.execute();
|
||||
};
|
||||
|
||||
/**
|
||||
* 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", "=", String(challengeId))
|
||||
.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", "=", String(passkey.id))
|
||||
.execute();
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
// Always delete the challenge
|
||||
await db
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", String(challengeId))
|
||||
.execute();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user