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": {
|
"packages/auth": {
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.20",
|
"version": "0.8.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/captcha": "workspace:*",
|
"@wrnexus/captcha": "workspace:*",
|
||||||
|
|||||||
@@ -405,10 +405,11 @@
|
|||||||
const form = element.querySelector("[data-auth-mfa-form]");
|
const form = element.querySelector("[data-auth-mfa-form]");
|
||||||
const tokenInput = form?.querySelector("[name=mfaToken]");
|
const tokenInput = form?.querySelector("[name=mfaToken]");
|
||||||
const challengeInput = form?.querySelector("[name=challengeId]");
|
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 codeInput = form?.querySelector("[name=code]");
|
||||||
const status = form?.querySelector("[data-auth-mfa-status]");
|
const status = form?.querySelector("[data-auth-mfa-status]");
|
||||||
const saved = readMfaState();
|
const saved = readMfaState();
|
||||||
|
let expired = false;
|
||||||
if (tokenInput && !tokenInput.value && saved?.mfaToken) {
|
if (tokenInput && !tokenInput.value && saved?.mfaToken) {
|
||||||
tokenInput.value = String(saved.mfaToken);
|
tokenInput.value = String(saved.mfaToken);
|
||||||
}
|
}
|
||||||
@@ -442,6 +443,16 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (event) => {
|
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;
|
const method = methodInput?.value;
|
||||||
if (!form || !["email-otp", "sms-otp"].includes(method) || challengeInput?.value) return;
|
if (!form || !["email-otp", "sms-otp"].includes(method) || challengeInput?.value) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -474,14 +485,24 @@
|
|||||||
if (event.target !== form) return;
|
if (event.target !== form) return;
|
||||||
sessionStorage.removeItem(MFA_STORAGE_KEY);
|
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();
|
onMethodChange();
|
||||||
methodInput?.addEventListener("change", onMethodChange);
|
methodInput?.addEventListener("change", onMethodChange);
|
||||||
form?.addEventListener("submit", onSubmit, true);
|
form?.addEventListener("submit", onSubmit, true);
|
||||||
element.addEventListener("wrn:success", onSuccess);
|
element.addEventListener("wrn:success", onSuccess);
|
||||||
|
element.addEventListener("wrn:error", onError);
|
||||||
flowCleanups.set(element, () => {
|
flowCleanups.set(element, () => {
|
||||||
methodInput?.removeEventListener("change", onMethodChange);
|
methodInput?.removeEventListener("change", onMethodChange);
|
||||||
form?.removeEventListener("submit", onSubmit, true);
|
form?.removeEventListener("submit", onSubmit, true);
|
||||||
element.removeEventListener("wrn:success", onSuccess);
|
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."
|
description = "Choose a verification method and enter the code."
|
||||||
submitLabel = "Verify"
|
submitLabel = "Verify"
|
||||||
recoveryHref = "/recover"
|
recoveryHref = "/recover"
|
||||||
|
signInHref = "/login"
|
||||||
color = "primary"
|
color = "primary"
|
||||||
size = "md"
|
size = "md"
|
||||||
class = ""
|
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>
|
<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" />
|
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||||
</form>
|
</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>
|
<a href='{recoveryHref}' class="mt-4 inline-flex text-sm text-[var(--wrn-color-primary)] hover:underline">Account recovery options</a>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/auth",
|
"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.",
|
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"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