Files
publisher-dashboard/apps/api-server/src/__tests__/e2e/webauthn.test.ts
RevIQ 6b9b04d1d0 Improve API token format and enhance auth status command
- Change token format to reviq_<base58> prefix instead of raw hex
- Add me.authStatus API endpoint for detailed auth information
- Enhance CLI `reviq auth status` to show token details from API
- Add comprehensive tests for token generation (18 tests)
- Extract bootstrap logic to @reviq/db for reusability and testing
- Remove default db export; callers must use createDb() directly

Token changes:
- New format: reviq_<base58-encoded-32-bytes>
- Added parseToken() for validation
- Added isValidTokenFormat() helper

Auth status endpoint returns:
- User profile information
- Auth method (api_token or session)
- Token/session details (name, expiration, last used)

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

1049 lines
34 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,
AuthenticatedContext,
LoginRequestContext,
} from "../../context.js";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js";
import { getUserPasskeys } from "../../utils/webauthn.js";
import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js";
import {
createTestDb,
createTestUser,
destroyTestDb,
runMigrations,
truncateAllTables,
} from "../helpers/test-db.js";
let db: Kysely<Database> | undefined;
/**
* Get the database connection, throwing if not initialized
*/
function getDb(): Kysely<Database> {
if (!db) {
throw new Error("Database not initialized");
}
return db;
}
/**
* Create an API context (for public endpoints)
*/
function createAPIContext(): APIContext {
return {
db: getDb(),
origin: TEST_RP.origin,
allowedOrigins: [...TEST_RP.allowedOrigins],
rpName: TEST_RP.rpName,
reqHeaders: new Headers(),
resHeaders: new Headers(),
};
}
/**
* Create an authenticated context (for protected endpoints)
*/
function createAuthenticatedContext(
userId: number,
email: string,
): AuthenticatedContext {
const now = new Date();
return {
...createAPIContext(),
user: {
id: userId,
email,
displayName: null,
emailVerifiedAt: null,
isSuperuser: false,
},
session: {
id: "1",
trustedMode: false,
createdAt: now,
},
auth: {
method: "session",
sessionId: "1",
expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000),
createdAt: now,
},
};
}
/**
* Create a login request context (for login flow endpoints)
*/
function createLoginRequestContext(
userId: number,
email: string,
): LoginRequestContext {
return {
...createAPIContext(),
loginRequestId: 1,
user: {
id: userId,
email,
displayName: null,
emailVerifiedAt: null,
isSuperuser: false,
},
};
}
/**
* 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(
userId: number,
email: string,
authenticator: VirtualAuthenticator,
) {
const apiCtx = createAPIContext();
const authCtx = createAuthenticatedContext(userId, email);
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(
userId: number,
email: string,
authenticator: VirtualAuthenticator,
) {
const loginCtx = createLoginRequestContext(userId, email);
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 },
);
}
beforeAll(async () => {
// Run migrations and create test database connection
await runMigrations();
db = createTestDb();
});
afterAll(async () => {
if (db) {
await destroyTestDb(db);
}
});
describe("registration flow", () => {
beforeAll(async () => {
await truncateAllTables(getDb());
});
test("creates registration options with challenge stored in DB via router", async () => {
const user = await createTestUser(getDb(), {
email: "reg-options@test.com",
});
const ctx = createAPIContext();
// 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", "=", String(challengeId))
.executeTakeFirst();
expect(challengeRow).toBeDefined();
});
test("verifies valid registration and stores passkey via router", async () => {
const user = await createTestUser(getDb(), {
email: "reg-verify@test.com",
});
const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
// Create registration options via router
const apiCtx = createAPIContext();
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
const authCtx = createAuthenticatedContext(user.id, user.email);
await call(
router.auth.webauthn.verifyRegistration,
{ challengeId, response },
{ context: authCtx },
);
// Verify passkey is stored in database
const passkeys = await getUserPasskeys(getDb(), 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 () => {
const user = await createTestUser(getDb(), {
email: "exclude-test@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
const apiCtx = createAPIContext();
const authCtx = createAuthenticatedContext(user.id, user.email);
// 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 () => {
const user = await createTestUser(getDb(), {
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();
const authCtx = createAuthenticatedContext(user.id, user.email);
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(getDb(), 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 () => {
const user = await createTestUser(getDb(), {
email: "cleanup-test@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
const apiCtx = createAPIContext();
const authCtx = createAuthenticatedContext(user.id, user.email);
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", "=", String(challengeId))
.executeTakeFirst();
expect(challengeRow).toBeUndefined();
});
test("rejects expired/missing challenges via router", async () => {
const user = await createTestUser(getDb(), {
email: "expired-test@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
const apiCtx = createAPIContext();
const authCtx = createAuthenticatedContext(user.id, user.email);
// 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", () => {
beforeAll(async () => {
await truncateAllTables(getDb());
});
test("creates authentication options with user's passkeys via router", async () => {
const user = await createTestUser(getDb(), {
email: "auth-options@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
// Register a passkey first via router
const regResponse = await registerPasskey(
user.id,
user.email,
authenticator,
);
// Create authentication options via router
const loginCtx = createLoginRequestContext(user.id, user.email);
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 () => {
const user = await createTestUser(getDb(), {
email: "auth-verify@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
// Register passkey via router
await registerPasskey(user.id, user.email, authenticator);
// Authenticate via router
const loginCtx = createLoginRequestContext(user.id, user.email);
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(getDb(), user.id);
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.counter).toBe(1);
});
test("updates last_used_at timestamp via router", async () => {
const user = await createTestUser(getDb(), { email: "last-used@test.com" });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
// Register passkey via router
await registerPasskey(user.id, user.email, authenticator);
// Check initial state
let passkeys = await getUserPasskeys(getDb(), user.id);
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.lastUsedAt).toBeNull();
// Authenticate via router
const loginCtx = createLoginRequestContext(user.id, user.email);
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(getDb(), user.id);
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.lastUsedAt).not.toBeNull();
});
test("cleans up challenge after authentication via router", async () => {
const user = await createTestUser(getDb(), {
email: "auth-cleanup@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
// Register passkey via router
await registerPasskey(user.id, user.email, authenticator);
// Authenticate via router
const loginCtx = createLoginRequestContext(user.id, user.email);
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", "=", String(authChallengeId))
.executeTakeFirst();
expect(challengeRow).toBeUndefined();
});
test("rejects unknown credential IDs", async () => {
const user = await createTestUser(getDb(), {
email: "unknown-cred@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
// Register passkey via router
await registerPasskey(user.id, user.email, authenticator);
// Create auth options via router
const loginCtx = createLoginRequestContext(user.id, user.email);
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();
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", () => {
beforeAll(async () => {
await truncateAllTables(getDb());
});
test("rejects replayed credentials (counter check) via router", async () => {
const user = await createTestUser(getDb(), {
email: "counter-replay@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
// Register passkey via router
const regResponse = await registerPasskey(
user.id,
user.email,
authenticator,
);
// First authentication should succeed
await authenticate(user.id, user.email, authenticator);
// Verify counter was updated to 1
let passkeys = await getUserPasskeys(getDb(), 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 loginCtx = createLoginRequestContext(user.id, user.email);
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(getDb(), user.id);
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.counter).toBe(1);
});
test("rejects tampered authentication response", async () => {
const user = await createTestUser(getDb(), {
email: "tampered-response@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
// Register passkey via router
await registerPasskey(user.id, user.email, authenticator);
// Create authentication challenge
const loginCtx = createLoginRequestContext(user.id, user.email);
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", () => {
beforeAll(async () => {
await truncateAllTables(getDb());
});
test("register → authenticate → add second passkey → authenticate with either via router", async () => {
const user = await createTestUser(getDb(), { 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(user.id, user.email, authenticator1);
// Authenticate with first passkey via router
await authenticate(user.id, user.email, authenticator1);
// Register second passkey via router
await registerPasskey(user.id, user.email, authenticator2);
// Verify user now has 2 passkeys
const passkeys = await getUserPasskeys(getDb(), user.id);
expect(passkeys).toHaveLength(2);
// Authenticate with second passkey via router
await authenticate(user.id, user.email, authenticator2);
// Authenticate with first passkey again via router
await authenticate(user.id, user.email, authenticator1);
});
test("register → authenticate multiple times → counter increments via router", async () => {
const user = await createTestUser(getDb(), {
email: "counter-test@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
// Register passkey via router
await registerPasskey(user.id, user.email, authenticator);
// Verify initial counter
let passkeys = await getUserPasskeys(getDb(), 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(user.id, user.email, authenticator);
// Verify counter incremented
passkeys = await getUserPasskeys(getDb(), user.id);
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.counter).toBe(i);
}
});
});
describe("passkey management", () => {
beforeAll(async () => {
await truncateAllTables(getDb());
});
test("lists passkeys with correct data via router", async () => {
const user = await createTestUser(getDb(), {
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(user.id, user.email, authenticator1);
await registerPasskey(user.id, user.email, authenticator2);
// List passkeys via router handler
const ctx = createAuthenticatedContext(user.id, user.email);
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 () => {
const user = await createTestUser(getDb(), {
email: "device-type@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user.id, user.email, authenticator);
const passkeys = await getUserPasskeys(getDb(), 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 () => {
const user = await createTestUser(getDb(), {
email: "rename-test@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user.id, user.email, authenticator);
const ctx = createAuthenticatedContext(user.id, user.email);
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 () => {
const user1 = await createTestUser(getDb(), {
email: "rename-user1@test.com",
});
const user2 = await createTestUser(getDb(), {
email: "rename-user2@test.com",
});
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user1.id, user1.email, auth1);
await registerPasskey(user2.id, user2.email, auth2);
const ctx1 = createAuthenticatedContext(user1.id, user1.email);
const ctx2 = createAuthenticatedContext(user2.id, user2.email);
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 not work)
await call(
router.me.passkeys.rename,
{ passkeyId: user2FirstPasskey.id, name: "Hacked Name" },
{ context: ctx1 },
);
// 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);
});
test("deletes passkey when user has password via router", async () => {
const user = await createTestUser(getDb(), {
email: "delete-with-password@test.com",
passwordHash: "fake-password-hash",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user.id, user.email, authenticator);
const ctx = createAuthenticatedContext(user.id, user.email);
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);
});
test("deletes passkey when user has multiple passkeys via router", async () => {
const user = await createTestUser(getDb(), {
email: "delete-multi@test.com",
});
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user.id, user.email, auth1);
await registerPasskey(user.id, user.email, auth2);
const ctx = createAuthenticatedContext(user.id, user.email);
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);
});
test("prevents deleting last passkey without password via router", async () => {
const user = await createTestUser(getDb(), {
email: "delete-last@test.com",
// No password set
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user.id, user.email, authenticator);
const ctx = createAuthenticatedContext(user.id, user.email);
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);
});
test("delete does not affect other user's passkeys via router", async () => {
const user1 = await createTestUser(getDb(), {
email: "delete-user1@test.com",
passwordHash: "fake-hash",
});
const user2 = await createTestUser(getDb(), {
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(user1.id, user1.email, auth1);
await registerPasskey(user2.id, user2.email, auth2);
const ctx1 = createAuthenticatedContext(user1.id, user1.email);
const ctx2 = createAuthenticatedContext(user2.id, user2.email);
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 not affect user2)
await call(
router.me.passkeys.delete,
{ passkeyId: user2FirstPasskey.id },
{ context: ctx1 },
);
// 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 () => {
const user = await createTestUser(getDb(), {
email: "credential-id@test.com",
});
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user.id, user.email, auth1);
await registerPasskey(user.id, user.email, auth2);
const passkeys = await getUserPasskeys(getDb(), 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 () => {
const user = await createTestUser(getDb(), {
email: "transports@test.com",
});
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user.id, user.email, authenticator);
const passkeys = await getUserPasskeys(getDb(), 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");
});
});