fix(auth): preserve MFA continuation through field hydration
This commit is contained in:
1 parent
24f15e8b78
commit
a077515336
5 files changed
+79
-3
No files matched your search
@@ -235,7 +235,7 @@
|
||||
},
|
||||
"packages/auth": {
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.8.20",
|
||||
"version": "0.8.21",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/captcha": "workspace:*",
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user