feat(auth): require verified new-browser trust with fixed expiry

This commit is contained in:
Clintchiz committed 2026-08-26 19:59:22 +05:30
1 parent bef67b0a24
commit 61a4f01321
9 files changed
+418 -33

No files matched your search

+14
View File
@@ -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
@@ -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 {
<div data-auth-mfa-pin><PinInput label="Verification code" name="otpCode" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" /></div>
<div data-auth-mfa-recovery hidden><Input label="Recovery code" name="recoveryCode" autocomplete="off" maxlength="32" color="{color}" size="{size}" /></div>
<p data-error="code" class="m-0 text-sm text-[var(--wrn-color-danger)]" aria-live="polite"></p>
<Checkbox name="rememberDevice" label="Trust this device for 30 days" value="true" />
<p data-auth-mfa-status role="status" hidden class="m-0 rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-primary)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-primary)]"></p>
<p data-error="mfaToken" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
+1 -1
View File
@@ -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,
+142 -30
View File
@@ -49,6 +49,7 @@ const DEFAULT_TOKEN_TTLS: Record<AuthTokenPurpose, number> = {
};
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<AuthResult>;
consumeMagicLink(token: string, sessionInput?: Partial<AuthSession>): Promise<AuthResult>;
consumeMagicLink(
token: string,
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
): Promise<AuthResult>;
issueOtp(
userId: string,
method: "email-otp" | "sms-otp",
@@ -120,18 +124,20 @@ export interface AuthEngine {
completeOtpLogin(
challengeId: string,
code: string,
sessionInput?: Partial<AuthSession>,
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
): Promise<AuthResult>;
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<AuthSession>;
session?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string };
}): Promise<AuthResult>;
beginTotp(
userId: string,
@@ -153,6 +159,7 @@ export interface AuthEngine {
provider: string,
profile: OAuthProfile,
tokens?: OAuthTokens,
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
): Promise<AuthResult>;
unlinkOAuth(userId: string, accountId: string): Promise<boolean>;
beginPasskeyRegistration(
@@ -176,7 +183,7 @@ export interface AuthEngine {
key: string;
response: unknown;
/** Request metadata used only for the resulting session. */
session?: Partial<AuthSession>;
session?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string };
}): Promise<AuthResult>;
changePassword(
userId: string,
@@ -481,6 +488,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
user: AuthUser,
metadata: Record<string, unknown> = {},
): Promise<AuthResult | undefined> {
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<string, unknown> = {},
): Promise<AuthResult | undefined> {
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<void> {
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<void> {
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();
+29
View File
@@ -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,
},
+6
View File
@@ -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<Record<AuthTokenPurpose, number>>;
/**
* 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;
+1
View File
@@ -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()
+222
View File
@@ -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");
});