diff --git a/bun.lock b/bun.lock index 95a85fc5..e5bfbdfe 100644 --- a/bun.lock +++ b/bun.lock @@ -235,7 +235,7 @@ }, "packages/auth": { "name": "@wrnexus/auth", - "version": "0.8.21", + "version": "0.8.22", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", diff --git a/packages/auth/assets/client/auth.js b/packages/auth/assets/client/auth.js index 5471cc32..1744ac3f 100644 --- a/packages/auth/assets/client/auth.js +++ b/packages/auth/assets/client/auth.js @@ -405,85 +405,122 @@ const form = element.querySelector("[data-auth-mfa-form]"); const tokenInput = form?.querySelector("[name=mfaToken]"); const challengeInput = form?.querySelector("[name=challengeId]"); - const methodInput = form?.querySelector("select[name=method]"); - const codeInput = form?.querySelector("[name=code]"); + const radios = Array.from(form?.querySelectorAll("input[type=radio][name=method]") || []); 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); - } const allowed = Array.isArray(saved?.requires?.mfa) ? saved.requires.mfa : []; - const initialMethod = element.dataset.mfaInitialMethod; - if (methodInput && allowed.length) { - Array.from(methodInput.options).forEach((option) => { - option.disabled = !allowed.includes(option.value); - option.hidden = option.disabled; - }); - const selected = allowed.includes(initialMethod) - ? initialMethod - : allowed.includes(methodInput.value) - ? methodInput.value - : allowed[0]; - if (selected) methodInput.value = selected; - } else if (methodInput && initialMethod) { - methodInput.value = initialMethod; - } - - const onMethodChange = () => { - if (challengeInput) challengeInput.value = ""; - setMessage(status, ""); - if (codeInput) { - codeInput.value = ""; - codeInput.setAttribute( - "inputmode", - methodInput?.value === "recovery-code" ? "text" : "numeric", - ); - } - }; - - 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) { + const challenges = new Map(); + const pending = new Map(); + let expired = false; + let disposed = false; + const restoreToken = () => { + if (!expired && 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(); - event.stopImmediatePropagation(); + }; + const method = () => radios.find((radio) => radio.checked && !radio.disabled)?.value; + const isOtp = (value) => ["email-otp", "sms-otp"].includes(value); + for (const radio of radios) { + radio.disabled = allowed.length > 0 && !allowed.includes(radio.value); + const label = radio.closest("label"); + if (label) label.hidden = radio.disabled; + } + const initial = + radios.find((radio) => !radio.disabled && radio.value === element.dataset.mfaInitialMethod) || + radios.find((radio) => !radio.disabled); + for (const radio of radios) radio.checked = radio === initial; + restoreToken(); + + const sendCode = async (selected) => { + if (disposed || expired || !isOtp(selected)) return; + restoreToken(); if (!tokenInput?.value) { setMessage(status, "Sign-in verification has expired. Start again.", true); + const restart = element.querySelector("[data-auth-mfa-restart]"); + if (restart) restart.hidden = false; return; } - setFormBusy(form, true); - setMessage(status, "Sending a one-time code…"); + const previous = challenges.get(selected); + if (previous?.expiresAt && Number(previous.expiresAt) <= Date.now()) + challenges.delete(selected); + if (!pending.has(selected) && !challenges.has(selected)) { + pending.set( + selected, + json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", { + mfaToken: tokenInput.value, + method: selected, + }), + ); + } + if (method() === selected) { + setFormBusy(form, true); + setMessage(status, "Sending a one-time code…"); + } try { - const result = await json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", { - mfaToken: tokenInput.value, - method, - }); + const result = challenges.get(selected) || (await pending.get(selected)); + if (disposed || expired) return; + challenges.set(selected, result); + if (method() !== selected) return; if (challengeInput) challengeInput.value = String(result.id || ""); - setMessage(status, "A one-time code has been sent."); - codeInput?.focus(); - } catch (error) { setMessage( status, - error instanceof Error ? error.message : "Unable to send a one-time code", - true, + selected === "email-otp" + ? "A code has been sent to your email." + : "A code has been sent to your mobile number.", ); + } catch (error) { + if (!disposed && !expired && method() === selected) + setMessage( + status, + error instanceof Error ? error.message : "Unable to send a one-time code", + true, + ); } finally { - setFormBusy(form, false); + pending.delete(selected); + if (!disposed && method() === selected) setFormBusy(form, false); + } + }; + const updateMethod = () => { + const selected = method(); + setFormBusy(form, false); + if (challengeInput) challengeInput.value = ""; + const code = form?.querySelector("[name=code]"); + if (code) code.value = ""; + const error = form?.querySelector("[data-error=code]"); + if (error) error.textContent = ""; + setMessage(status, ""); + for (const [selector, active] of [ + ["[data-auth-mfa-pin]", selected !== "recovery-code"], + ["[data-auth-mfa-recovery]", selected === "recovery-code"], + ]) { + const group = form?.querySelector(selector); + if (!group) continue; + group.hidden = !active; + for (const input of group.querySelectorAll("input")) { + input.disabled = !active; + input.value = ""; + input.dispatchEvent(new window.Event("input", { bubbles: true })); + } + } + if (isOtp(selected)) void sendCode(selected); + }; + const onMethodChange = (event) => { + if (radios.includes(event.target)) updateMethod(); + }; + const onSubmit = (event) => { + restoreToken(); + const code = form?.querySelector("[name=code]"); + const active = form?.querySelector( + method() === "recovery-code" ? "[name=recoveryCode]" : "[name=otpCode]", + ); + if (code && active) code.value = active.value; + if (expired || !method() || (isOtp(method()) && !challengeInput?.value)) { + event.preventDefault(); + event.stopImmediatePropagation(); + if (!expired && isOtp(method())) void sendCode(method()); } }; const onSuccess = (event) => { - if (event.target !== form) return; - sessionStorage.removeItem(MFA_STORAGE_KEY); + if (event.target === form) sessionStorage.removeItem(MFA_STORAGE_KEY); }; const onError = (event) => { if (event.target !== form || event.detail?.code !== "mfa-transaction-expired") return; @@ -493,13 +530,15 @@ const restart = element.querySelector("[data-auth-mfa-restart]"); if (restart) restart.hidden = false; }; - onMethodChange(); - methodInput?.addEventListener("change", onMethodChange); + updateMethod(); + // Capture native change before the shared Radio emits its component output. + form?.addEventListener("change", onMethodChange, true); form?.addEventListener("submit", onSubmit, true); element.addEventListener("wrn:success", onSuccess); element.addEventListener("wrn:error", onError); flowCleanups.set(element, () => { - methodInput?.removeEventListener("change", onMethodChange); + disposed = true; + form?.removeEventListener("change", onMethodChange, true); 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 66e3db6b..89503234 100644 --- a/packages/auth/components/TwoFactorChallenge.wrn +++ b/packages/auth/components/TwoFactorChallenge.wrn @@ -1,4 +1,4 @@ -import { Button, PinInput, Select } from "@wrnexus/ui" +import { Button, Input, PinInput, Radio } from "@wrnexus/ui" component TwoFactorChallenge { props { @@ -40,8 +40,11 @@ component TwoFactorChallenge { - + +
+ +

diff --git a/packages/auth/package.json b/packages/auth/package.json index 930e8c76..5f73c3f5 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/auth", - "version": "0.8.21", + "version": "0.8.22", "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/components.test.ts b/packages/auth/test/components.test.ts index d2f24601..638cc377 100644 --- a/packages/auth/test/components.test.ts +++ b/packages/auth/test/components.test.ts @@ -92,7 +92,7 @@ test("authentication journeys use themed UI form controls", () => { "MagicLinkSignIn.wrn": [" { +test("selecting delivery radios sends once, ignores stale responses, and never sends for local methods", async () => { + const window = new Window({ url: "https://sso.example.test/mfa" }); + try { + window.sessionStorage.setItem( + "wrnexus.auth.mfa", + JSON.stringify({ mfaToken: "synthetic-test-challenge" }), + ); + window.document.body.innerHTML = `
+ + ${["totp", "recovery-code", "email-otp", "sms-otp"].map((value) => ``).join("")} +
+

+
`; + const requests: Array<{ method: string; resolve: (response: Response) => void }> = []; + window.fetch = ((_: unknown, init: RequestInit) => + new Promise((resolve) => { + requests.push({ method: JSON.parse(String(init.body)).method, resolve }); + })) as any; + 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 field = (name: string) => form.querySelector(`[name=${name}]`)!; + const choose = (value: string) => { + const radio = form.querySelector(`[name=method][value="${value}"]`)!; + radio.checked = true; + radio.dispatchEvent(new window.Event("change", { bubbles: true })); + }; + choose("recovery-code"); + expect(requests).toHaveLength(0); + field("recoveryCode").value = "abc123-def456"; + form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); + expect(field("code").value).toBe("abc123-def456"); + choose("email-otp"); + choose("email-otp"); + expect(requests.map((x) => x.method)).toEqual(["email-otp"]); + choose("sms-otp"); + expect(requests.map((x) => x.method)).toEqual(["email-otp", "sms-otp"]); + requests[0]!.resolve(Response.json({ id: "email-challenge" })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(field("challengeId").value).toBe(""); + requests[1]!.resolve(Response.json({ id: "sms-challenge" })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(field("challengeId").value).toBe("sms-challenge"); + choose("totp"); + expect(field("challengeId").value).toBe(""); + expect(requests).toHaveLength(2); + field("otpCode").value = "123456"; + form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); + expect(field("code").value).toBe("123456"); + } finally { + await window.happyDOM.close(); + } +}); + +test("MFA radios preserve allowed methods and restore continuation without reviving expired challenges", async () => { const window = new Window({ url: "https://sso.example.test/mfa" }); try { window.sessionStorage.setItem( @@ -16,7 +70,7 @@ test("MFA uses the nested Select and restores continuation after hydration witho
-
+

`; @@ -25,7 +79,7 @@ test("MFA uses the nested Select and restores continuation after hydration witho 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); + expect(form.querySelector('input[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");