diff --git a/bun.lock b/bun.lock index e5bfbdfe..cdf7d437 100644 --- a/bun.lock +++ b/bun.lock @@ -235,7 +235,7 @@ }, "packages/auth": { "name": "@wrnexus/auth", - "version": "0.8.22", + "version": "0.8.23", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", diff --git a/packages/auth/assets/client/auth.js b/packages/auth/assets/client/auth.js index 1744ac3f..1ee18894 100644 --- a/packages/auth/assets/client/auth.js +++ b/packages/auth/assets/client/auth.js @@ -413,7 +413,32 @@ const pending = new Map(); let expired = false; let disposed = false; + let expiryTimer; + const expiresAt = Number(saved?.mfaExpiresAt); + const expire = () => { + if (disposed) return; + expired = true; + window.clearTimeout(expiryTimer); + sessionStorage.removeItem(MFA_STORAGE_KEY); + if (tokenInput) tokenInput.value = ""; + form?.querySelectorAll("input, button").forEach((control) => { + control.disabled = true; + }); + setMessage( + status, + "Your sign-in request has expired. Sign in again to get a fresh verification request.", + true, + ); + const restart = element.querySelector("[data-auth-mfa-restart]"); + if (restart) restart.hidden = false; + }; + const checkExpiry = () => { + // Old stored challenges have no verifiable deadline: require fresh sign-in. + if (saved?.mfaToken && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) expire(); + return expired; + }; const restoreToken = () => { + checkExpiry(); if (!expired && tokenInput && !tokenInput.value && saved?.mfaToken) tokenInput.value = String(saved.mfaToken); }; @@ -433,6 +458,7 @@ const sendCode = async (selected) => { if (disposed || expired || !isOtp(selected)) return; restoreToken(); + if (expired) return; if (!tokenInput?.value) { setMessage(status, "Sign-in verification has expired. Start again.", true); const restart = element.querySelector("[data-auth-mfa-restart]"); @@ -476,10 +502,11 @@ ); } finally { pending.delete(selected); - if (!disposed && method() === selected) setFormBusy(form, false); + if (!disposed && !expired && method() === selected) setFormBusy(form, false); } }; const updateMethod = () => { + if (checkExpiry()) return; const selected = method(); setFormBusy(form, false); if (challengeInput) challengeInput.value = ""; @@ -524,13 +551,12 @@ }; const onError = (event) => { if (event.target !== form || event.detail?.code !== "mfa-transaction-expired") return; - expired = true; - sessionStorage.removeItem(MFA_STORAGE_KEY); - if (tokenInput) tokenInput.value = ""; - const restart = element.querySelector("[data-auth-mfa-restart]"); - if (restart) restart.hidden = false; + expire(); }; updateMethod(); + if (!expired && Number.isFinite(expiresAt)) { + expiryTimer = window.setTimeout(expire, Math.max(0, expiresAt - Date.now())); + } // Capture native change before the shared Radio emits its component output. form?.addEventListener("change", onMethodChange, true); form?.addEventListener("submit", onSubmit, true); @@ -538,6 +564,7 @@ element.addEventListener("wrn:error", onError); flowCleanups.set(element, () => { disposed = true; + window.clearTimeout(expiryTimer); form?.removeEventListener("change", onMethodChange, true); form?.removeEventListener("submit", onSubmit, true); element.removeEventListener("wrn:success", onSuccess); diff --git a/packages/auth/components/TwoFactorChallenge.wrn b/packages/auth/components/TwoFactorChallenge.wrn index 89503234..51529f4f 100644 --- a/packages/auth/components/TwoFactorChallenge.wrn +++ b/packages/auth/components/TwoFactorChallenge.wrn @@ -51,8 +51,15 @@ component TwoFactorChallenge {
- Sign-in verification expired. Back to sign in - Account recovery options + } + style { + .wrn-auth-mfa-links { display: flex; flex-direction: column; align-items: flex-start; gap: 0.75rem; margin-top: 1.25rem; } + .wrn-auth-mfa-links a { color: var(--wrn-color-primary); font-size: 0.875rem; } + .wrn-auth-mfa-links a:hover { text-decoration: underline; } + } } diff --git a/packages/auth/package.json b/packages/auth/package.json index 5f73c3f5..6e9ec5e8 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/auth", - "version": "0.8.22", + "version": "0.8.23", "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 e5d1acea..421ef194 100644 --- a/packages/auth/src/engine.ts +++ b/packages/auth/src/engine.ts @@ -490,6 +490,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine { message: "Additional verification is required but no verification method is available", }; } + const mfaExpiresAt = now() + 5 * 60_000; const mfaToken = await issueToken(user.id, "login-mfa", { ttlMs: 5 * 60_000, metadata, @@ -500,6 +501,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine { message: "Complete two-step verification to finish signing in.", user: publicUser(user), mfaToken, + mfaExpiresAt, requires: { mfa: methods }, }; } @@ -1081,6 +1083,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine { risk, }; } + const mfaExpiresAt = now() + 5 * 60_000; const mfaToken = await issueToken(user.id, "login-mfa", { ttlMs: 5 * 60_000, metadata: { @@ -1099,6 +1102,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine { user: publicUser(user), risk, mfaToken, + mfaExpiresAt, requires: { captcha: risk.requireCaptcha, mfa: methods }, }; } @@ -1961,6 +1965,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine { account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens); 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 }, @@ -1970,6 +1975,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine { code: "mfa-required", user: publicUser(user), mfaToken, + mfaExpiresAt, requires: { mfa: methods }, }; } diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index be33fb69..054ba0be 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -505,6 +505,8 @@ export interface AuthResult { session?: AuthSession; risk?: AuthRiskDecision; mfaToken?: string; + /** Absolute expiry of the pending sign-in challenge, in epoch milliseconds. */ + mfaExpiresAt?: number; requires?: { captcha?: boolean; mfa?: AuthMfaMethod[]; diff --git a/packages/auth/test/engine.test.ts b/packages/auth/test/engine.test.ts index 94ade447..93622a7d 100644 --- a/packages/auth/test/engine.test.ts +++ b/packages/auth/test/engine.test.ts @@ -863,6 +863,7 @@ describe("authentication engine", () => { requires: { mfa: ["totp"] }, }); expect(await engine.beginMfaOtp(firstLogin.mfaToken!, "email-otp")).toBeUndefined(); + expect(firstLogin.mfaExpiresAt).toBe(now() + 5 * 60_000); expect((await engine.verifyEmail(verificationToken)).ok).toBe(true); const secondLogin = await engine.login({ diff --git a/packages/auth/test/mfa-runtime.test.ts b/packages/auth/test/mfa-runtime.test.ts index 2c0e4202..51894390 100644 --- a/packages/auth/test/mfa-runtime.test.ts +++ b/packages/auth/test/mfa-runtime.test.ts @@ -2,12 +2,39 @@ import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { Window, type HTMLInputElement, type HTMLElement } from "happy-dom"; +test("expired and legacy MFA requests are rejected on mount before entering a code", async () => { + for (const deadline of [undefined, Date.now() - 1]) { + const window = new Window({ url: "https://sso.example.test/mfa" }); + try { + window.sessionStorage.setItem( + "wrnexus.auth.mfa", + JSON.stringify({ mfaToken: "old-challenge", mfaExpiresAt: deadline }), + ); + window.document.body.innerHTML = `