feat(auth): require verified new-browser trust with fixed expiry
This commit is contained in:
1 parent
bef67b0a24
commit
61a4f01321
9 files changed
+418
-33
No files matched your search
@@ -0,0 +1,222 @@
|
||||
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");
|
||||
});
|
||||
Reference in new issue
Block a user