- Add formatError() helper in CLI to safely handle unknown error types - Add uniqueTestId() helper for generating unique test identifiers - Replace String(id) with id.toString() for database ID conversions - Replace String(n) with n.toLocaleString() for user-facing number formatting - Fix TypeScript errors in test files (undefined checks, unused variables) - Update lint commands to include ast-grep scanning Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1219 lines
42 KiB
TypeScript
1219 lines
42 KiB
TypeScript
/**
|
|
* End-to-end tests for WebAuthn functionality
|
|
*
|
|
* These tests use a real PostgreSQL database and a virtual authenticator
|
|
* to exercise the full WebAuthn registration and authentication flows.
|
|
*
|
|
* All tests call router handlers directly via `call()` from @orpc/server.
|
|
*/
|
|
|
|
import type { Database } from "@reviq/db-schema";
|
|
import type { Kysely } from "kysely";
|
|
import type { APIContext } from "../../context.js";
|
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
import { call } from "@orpc/server";
|
|
import {
|
|
createTestUser,
|
|
describeE2E,
|
|
destroySharedDb,
|
|
getSharedDb,
|
|
initTestDb,
|
|
KNOWN_AAGUIDS,
|
|
TEST_RP,
|
|
uniqueTestId,
|
|
withTestTransaction,
|
|
} from "@reviq/test-helpers";
|
|
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
|
|
import { router } from "../../router.js";
|
|
import { COOKIE_NAMES } from "../../utils/cookies.js";
|
|
import { hashToken } from "../../utils/crypto.js";
|
|
import { getUserPasskeys } from "../../utils/webauthn.js";
|
|
|
|
/** Session expiry duration: 24 hours in milliseconds */
|
|
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* Create an API context with optional session token
|
|
*/
|
|
function createAPIContext(
|
|
db: Kysely<Database>,
|
|
sessionToken?: string,
|
|
): APIContext {
|
|
const reqHeaders = new Headers();
|
|
if (sessionToken) {
|
|
reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`);
|
|
}
|
|
|
|
return {
|
|
db,
|
|
origin: TEST_RP.origin,
|
|
allowedOrigins: [...TEST_RP.allowedOrigins],
|
|
rpName: TEST_RP.rpName,
|
|
reqHeaders,
|
|
resHeaders: new Headers(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a real session in the database and return the token
|
|
*/
|
|
async function createSession(
|
|
db: Kysely<Database>,
|
|
userId: number,
|
|
): Promise<string> {
|
|
const token = `test-session-${uniqueTestId()}`;
|
|
const tokenHashValue = await hashToken(token);
|
|
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
|
|
|
await db
|
|
.insertInto("sessions")
|
|
.values({
|
|
user_id: userId,
|
|
token_hash: tokenHashValue,
|
|
ip_address: "127.0.0.1",
|
|
user_agent: "test-agent",
|
|
expires_at: expiresAt,
|
|
trusted_mode: false,
|
|
})
|
|
.execute();
|
|
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* Create a login request in the database and return ID and token
|
|
*/
|
|
async function createLoginRequest(
|
|
db: Kysely<Database>,
|
|
userId: number,
|
|
email: string,
|
|
): Promise<{ id: number; token: string }> {
|
|
const token = `test-login-${uniqueTestId()}`;
|
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
|
|
|
const result = await db
|
|
.insertInto("login_requests")
|
|
.values({
|
|
user_id: userId,
|
|
email,
|
|
token,
|
|
expires_at: expiresAt,
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return { id: Number(result.id), token };
|
|
}
|
|
|
|
/**
|
|
* Create an authenticated API context for a user (creates session + context)
|
|
*/
|
|
async function createUserAPIContext(
|
|
db: Kysely<Database>,
|
|
userId: number,
|
|
): Promise<APIContext> {
|
|
const sessionToken = await createSession(db, userId);
|
|
return createAPIContext(db, sessionToken);
|
|
}
|
|
|
|
/**
|
|
* Create an API context with login request cookie
|
|
*/
|
|
function createLoginRequestContext(
|
|
db: Kysely<Database>,
|
|
loginToken: string,
|
|
): APIContext {
|
|
const reqHeaders = new Headers();
|
|
reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`);
|
|
|
|
return {
|
|
db,
|
|
origin: TEST_RP.origin,
|
|
allowedOrigins: [...TEST_RP.allowedOrigins],
|
|
rpName: TEST_RP.rpName,
|
|
reqHeaders,
|
|
resHeaders: new Headers(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper to get the first element of an array, throwing if empty.
|
|
* Provides consistent null checking across tests.
|
|
*/
|
|
function expectFirst<T>(arr: T[], message: string): T {
|
|
const first = arr[0];
|
|
if (!first) {
|
|
throw new Error(message);
|
|
}
|
|
return first;
|
|
}
|
|
|
|
/**
|
|
* Register a passkey using router handlers.
|
|
* Shared helper to avoid duplication across test suites.
|
|
*/
|
|
async function registerPasskey(
|
|
db: Kysely<Database>,
|
|
userId: number,
|
|
email: string,
|
|
authenticator: VirtualAuthenticator,
|
|
) {
|
|
const apiCtx = createAPIContext(db);
|
|
const authCtx = await createUserAPIContext(db, userId);
|
|
|
|
const { options, challengeId } = await call(
|
|
router.auth.webauthn.createRegistrationOptions,
|
|
{ email },
|
|
{ context: apiCtx },
|
|
);
|
|
const response = authenticator.createCredential(options);
|
|
await call(
|
|
router.auth.webauthn.verifyRegistration,
|
|
{ challengeId, response },
|
|
{ context: authCtx },
|
|
);
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Authenticate using router handlers.
|
|
* Shared helper to avoid duplication across test suites.
|
|
*/
|
|
async function authenticate(
|
|
db: Kysely<Database>,
|
|
userId: number,
|
|
email: string,
|
|
authenticator: VirtualAuthenticator,
|
|
) {
|
|
const { token: loginToken } = await createLoginRequest(db, userId, email);
|
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
|
|
|
const { options, challengeId } = await call(
|
|
router.auth.webauthn.createAuthenticationOptions,
|
|
undefined,
|
|
{ context: loginCtx },
|
|
);
|
|
const response = authenticator.getAssertion(options);
|
|
await call(
|
|
router.auth.webauthn.verifyAuthentication,
|
|
{ challengeId, response },
|
|
{ context: loginCtx },
|
|
);
|
|
}
|
|
|
|
describeE2E("webauthn", () => {
|
|
beforeAll(async () => {
|
|
await initTestDb();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await destroySharedDb();
|
|
});
|
|
|
|
describe("registration flow", () => {
|
|
test("creates registration options with challenge stored in DB via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "reg-options@test.com",
|
|
});
|
|
const ctx = createAPIContext(db);
|
|
|
|
// Call router handler directly
|
|
const { options, challengeId } = await call(
|
|
router.auth.webauthn.createRegistrationOptions,
|
|
{ email: user.email },
|
|
{ context: ctx },
|
|
);
|
|
|
|
// Verify options structure
|
|
expect(options.challenge).toBeDefined();
|
|
expect(options.rp.name).toBe(TEST_RP.rpName);
|
|
expect(options.rp.id).toBe(TEST_RP.rpID);
|
|
// user.name is displayName if available, otherwise email
|
|
expect(options.user.name).toBe("Test User");
|
|
expect(challengeId).toBeGreaterThan(0);
|
|
|
|
// Verify challenge is stored in database
|
|
const challengeRow = await db
|
|
.selectFrom("webauthn_challenges")
|
|
.select("id")
|
|
.where("id", "=", challengeId.toString())
|
|
.executeTakeFirst();
|
|
|
|
expect(challengeRow).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("verifies valid registration and stores passkey via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "reg-verify@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
// Create registration options via router
|
|
const apiCtx = createAPIContext(db);
|
|
const { options, challengeId } = await call(
|
|
router.auth.webauthn.createRegistrationOptions,
|
|
{ email: user.email },
|
|
{ context: apiCtx },
|
|
);
|
|
|
|
// Create credential with virtual authenticator
|
|
const response = authenticator.createCredential(options);
|
|
|
|
// Verify registration via router (requires authenticated session)
|
|
const authCtx = await createUserAPIContext(db, user.id);
|
|
await call(
|
|
router.auth.webauthn.verifyRegistration,
|
|
{ challengeId, response },
|
|
{ context: authCtx },
|
|
);
|
|
|
|
// Verify passkey is stored in database
|
|
const passkeys = await getUserPasskeys(db, user.id);
|
|
expect(passkeys).toHaveLength(1);
|
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
expect(firstPasskey.rpid).toBe(TEST_RP.rpID);
|
|
expect(firstPasskey.counter).toBe(0);
|
|
});
|
|
});
|
|
|
|
test("excludes existing passkeys for returning users via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "exclude-test@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
const apiCtx = createAPIContext(db);
|
|
const authCtx = await createUserAPIContext(db, user.id);
|
|
|
|
// Register first passkey via router
|
|
const { options: options1, challengeId: challengeId1 } = await call(
|
|
router.auth.webauthn.createRegistrationOptions,
|
|
{ email: user.email },
|
|
{ context: apiCtx },
|
|
);
|
|
const response1 = authenticator.createCredential(options1);
|
|
await call(
|
|
router.auth.webauthn.verifyRegistration,
|
|
{ challengeId: challengeId1, response: response1 },
|
|
{ context: authCtx },
|
|
);
|
|
|
|
// Get second registration options via router
|
|
const { options: options2 } = await call(
|
|
router.auth.webauthn.createRegistrationOptions,
|
|
{ email: user.email },
|
|
{ context: apiCtx },
|
|
);
|
|
|
|
// Should have excludeCredentials with the first passkey
|
|
expect(options2.excludeCredentials).toHaveLength(1);
|
|
const excludedCred = expectFirst(
|
|
options2.excludeCredentials ?? [],
|
|
"Expected excluded credential to exist",
|
|
);
|
|
expect(excludedCred.id).toBe(response1.id);
|
|
});
|
|
});
|
|
|
|
test("assigns friendly name from known AAGUID via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "aaguid-test@test.com",
|
|
});
|
|
|
|
// Use iCloud Keychain AAGUID
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN,
|
|
});
|
|
|
|
const apiCtx = createAPIContext(db);
|
|
const authCtx = await createUserAPIContext(db, user.id);
|
|
|
|
const { options, challengeId } = await call(
|
|
router.auth.webauthn.createRegistrationOptions,
|
|
{ email: user.email },
|
|
{ context: apiCtx },
|
|
);
|
|
const response = authenticator.createCredential(options);
|
|
await call(
|
|
router.auth.webauthn.verifyRegistration,
|
|
{ challengeId, response },
|
|
{ context: authCtx },
|
|
);
|
|
|
|
const passkeys = await getUserPasskeys(db, user.id);
|
|
expect(passkeys).toHaveLength(1);
|
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
expect(firstPasskey.name).toBe("iCloud Keychain");
|
|
});
|
|
});
|
|
|
|
test("cleans up challenge after verification via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "cleanup-test@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
const apiCtx = createAPIContext(db);
|
|
const authCtx = await createUserAPIContext(db, user.id);
|
|
|
|
const { options, challengeId } = await call(
|
|
router.auth.webauthn.createRegistrationOptions,
|
|
{ email: user.email },
|
|
{ context: apiCtx },
|
|
);
|
|
const response = authenticator.createCredential(options);
|
|
await call(
|
|
router.auth.webauthn.verifyRegistration,
|
|
{ challengeId, response },
|
|
{ context: authCtx },
|
|
);
|
|
|
|
// Challenge should be deleted
|
|
const challengeRow = await db
|
|
.selectFrom("webauthn_challenges")
|
|
.select("id")
|
|
.where("id", "=", challengeId.toString())
|
|
.executeTakeFirst();
|
|
|
|
expect(challengeRow).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
test("rejects expired/missing challenges via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "expired-test@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
const apiCtx = createAPIContext(db);
|
|
const authCtx = await createUserAPIContext(db, user.id);
|
|
|
|
// Create options via router
|
|
const { options } = await call(
|
|
router.auth.webauthn.createRegistrationOptions,
|
|
{ email: user.email },
|
|
{ context: apiCtx },
|
|
);
|
|
const response = authenticator.createCredential(options);
|
|
|
|
// Use a non-existent challenge ID - should fail
|
|
try {
|
|
await call(
|
|
router.auth.webauthn.verifyRegistration,
|
|
{ challengeId: 999999, response },
|
|
{ context: authCtx },
|
|
);
|
|
throw new Error("Expected verification to fail");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toContain("Registration timed out");
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("authentication flow", () => {
|
|
test("creates authentication options with user's passkeys via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "auth-options@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
// Register a passkey first via router
|
|
const regResponse = await registerPasskey(
|
|
db,
|
|
user.id,
|
|
user.email,
|
|
authenticator,
|
|
);
|
|
|
|
// Create authentication options via router
|
|
const { token: loginToken } = await createLoginRequest(
|
|
db,
|
|
user.id,
|
|
user.email,
|
|
);
|
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
|
const { options, challengeId } = await call(
|
|
router.auth.webauthn.createAuthenticationOptions,
|
|
undefined,
|
|
{ context: loginCtx },
|
|
);
|
|
|
|
expect(options.challenge).toBeDefined();
|
|
expect(options.rpId).toBe(TEST_RP.rpID);
|
|
expect(options.allowCredentials).toHaveLength(1);
|
|
const allowedCred = expectFirst(
|
|
options.allowCredentials ?? [],
|
|
"Expected allowed credential to exist",
|
|
);
|
|
expect(allowedCred.id).toBe(regResponse.id);
|
|
expect(challengeId).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
test("verifies valid authentication and updates counter via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "auth-verify@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
// Register passkey via router
|
|
await registerPasskey(db, user.id, user.email, authenticator);
|
|
|
|
// Authenticate via router
|
|
const { token: loginToken } = await createLoginRequest(
|
|
db,
|
|
user.id,
|
|
user.email,
|
|
);
|
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
|
const { options: authOptions, challengeId: authChallengeId } =
|
|
await call(
|
|
router.auth.webauthn.createAuthenticationOptions,
|
|
undefined,
|
|
{ context: loginCtx },
|
|
);
|
|
const authResponse = authenticator.getAssertion(authOptions);
|
|
|
|
await call(
|
|
router.auth.webauthn.verifyAuthentication,
|
|
{ challengeId: authChallengeId, response: authResponse },
|
|
{ context: loginCtx },
|
|
);
|
|
|
|
// Verify counter was updated
|
|
const passkeys = await getUserPasskeys(db, user.id);
|
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
expect(firstPasskey.counter).toBe(1);
|
|
});
|
|
});
|
|
|
|
test("updates last_used_at timestamp via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, { email: "last-used@test.com" });
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
// Register passkey via router
|
|
await registerPasskey(db, user.id, user.email, authenticator);
|
|
|
|
// Check initial state
|
|
let passkeys = await getUserPasskeys(db, user.id);
|
|
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
expect(firstPasskey.lastUsedAt).toBeNull();
|
|
|
|
// Authenticate via router
|
|
const { token: loginToken } = await createLoginRequest(
|
|
db,
|
|
user.id,
|
|
user.email,
|
|
);
|
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
|
const { options: authOptions, challengeId: authChallengeId } =
|
|
await call(
|
|
router.auth.webauthn.createAuthenticationOptions,
|
|
undefined,
|
|
{ context: loginCtx },
|
|
);
|
|
const authResponse = authenticator.getAssertion(authOptions);
|
|
await call(
|
|
router.auth.webauthn.verifyAuthentication,
|
|
{ challengeId: authChallengeId, response: authResponse },
|
|
{ context: loginCtx },
|
|
);
|
|
|
|
// Check last_used_at is now set
|
|
passkeys = await getUserPasskeys(db, user.id);
|
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
expect(firstPasskey.lastUsedAt).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
test("cleans up challenge after authentication via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "auth-cleanup@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
// Register passkey via router
|
|
await registerPasskey(db, user.id, user.email, authenticator);
|
|
|
|
// Authenticate via router
|
|
const { token: loginToken } = await createLoginRequest(
|
|
db,
|
|
user.id,
|
|
user.email,
|
|
);
|
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
|
const { options: authOptions, challengeId: authChallengeId } =
|
|
await call(
|
|
router.auth.webauthn.createAuthenticationOptions,
|
|
undefined,
|
|
{ context: loginCtx },
|
|
);
|
|
const authResponse = authenticator.getAssertion(authOptions);
|
|
await call(
|
|
router.auth.webauthn.verifyAuthentication,
|
|
{ challengeId: authChallengeId, response: authResponse },
|
|
{ context: loginCtx },
|
|
);
|
|
|
|
// Challenge should be deleted
|
|
const challengeRow = await db
|
|
.selectFrom("webauthn_challenges")
|
|
.select("id")
|
|
.where("id", "=", authChallengeId.toString())
|
|
.executeTakeFirst();
|
|
|
|
expect(challengeRow).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
test("rejects unknown credential IDs", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "unknown-cred@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
// Register passkey via router
|
|
await registerPasskey(db, user.id, user.email, authenticator);
|
|
|
|
// Create auth options via router
|
|
const { token: loginToken } = await createLoginRequest(
|
|
db,
|
|
user.id,
|
|
user.email,
|
|
);
|
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
|
const { options: authOptions } = await call(
|
|
router.auth.webauthn.createAuthenticationOptions,
|
|
undefined,
|
|
{ context: loginCtx },
|
|
);
|
|
|
|
// Use a fresh authenticator that doesn't have the registered credential
|
|
const freshAuthenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
// First create a credential so the authenticator has something (use same registration options)
|
|
const apiCtx = createAPIContext(db);
|
|
const { options: regOptions } = await call(
|
|
router.auth.webauthn.createRegistrationOptions,
|
|
{ email: user.email },
|
|
{ context: apiCtx },
|
|
);
|
|
freshAuthenticator.createCredential(regOptions);
|
|
|
|
// This should fail because the fresh authenticator doesn't have the right credential
|
|
try {
|
|
freshAuthenticator.getAssertion(authOptions);
|
|
throw new Error("Expected assertion to fail");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toContain(
|
|
"No matching credential found",
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("security tests", () => {
|
|
test("rejects replayed credentials (counter check) via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "counter-replay@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
// Register passkey via router
|
|
const regResponse = await registerPasskey(
|
|
db,
|
|
user.id,
|
|
user.email,
|
|
authenticator,
|
|
);
|
|
|
|
// First authentication should succeed
|
|
await authenticate(db, user.id, user.email, authenticator);
|
|
|
|
// Verify counter was updated to 1
|
|
let passkeys = await getUserPasskeys(db, user.id);
|
|
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
expect(firstPasskey.counter).toBe(1);
|
|
|
|
// Reset the authenticator's sign count to 0 (simulating replay attack)
|
|
authenticator.setSignCount(regResponse.id, 0);
|
|
|
|
// Create a new authentication challenge
|
|
const { token: loginToken } = await createLoginRequest(
|
|
db,
|
|
user.id,
|
|
user.email,
|
|
);
|
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
|
const { options, challengeId } = await call(
|
|
router.auth.webauthn.createAuthenticationOptions,
|
|
undefined,
|
|
{ context: loginCtx },
|
|
);
|
|
|
|
// Get assertion with replayed (lower) counter
|
|
const authResponse = authenticator.getAssertion(options);
|
|
|
|
// Verify authentication should fail due to counter replay (throws an error)
|
|
try {
|
|
await call(
|
|
router.auth.webauthn.verifyAuthentication,
|
|
{ challengeId, response: authResponse },
|
|
{ context: loginCtx },
|
|
);
|
|
throw new Error("Expected verification to fail");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toContain("counter");
|
|
}
|
|
|
|
// Counter should not have changed
|
|
passkeys = await getUserPasskeys(db, user.id);
|
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
expect(firstPasskey.counter).toBe(1);
|
|
});
|
|
});
|
|
|
|
test("rejects tampered authentication response", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "tampered-response@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
// Register passkey via router
|
|
await registerPasskey(db, user.id, user.email, authenticator);
|
|
|
|
// Create authentication challenge
|
|
const { token: loginToken } = await createLoginRequest(
|
|
db,
|
|
user.id,
|
|
user.email,
|
|
);
|
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
|
const { options, challengeId } = await call(
|
|
router.auth.webauthn.createAuthenticationOptions,
|
|
undefined,
|
|
{ context: loginCtx },
|
|
);
|
|
|
|
// Get valid assertion
|
|
const authResponse = authenticator.getAssertion(options);
|
|
|
|
// Tamper with the authenticatorData (flip some bits in the middle)
|
|
// This causes signature verification to fail without breaking ASN.1 parsing
|
|
const tamperedAuthData = Buffer.from(
|
|
authResponse.response.authenticatorData,
|
|
"base64url",
|
|
);
|
|
// Ensure buffer is long enough (authenticatorData is always > 37 bytes)
|
|
if (tamperedAuthData.length < 21) {
|
|
throw new Error("authenticatorData too short for tampering");
|
|
}
|
|
const originalByte = tamperedAuthData[20];
|
|
if (originalByte === undefined) {
|
|
throw new Error("Failed to read byte at index 20");
|
|
}
|
|
tamperedAuthData[20] = originalByte ^ 0xff; // Flip bits in a byte (within rpIdHash)
|
|
const tamperedResponse = {
|
|
...authResponse,
|
|
response: {
|
|
...authResponse.response,
|
|
authenticatorData: tamperedAuthData.toString("base64url"),
|
|
},
|
|
};
|
|
|
|
// Verify authentication should fail due to tampering (throws an error)
|
|
try {
|
|
await call(
|
|
router.auth.webauthn.verifyAuthentication,
|
|
{ challengeId, response: tamperedResponse },
|
|
{ context: loginCtx },
|
|
);
|
|
throw new Error("Expected verification to fail");
|
|
} catch (error) {
|
|
// Tampering should cause verification to fail with an error
|
|
expect(error).toBeInstanceOf(Error);
|
|
// Should not be our sentinel error
|
|
expect((error as Error).message).not.toBe(
|
|
"Expected verification to fail",
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("full passkey lifecycle", () => {
|
|
test("register → authenticate → add second passkey → authenticate with either via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, { email: "lifecycle@test.com" });
|
|
const authenticator1 = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
const authenticator2 = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
// Register first passkey via router
|
|
await registerPasskey(db, user.id, user.email, authenticator1);
|
|
|
|
// Authenticate with first passkey via router
|
|
await authenticate(db, user.id, user.email, authenticator1);
|
|
|
|
// Register second passkey via router
|
|
await registerPasskey(db, user.id, user.email, authenticator2);
|
|
|
|
// Verify user now has 2 passkeys
|
|
const passkeys = await getUserPasskeys(db, user.id);
|
|
expect(passkeys).toHaveLength(2);
|
|
|
|
// Authenticate with second passkey via router
|
|
await authenticate(db, user.id, user.email, authenticator2);
|
|
|
|
// Authenticate with first passkey again via router
|
|
await authenticate(db, user.id, user.email, authenticator1);
|
|
});
|
|
});
|
|
|
|
test("register → authenticate multiple times → counter increments via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "counter-test@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
// Register passkey via router
|
|
await registerPasskey(db, user.id, user.email, authenticator);
|
|
|
|
// Verify initial counter
|
|
let passkeys = await getUserPasskeys(db, user.id);
|
|
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
expect(firstPasskey.counter).toBe(0);
|
|
|
|
// Authenticate 5 times via router
|
|
for (let i = 1; i <= 5; i++) {
|
|
await authenticate(db, user.id, user.email, authenticator);
|
|
|
|
// Verify counter incremented
|
|
passkeys = await getUserPasskeys(db, user.id);
|
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
expect(firstPasskey.counter).toBe(i);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("passkey management", () => {
|
|
test("lists passkeys with correct data via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "list-passkeys@test.com",
|
|
});
|
|
const authenticator1 = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN,
|
|
});
|
|
const authenticator2 = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
aaguid: KNOWN_AAGUIDS.GOOGLE_PASSWORD_MANAGER,
|
|
});
|
|
|
|
// Register two passkeys
|
|
await registerPasskey(db, user.id, user.email, authenticator1);
|
|
await registerPasskey(db, user.id, user.email, authenticator2);
|
|
|
|
// List passkeys via router handler
|
|
const ctx = await createUserAPIContext(db, user.id);
|
|
const passkeys = await call(router.me.passkeys.list, undefined, {
|
|
context: ctx,
|
|
});
|
|
|
|
expect(passkeys).toHaveLength(2);
|
|
|
|
// Verify first passkey data (router returns id, name, createdAt, lastUsedAt)
|
|
const icloudPasskey = passkeys.find(
|
|
(p) => p.name === "iCloud Keychain",
|
|
);
|
|
if (!icloudPasskey) {
|
|
throw new Error("Expected iCloud Keychain passkey to exist");
|
|
}
|
|
expect(icloudPasskey.id).toBeGreaterThan(0);
|
|
expect(icloudPasskey.createdAt).toBeInstanceOf(Date);
|
|
expect(icloudPasskey.lastUsedAt).toBeNull();
|
|
|
|
// Verify second passkey data
|
|
const googlePasskey = passkeys.find(
|
|
(p) => p.name === "Google Password Manager",
|
|
);
|
|
if (!googlePasskey) {
|
|
throw new Error("Expected Google Password Manager passkey to exist");
|
|
}
|
|
});
|
|
});
|
|
|
|
test("passkey stores correct device type and backup status", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "device-type@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
await registerPasskey(db, user.id, user.email, authenticator);
|
|
|
|
const passkeys = await getUserPasskeys(db, user.id);
|
|
expect(passkeys).toHaveLength(1);
|
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
|
|
// Virtual authenticator creates singleDevice passkeys (no backup flags set)
|
|
expect(firstPasskey.deviceType).toBe("singleDevice");
|
|
expect(firstPasskey.backupEligible).toBe(false);
|
|
expect(firstPasskey.backupStatus).toBe(false);
|
|
});
|
|
});
|
|
|
|
test("renames passkey successfully via router", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "rename-test@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
await registerPasskey(db, user.id, user.email, authenticator);
|
|
|
|
const ctx = await createUserAPIContext(db, user.id);
|
|
let passkeys = await call(router.me.passkeys.list, undefined, {
|
|
context: ctx,
|
|
});
|
|
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
const passkeyId = firstPasskey.id;
|
|
const originalName = firstPasskey.name;
|
|
|
|
// Rename the passkey via router handler
|
|
const newName = "My MacBook Pro";
|
|
await call(
|
|
router.me.passkeys.rename,
|
|
{ passkeyId, name: newName },
|
|
{ context: ctx },
|
|
);
|
|
|
|
// Verify name changed
|
|
passkeys = await call(router.me.passkeys.list, undefined, {
|
|
context: ctx,
|
|
});
|
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
expect(firstPasskey.name).toBe(newName);
|
|
expect(firstPasskey.name).not.toBe(originalName);
|
|
});
|
|
});
|
|
|
|
test("rename does not affect other user's passkeys", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user1 = await createTestUser(db, {
|
|
email: "rename-user1@test.com",
|
|
});
|
|
const user2 = await createTestUser(db, {
|
|
email: "rename-user2@test.com",
|
|
});
|
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
|
|
|
await registerPasskey(db, user1.id, user1.email, auth1);
|
|
await registerPasskey(db, user2.id, user2.email, auth2);
|
|
|
|
const ctx1 = await createUserAPIContext(db, user1.id);
|
|
const ctx2 = await createUserAPIContext(db, user2.id);
|
|
|
|
const user2Passkeys = await call(router.me.passkeys.list, undefined, {
|
|
context: ctx2,
|
|
});
|
|
const user2FirstPasskey = user2Passkeys[0];
|
|
if (!user2FirstPasskey) {
|
|
throw new Error("Expected user2 passkey to exist");
|
|
}
|
|
|
|
// Try to rename user2's passkey using user1's context (should throw NOT_FOUND)
|
|
try {
|
|
await call(
|
|
router.me.passkeys.rename,
|
|
{ passkeyId: user2FirstPasskey.id, name: "Hacked Name" },
|
|
{ context: ctx1 },
|
|
);
|
|
throw new Error("Expected rename to fail with NOT_FOUND");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toContain("Passkey not found");
|
|
}
|
|
|
|
// User2's passkey should be unchanged
|
|
const user2PasskeysAfter = await call(
|
|
router.me.passkeys.list,
|
|
undefined,
|
|
{
|
|
context: ctx2,
|
|
},
|
|
);
|
|
const user2FirstPasskeyAfter = user2PasskeysAfter[0];
|
|
if (!user2FirstPasskeyAfter) {
|
|
throw new Error("Expected user2 passkey to exist after");
|
|
}
|
|
expect(user2FirstPasskeyAfter.name).toBe(user2FirstPasskey.name);
|
|
});
|
|
});
|
|
|
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
|
test("deletes passkey when user has password via router", async () => {
|
|
const db = getSharedDb();
|
|
const user = await createTestUser(db, {
|
|
email: "delete-with-password@test.com",
|
|
passwordHash: "fake-password-hash",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
await registerPasskey(db, user.id, user.email, authenticator);
|
|
|
|
const ctx = await createUserAPIContext(db, user.id);
|
|
let passkeys = await call(router.me.passkeys.list, undefined, {
|
|
context: ctx,
|
|
});
|
|
expect(passkeys).toHaveLength(1);
|
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
const passkeyId = firstPasskey.id;
|
|
|
|
// Delete the passkey via router (should work because user has password)
|
|
await call(router.me.passkeys.delete, { passkeyId }, { context: ctx });
|
|
|
|
// Verify passkey is deleted
|
|
passkeys = await call(router.me.passkeys.list, undefined, {
|
|
context: ctx,
|
|
});
|
|
expect(passkeys).toHaveLength(0);
|
|
});
|
|
|
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
|
test("deletes passkey when user has multiple passkeys via router", async () => {
|
|
const db = getSharedDb();
|
|
const user = await createTestUser(db, {
|
|
email: "delete-multi@test.com",
|
|
});
|
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
|
|
|
await registerPasskey(db, user.id, user.email, auth1);
|
|
await registerPasskey(db, user.id, user.email, auth2);
|
|
|
|
const ctx = await createUserAPIContext(db, user.id);
|
|
let passkeys = await call(router.me.passkeys.list, undefined, {
|
|
context: ctx,
|
|
});
|
|
expect(passkeys).toHaveLength(2);
|
|
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
const firstPasskeyId = firstPasskey.id;
|
|
|
|
// Delete first passkey via router (should work because user has another)
|
|
await call(
|
|
router.me.passkeys.delete,
|
|
{ passkeyId: firstPasskeyId },
|
|
{ context: ctx },
|
|
);
|
|
|
|
// Verify only one passkey remains
|
|
passkeys = await call(router.me.passkeys.list, undefined, {
|
|
context: ctx,
|
|
});
|
|
expect(passkeys).toHaveLength(1);
|
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
expect(firstPasskey.id).not.toBe(firstPasskeyId);
|
|
});
|
|
|
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
|
test("prevents deleting last passkey without password via router", async () => {
|
|
const db = getSharedDb();
|
|
const user = await createTestUser(db, {
|
|
email: "delete-last@test.com",
|
|
// No password set
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
await registerPasskey(db, user.id, user.email, authenticator);
|
|
|
|
const ctx = await createUserAPIContext(db, user.id);
|
|
const passkeys = await call(router.me.passkeys.list, undefined, {
|
|
context: ctx,
|
|
});
|
|
expect(passkeys).toHaveLength(1);
|
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
const passkeyId = firstPasskey.id;
|
|
|
|
// Try to delete the only passkey via router (should fail)
|
|
try {
|
|
await call(router.me.passkeys.delete, { passkeyId }, { context: ctx });
|
|
throw new Error("Expected deletion to fail");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toContain(
|
|
"Cannot delete the last passkey when you have no password set",
|
|
);
|
|
}
|
|
|
|
// Verify passkey still exists
|
|
const passkeysAfter = await call(router.me.passkeys.list, undefined, {
|
|
context: ctx,
|
|
});
|
|
expect(passkeysAfter).toHaveLength(1);
|
|
});
|
|
|
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
|
test("delete does not affect other user's passkeys via router", async () => {
|
|
const db = getSharedDb();
|
|
const user1 = await createTestUser(db, {
|
|
email: "delete-user1@test.com",
|
|
passwordHash: "fake-hash",
|
|
});
|
|
const user2 = await createTestUser(db, {
|
|
email: "delete-user2@test.com",
|
|
passwordHash: "fake-hash",
|
|
});
|
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
|
|
|
await registerPasskey(db, user1.id, user1.email, auth1);
|
|
await registerPasskey(db, user2.id, user2.email, auth2);
|
|
|
|
const ctx1 = await createUserAPIContext(db, user1.id);
|
|
const ctx2 = await createUserAPIContext(db, user2.id);
|
|
|
|
const user2Passkeys = await call(router.me.passkeys.list, undefined, {
|
|
context: ctx2,
|
|
});
|
|
const user2FirstPasskey = user2Passkeys[0];
|
|
if (!user2FirstPasskey) {
|
|
throw new Error("Expected user2 passkey to exist");
|
|
}
|
|
|
|
// Try to delete user2's passkey using user1's context (should throw NOT_FOUND)
|
|
try {
|
|
await call(
|
|
router.me.passkeys.delete,
|
|
{ passkeyId: user2FirstPasskey.id },
|
|
{ context: ctx1 },
|
|
);
|
|
throw new Error("Expected delete to fail with NOT_FOUND");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toContain("Passkey not found");
|
|
}
|
|
|
|
// User2's passkey should still exist
|
|
const user2PasskeysAfter = await call(
|
|
router.me.passkeys.list,
|
|
undefined,
|
|
{
|
|
context: ctx2,
|
|
},
|
|
);
|
|
expect(user2PasskeysAfter).toHaveLength(1);
|
|
});
|
|
|
|
test("passkey credentialId is unique and stored correctly", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "credential-id@test.com",
|
|
});
|
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
|
|
|
await registerPasskey(db, user.id, user.email, auth1);
|
|
await registerPasskey(db, user.id, user.email, auth2);
|
|
|
|
const passkeys = await getUserPasskeys(db, user.id);
|
|
expect(passkeys).toHaveLength(2);
|
|
const firstPasskey = passkeys[0];
|
|
const secondPasskey = passkeys[1];
|
|
if (!(firstPasskey && secondPasskey)) {
|
|
throw new Error("Expected both passkeys to exist");
|
|
}
|
|
|
|
// Credential IDs should be unique
|
|
expect(firstPasskey.credentialId).not.toBe(secondPasskey.credentialId);
|
|
|
|
// Credential IDs should be base64url encoded
|
|
expect(firstPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
expect(secondPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
});
|
|
});
|
|
|
|
test("passkey transports are stored and retrieved correctly", async () => {
|
|
await withTestTransaction(getSharedDb(), async (db) => {
|
|
const user = await createTestUser(db, {
|
|
email: "transports@test.com",
|
|
});
|
|
const authenticator = new VirtualAuthenticator({
|
|
origin: TEST_RP.origin,
|
|
});
|
|
|
|
await registerPasskey(db, user.id, user.email, authenticator);
|
|
|
|
const passkeys = await getUserPasskeys(db, user.id);
|
|
expect(passkeys).toHaveLength(1);
|
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
|
|
|
// Virtual authenticator sets transports to ["internal", "hybrid"]
|
|
expect(firstPasskey.transports).toContain("internal");
|
|
expect(firstPasskey.transports).toContain("hybrid");
|
|
});
|
|
});
|
|
});
|
|
}); // Close outer describe.skipIf
|