feat(auth): use MFA radio methods and request selected delivery codes
This commit is contained in:
1 parent
65d63dfbe7
commit
298e34ef2e
6 files changed
+171
-75
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
<input type="hidden" name="mfaToken" value='{mfaToken}' />
|
||||
<input type="hidden" name="challengeId" value='{challengeId}' />
|
||||
<input type="hidden" name="returnTo" value='{returnTo}' />
|
||||
<Select id="auth-mfa-method" name="method" data-auth-mfa-method label="Verification method" value="{method}" options="{methodOptions}" color="{color}" size="{size}" />
|
||||
<PinInput label="Verification code" name="code" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
|
||||
<input type="hidden" name="code" value="" />
|
||||
<Radio id="auth-mfa-method" name="method" data-auth-mfa-method label="Verification method" value="{method}" options="{methodOptions}" card="true" color="{color}" size="{size}" />
|
||||
<div data-auth-mfa-pin><PinInput label="Verification code" name="otpCode" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" /></div>
|
||||
<div data-auth-mfa-recovery hidden><Input label="Recovery code" name="recoveryCode" autocomplete="off" maxlength="32" color="{color}" size="{size}" /></div>
|
||||
<p data-error="code" class="m-0 text-sm text-[var(--wrn-color-danger)]" aria-live="polite"></p>
|
||||
<p data-auth-mfa-status role="status" hidden class="m-0 rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-primary)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-primary)]"></p>
|
||||
<p data-error="mfaToken" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
|
||||
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -92,7 +92,7 @@ test("authentication journeys use themed UI form controls", () => {
|
||||
"MagicLinkSignIn.wrn": ["<Input", "<Button"],
|
||||
"InvitationAccept.wrn": ["<Input", "<StrongPassword", "<Button"],
|
||||
"OtpSignIn.wrn": ["<Input", "<PinInput", "<Button"],
|
||||
"TwoFactorChallenge.wrn": ["<Select", "<PinInput", "<Button"],
|
||||
"TwoFactorChallenge.wrn": ["<Radio", "<Input", "<PinInput", "<Button"],
|
||||
"AuthenticatorSetup.wrn": ["<Input", "<PinInput", "<Button"],
|
||||
"VerifyEmail.wrn": ["<Input", "<Button"],
|
||||
"VerifyPhone.wrn": ["<PinInput", "<Button"],
|
||||
|
||||
@@ -1,8 +1,62 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Window, type HTMLInputElement, type HTMLOptionElement, type HTMLElement } from "happy-dom";
|
||||
import { Window, type HTMLInputElement, type HTMLElement } from "happy-dom";
|
||||
|
||||
test("MFA uses the nested Select and restores continuation after hydration without reviving expired challenges", async () => {
|
||||
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 = `<section data-auth-mfa-challenge data-mfa-initial-method="totp"><form data-auth-mfa-form>
|
||||
<input name="mfaToken"><input name="challengeId"><input name="code">
|
||||
${["totp", "recovery-code", "email-otp", "sms-otp"].map((value) => `<label><input type="radio" name="method" value="${value}">${value}</label>`).join("")}
|
||||
<div data-auth-mfa-pin><input name="otpCode"></div><div data-auth-mfa-recovery hidden><input name="recoveryCode"></div>
|
||||
<p data-auth-mfa-status></p><button type="submit">Verify</button>
|
||||
</form></section>`;
|
||||
const requests: Array<{ method: string; resolve: (response: Response) => void }> = [];
|
||||
window.fetch = ((_: unknown, init: RequestInit) =>
|
||||
new Promise<Response>((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<HTMLInputElement>(`[name=${name}]`)!;
|
||||
const choose = (value: string) => {
|
||||
const radio = form.querySelector<HTMLInputElement>(`[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
|
||||
<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>
|
||||
<div data-auth-mfa-method><label><input type="radio" name="method" value="totp">Authenticator app</label><label><input type="radio" name="method" value="email-otp">Email</label></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>`;
|
||||
@@ -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<HTMLInputElement>('[name="mfaToken"]')!;
|
||||
expect(token.value).toBe("synthetic-test-challenge");
|
||||
expect(form.querySelector<HTMLOptionElement>('option[value="email-otp"]')!.disabled).toBe(true);
|
||||
expect(form.querySelector<HTMLInputElement>('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");
|
||||
|
||||
Reference in new issue
Block a user