From 61a4f01321a115f8c42da4febb3f54a8c026f81d Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 26 Aug 2026 19:59:22 +0530 Subject: [PATCH] feat(auth): require verified new-browser trust with fixed expiry --- bun.lock | 2 +- packages/auth/README.md | 14 ++ .../auth/components/TwoFactorChallenge.wrn | 3 +- packages/auth/package.json | 2 +- packages/auth/src/engine.ts | 172 +++++++++++--- packages/auth/src/http/index.ts | 29 +++ packages/auth/src/types.ts | 6 + packages/auth/src/validation.ts | 1 + packages/auth/test/device-trust.test.ts | 222 ++++++++++++++++++ 9 files changed, 418 insertions(+), 33 deletions(-) create mode 100644 packages/auth/test/device-trust.test.ts 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"); +});