Merge branch 'wt3': WebAuthn enhancements and virtual authenticator
- Enhanced createRegistrationOptions to look up existing users - Added virtual-authenticator testing package - Added WebAuthn e2e and unit tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
210
apps/api-server/README.md
Normal file
210
apps/api-server/README.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# API Server
|
||||||
|
|
||||||
|
Backend API server for the publisher dashboard.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start development server
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
bun run typecheck
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
bun run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run e2e tests (requires PostgreSQL)
|
||||||
|
bun run test:e2e
|
||||||
|
|
||||||
|
# Run unit tests
|
||||||
|
bun run test:unit
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Test Setup
|
||||||
|
|
||||||
|
E2E tests use a real PostgreSQL database. The test infrastructure handles:
|
||||||
|
|
||||||
|
1. **Database creation** - Creates the test database if it doesn't exist
|
||||||
|
2. **Migrations** - Runs dbmate migrations before tests
|
||||||
|
3. **Cleanup** - Truncates tables between test files
|
||||||
|
|
||||||
|
#### Environment
|
||||||
|
|
||||||
|
Set `TEST_DATABASE_URL` in your environment (devenv.nix sets this automatically):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TEST_DATABASE_URL=postgres://reviq:reviq@localhost/reviq-dashboard_test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Writing E2E Tests
|
||||||
|
|
||||||
|
Create test files in `src/__tests__/e2e/`. E2E tests should call router handlers directly using the `call` function from `@orpc/server`.
|
||||||
|
|
||||||
|
#### Basic Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import type { Kysely } from "kysely";
|
||||||
|
import type { Database } from "@reviq/db-schema";
|
||||||
|
import { call } from "@orpc/server";
|
||||||
|
import { router } from "../../router.js";
|
||||||
|
import type { AuthenticatedContext } from "../../context.js";
|
||||||
|
import {
|
||||||
|
createTestDb,
|
||||||
|
createTestUser,
|
||||||
|
destroyTestDb,
|
||||||
|
runMigrations,
|
||||||
|
truncateAllTables,
|
||||||
|
} from "../helpers/test-db.js";
|
||||||
|
|
||||||
|
let db: Kysely<Database>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await runMigrations();
|
||||||
|
db = createTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("my feature", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await truncateAllTables(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does something", async () => {
|
||||||
|
const user = await createTestUser(db, { email: "test@example.com" });
|
||||||
|
expect(user.id).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Calling Router Handlers
|
||||||
|
|
||||||
|
Use `call()` from `@orpc/server` to invoke router handlers directly with the appropriate context:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { call } from "@orpc/server";
|
||||||
|
import { router } from "../../router.js";
|
||||||
|
import type { AuthenticatedContext } from "../../context.js";
|
||||||
|
|
||||||
|
// Create a context object for authenticated endpoints
|
||||||
|
function createAuthContext(userId: number, email: string): AuthenticatedContext {
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
allowedOrigins: ["http://localhost:3000"],
|
||||||
|
rpName: "Test App",
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
displayName: null,
|
||||||
|
emailVerifiedAt: null,
|
||||||
|
isSuperuser: false,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
id: 1,
|
||||||
|
trustedMode: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("lists passkeys via router", async () => {
|
||||||
|
const user = await createTestUser(db, { email: "test@example.com" });
|
||||||
|
const ctx = createAuthContext(user.id, user.email);
|
||||||
|
|
||||||
|
// Call router handler directly
|
||||||
|
const passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(passkeys).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renames passkey via router", async () => {
|
||||||
|
const user = await createTestUser(db, { email: "test@example.com" });
|
||||||
|
const ctx = createAuthContext(user.id, user.email);
|
||||||
|
|
||||||
|
// Call with input
|
||||||
|
await call(
|
||||||
|
router.me.passkeys.rename,
|
||||||
|
{ passkeyId: 1, name: "My Key" },
|
||||||
|
{ context: ctx }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles errors from router", async () => {
|
||||||
|
const user = await createTestUser(db, { email: "test@example.com" });
|
||||||
|
const ctx = createAuthContext(user.id, user.email);
|
||||||
|
|
||||||
|
// Expect router to throw
|
||||||
|
await expect(
|
||||||
|
call(router.me.passkeys.delete, { passkeyId: 999 }, { context: ctx })
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Context Types
|
||||||
|
|
||||||
|
Different endpoints require different context types:
|
||||||
|
|
||||||
|
| Context Type | Use Case |
|
||||||
|
|-------------|----------|
|
||||||
|
| `APIContext` | Public endpoints (no auth required) |
|
||||||
|
| `AuthenticatedContext` | Protected endpoints (requires user session) |
|
||||||
|
| `LoginRequestContext` | Login flow endpoints |
|
||||||
|
|
||||||
|
See `src/context.ts` for the full interface definitions.
|
||||||
|
|
||||||
|
### Test Helpers
|
||||||
|
|
||||||
|
#### `test-db.ts`
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `createTestDb()` | Creates a Kysely connection to the test database |
|
||||||
|
| `runMigrations()` | Runs dbmate migrations (creates DB if needed) |
|
||||||
|
| `truncateAllTables(db)` | Truncates all tables with CASCADE |
|
||||||
|
| `createTestUser(db, overrides?)` | Creates a test user with optional overrides |
|
||||||
|
| `destroyTestDb(db)` | Closes the database connection |
|
||||||
|
|
||||||
|
#### `test-constants.ts`
|
||||||
|
|
||||||
|
Test constants for RP configuration and known values:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TEST_RP, KNOWN_AAGUIDS } from "../helpers/test-constants.js";
|
||||||
|
```
|
||||||
|
|
||||||
|
#### VirtualAuthenticator
|
||||||
|
|
||||||
|
For WebAuthn testing, generates real cryptographic credentials. Available from the `@reviq/virtual-authenticator` package:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
|
||||||
|
|
||||||
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Registration
|
||||||
|
const regResponse = await authenticator.createCredential(regOptions);
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
const authResponse = await authenticator.getAssertion(authOptions);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Isolation
|
||||||
|
|
||||||
|
- Tests run serially (`--no-parallel`) to avoid database conflicts
|
||||||
|
- Each test file should call `truncateAllTables()` in `beforeAll`
|
||||||
|
- Use unique emails/identifiers per test to avoid collisions within a file
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
"build": "bun build src/index.ts --outdir dist",
|
"build": "bun build src/index.ts --outdir dist",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"clean": "rm -rf dist .eslintcache"
|
"clean": "rm -rf dist .eslintcache",
|
||||||
|
"test:e2e": "bun test src/__tests__/e2e --no-parallel",
|
||||||
|
"test:unit": "bun test src/__tests__/unit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-durationformat": "^0.9.2",
|
"@formatjs/intl-durationformat": "^0.9.2",
|
||||||
@@ -26,9 +28,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
"@macalinao/tsconfig": "catalog:",
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@reviq/virtual-authenticator": "workspace:*",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
|
"pg": "^8.16.3",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1039
apps/api-server/src/__tests__/e2e/webauthn.test.ts
Normal file
1039
apps/api-server/src/__tests__/e2e/webauthn.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
26
apps/api-server/src/__tests__/helpers/test-constants.ts
Normal file
26
apps/api-server/src/__tests__/helpers/test-constants.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Test constants for WebAuthn e2e tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Test Relying Party configuration */
|
||||||
|
export const TEST_RP = {
|
||||||
|
rpName: "Test App",
|
||||||
|
rpID: "localhost",
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
allowedOrigins: ["http://localhost:3000"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known AAGUIDs for testing passkey name assignment.
|
||||||
|
* These match the AAGUID_MAP in webauthn.ts
|
||||||
|
*/
|
||||||
|
export const KNOWN_AAGUIDS = {
|
||||||
|
ICLOUD_KEYCHAIN: "fbfc3007-154e-4ecc-8c0b-6e020557d7bd",
|
||||||
|
GOOGLE_PASSWORD_MANAGER: "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4",
|
||||||
|
CHROME_ON_MAC: "adce0002-35bc-c60a-648b-0b25f1f05503",
|
||||||
|
WINDOWS_HELLO: "6028b017-b1d4-4c02-b4b3-afcdafc96bb2",
|
||||||
|
ONEPASSWORD: "bada5566-a7aa-401f-bd96-45619a55120d",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Default AAGUID for virtual authenticator (unknown authenticator) */
|
||||||
|
export const DEFAULT_TEST_AAGUID = "00000000-0000-0000-0000-000000000000";
|
||||||
222
apps/api-server/src/__tests__/helpers/test-db.ts
Normal file
222
apps/api-server/src/__tests__/helpers/test-db.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Test database utilities for e2e tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Database } from "@reviq/db-schema";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Kysely, PostgresDialect, sql } from "kysely";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
const { Pool, Client } = pg;
|
||||||
|
|
||||||
|
/** Tables to truncate between tests (in order that respects foreign keys) */
|
||||||
|
const TABLES_TO_TRUNCATE = [
|
||||||
|
"sessions",
|
||||||
|
"api_tokens",
|
||||||
|
"login_requests",
|
||||||
|
"passkeys",
|
||||||
|
"user_devices",
|
||||||
|
"webauthn_challenges",
|
||||||
|
"email_verifications",
|
||||||
|
"password_resets",
|
||||||
|
"org_invites",
|
||||||
|
"org_sites",
|
||||||
|
"org_members",
|
||||||
|
"orgs",
|
||||||
|
"users",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a test database connection.
|
||||||
|
* Uses TEST_DATABASE_URL env var, falls back to DATABASE_URL with _test suffix.
|
||||||
|
*/
|
||||||
|
export function createTestDb(): Kysely<Database> {
|
||||||
|
const connectionString = getTestDatabaseUrl();
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error(
|
||||||
|
"Test database URL not configured. Set TEST_DATABASE_URL environment variable.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialect = new PostgresDialect({
|
||||||
|
pool: new Pool({ connectionString }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Kysely<Database>({ dialect });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the test database URL from environment.
|
||||||
|
* Adds sslmode=disable for local development.
|
||||||
|
*/
|
||||||
|
export function getTestDatabaseUrl(): string {
|
||||||
|
let url: string;
|
||||||
|
|
||||||
|
// Prefer explicit TEST_DATABASE_URL
|
||||||
|
if (Bun.env.TEST_DATABASE_URL) {
|
||||||
|
url = Bun.env.TEST_DATABASE_URL;
|
||||||
|
} else if (Bun.env.DATABASE_URL) {
|
||||||
|
// Fall back to DATABASE_URL with _test suffix
|
||||||
|
const parsed = new URL(Bun.env.DATABASE_URL);
|
||||||
|
parsed.pathname = `${parsed.pathname}_test`;
|
||||||
|
url = parsed.toString();
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sslmode=disable for local postgres
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!parsed.searchParams.has("sslmode")) {
|
||||||
|
parsed.searchParams.set("sslmode", "disable");
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a postgres URL to extract components
|
||||||
|
*/
|
||||||
|
function parsePostgresUrl(url: string): {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
} {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return {
|
||||||
|
host: parsed.hostname,
|
||||||
|
port: Number.parseInt(parsed.port || "5432", 10),
|
||||||
|
user: parsed.username,
|
||||||
|
password: parsed.password,
|
||||||
|
database: parsed.pathname.slice(1), // Remove leading /
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the test database if it doesn't exist.
|
||||||
|
*/
|
||||||
|
async function ensureTestDatabaseExists(): Promise<void> {
|
||||||
|
const testDbUrl = getTestDatabaseUrl();
|
||||||
|
if (!testDbUrl) {
|
||||||
|
throw new Error("Test database URL not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host, port, user, password, database } = parsePostgresUrl(testDbUrl);
|
||||||
|
|
||||||
|
// Connect to 'postgres' database to create the test database
|
||||||
|
const client = new Client({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
database: "postgres",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Check if database exists
|
||||||
|
const result = await client.query(
|
||||||
|
"SELECT 1 FROM pg_database WHERE datname = $1",
|
||||||
|
[database],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
// Create the database
|
||||||
|
// Note: database names can't be parameterized, but we control this value
|
||||||
|
await client.query(`CREATE DATABASE "${database}"`);
|
||||||
|
console.log(`Created test database: ${database}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs database migrations using dbmate CLI.
|
||||||
|
* Creates the database if it doesn't exist.
|
||||||
|
* Should be called once before running test suite.
|
||||||
|
*/
|
||||||
|
export async function runMigrations(): Promise<void> {
|
||||||
|
const testDbUrl = getTestDatabaseUrl();
|
||||||
|
if (!testDbUrl) {
|
||||||
|
throw new Error("Test database URL not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the database exists first
|
||||||
|
await ensureTestDatabaseExists();
|
||||||
|
|
||||||
|
// Find the repo root (where db/migrations lives)
|
||||||
|
// From apps/api-server/src/__tests__/helpers/ -> repo root is 5 levels up
|
||||||
|
const repoRoot = join(import.meta.dir, "../../../../..");
|
||||||
|
|
||||||
|
const proc = Bun.spawn(["dbmate", "up"], {
|
||||||
|
env: { ...process.env, DATABASE_URL: testDbUrl },
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
const stderr = await new Response(proc.stderr).text();
|
||||||
|
throw new Error(
|
||||||
|
`Migration failed with code ${String(exitCode)}: ${stderr}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncates all tables to reset database state.
|
||||||
|
* Uses TRUNCATE ... CASCADE to handle foreign keys.
|
||||||
|
*/
|
||||||
|
export async function truncateAllTables(db: Kysely<Database>): Promise<void> {
|
||||||
|
// Use a single TRUNCATE statement with CASCADE for efficiency
|
||||||
|
const tableList = TABLES_TO_TRUNCATE.join(", ");
|
||||||
|
await sql`TRUNCATE ${sql.raw(tableList)} RESTART IDENTITY CASCADE`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a test user for e2e tests.
|
||||||
|
* Returns the created user with ID.
|
||||||
|
*/
|
||||||
|
export async function createTestUser(
|
||||||
|
db: Kysely<Database>,
|
||||||
|
overrides: Partial<{
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
fullName: string;
|
||||||
|
passwordHash: string;
|
||||||
|
emailVerifiedAt: Date;
|
||||||
|
isSuperuser: boolean;
|
||||||
|
}> = {},
|
||||||
|
): Promise<{ id: number; email: string }> {
|
||||||
|
const email = overrides.email ?? `test-${String(Date.now())}@example.com`;
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.insertInto("users")
|
||||||
|
.values({
|
||||||
|
email,
|
||||||
|
display_name: overrides.displayName ?? "Test User",
|
||||||
|
full_name: overrides.fullName ?? null,
|
||||||
|
password_hash: overrides.passwordHash ?? null,
|
||||||
|
email_verified_at: overrides.emailVerifiedAt ?? null,
|
||||||
|
is_superuser: overrides.isSuperuser ?? false,
|
||||||
|
})
|
||||||
|
.returning(["id", "email"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the database connection pool.
|
||||||
|
* Call this in afterAll() to clean up.
|
||||||
|
*/
|
||||||
|
export async function destroyTestDb(db: Kysely<Database>): Promise<void> {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
191
apps/api-server/src/__tests__/unit/passkey-helpers.test.ts
Normal file
191
apps/api-server/src/__tests__/unit/passkey-helpers.test.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for passkey-helpers utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PasskeyRow } from "../../utils/passkey-helpers.js";
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
base64urlToUint8Array,
|
||||||
|
formatPasskeyDate,
|
||||||
|
parsePasskeyRow,
|
||||||
|
uint8ArrayToBase64url,
|
||||||
|
} from "../../utils/passkey-helpers.js";
|
||||||
|
|
||||||
|
describe("base64urlToUint8Array", () => {
|
||||||
|
test("converts base64url string to Uint8Array", () => {
|
||||||
|
// "Hello" in base64url
|
||||||
|
const base64url = "SGVsbG8";
|
||||||
|
const result = base64urlToUint8Array(base64url);
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(Buffer.from(result).toString()).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty string", () => {
|
||||||
|
const result = base64urlToUint8Array("");
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles base64url without padding", () => {
|
||||||
|
// "abc" in base64url (no padding needed)
|
||||||
|
const base64url = "YWJj";
|
||||||
|
const result = base64urlToUint8Array(base64url);
|
||||||
|
expect(Buffer.from(result).toString()).toBe("abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles binary data with special characters", () => {
|
||||||
|
// Binary data that would have + and / in standard base64
|
||||||
|
const original = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]);
|
||||||
|
const base64url = Buffer.from(original).toString("base64url");
|
||||||
|
const result = base64urlToUint8Array(base64url);
|
||||||
|
|
||||||
|
expect(result).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uint8ArrayToBase64url", () => {
|
||||||
|
test("converts Uint8Array to base64url string", () => {
|
||||||
|
// "Hello" as bytes
|
||||||
|
const bytes = new Uint8Array([72, 101, 108, 108, 111]);
|
||||||
|
const result = uint8ArrayToBase64url(bytes);
|
||||||
|
|
||||||
|
expect(result).toBe("SGVsbG8");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty array", () => {
|
||||||
|
const result = uint8ArrayToBase64url(new Uint8Array([]));
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("produces URL-safe output (no +, /, or =)", () => {
|
||||||
|
// Binary data that would produce + and / in standard base64
|
||||||
|
const bytes = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa]);
|
||||||
|
const result = uint8ArrayToBase64url(bytes);
|
||||||
|
|
||||||
|
expect(result).not.toContain("+");
|
||||||
|
expect(result).not.toContain("/");
|
||||||
|
expect(result).not.toContain("=");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("roundtrips correctly with base64urlToUint8Array", () => {
|
||||||
|
const original = new Uint8Array([1, 2, 3, 4, 5, 100, 200, 255]);
|
||||||
|
const encoded = uint8ArrayToBase64url(original);
|
||||||
|
const decoded = base64urlToUint8Array(encoded);
|
||||||
|
|
||||||
|
expect(decoded).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePasskeyRow", () => {
|
||||||
|
const createMockRow = (overrides: Partial<PasskeyRow> = {}): PasskeyRow => ({
|
||||||
|
id: 1,
|
||||||
|
user_id: 100,
|
||||||
|
credential_id: new Uint8Array([1, 2, 3, 4, 5]),
|
||||||
|
public_key: new Uint8Array([10, 20, 30, 40, 50]),
|
||||||
|
webauthn_user_id: "webauthn-user-123",
|
||||||
|
counter: "42",
|
||||||
|
device_type: "multiDevice",
|
||||||
|
backup_eligible: true,
|
||||||
|
backup_status: true,
|
||||||
|
transports: ["internal", "hybrid"],
|
||||||
|
rpid: "localhost",
|
||||||
|
name: "Test Passkey",
|
||||||
|
last_used_at: new Date("2024-01-15T10:00:00Z"),
|
||||||
|
created_at: new Date("2024-01-01T00:00:00Z"),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts DB row to ParsedPasskey format", () => {
|
||||||
|
const row = createMockRow();
|
||||||
|
const result = parsePasskeyRow(row);
|
||||||
|
|
||||||
|
expect(result.id).toBe(1);
|
||||||
|
expect(result.credentialId).toBe(uint8ArrayToBase64url(row.credential_id));
|
||||||
|
expect(result.publicKey).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(result.counter).toBe(42);
|
||||||
|
expect(result.deviceType).toBe("multiDevice");
|
||||||
|
expect(result.backupEligible).toBe(true);
|
||||||
|
expect(result.backupStatus).toBe(true);
|
||||||
|
expect(result.transports).toEqual(["internal", "hybrid"]);
|
||||||
|
expect(result.rpid).toBe("localhost");
|
||||||
|
expect(result.name).toBe("Test Passkey");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles string counter", () => {
|
||||||
|
const row = createMockRow({ counter: "100" });
|
||||||
|
const result = parsePasskeyRow(row);
|
||||||
|
expect(result.counter).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles number counter", () => {
|
||||||
|
const row = createMockRow({ counter: 200 });
|
||||||
|
const result = parsePasskeyRow(row);
|
||||||
|
expect(result.counter).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles bigint counter", () => {
|
||||||
|
const row = createMockRow({ counter: BigInt(300) });
|
||||||
|
const result = parsePasskeyRow(row);
|
||||||
|
expect(result.counter).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null transports", () => {
|
||||||
|
const row = createMockRow({ transports: null });
|
||||||
|
const result = parsePasskeyRow(row);
|
||||||
|
expect(result.transports).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null last_used_at", () => {
|
||||||
|
const row = createMockRow({ last_used_at: null });
|
||||||
|
const result = parsePasskeyRow(row);
|
||||||
|
expect(result.lastUsedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves created_at date", () => {
|
||||||
|
const createdAt = new Date("2024-06-15T12:00:00Z");
|
||||||
|
const row = createMockRow({ created_at: createdAt });
|
||||||
|
const result = parsePasskeyRow(row);
|
||||||
|
expect(result.createdAt).toEqual(createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles singleDevice device type", () => {
|
||||||
|
const row = createMockRow({ device_type: "singleDevice" });
|
||||||
|
const result = parsePasskeyRow(row);
|
||||||
|
expect(result.deviceType).toBe("singleDevice");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatPasskeyDate", () => {
|
||||||
|
test("formats date with month, day, year, and time", () => {
|
||||||
|
const date = new Date("2024-01-15T10:30:00");
|
||||||
|
const result = formatPasskeyDate(date);
|
||||||
|
|
||||||
|
// Should contain month abbreviation
|
||||||
|
expect(result).toContain("Jan");
|
||||||
|
// Should contain day
|
||||||
|
expect(result).toContain("15");
|
||||||
|
// Should contain year
|
||||||
|
expect(result).toContain("2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes time component", () => {
|
||||||
|
const date = new Date("2024-06-20T14:45:00");
|
||||||
|
const result = formatPasskeyDate(date);
|
||||||
|
|
||||||
|
// Should contain time in some format
|
||||||
|
expect(result).toMatch(/\d{1,2}:\d{2}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles different months", () => {
|
||||||
|
const dates = [
|
||||||
|
{ date: new Date("2024-03-01"), month: "Mar" },
|
||||||
|
{ date: new Date("2024-07-15"), month: "Jul" },
|
||||||
|
{ date: new Date("2024-12-31"), month: "Dec" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { date, month } of dates) {
|
||||||
|
const result = formatPasskeyDate(date);
|
||||||
|
expect(result).toContain(month);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,15 +76,24 @@ const createRegistrationOptions =
|
|||||||
async ({ input, context }) => {
|
async ({ input, context }) => {
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
// For signup flow, we don't have a user yet
|
// Look up existing user by email to exclude their credentials
|
||||||
// The user will be created when signup is called with the passkeyInfo
|
const existingUser = await context.db
|
||||||
|
.selectFrom("users")
|
||||||
|
.select(["id", "display_name"])
|
||||||
|
.where("email", "=", email)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
const rpInfo = getRPInfo(
|
const rpInfo = getRPInfo(
|
||||||
context.origin,
|
context.origin,
|
||||||
context.allowedOrigins,
|
context.allowedOrigins,
|
||||||
context.rpName,
|
context.rpName,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await createRegOptions(context.db, rpInfo, { email });
|
const result = await createRegOptions(context.db, rpInfo, {
|
||||||
|
id: existingUser?.id,
|
||||||
|
email,
|
||||||
|
displayName: existingUser?.display_name,
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface ParsedPasskey {
|
|||||||
* Raw passkey row from database
|
* Raw passkey row from database
|
||||||
*/
|
*/
|
||||||
export interface PasskeyRow {
|
export interface PasskeyRow {
|
||||||
id: number;
|
id: string | number; // Int8 from DB comes as string
|
||||||
user_id: number;
|
user_id: number;
|
||||||
credential_id: Uint8Array;
|
credential_id: Uint8Array;
|
||||||
public_key: Uint8Array;
|
public_key: Uint8Array;
|
||||||
@@ -64,7 +64,7 @@ export const parsePasskeyRow = (row: PasskeyRow): ParsedPasskey => {
|
|||||||
const publicKeyBytes = new Uint8Array(row.public_key);
|
const publicKeyBytes = new Uint8Array(row.public_key);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: Number(row.id), // Convert Int8 (string) to number
|
||||||
credentialId: uint8ArrayToBase64url(row.credential_id),
|
credentialId: uint8ArrayToBase64url(row.credential_id),
|
||||||
publicKey: publicKeyBytes,
|
publicKey: publicKeyBytes,
|
||||||
counter: Number(row.counter),
|
counter: Number(row.counter),
|
||||||
|
|||||||
22
bun.lock
22
bun.lock
@@ -30,9 +30,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
"@macalinao/tsconfig": "catalog:",
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@reviq/virtual-authenticator": "workspace:*",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
|
"pg": "^8.16.3",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -146,6 +149,21 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/testing/virtual-authenticator": {
|
||||||
|
"name": "@reviq/virtual-authenticator",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@simplewebauthn/types": "^12.0.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@macalinao/eslint-config": "catalog:",
|
||||||
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/node": "^25.0.3",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@macalinao/eslint-config": "^7.0.3",
|
"@macalinao/eslint-config": "^7.0.3",
|
||||||
@@ -354,6 +372,8 @@
|
|||||||
|
|
||||||
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
|
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
|
||||||
|
|
||||||
|
"@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="],
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="],
|
||||||
@@ -422,7 +442,7 @@
|
|||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.3", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-a+uxqQ9j6Lxmq4plbGaNdM9hgDCZyxAv/yvuyF5iWoA2H5icZkqD3rdK155ZQgFLX2lc3NvahHG4OgKpYqYPiQ=="],
|
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.3", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-a+uxqQ9j6Lxmq4plbGaNdM9hgDCZyxAv/yvuyF5iWoA2H5icZkqD3rdK155ZQgFLX2lc3NvahHG4OgKpYqYPiQ=="],
|
||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
|
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
|
||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="],
|
"@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
\restrict ociGb4MsWN1fhg6id8BhboNMDdeJ0f1xyci4ALu7scxk6gFiVAy9pFRDOJdhjfZ
|
\restrict Trg340CgUaHnQsqUDFepZ6WnV8O2lwkEMfhS9CGxBAJbWOA8qTnig08shTgrMcE
|
||||||
|
|
||||||
-- Dumped from database version 17.7
|
-- Dumped from database version 17.7
|
||||||
-- Dumped by pg_dump version 17.7
|
-- Dumped by pg_dump version 17.7
|
||||||
@@ -1069,7 +1069,7 @@ ALTER TABLE ONLY public.user_devices
|
|||||||
-- PostgreSQL database dump complete
|
-- PostgreSQL database dump complete
|
||||||
--
|
--
|
||||||
|
|
||||||
\unrestrict ociGb4MsWN1fhg6id8BhboNMDdeJ0f1xyci4ALu7scxk6gFiVAy9pFRDOJdhjfZ
|
\unrestrict Trg340CgUaHnQsqUDFepZ6WnV8O2lwkEMfhS9CGxBAJbWOA8qTnig08shTgrMcE
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -18,10 +18,14 @@
|
|||||||
|
|
||||||
services.postgres = {
|
services.postgres = {
|
||||||
enable = true;
|
enable = true;
|
||||||
initialDatabases = [ { name = "reviq-dashboard"; } ];
|
initialDatabases = [
|
||||||
|
{ name = "reviq-dashboard"; }
|
||||||
|
{ name = "reviq-dashboard_test"; }
|
||||||
|
];
|
||||||
initialScript = ''
|
initialScript = ''
|
||||||
CREATE USER reviq WITH PASSWORD 'reviq' SUPERUSER;
|
CREATE USER reviq WITH PASSWORD 'reviq' SUPERUSER;
|
||||||
GRANT ALL PRIVILEGES ON DATABASE "reviq-dashboard" TO reviq;
|
GRANT ALL PRIVILEGES ON DATABASE "reviq-dashboard" TO reviq;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE "reviq-dashboard_test" TO reviq;
|
||||||
'';
|
'';
|
||||||
listen_addresses = "localhost";
|
listen_addresses = "localhost";
|
||||||
};
|
};
|
||||||
@@ -32,6 +36,9 @@
|
|||||||
"api-server".exec = "bun run --cwd apps/api-server dev";
|
"api-server".exec = "bun run --cwd apps/api-server dev";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
env.DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard";
|
||||||
|
env.TEST_DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard_test";
|
||||||
|
|
||||||
scripts = {
|
scripts = {
|
||||||
"db-up".exec = "dbmate up";
|
"db-up".exec = "dbmate up";
|
||||||
"db-new".exec = "dbmate new \"$1\"";
|
"db-new".exec = "dbmate new \"$1\"";
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*",
|
||||||
|
"packages/testing/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
|
|||||||
12
packages/testing/virtual-authenticator/eslint.config.js
Normal file
12
packages/testing/virtual-authenticator/eslint.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { configs } from "@macalinao/eslint-config";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...configs.fast,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
26
packages/testing/virtual-authenticator/package.json
Normal file
26
packages/testing/virtual-authenticator/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@reviq/virtual-authenticator",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||||
|
"lint": "eslint . --cache",
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@simplewebauthn/types": "^12.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@macalinao/eslint-config": "catalog:",
|
||||||
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/node": "^25.0.3",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import type {
|
||||||
|
PublicKeyCredentialCreationOptionsJSON,
|
||||||
|
PublicKeyCredentialRequestOptionsJSON,
|
||||||
|
} from "@simplewebauthn/types";
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
base64urlToUint8Array,
|
||||||
|
DEFAULT_AAGUID,
|
||||||
|
parseAaguid,
|
||||||
|
uint8ArrayToBase64url,
|
||||||
|
VirtualAuthenticator,
|
||||||
|
} from "../virtual-authenticator.js";
|
||||||
|
|
||||||
|
describe("base64url encoding helpers", () => {
|
||||||
|
test("uint8ArrayToBase64url encodes bytes correctly", () => {
|
||||||
|
const bytes = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
|
||||||
|
const encoded = uint8ArrayToBase64url(bytes);
|
||||||
|
expect(encoded).toBe("SGVsbG8");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("base64urlToUint8Array decodes bytes correctly", () => {
|
||||||
|
const decoded = base64urlToUint8Array("SGVsbG8");
|
||||||
|
expect(Array.from(decoded)).toEqual([72, 101, 108, 108, 111]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trip encoding preserves data", () => {
|
||||||
|
const original = new Uint8Array([0, 1, 2, 255, 254, 253]);
|
||||||
|
const encoded = uint8ArrayToBase64url(original);
|
||||||
|
const decoded = base64urlToUint8Array(encoded);
|
||||||
|
expect(Array.from(decoded)).toEqual(Array.from(original));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty arrays", () => {
|
||||||
|
const empty = new Uint8Array([]);
|
||||||
|
const encoded = uint8ArrayToBase64url(empty);
|
||||||
|
expect(encoded).toBe("");
|
||||||
|
const decoded = base64urlToUint8Array("");
|
||||||
|
expect(decoded.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles URL-unsafe characters correctly", () => {
|
||||||
|
// Base64url should use - instead of + and _ instead of /
|
||||||
|
const bytes = new Uint8Array([251, 255]); // Would be +/8= in standard base64
|
||||||
|
const encoded = uint8ArrayToBase64url(bytes);
|
||||||
|
expect(encoded).not.toContain("+");
|
||||||
|
expect(encoded).not.toContain("/");
|
||||||
|
expect(encoded).not.toContain("=");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseAaguid", () => {
|
||||||
|
test("parses default AAGUID (all zeros)", () => {
|
||||||
|
const bytes = parseAaguid(DEFAULT_AAGUID);
|
||||||
|
expect(bytes.length).toBe(16);
|
||||||
|
expect(Array.from(bytes)).toEqual(new Array<number>(16).fill(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses AAGUID with dashes", () => {
|
||||||
|
const bytes = parseAaguid("fbfc3007-154e-4ecc-8c0b-6e020557d7bd");
|
||||||
|
expect(bytes.length).toBe(16);
|
||||||
|
expect(bytes[0]).toBe(0xfb);
|
||||||
|
expect(bytes[1]).toBe(0xfc);
|
||||||
|
expect(bytes[2]).toBe(0x30);
|
||||||
|
expect(bytes[3]).toBe(0x07);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses specific byte values correctly", () => {
|
||||||
|
const bytes = parseAaguid("01020304-0506-0708-090a-0b0c0d0e0f10");
|
||||||
|
expect(Array.from(bytes)).toEqual([
|
||||||
|
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
|
||||||
|
0x0d, 0x0e, 0x0f, 0x10,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VirtualAuthenticator", () => {
|
||||||
|
const createRegistrationOptions = (
|
||||||
|
challenge: string,
|
||||||
|
userId: string,
|
||||||
|
): PublicKeyCredentialCreationOptionsJSON => ({
|
||||||
|
challenge,
|
||||||
|
rp: {
|
||||||
|
name: "Test App",
|
||||||
|
id: "localhost",
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
name: "test@example.com",
|
||||||
|
displayName: "Test User",
|
||||||
|
},
|
||||||
|
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||||
|
timeout: 60000,
|
||||||
|
attestation: "none",
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: "preferred",
|
||||||
|
userVerification: "preferred",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAuthenticationOptions = (
|
||||||
|
challenge: string,
|
||||||
|
credentialId: string,
|
||||||
|
): PublicKeyCredentialRequestOptionsJSON => ({
|
||||||
|
challenge,
|
||||||
|
rpId: "localhost",
|
||||||
|
timeout: 60000,
|
||||||
|
userVerification: "preferred",
|
||||||
|
allowCredentials: [
|
||||||
|
{
|
||||||
|
type: "public-key",
|
||||||
|
id: credentialId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("constructor", () => {
|
||||||
|
test("uses default AAGUID when not specified", () => {
|
||||||
|
const auth = new VirtualAuthenticator();
|
||||||
|
expect(auth.getCredentials()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses default origin when not specified", () => {
|
||||||
|
const auth = new VirtualAuthenticator();
|
||||||
|
// We can verify this by checking clientDataJSON origin in created credentials
|
||||||
|
expect(auth.getCredentials()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts custom origin", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "https://example.com",
|
||||||
|
});
|
||||||
|
expect(auth.getCredentials()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts custom AAGUID", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
aaguid: "fbfc3007-154e-4ecc-8c0b-6e020557d7bd",
|
||||||
|
});
|
||||||
|
expect(auth.getCredentials()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCredential", () => {
|
||||||
|
test("creates a valid registration response", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
const options = createRegistrationOptions("test-challenge", "dXNlci0x");
|
||||||
|
|
||||||
|
const response = auth.createCredential(options);
|
||||||
|
|
||||||
|
expect(response.type).toBe("public-key");
|
||||||
|
expect(response.id).toBeDefined();
|
||||||
|
expect(response.rawId).toBe(response.id);
|
||||||
|
expect(response.authenticatorAttachment).toBe("platform");
|
||||||
|
expect(response.response.clientDataJSON).toBeDefined();
|
||||||
|
expect(response.response.attestationObject).toBeDefined();
|
||||||
|
expect(response.response.transports).toContain("internal");
|
||||||
|
expect(response.response.publicKeyAlgorithm).toBe(-7); // ES256
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stores credential after creation", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
const options = createRegistrationOptions("test-challenge", "dXNlci0x");
|
||||||
|
|
||||||
|
expect(auth.getCredentials()).toHaveLength(0);
|
||||||
|
|
||||||
|
const response = auth.createCredential(options);
|
||||||
|
|
||||||
|
const credentials = auth.getCredentials();
|
||||||
|
expect(credentials).toHaveLength(1);
|
||||||
|
expect(credentials[0]?.credentialId).toBe(response.id);
|
||||||
|
expect(credentials[0]?.rpId).toBe("localhost");
|
||||||
|
expect(credentials[0]?.signCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates unique credential IDs for each registration", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response1 = auth.createCredential(
|
||||||
|
createRegistrationOptions("challenge-1", "dXNlci0x"),
|
||||||
|
);
|
||||||
|
const response2 = auth.createCredential(
|
||||||
|
createRegistrationOptions("challenge-2", "dXNlci0y"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response1.id).not.toBe(response2.id);
|
||||||
|
expect(auth.getCredentials()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes challenge in clientDataJSON", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
const challenge = "unique-test-challenge";
|
||||||
|
const options = createRegistrationOptions(challenge, "dXNlci0x");
|
||||||
|
|
||||||
|
const response = auth.createCredential(options);
|
||||||
|
|
||||||
|
const clientDataJSON = JSON.parse(
|
||||||
|
Buffer.from(response.response.clientDataJSON, "base64url").toString(),
|
||||||
|
) as { type: string; challenge: string; origin: string };
|
||||||
|
expect(clientDataJSON.type).toBe("webauthn.create");
|
||||||
|
expect(clientDataJSON.challenge).toBe(challenge);
|
||||||
|
expect(clientDataJSON.origin).toBe("http://localhost:3000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAssertion", () => {
|
||||||
|
test("creates a valid authentication response", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
const regOptions = createRegistrationOptions("reg-challenge", "dXNlci0x");
|
||||||
|
const regResponse = auth.createCredential(regOptions);
|
||||||
|
|
||||||
|
const authOptions = createAuthenticationOptions(
|
||||||
|
"auth-challenge",
|
||||||
|
regResponse.id,
|
||||||
|
);
|
||||||
|
const authResponse = auth.getAssertion(authOptions);
|
||||||
|
|
||||||
|
expect(authResponse.type).toBe("public-key");
|
||||||
|
expect(authResponse.id).toBe(regResponse.id);
|
||||||
|
expect(authResponse.response.authenticatorData).toBeDefined();
|
||||||
|
expect(authResponse.response.signature).toBeDefined();
|
||||||
|
expect(authResponse.response.userHandle).toBeDefined();
|
||||||
|
expect(authResponse.response.clientDataJSON).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("increments sign count on each assertion", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
const regOptions = createRegistrationOptions("reg-challenge", "dXNlci0x");
|
||||||
|
const regResponse = auth.createCredential(regOptions);
|
||||||
|
|
||||||
|
const credentials = auth.getCredentials();
|
||||||
|
expect(credentials[0]?.signCount).toBe(0);
|
||||||
|
|
||||||
|
auth.getAssertion(
|
||||||
|
createAuthenticationOptions("challenge-1", regResponse.id),
|
||||||
|
);
|
||||||
|
expect(auth.getCredentials()[0]?.signCount).toBe(1);
|
||||||
|
|
||||||
|
auth.getAssertion(
|
||||||
|
createAuthenticationOptions("challenge-2", regResponse.id),
|
||||||
|
);
|
||||||
|
expect(auth.getCredentials()[0]?.signCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws error when no matching credential found", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
const authOptions = createAuthenticationOptions(
|
||||||
|
"auth-challenge",
|
||||||
|
"non-existent-credential-id",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => auth.getAssertion(authOptions)).toThrow(
|
||||||
|
"No matching credential found",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes challenge in clientDataJSON", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
const regResponse = auth.createCredential(
|
||||||
|
createRegistrationOptions("reg-challenge", "dXNlci0x"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const challenge = "unique-auth-challenge";
|
||||||
|
const authResponse = auth.getAssertion(
|
||||||
|
createAuthenticationOptions(challenge, regResponse.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientDataJSON = JSON.parse(
|
||||||
|
Buffer.from(
|
||||||
|
authResponse.response.clientDataJSON,
|
||||||
|
"base64url",
|
||||||
|
).toString(),
|
||||||
|
) as { type: string; challenge: string; origin: string };
|
||||||
|
expect(clientDataJSON.type).toBe("webauthn.get");
|
||||||
|
expect(clientDataJSON.challenge).toBe(challenge);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("credential management", () => {
|
||||||
|
test("clear removes all credentials", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
auth.createCredential(
|
||||||
|
createRegistrationOptions("challenge-1", "dXNlci0x"),
|
||||||
|
);
|
||||||
|
auth.createCredential(
|
||||||
|
createRegistrationOptions("challenge-2", "dXNlci0y"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auth.getCredentials()).toHaveLength(2);
|
||||||
|
|
||||||
|
auth.clear();
|
||||||
|
|
||||||
|
expect(auth.getCredentials()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setSignCount updates credential sign count", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
const response = auth.createCredential(
|
||||||
|
createRegistrationOptions("challenge", "dXNlci0x"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auth.getCredentials()[0]?.signCount).toBe(0);
|
||||||
|
|
||||||
|
auth.setSignCount(response.id, 100);
|
||||||
|
|
||||||
|
expect(auth.getCredentials()[0]?.signCount).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setSignCount throws for non-existent credential", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
auth.setSignCount("non-existent", 100);
|
||||||
|
}).toThrow("Credential not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getCredentials returns credential info without private keys", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
auth.createCredential(createRegistrationOptions("challenge", "dXNlci0x"));
|
||||||
|
|
||||||
|
const credentials = auth.getCredentials();
|
||||||
|
expect(credentials).toHaveLength(1);
|
||||||
|
|
||||||
|
const cred = credentials[0];
|
||||||
|
expect(cred).toHaveProperty("credentialId");
|
||||||
|
expect(cred).toHaveProperty("rpId");
|
||||||
|
expect(cred).toHaveProperty("signCount");
|
||||||
|
// Should NOT expose private key
|
||||||
|
expect(cred).not.toHaveProperty("privateKey");
|
||||||
|
expect(cred).not.toHaveProperty("publicKey");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authenticator data structure", () => {
|
||||||
|
test("registration authenticator data has correct flags", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
const response = auth.createCredential(
|
||||||
|
createRegistrationOptions("challenge", "dXNlci0x"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.response.authenticatorData) {
|
||||||
|
throw new Error("authenticatorData missing from registration response");
|
||||||
|
}
|
||||||
|
const authData = base64urlToUint8Array(
|
||||||
|
response.response.authenticatorData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authenticator data structure:
|
||||||
|
// - 32 bytes: RP ID hash
|
||||||
|
// - 1 byte: flags
|
||||||
|
// - 4 bytes: sign count
|
||||||
|
// - variable: attested credential data (for registration)
|
||||||
|
expect(authData.length).toBeGreaterThan(37);
|
||||||
|
|
||||||
|
// Flags byte (index 32): UP (0x01) + UV (0x04) + AT (0x40) = 0x45
|
||||||
|
const flags = authData[32];
|
||||||
|
expect(flags).toBe(0x45);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assertion authenticator data has correct flags", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
const regResponse = auth.createCredential(
|
||||||
|
createRegistrationOptions("reg-challenge", "dXNlci0x"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authResponse = auth.getAssertion(
|
||||||
|
createAuthenticationOptions("auth-challenge", regResponse.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authData = base64urlToUint8Array(
|
||||||
|
authResponse.response.authenticatorData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assertion authenticator data: 32 + 1 + 4 = 37 bytes (no attested credential data)
|
||||||
|
expect(authData.length).toBe(37);
|
||||||
|
|
||||||
|
// Flags byte: UP (0x01) + UV (0x04) = 0x05 (no AT flag for assertions)
|
||||||
|
const flags = authData[32];
|
||||||
|
expect(flags).toBe(0x05);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sign count is encoded in big-endian", () => {
|
||||||
|
const auth = new VirtualAuthenticator({
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
const regResponse = auth.createCredential(
|
||||||
|
createRegistrationOptions("reg-challenge", "dXNlci0x"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set a specific sign count
|
||||||
|
auth.setSignCount(regResponse.id, 0x01020304);
|
||||||
|
|
||||||
|
const authResponse = auth.getAssertion(
|
||||||
|
createAuthenticationOptions("auth-challenge", regResponse.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authData = base64urlToUint8Array(
|
||||||
|
authResponse.response.authenticatorData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sign count is at bytes 33-36 (after RP ID hash and flags)
|
||||||
|
// After getAssertion, sign count will be incremented by 1
|
||||||
|
const signCountBytes = authData.slice(33, 37);
|
||||||
|
expect(signCountBytes[0]).toBe(0x01);
|
||||||
|
expect(signCountBytes[1]).toBe(0x02);
|
||||||
|
expect(signCountBytes[2]).toBe(0x03);
|
||||||
|
expect(signCountBytes[3]).toBe(0x05); // 0x01020304 + 1 = 0x01020305
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
8
packages/testing/virtual-authenticator/src/index.ts
Normal file
8
packages/testing/virtual-authenticator/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type { VirtualAuthenticatorOptions } from "./virtual-authenticator.js";
|
||||||
|
export {
|
||||||
|
base64urlToUint8Array,
|
||||||
|
DEFAULT_AAGUID,
|
||||||
|
parseAaguid,
|
||||||
|
uint8ArrayToBase64url,
|
||||||
|
VirtualAuthenticator,
|
||||||
|
} from "./virtual-authenticator.js";
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
/**
|
||||||
|
* Virtual WebAuthn Authenticator for testing
|
||||||
|
*
|
||||||
|
* Generates real cryptographically-signed WebAuthn credentials that pass
|
||||||
|
* @simplewebauthn/server verification without needing a browser or physical device.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { KeyObject } from "node:crypto";
|
||||||
|
import type {
|
||||||
|
AuthenticationResponseJSON,
|
||||||
|
AuthenticatorTransportFuture,
|
||||||
|
PublicKeyCredentialCreationOptionsJSON,
|
||||||
|
PublicKeyCredentialRequestOptionsJSON,
|
||||||
|
RegistrationResponseJSON,
|
||||||
|
} from "@simplewebauthn/types";
|
||||||
|
import { createHash, createSign, generateKeyPairSync } from "node:crypto";
|
||||||
|
|
||||||
|
/** Default AAGUID for virtual authenticator (unknown authenticator) */
|
||||||
|
export const DEFAULT_AAGUID = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
/** Stored credential in the virtual authenticator */
|
||||||
|
interface StoredCredential {
|
||||||
|
credentialId: Uint8Array;
|
||||||
|
privateKey: KeyObject;
|
||||||
|
publicKey: KeyObject;
|
||||||
|
rpId: string;
|
||||||
|
userHandle: Uint8Array;
|
||||||
|
signCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for creating a virtual authenticator */
|
||||||
|
export interface VirtualAuthenticatorOptions {
|
||||||
|
/** AAGUID to use (defaults to all zeros) */
|
||||||
|
aaguid?: string;
|
||||||
|
/** Origin for clientDataJSON */
|
||||||
|
origin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtual WebAuthn authenticator for testing.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* const authenticator = new VirtualAuthenticator();
|
||||||
|
* const regResponse = await authenticator.createCredential(regOptions);
|
||||||
|
* // ... verify registration ...
|
||||||
|
* const authResponse = await authenticator.getAssertion(authOptions);
|
||||||
|
* // ... verify authentication ...
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class VirtualAuthenticator {
|
||||||
|
private _credentials = new Map<string, StoredCredential>();
|
||||||
|
private _aaguid: Uint8Array;
|
||||||
|
private _origin: string;
|
||||||
|
|
||||||
|
constructor(options: VirtualAuthenticatorOptions = {}) {
|
||||||
|
this._aaguid = parseAaguid(options.aaguid ?? DEFAULT_AAGUID);
|
||||||
|
this._origin = options.origin ?? "http://localhost:3000";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new credential (registration).
|
||||||
|
* Returns a RegistrationResponseJSON that can be verified by @simplewebauthn/server.
|
||||||
|
*/
|
||||||
|
createCredential(
|
||||||
|
options: PublicKeyCredentialCreationOptionsJSON,
|
||||||
|
): RegistrationResponseJSON {
|
||||||
|
// Generate ECDSA P-256 key pair
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync("ec", {
|
||||||
|
namedCurve: "P-256",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate random credential ID (32 bytes)
|
||||||
|
const credentialId = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(credentialId);
|
||||||
|
const credentialIdBase64 = uint8ArrayToBase64url(credentialId);
|
||||||
|
|
||||||
|
// Parse user handle from options
|
||||||
|
const userHandle = base64urlToUint8Array(options.user.id);
|
||||||
|
|
||||||
|
// Store the credential
|
||||||
|
const rpId = options.rp.id ?? new URL(this._origin).hostname;
|
||||||
|
this._credentials.set(credentialIdBase64, {
|
||||||
|
credentialId,
|
||||||
|
privateKey,
|
||||||
|
publicKey,
|
||||||
|
rpId,
|
||||||
|
userHandle,
|
||||||
|
signCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build authenticator data with attested credential data
|
||||||
|
const authData = this._buildAuthenticatorData(
|
||||||
|
rpId,
|
||||||
|
true, // attestedCredentialData included
|
||||||
|
0, // initial sign count
|
||||||
|
{
|
||||||
|
credentialId,
|
||||||
|
publicKey,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build clientDataJSON
|
||||||
|
const clientDataJSON = JSON.stringify({
|
||||||
|
type: "webauthn.create",
|
||||||
|
challenge: options.challenge,
|
||||||
|
origin: this._origin,
|
||||||
|
crossOrigin: false,
|
||||||
|
});
|
||||||
|
const clientDataJSONBytes = new TextEncoder().encode(clientDataJSON);
|
||||||
|
|
||||||
|
// Build attestation object (using "none" attestation)
|
||||||
|
// Manually encode to avoid cbor-x adding tags that break parsing
|
||||||
|
const attestationObject = encodeAttestationObject(authData);
|
||||||
|
|
||||||
|
// Get public key in SPKI format for the response
|
||||||
|
const publicKeySpki = publicKey.export({ format: "der", type: "spki" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: credentialIdBase64,
|
||||||
|
rawId: credentialIdBase64,
|
||||||
|
type: "public-key",
|
||||||
|
response: {
|
||||||
|
clientDataJSON: uint8ArrayToBase64url(clientDataJSONBytes),
|
||||||
|
attestationObject: uint8ArrayToBase64url(attestationObject),
|
||||||
|
transports: ["internal", "hybrid"] as AuthenticatorTransportFuture[],
|
||||||
|
publicKeyAlgorithm: -7, // ES256
|
||||||
|
publicKey: uint8ArrayToBase64url(new Uint8Array(publicKeySpki)),
|
||||||
|
authenticatorData: uint8ArrayToBase64url(authData),
|
||||||
|
},
|
||||||
|
clientExtensionResults: {},
|
||||||
|
authenticatorAttachment: "platform",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an assertion (authentication).
|
||||||
|
* Returns an AuthenticationResponseJSON that can be verified by @simplewebauthn/server.
|
||||||
|
*/
|
||||||
|
getAssertion(
|
||||||
|
options: PublicKeyCredentialRequestOptionsJSON,
|
||||||
|
): AuthenticationResponseJSON {
|
||||||
|
// Find a matching credential
|
||||||
|
const allowedIds = options.allowCredentials?.map((c) => c.id) ?? [];
|
||||||
|
let credential: StoredCredential | undefined;
|
||||||
|
let credentialIdBase64: string | undefined;
|
||||||
|
|
||||||
|
for (const id of allowedIds) {
|
||||||
|
if (this._credentials.has(id)) {
|
||||||
|
credential = this._credentials.get(id);
|
||||||
|
credentialIdBase64 = id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(credential && credentialIdBase64)) {
|
||||||
|
throw new Error("No matching credential found in virtual authenticator");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment sign count
|
||||||
|
credential.signCount++;
|
||||||
|
|
||||||
|
// Build authenticator data (no attested credential data for assertions)
|
||||||
|
const rpId = options.rpId ?? new URL(this._origin).hostname;
|
||||||
|
const authData = this._buildAuthenticatorData(
|
||||||
|
rpId,
|
||||||
|
false, // no attestedCredentialData
|
||||||
|
credential.signCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build clientDataJSON
|
||||||
|
const clientDataJSON = JSON.stringify({
|
||||||
|
type: "webauthn.get",
|
||||||
|
challenge: options.challenge,
|
||||||
|
origin: this._origin,
|
||||||
|
crossOrigin: false,
|
||||||
|
});
|
||||||
|
const clientDataJSONBytes = new TextEncoder().encode(clientDataJSON);
|
||||||
|
|
||||||
|
// Create signature over authData || sha256(clientDataJSON)
|
||||||
|
const clientDataHash = createHash("sha256")
|
||||||
|
.update(clientDataJSONBytes)
|
||||||
|
.digest();
|
||||||
|
const signedData = Buffer.concat([authData, clientDataHash]);
|
||||||
|
|
||||||
|
const signature = createSign("sha256")
|
||||||
|
.update(signedData)
|
||||||
|
.sign(credential.privateKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: credentialIdBase64,
|
||||||
|
rawId: credentialIdBase64,
|
||||||
|
type: "public-key",
|
||||||
|
response: {
|
||||||
|
clientDataJSON: uint8ArrayToBase64url(clientDataJSONBytes),
|
||||||
|
authenticatorData: uint8ArrayToBase64url(authData),
|
||||||
|
signature: uint8ArrayToBase64url(new Uint8Array(signature)),
|
||||||
|
userHandle: uint8ArrayToBase64url(credential.userHandle),
|
||||||
|
},
|
||||||
|
clientExtensionResults: {},
|
||||||
|
authenticatorAttachment: "platform",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all stored credentials (for test assertions).
|
||||||
|
*/
|
||||||
|
getCredentials(): {
|
||||||
|
credentialId: string;
|
||||||
|
rpId: string;
|
||||||
|
signCount: number;
|
||||||
|
}[] {
|
||||||
|
return Array.from(this._credentials.entries()).map(([id, cred]) => ({
|
||||||
|
credentialId: id,
|
||||||
|
rpId: cred.rpId,
|
||||||
|
signCount: cred.signCount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all stored credentials.
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this._credentials.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the sign count for a credential (for testing counter replay attacks).
|
||||||
|
* @param credentialId - The base64url-encoded credential ID
|
||||||
|
* @param signCount - The sign count to set
|
||||||
|
*/
|
||||||
|
setSignCount(credentialId: string, signCount: number): void {
|
||||||
|
const credential = this._credentials.get(credentialId);
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error(`Credential not found: ${credentialId}`);
|
||||||
|
}
|
||||||
|
credential.signCount = signCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds WebAuthn authenticator data.
|
||||||
|
*/
|
||||||
|
private _buildAuthenticatorData(
|
||||||
|
rpId: string,
|
||||||
|
includeAttestedCredentialData: boolean,
|
||||||
|
signCount: number,
|
||||||
|
attestedCredentialData?: {
|
||||||
|
credentialId: Uint8Array;
|
||||||
|
publicKey: KeyObject;
|
||||||
|
},
|
||||||
|
): Uint8Array {
|
||||||
|
// RP ID hash (32 bytes)
|
||||||
|
const rpIdHash = createHash("sha256").update(rpId).digest();
|
||||||
|
|
||||||
|
// Flags (1 byte)
|
||||||
|
// Bit 0: User Present (UP) = 1
|
||||||
|
// Bit 2: User Verified (UV) = 1
|
||||||
|
// Bit 6: Attested credential data included (AT) = 1 if registration
|
||||||
|
let flags = 0x01 | 0x04; // UP + UV
|
||||||
|
if (includeAttestedCredentialData) {
|
||||||
|
flags |= 0x40; // AT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign count (4 bytes, big-endian)
|
||||||
|
const signCountBytes = new Uint8Array(4);
|
||||||
|
new DataView(signCountBytes.buffer).setUint32(0, signCount, false);
|
||||||
|
|
||||||
|
if (!(includeAttestedCredentialData && attestedCredentialData)) {
|
||||||
|
// Simple authenticator data (for assertions)
|
||||||
|
return new Uint8Array([...rpIdHash, flags, ...signCountBytes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build attested credential data for registration
|
||||||
|
const { credentialId, publicKey } = attestedCredentialData;
|
||||||
|
|
||||||
|
// AAGUID (16 bytes)
|
||||||
|
const aaguid = this._aaguid;
|
||||||
|
|
||||||
|
// Credential ID length (2 bytes, big-endian)
|
||||||
|
const credIdLenBytes = new Uint8Array(2);
|
||||||
|
new DataView(credIdLenBytes.buffer).setUint16(
|
||||||
|
0,
|
||||||
|
credentialId.length,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// COSE public key
|
||||||
|
const coseKey = this._buildCosePublicKey(publicKey);
|
||||||
|
|
||||||
|
return new Uint8Array([
|
||||||
|
...rpIdHash,
|
||||||
|
flags,
|
||||||
|
...signCountBytes,
|
||||||
|
...aaguid,
|
||||||
|
...credIdLenBytes,
|
||||||
|
...credentialId,
|
||||||
|
...coseKey,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a COSE-encoded public key for ES256 (P-256).
|
||||||
|
*
|
||||||
|
* COSE key format for ES256:
|
||||||
|
* {
|
||||||
|
* 1: 2, // kty: EC2
|
||||||
|
* 3: -7, // alg: ES256
|
||||||
|
* -1: 1, // crv: P-256
|
||||||
|
* -2: x, // x-coordinate (32 bytes)
|
||||||
|
* -3: y // y-coordinate (32 bytes)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private _buildCosePublicKey(publicKey: KeyObject): Uint8Array {
|
||||||
|
// Export public key in JWK format to get x and y coordinates
|
||||||
|
const jwk = publicKey.export({ format: "jwk" });
|
||||||
|
|
||||||
|
if (!(jwk.x && jwk.y)) {
|
||||||
|
throw new Error("Failed to export public key coordinates");
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = base64urlToUint8Array(jwk.x);
|
||||||
|
const y = base64urlToUint8Array(jwk.y);
|
||||||
|
|
||||||
|
// Build COSE key manually to avoid cbor-x adding tags to Map
|
||||||
|
// CBOR map with 5 items: A5
|
||||||
|
// Key 1 (kty): 01 -> Value 2: 02
|
||||||
|
// Key 3 (alg): 03 -> Value -7: 26 (negative int -7 = 0x20 + 6)
|
||||||
|
// Key -1 (crv): 20 (negative int -1 = 0x20 + 0) -> Value 1: 01
|
||||||
|
// Key -2 (x): 21 (negative int -2 = 0x20 + 1) -> Value: bytes(32)
|
||||||
|
// Key -3 (y): 22 (negative int -3 = 0x20 + 2) -> Value: bytes(32)
|
||||||
|
const coseKey = new Uint8Array([
|
||||||
|
0xa5, // map(5)
|
||||||
|
0x01,
|
||||||
|
0x02, // 1: 2 (kty: EC2)
|
||||||
|
0x03,
|
||||||
|
0x26, // 3: -7 (alg: ES256)
|
||||||
|
0x20,
|
||||||
|
0x01, // -1: 1 (crv: P-256)
|
||||||
|
0x21,
|
||||||
|
0x58,
|
||||||
|
0x20,
|
||||||
|
...x, // -2: bytes(32) x-coordinate
|
||||||
|
0x22,
|
||||||
|
0x58,
|
||||||
|
0x20,
|
||||||
|
...y, // -3: bytes(32) y-coordinate
|
||||||
|
]);
|
||||||
|
|
||||||
|
return coseKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an AAGUID string (e.g., "fbfc3007-154e-4ecc-8c0b-6e020557d7bd") to bytes.
|
||||||
|
*/
|
||||||
|
export function parseAaguid(aaguid: string): Uint8Array {
|
||||||
|
const hex = aaguid.replace(/-/g, "");
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
bytes[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Uint8Array to a base64url-encoded string.
|
||||||
|
*/
|
||||||
|
export function uint8ArrayToBase64url(bytes: Uint8Array): string {
|
||||||
|
return Buffer.from(bytes).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a base64url-encoded string to a Uint8Array.
|
||||||
|
*/
|
||||||
|
export function base64urlToUint8Array(base64url: string): Uint8Array {
|
||||||
|
return new Uint8Array(Buffer.from(base64url, "base64url"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually encode the attestation object in CBOR format.
|
||||||
|
* This avoids cbor-x adding tags that break @simplewebauthn/server parsing.
|
||||||
|
*
|
||||||
|
* CBOR structure:
|
||||||
|
* A3 - map(3)
|
||||||
|
* 63 "fmt" -> 64 "none"
|
||||||
|
* 67 "attStmt" -> A0 (empty map)
|
||||||
|
* 68 "authData" -> bytes(authData)
|
||||||
|
*/
|
||||||
|
function encodeAttestationObject(authData: Uint8Array): Uint8Array {
|
||||||
|
// Encode "fmt" key (text string, 3 bytes)
|
||||||
|
const fmtKey = new Uint8Array([0x63, 0x66, 0x6d, 0x74]); // text(3) "fmt"
|
||||||
|
// Encode "none" value (text string, 4 bytes)
|
||||||
|
const noneValue = new Uint8Array([0x64, 0x6e, 0x6f, 0x6e, 0x65]); // text(4) "none"
|
||||||
|
|
||||||
|
// Encode "attStmt" key (text string, 7 bytes)
|
||||||
|
const attStmtKey = new Uint8Array([
|
||||||
|
0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74,
|
||||||
|
]); // text(7) "attStmt"
|
||||||
|
// Encode empty map value
|
||||||
|
const emptyMap = new Uint8Array([0xa0]); // map(0)
|
||||||
|
|
||||||
|
// Encode "authData" key (text string, 8 bytes)
|
||||||
|
const authDataKey = new Uint8Array([
|
||||||
|
0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61,
|
||||||
|
]); // text(8) "authData"
|
||||||
|
|
||||||
|
// Encode authData value as bytes
|
||||||
|
// For lengths 24-255, use 0x58 + 1-byte length
|
||||||
|
// For lengths 256-65535, use 0x59 + 2-byte length
|
||||||
|
let authDataEncoded: Uint8Array;
|
||||||
|
if (authData.length < 24) {
|
||||||
|
authDataEncoded = new Uint8Array([0x40 + authData.length, ...authData]);
|
||||||
|
} else if (authData.length < 256) {
|
||||||
|
authDataEncoded = new Uint8Array([0x58, authData.length, ...authData]);
|
||||||
|
} else {
|
||||||
|
authDataEncoded = new Uint8Array([
|
||||||
|
0x59,
|
||||||
|
(authData.length >> 8) & 0xff,
|
||||||
|
authData.length & 0xff,
|
||||||
|
...authData,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine into final CBOR map(3)
|
||||||
|
return new Uint8Array([
|
||||||
|
0xa3, // map(3)
|
||||||
|
...fmtKey,
|
||||||
|
...noneValue,
|
||||||
|
...attStmtKey,
|
||||||
|
...emptyMap,
|
||||||
|
...authDataKey,
|
||||||
|
...authDataEncoded,
|
||||||
|
]);
|
||||||
|
}
|
||||||
15
packages/testing/virtual-authenticator/tsconfig.json
Normal file
15
packages/testing/virtual-authenticator/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"composite": true,
|
||||||
|
"types": ["node", "bun"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
11
turbo.json
11
turbo.json
@@ -18,6 +18,17 @@
|
|||||||
},
|
},
|
||||||
"clean": {
|
"clean": {
|
||||||
"cache": false
|
"cache": false
|
||||||
|
},
|
||||||
|
"test:e2e": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"inputs": ["src/**/*.ts", "src/**/*.test.ts"],
|
||||||
|
"env": ["TEST_DATABASE_URL"],
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"test:unit": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"inputs": ["src/**/*.ts", "src/**/*.test.ts"],
|
||||||
|
"cache": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user