223 lines
7.8 KiB
TypeScript
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");
|
|
});
|