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
@@ -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