fix(auth): preserve MFA continuation through field hydration

This commit is contained in:
Clintchiz committed 2026-08-26 18:03:02 +05:30
1 parent 24f15e8b78
commit a077515336
5 files changed
+79 -3

No files matched your search

+22 -1
View File
@@ -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);
});
}
@@ -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 {
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-danger)]"></p>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<a href='{signInHref}' data-auth-mfa-restart hidden class="mt-4 text-sm text-[var(--wrn-color-primary)] underline">Sign-in verification expired. Back to sign in</a>
<a href='{recoveryHref}' class="mt-4 inline-flex text-sm text-[var(--wrn-color-primary)] hover:underline">Account recovery options</a>
</section>
}
+1 -1
View File
@@ -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,
+53
View File
@@ -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 = `<section data-auth-mfa-challenge data-mfa-initial-method="totp">
<form data-auth-mfa-form>
<input type="hidden" name="mfaToken">
<input type="hidden" name="challengeId">
<div data-auth-mfa-method><select name="method"><option value="totp">Authenticator app</option><option value="email-otp">Email</option></select></div>
<input name="code"><p data-auth-mfa-status></p><button type="submit">Verify</button>
</form><a data-auth-mfa-restart hidden href="/login">Back to sign in</a>
</section>`;
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<HTMLInputElement>('[name="mfaToken"]')!;
expect(token.value).toBe("synthetic-test-challenge");
expect(form.querySelector<HTMLOptionElement>('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<HTMLElement>("[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();
}
});