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:
RevIQ
2026-01-09 12:34:26 +08:00
parent a4dff188eb
commit b46146faa5
8 changed files with 709 additions and 24 deletions

View 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",
});
};