diff --git a/bun.lock b/bun.lock index 2215a535..169b95db 100644 --- a/bun.lock +++ b/bun.lock @@ -235,7 +235,7 @@ }, "packages/auth": { "name": "@wrnexus/auth", - "version": "0.8.20", + "version": "0.8.21", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", diff --git a/packages/auth/assets/client/auth.js b/packages/auth/assets/client/auth.js index 353d83ad..5471cc32 100644 --- a/packages/auth/assets/client/auth.js +++ b/packages/auth/assets/client/auth.js @@ -405,10 +405,11 @@ const form = element.querySelector("[data-auth-mfa-form]"); const tokenInput = form?.querySelector("[name=mfaToken]"); const challengeInput = form?.querySelector("[name=challengeId]"); - const methodInput = form?.querySelector("[data-auth-mfa-method]"); + const methodInput = form?.querySelector("select[name=method]"); const codeInput = form?.querySelector("[name=code]"); const status = form?.querySelector("[data-auth-mfa-status]"); const saved = readMfaState(); + let expired = false; if (tokenInput && !tokenInput.value && saved?.mfaToken) { tokenInput.value = String(saved.mfaToken); } @@ -442,6 +443,16 @@ }; const onSubmit = async (event) => { + // Hydration may restore the component's empty initial prop after mount. + // Recover only this tab's existing challenge; never mint or extend one. + if (expired) { + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } + if (tokenInput && !tokenInput.value && saved?.mfaToken) { + tokenInput.value = String(saved.mfaToken); + } const method = methodInput?.value; if (!form || !["email-otp", "sms-otp"].includes(method) || challengeInput?.value) return; event.preventDefault(); @@ -474,14 +485,24 @@ if (event.target !== form) return; sessionStorage.removeItem(MFA_STORAGE_KEY); }; + 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; + }; onMethodChange(); methodInput?.addEventListener("change", onMethodChange); form?.addEventListener("submit", onSubmit, true); element.addEventListener("wrn:success", onSuccess); + element.addEventListener("wrn:error", onError); flowCleanups.set(element, () => { methodInput?.removeEventListener("change", onMethodChange); form?.removeEventListener("submit", onSubmit, true); element.removeEventListener("wrn:success", onSuccess); + element.removeEventListener("wrn:error", onError); }); } diff --git a/packages/auth/components/TwoFactorChallenge.wrn b/packages/auth/components/TwoFactorChallenge.wrn index b486dc1f..66e3db6b 100644 --- a/packages/auth/components/TwoFactorChallenge.wrn +++ b/packages/auth/components/TwoFactorChallenge.wrn @@ -13,6 +13,7 @@ component TwoFactorChallenge { description = "Choose a verification method and enter the code." submitLabel = "Verify" recoveryHref = "/recover" + signInHref = "/login" color = "primary" size = "md" class = "" @@ -47,6 +48,7 @@ component TwoFactorChallenge {
+ Sign-in verification expired. Back to sign in Account recovery options } diff --git a/packages/auth/package.json b/packages/auth/package.json index 43490e76..930e8c76 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/auth", - "version": "0.8.20", + "version": "0.8.21", "description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.", "type": "module", "sideEffects": false, diff --git a/packages/auth/test/mfa-runtime.test.ts b/packages/auth/test/mfa-runtime.test.ts new file mode 100644 index 00000000..95d9544b --- /dev/null +++ b/packages/auth/test/mfa-runtime.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { Window } from "happy-dom"; + +test("MFA uses the nested Select and restores continuation after hydration without reviving expired challenges", async () => { + const window = new Window({ url: "https://sso.example.test/mfa" }); + try { + window.sessionStorage.setItem( + "wrnexus.auth.mfa", + JSON.stringify({ + mfaToken: "synthetic-test-challenge", + requires: { mfa: ["totp"] }, + }), + ); + window.document.body.innerHTML = `