- 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>
94 lines
2.4 KiB
TypeScript
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",
|
|
});
|
|
};
|