diff --git a/bun.lock b/bun.lock
index abb7dabe..da079b8e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -235,7 +235,7 @@
},
"packages/auth": {
"name": "@wrnexus/auth",
- "version": "0.8.25",
+ "version": "0.8.26",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/captcha": "workspace:*",
diff --git a/packages/auth/README.md b/packages/auth/README.md
index 52e5b957..f0a63568 100644
--- a/packages/auth/README.md
+++ b/packages/auth/README.md
@@ -1,5 +1,19 @@
# @wrnexus/auth
+### New-browser verification and device trust
+
+Enable `requireTrustedDeviceVerification: true` to require a verified email or
+SMS challenge before an untrusted browser receives a sign-in session. The HTTP
+handlers use a host-only HttpOnly random cookie, ignoring posted fingerprints.
+Direct engine callers must supply a trusted server-side browser fingerprint.
+`trustSignupDevice: true` grants the registration browser one-time trust after
+required account verification, at its first successful sign-in.
+
+Use `trustedDeviceTtlMs: 30 * 86400_000` for 30-day trust. Ordinary logins do not
+renew it. The verification form offers explicit trust consent; expiry/revocation
+requires a new email/SMS challenge. Successful login alerts use both verified
+contact channels when configured. Delivery still requires an application adapter.
+
### Signup business logic and verification-only sessions
```ts
diff --git a/packages/auth/components/TwoFactorChallenge.wrn b/packages/auth/components/TwoFactorChallenge.wrn
index 24626bf4..6ed0e3b3 100644
--- a/packages/auth/components/TwoFactorChallenge.wrn
+++ b/packages/auth/components/TwoFactorChallenge.wrn
@@ -1,4 +1,4 @@
-import { Button, Input, PinInput, Radio } from "@wrnexus/ui"
+import { Button, Input, PinInput, Radio, Checkbox } from "@wrnexus/ui"
component TwoFactorChallenge {
props {
@@ -45,6 +45,7 @@ component TwoFactorChallenge {
+
diff --git a/packages/auth/package.json b/packages/auth/package.json
index 7095b9e8..d4218185 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
- "version": "0.8.25",
+ "version": "0.8.26",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
diff --git a/packages/auth/src/engine.ts b/packages/auth/src/engine.ts
index 421ef194..09f726ae 100644
--- a/packages/auth/src/engine.ts
+++ b/packages/auth/src/engine.ts
@@ -49,6 +49,7 @@ const DEFAULT_TOKEN_TTLS: Record = {
};
export interface AuthEngine {
+ readonly deviceTrustPolicyEnabled: boolean;
readonly store: AuthStore;
readonly onSignedIn?: AuthSignedInHandler;
readonly onSignedOut?: AuthSignedOutHandler;
@@ -101,7 +102,10 @@ export interface AuthEngine {
token: string,
input: { password?: string; displayName?: string },
): Promise;
- consumeMagicLink(token: string, sessionInput?: Partial): Promise;
+ consumeMagicLink(
+ token: string,
+ sessionInput?: Partial & { fingerprint?: string; deviceName?: string },
+ ): Promise;
issueOtp(
userId: string,
method: "email-otp" | "sms-otp",
@@ -120,18 +124,20 @@ export interface AuthEngine {
completeOtpLogin(
challengeId: string,
code: string,
- sessionInput?: Partial,
+ sessionInput?: Partial & { fingerprint?: string; deviceName?: string },
): Promise;
beginMfaOtp(
mfaToken: string,
method: "email-otp" | "sms-otp",
): Promise<{ id: string; expiresAt: number } | undefined>;
completeMfa(input: {
+ rememberDevice?: boolean;
+ fingerprint?: string;
mfaToken: string;
method: "totp" | "recovery-code" | "email-otp" | "sms-otp";
code: string;
challengeId?: string;
- session?: Partial;
+ session?: Partial & { fingerprint?: string; deviceName?: string };
}): Promise;
beginTotp(
userId: string,
@@ -153,6 +159,7 @@ export interface AuthEngine {
provider: string,
profile: OAuthProfile,
tokens?: OAuthTokens,
+ sessionInput?: Partial & { fingerprint?: string; deviceName?: string },
): Promise;
unlinkOAuth(userId: string, accountId: string): Promise;
beginPasskeyRegistration(
@@ -176,7 +183,7 @@ export interface AuthEngine {
key: string;
response: unknown;
/** Request metadata used only for the resulting session. */
- session?: Partial;
+ session?: Partial & { fingerprint?: string; deviceName?: string };
}): Promise;
changePassword(
userId: string,
@@ -481,6 +488,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
user: AuthUser,
metadata: Record = {},
): Promise {
+ const deviceCheck = await newDeviceRequirement(user, metadata);
+ if (deviceCheck) return deviceCheck;
if (!(await hasMfa(user.id))) return undefined;
const methods = await availableMfaMethods(user.id);
if (!methods.length) {
@@ -506,6 +515,54 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
};
}
+ async function newDeviceRequirement(
+ user: AuthUser,
+ metadata: Record = {},
+ ): Promise {
+ if (!options.requireTrustedDeviceVerification) return;
+ const browser = typeof metadata.fingerprint === "string" ? metadata.fingerprint : undefined;
+ if (browser) {
+ const hash = await fingerprint(browser);
+ const existing = await store.findTrustedDeviceByFingerprint(user.id, hash);
+ if (existing && !existing.revokedAt && existing.expiresAt > now()) return;
+ // Only the original registration browser can claim this one-time trust.
+ // Account-state / required-contact checks run before this helper.
+ if (options.trustSignupDevice && user.metadata?.signupDeviceHash === hash) {
+ const { signupDeviceHash: _signup, ...rest } = user.metadata;
+ user.metadata = rest;
+ await store.updateUser(user);
+ await engine.trustDevice(user.id, {
+ fingerprint: browser,
+ name: String(metadata.deviceName || "Signup browser"),
+ });
+ return;
+ }
+ }
+ const methods = (await availableMfaMethods(user.id)).filter(
+ (method) => method === "email-otp" || method === "sms-otp",
+ );
+ if (!methods.length)
+ return {
+ ok: false,
+ code: "device-verification-unavailable",
+ message:
+ "A verified email address or mobile number is required to verify this device. Use account recovery.",
+ };
+ const mfaExpiresAt = now() + 5 * 60_000;
+ const mfaToken = await issueToken(user.id, "login-mfa", {
+ ttlMs: 5 * 60_000,
+ metadata: { ...metadata, deviceVerification: true, rememberDevice: false },
+ });
+ return {
+ ok: false,
+ code: "mfa-required",
+ message: "Verify this new device using email or SMS. You can then trust it for 30 days.",
+ mfaToken,
+ mfaExpiresAt,
+ requires: { mfa: methods },
+ };
+ }
+
async function assertPasswordPolicy(password: string): Promise {
if (password.length < passwordMinLength) {
throw new Error(`Password must be at least ${passwordMinLength} characters`);
@@ -665,24 +722,26 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
async function sendLoginAlert(user: AuthUser, session: AuthSession): Promise {
if (!options.sendLoginAlerts || !options.delivery) return;
const identities = await store.listIdentities(user.id);
- const destination =
- identities.find((item) => item.type === "email" && item.primary)?.value ??
- identities.find((item) => item.type === "email")?.value;
- if (!destination) return;
- await deliver({
- channel: "email",
- template: "login-alert",
- destination,
- user: publicUser(user),
- expiresAt: session.expiresAt,
- data: {
- sessionId: session.id,
- deviceId: session.deviceId,
- ip: session.ip,
- userAgent: session.userAgent,
- createdAt: session.createdAt,
- },
- });
+ for (const type of ["email", "phone"] as const) {
+ const destination =
+ identities.find((item) => item.type === type && item.verifiedAt && item.primary)?.value ??
+ identities.find((item) => item.type === type && item.verifiedAt)?.value;
+ if (!destination) continue;
+ await deliver({
+ channel: type === "phone" ? "sms" : "email",
+ template: "login-alert",
+ destination,
+ user: publicUser(user),
+ expiresAt: session.expiresAt,
+ data: {
+ sessionId: session.id,
+ deviceId: session.deviceId,
+ ip: session.ip,
+ userAgent: session.userAgent,
+ createdAt: session.createdAt,
+ },
+ });
+ }
}
async function createSession(
@@ -853,6 +912,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
}
const engine: AuthEngine = {
+ deviceTrustPolicyEnabled: options.requireTrustedDeviceVerification === true,
store,
onSignedIn: options.onSignedIn,
onSignedOut: options.onSignedOut,
@@ -906,7 +966,12 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
timezone: input.timezone,
createdAt: timestamp,
updatedAt: timestamp,
- metadata: input.metadata,
+ metadata: {
+ ...input.metadata,
+ ...(options.trustSignupDevice && input.fingerprint
+ ? { signupDeviceHash: await fingerprint(input.fingerprint) }
+ : {}),
+ },
};
await store.createUser(user);
if (input.email) await createIdentity(user.id, "email", input.email, true);
@@ -1062,6 +1127,13 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const verification = verificationUnavailable(user);
if (verification) return { ...verification, risk };
const mfaEnabled = await hasMfa(user.id);
+ const deviceCheck = await newDeviceRequirement(user, {
+ fingerprint: input.fingerprint,
+ deviceName: input.deviceName,
+ ip: input.ip,
+ userAgent: input.userAgent,
+ });
+ if (deviceCheck) return deviceCheck;
const trustedDevice = input.fingerprint
? await store.findTrustedDeviceByFingerprint(user.id, await fingerprint(input.fingerprint))
: undefined;
@@ -1107,7 +1179,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
};
}
const rememberedDevice =
- input.rememberDevice && input.fingerprint
+ input.rememberDevice && input.fingerprint && !trusted
? await engine.trustDevice(user.id, {
fingerprint: input.fingerprint,
name: input.deviceName,
@@ -1496,6 +1568,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const verification = verificationUnavailable(user);
if (verification) return verification;
const mfa = await mfaRequirement(user, {
+ fingerprint: sessionInput.fingerprint,
+ deviceName: sessionInput.deviceName,
ip: sessionInput.ip,
userAgent: sessionInput.userAgent,
deviceId: sessionInput.deviceId,
@@ -1601,6 +1675,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const verification = verificationUnavailable(user);
if (verification) return verification;
const mfa = await mfaRequirement(user, {
+ fingerprint: sessionInput.fingerprint,
+ deviceName: sessionInput.deviceName,
ip: sessionInput.ip,
userAgent: sessionInput.userAgent,
deviceId: sessionInput.deviceId,
@@ -1630,7 +1706,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
await restoreExpiredLoginLock(user);
if (accountStatusUnavailable(user) || verificationUnavailable(user)) return undefined;
try {
- return await issueOtp(token.userId, method, undefined, "mfa");
+ if (!(await availableMfaMethods(user.id)).includes(method)) return undefined;
+ const key = "otp:" + method;
+ const previousId = token.metadata?.[key];
+ if (typeof previousId === "string") {
+ const previous = await store.findOtp(previousId);
+ if (previous && !previous.usedAt && previous.expiresAt > now())
+ return { id: previous.id, expiresAt: previous.expiresAt };
+ }
+ const challenge = await issueOtp(token.userId, method, undefined, "mfa");
+ token.metadata = { ...token.metadata, [key]: challenge.id };
+ await store.updateToken(token);
+ return challenge;
} catch {
return undefined;
}
@@ -1651,6 +1738,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
if (unavailable) return unavailable;
const verification = verificationUnavailable(transactionUser);
if (verification) return verification;
+ if (
+ transaction.metadata?.deviceVerification === true &&
+ (input.fingerprint !== transaction.metadata.fingerprint ||
+ !input.fingerprint ||
+ !["email-otp", "sms-otp"].includes(input.method))
+ ) {
+ return {
+ ok: false,
+ code: "mfa-invalid",
+ message: "Verify this device using email or SMS in the browser that started sign-in.",
+ };
+ }
let verified = false;
if (input.method === "totp")
verified = await engine.verifyTotp(transaction.userId, input.code);
@@ -1661,7 +1760,9 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
if (
otp?.userId === transaction.userId &&
otp.method === input.method &&
- otp.purpose === "mfa"
+ otp.purpose === "mfa" &&
+ (transaction.metadata?.deviceVerification !== true ||
+ transaction.metadata?.["otp:" + input.method] === input.challengeId)
) {
verified = (await engine.verifyOtp(input.challengeId, input.code, "mfa")).ok;
}
@@ -1682,7 +1783,10 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const user = transactionUser;
const metadata = transaction.metadata ?? {};
const rememberedDevice =
- metadata.rememberDevice === true && typeof metadata.fingerprint === "string"
+ (metadata.deviceVerification === true
+ ? input.rememberDevice === true
+ : metadata.rememberDevice === true || input.rememberDevice === true) &&
+ typeof metadata.fingerprint === "string"
? await engine.trustDevice(user.id, {
fingerprint: metadata.fingerprint,
name: typeof metadata.deviceName === "string" ? metadata.deviceName : undefined,
@@ -1716,6 +1820,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
severity: "info",
data: { method: input.method },
});
+ await sendLoginAlert(user, session);
return { ok: true, user: publicUser(user), session };
},
@@ -1910,7 +2015,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
return account;
},
- async loginWithOAuth(provider, profile, tokens) {
+ async loginWithOAuth(provider, profile, tokens, sessionInput = {}) {
const providerName = normalizedOAuthProvider(provider);
const providerAccountId = normalizedOAuthAccountId(profile.id);
let account = await store.findOAuthAccount(providerName, providerAccountId);
@@ -1963,12 +2068,17 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const verification = verificationUnavailable(user);
if (verification) return verification;
account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens);
+ const deviceCheck = await newDeviceRequirement(user, {
+ ...sessionInput,
+ oauthProvider: providerName,
+ });
+ if (deviceCheck) return deviceCheck;
if (await hasMfa(user.id)) {
const methods = await availableMfaMethods(user.id);
const mfaExpiresAt = now() + 5 * 60_000;
const mfaToken = await issueToken(user.id, "login-mfa", {
ttlMs: 5 * 60_000,
- metadata: { oauthProvider: providerName },
+ metadata: { ...sessionInput, oauthProvider: providerName },
});
return {
ok: false,
@@ -1979,7 +2089,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
requires: { mfa: methods },
};
}
- const session = await createSession(user.id);
+ const session = await createSession(user.id, sessionInput);
user.lastLoginAt = now();
user.updatedAt = now();
await store.updateUser(user);
@@ -2175,6 +2285,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
credential.lastUsedAt = now();
await store.updatePasskey(credential);
}
+ const deviceCheck = await newDeviceRequirement(user, { ...input.session });
+ if (deviceCheck) return deviceCheck;
const session = await createSession(user.id, input.session);
user.lastLoginAt = now();
user.updatedAt = now();
diff --git a/packages/auth/src/http/index.ts b/packages/auth/src/http/index.ts
index ee4f4483..7586a764 100644
--- a/packages/auth/src/http/index.ts
+++ b/packages/auth/src/http/index.ts
@@ -64,6 +64,26 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const onSignedOut = engine.onSignedOut;
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
+ function browserDevice(ctx: Context) {
+ if (!engine.deviceTrustPolicyEnabled) return {};
+ const cookie = "wrnexus.auth.device";
+ let value = ctx.cookies.get(cookie);
+ if (!value || !/^[a-f0-9-]{36}$/.test(value)) {
+ value = crypto.randomUUID();
+ ctx.cookies.set(cookie, value, {
+ httpOnly: true,
+ secure: ctx.url.protocol === "https:",
+ sameSite: "Lax",
+ path: "/",
+ maxAge: 30 * 24 * 60 * 60,
+ });
+ }
+ return {
+ fingerprint: value,
+ deviceName: (ctx.req.headers.get("user-agent") || "Browser").slice(0, 120),
+ };
+ }
+
function oauthProvider(ctx: Context): OAuthProvider | undefined {
return options.oauth?.[String(ctx.params.provider ?? "").toLowerCase()];
}
@@ -148,6 +168,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
provider.name,
completed.profile,
completed.tokens,
+ browserDevice(ctx),
);
if (result.code === "mfa-required" && result.mfaToken) {
ctx.cookies.transaction("wrnexus.auth.mfa", { sameSite: "Lax", maxAge: 300 }).set({
@@ -181,6 +202,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.register({
+ ...browserDevice(ctx),
baseUrl: options.baseUrl ?? ctx.url.origin,
email: text(input.email) || undefined,
phone: text(input.phone) || undefined,
@@ -200,6 +222,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (action?.autoSignIn) {
const identifier = text(input.email) || text(input.phone) || text(input.username);
const loginResult = await engine.login({
+ ...browserDevice(ctx),
identifier,
password: text(input.password),
ip: ctx.ip,
@@ -229,6 +252,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
fingerprint: text(input.deviceFingerprint) || undefined,
deviceName: text(input.deviceName) || undefined,
rememberDevice: boolean(input.rememberDevice),
+ ...browserDevice(ctx),
captchaVerified:
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
ctx.locals.captchaVerified === true,
@@ -340,6 +364,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.consumeMagicLink(text(input.token), {
+ ...browserDevice(ctx),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
@@ -366,6 +391,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.completeOtpLogin(text(input.challengeId), text(input.code), {
+ ...browserDevice(ctx),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
@@ -500,6 +526,8 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
: "totp";
const result = await engine.completeMfa({
+ ...browserDevice(ctx),
+ rememberDevice: boolean(input.rememberDevice),
mfaToken: text(input.mfaToken) || continuation?.mfaToken || "",
method,
code: text(input.code),
@@ -618,6 +646,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
key: text(input.key),
response: input.response,
session: {
+ ...browserDevice(ctx),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
},
diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts
index 054ba0be..7816772b 100644
--- a/packages/auth/src/types.ts
+++ b/packages/auth/src/types.ts
@@ -438,6 +438,10 @@ export interface AuthEngineOptions {
sessionTtlMs?: number;
sessionAbsoluteTtlMs?: number;
trustedDeviceTtlMs?: number;
+ /** Require verified email/SMS confirmation before a new browser receives a session. */
+ requireTrustedDeviceVerification?: boolean;
+ /** Remember the registration browser after required account verification succeeds. */
+ trustSignupDevice?: boolean;
tokenTtlMs?: Partial>;
/**
* Customize action links delivered with one-time tokens. Returning undefined
@@ -472,6 +476,8 @@ export interface AuthEngineOptions {
}
export interface RegisterInput {
+ fingerprint?: string;
+ deviceName?: string;
/** Trusted server-side origin for verification links; never copied from request data. */
baseUrl?: string;
email?: string;
diff --git a/packages/auth/src/validation.ts b/packages/auth/src/validation.ts
index d08ed16f..91268c39 100644
--- a/packages/auth/src/validation.ts
+++ b/packages/auth/src/validation.ts
@@ -160,6 +160,7 @@ export const mfaOtpRequestSchema = v.object({
});
export const mfaSchema = v.object({
+ rememberDevice: v.boolean().optional(),
mfaToken: v.string().min(20, "MFA transaction is invalid").max(512).optional(),
method: v
.string()
diff --git a/packages/auth/test/device-trust.test.ts b/packages/auth/test/device-trust.test.ts
new file mode 100644
index 00000000..c03b32a6
--- /dev/null
+++ b/packages/auth/test/device-trust.test.ts
@@ -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");
+});