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 { + + `; + window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8")); + (window as any).WRNexusAuth.mount(window.document); + const form = window.document.querySelector("form")!; + const token = form.querySelector('[name="mfaToken"]')!; + expect(token.value).toBe("synthetic-test-challenge"); + expect(form.querySelector('option[value="email-otp"]')!.disabled).toBe(true); + token.value = ""; // Hydration reapplies the component's initial empty prop. + form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); + expect(token.value).toBe("synthetic-test-challenge"); + form.dispatchEvent( + new window.CustomEvent("wrn:error", { bubbles: true, detail: { code: "mfa-invalid" } }), + ); + expect(token.value).toBe("synthetic-test-challenge"); + form.dispatchEvent( + new window.CustomEvent("wrn:error", { + bubbles: true, + detail: { code: "mfa-transaction-expired" }, + }), + ); + expect(window.sessionStorage.getItem("wrnexus.auth.mfa")).toBeNull(); + expect(window.document.querySelector("[data-auth-mfa-restart]")!.hidden).toBe( + false, + ); + const retry = new window.Event("submit", { bubbles: true, cancelable: true }); + form.dispatchEvent(retry); + expect(retry.defaultPrevented).toBe(true); + expect(token.value).toBe(""); + } finally { + await window.happyDOM.close(); + } +});