Add comprehensive WebAuthn e2e/unit tests and virtual authenticator package
- Create @reviq/virtual-authenticator package with cryptographically valid WebAuthn credential generation for testing - Add e2e tests for WebAuthn registration, authentication, passkey management - Add unit tests for passkey-helpers and VirtualAuthenticator - Add security tests for counter replay and tampered responses - Configure test database environment in devenv.nix - Add turbo.json test tasks and workspace configuration Test results: 98 tests passing (54 virtual-authenticator, 25 e2e, 19 unit) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user