Files
publisher-dashboard/apps/api-server/src/utils/passkey-helpers.ts
RevIQ bd9be3e441 Add comprehensive WebAuthn e2e/unit tests and virtual authenticator package
- Create @reviq/virtual-authenticator package with cryptographically valid
  WebAuthn credential generation for testing
- Add e2e tests for WebAuthn registration, authentication, passkey management
- Add unit tests for passkey-helpers and VirtualAuthenticator
- Add security tests for counter replay and tampered responses
- Configure test database environment in devenv.nix
- Add turbo.json test tasks and workspace configuration

Test results: 98 tests passing (54 virtual-authenticator, 25 e2e, 19 unit)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:46:02 +08:00

94 lines
2.4 KiB
TypeScript

/**
* 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: string | number; // Int8 from DB comes as string
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: Number(row.id), // Convert Int8 (string) to number
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",
});
};