Files
WRNexusJS/packages/auth/test/device-trust.test.ts
T

223 lines
7.8 KiB
TypeScript

import { expect, test } from "bun:test";
import { createAuthEngine } from "../src/engine.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import { createAuthHttpHandlers } from "../src/http/index.ts";
import { createContext } from "@wrnexus/core";
import type { AuthDeliveryMessage } from "../src/types.ts";
test("magic-link, OTP, and passkey sign-in cannot bypass new-device verification", async () => {
const messages: AuthDeliveryMessage[] = [];
let userId = "";
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "all-flows-trust-secret-at-least-32-characters",
requireVerifiedEmail: true,
requireTrustedDeviceVerification: true,
delivery: {
async send(message) {
messages.push(message);
},
},
passkeys: {
async registrationOptions() {
throw new Error("Not used");
},
async verifyRegistration() {
throw new Error("Not used");
},
async authenticationOptions() {
return {
challenge: "AQID",
rpId: "example.test",
timeout: 60000,
userVerification: "required" as const,
};
},
async verifyAuthentication() {
return { verified: true, userId };
},
},
});
userId = (await engine.register({ email: "flows@example.test", password: "StrongPassword123!" }))
.user!.id;
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
await engine.requestMagicLink("flows@example.test");
const magic = messages.find((m) => m.template === "magic-link")!.token!;
expect((await engine.consumeMagicLink(magic, { fingerprint: "browser" })).code).toBe(
"mfa-required",
);
const otp = await engine.requestOtpLogin("flows@example.test", "email-otp");
expect(
(
await engine.completeOtpLogin(
otp.id,
messages.filter((m) => m.template === "email-otp").at(-1)!.code!,
{ fingerprint: "browser" },
)
).code,
).toBe("mfa-required");
const passkey = await engine.beginPasskeyAuthentication({
identifier: "flows@example.test",
rpId: "example.test",
origin: "https://example.test",
});
expect(
(
await engine.finishPasskeyAuthentication({
key: passkey.key,
response: {},
session: { fingerprint: "browser" },
})
).code,
).toBe("mfa-required");
expect(await engine.listSessions(userId)).toHaveLength(0);
});
test("new browsers require bound email/SMS verification; signup trust expires without sliding", async () => {
let now = 1_780_000_000_000;
const messages: AuthDeliveryMessage[] = [];
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "test-browser-trust-secret-at-least-32-characters",
clock: { now: () => now },
requireVerifiedEmail: true,
requireTrustedDeviceVerification: true,
trustSignupDevice: true,
trustedDeviceTtlMs: 30 * 86400_000,
sendLoginAlerts: true,
delivery: {
async send(message) {
messages.push(message);
},
},
});
const registered = await engine.register({
email: "trust@example.test",
phone: "+15551234567",
password: "StrongPassword123!",
fingerprint: "signup-browser",
});
const userId = registered.user!.id;
expect(
(
await engine.login({
identifier: "trust@example.test",
password: "StrongPassword123!",
fingerprint: "signup-browser",
})
).code,
).toBe("email-unverified");
expect(await engine.listTrustedDevices(userId)).toHaveLength(0);
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
await engine.verifyPhone(messages.find((m) => m.template === "verify-phone")!.token!);
const login = (fingerprint: string, rememberDevice = false) =>
engine.login({
identifier: "trust@example.test",
password: "StrongPassword123!",
fingerprint,
rememberDevice,
});
expect((await login("signup-browser")).session?.trusted).toBe(true);
const firstExpiry = (await engine.listTrustedDevices(userId))[0]!.expiresAt;
now += 86400_000;
expect((await login("signup-browser", true)).ok).toBe(true);
expect((await engine.listTrustedDevices(userId))[0]!.expiresAt).toBe(firstExpiry);
const before = (await engine.listSessions(userId)).length;
const pending = await login("new-browser", true);
expect(pending.code).toBe("mfa-required");
expect(pending.requires?.mfa).toEqual(["email-otp", "sms-otp"]);
expect((await engine.listSessions(userId)).length).toBe(before);
const challenge = await engine.beginMfaOtp(pending.mfaToken!, "email-otp");
const count = messages.length;
expect(await engine.beginMfaOtp(pending.mfaToken!, "email-otp")).toEqual(challenge);
expect(messages.length).toBe(count);
const code = messages.filter((m) => m.template === "email-otp").at(-1)!.code!;
const complete = {
mfaToken: pending.mfaToken!,
method: "email-otp" as const,
challengeId: challenge!.id,
code,
fingerprint: "new-browser",
};
expect((await engine.completeMfa({ ...complete, fingerprint: "other-browser" })).ok).toBe(false);
expect((await engine.completeMfa({ ...complete, method: "totp" })).ok).toBe(false);
expect((await engine.completeMfa(complete)).ok).toBe(true);
expect((await engine.listTrustedDevices(userId)).length).toBe(1); // Explicit opt-in only.
expect((await engine.completeMfa(complete)).ok).toBe(false);
const again = await login("new-browser");
const sms = await engine.beginMfaOtp(again.mfaToken!, "sms-otp");
const smsCode = messages.filter((m) => m.template === "sms-otp").at(-1)!.code!;
expect(
(
await engine.completeMfa({
mfaToken: again.mfaToken!,
method: "sms-otp",
challengeId: sms!.id,
code: smsCode,
fingerprint: "new-browser",
rememberDevice: true,
})
).session?.trusted,
).toBe(true);
expect(
messages
.filter((m) => m.template === "login-alert")
.slice(-2)
.map((m) => m.channel),
).toEqual(["email", "sms"]);
const second = (await engine.listTrustedDevices(userId)).find(
(d) => d.expiresAt !== firstExpiry,
)!;
await engine.revokeTrustedDevice(userId, second.id);
expect((await login("new-browser")).code).toBe("mfa-required");
now = firstExpiry + 1;
expect((await login("signup-browser")).code).toBe("mfa-required");
});
test("HTTP signup binds trust to an HttpOnly cookie, not a supplied device fingerprint", async () => {
const messages: AuthDeliveryMessage[] = [];
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "http-device-trust-secret-at-least-32-characters",
requireVerifiedEmail: true,
requireTrustedDeviceVerification: true,
trustSignupDevice: true,
delivery: {
async send(message) {
messages.push(message);
},
},
});
const url = new URL("https://sso.example.test/api/auth/register");
const ctx = createContext(
new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
displayName: "Trust Test",
consent: true,
email: "http@example.test",
password: "StrongPassword123!",
deviceFingerprint: "attacker",
}),
}),
url,
);
await createAuthHttpHandlers({ engine }).register(ctx);
const cookie = ctx.cookies.headers().find((c) => c.startsWith("wrnexus.auth.device="))!;
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("Secure");
expect(cookie).toContain("Max-Age=2592000");
expect(cookie).not.toContain("attacker");
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
expect(
(
await engine.login({
identifier: "http@example.test",
password: "StrongPassword123!",
fingerprint: "attacker",
})
).code,
).toBe("mfa-required");
});