fix(auth): expire stale MFA forms before accepting codes
This commit is contained in:
1 parent
298e34ef2e
commit
0c77de93b4
8 files changed
+82
-11
No files matched your search
@@ -235,7 +235,7 @@
|
|||||||
},
|
},
|
||||||
"packages/auth": {
|
"packages/auth": {
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.22",
|
"version": "0.8.23",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/captcha": "workspace:*",
|
"@wrnexus/captcha": "workspace:*",
|
||||||
|
|||||||
@@ -413,7 +413,32 @@
|
|||||||
const pending = new Map();
|
const pending = new Map();
|
||||||
let expired = false;
|
let expired = false;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
let expiryTimer;
|
||||||
|
const expiresAt = Number(saved?.mfaExpiresAt);
|
||||||
|
const expire = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
expired = true;
|
||||||
|
window.clearTimeout(expiryTimer);
|
||||||
|
sessionStorage.removeItem(MFA_STORAGE_KEY);
|
||||||
|
if (tokenInput) tokenInput.value = "";
|
||||||
|
form?.querySelectorAll("input, button").forEach((control) => {
|
||||||
|
control.disabled = true;
|
||||||
|
});
|
||||||
|
setMessage(
|
||||||
|
status,
|
||||||
|
"Your sign-in request has expired. Sign in again to get a fresh verification request.",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const restart = element.querySelector("[data-auth-mfa-restart]");
|
||||||
|
if (restart) restart.hidden = false;
|
||||||
|
};
|
||||||
|
const checkExpiry = () => {
|
||||||
|
// Old stored challenges have no verifiable deadline: require fresh sign-in.
|
||||||
|
if (saved?.mfaToken && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) expire();
|
||||||
|
return expired;
|
||||||
|
};
|
||||||
const restoreToken = () => {
|
const restoreToken = () => {
|
||||||
|
checkExpiry();
|
||||||
if (!expired && tokenInput && !tokenInput.value && saved?.mfaToken)
|
if (!expired && tokenInput && !tokenInput.value && saved?.mfaToken)
|
||||||
tokenInput.value = String(saved.mfaToken);
|
tokenInput.value = String(saved.mfaToken);
|
||||||
};
|
};
|
||||||
@@ -433,6 +458,7 @@
|
|||||||
const sendCode = async (selected) => {
|
const sendCode = async (selected) => {
|
||||||
if (disposed || expired || !isOtp(selected)) return;
|
if (disposed || expired || !isOtp(selected)) return;
|
||||||
restoreToken();
|
restoreToken();
|
||||||
|
if (expired) return;
|
||||||
if (!tokenInput?.value) {
|
if (!tokenInput?.value) {
|
||||||
setMessage(status, "Sign-in verification has expired. Start again.", true);
|
setMessage(status, "Sign-in verification has expired. Start again.", true);
|
||||||
const restart = element.querySelector("[data-auth-mfa-restart]");
|
const restart = element.querySelector("[data-auth-mfa-restart]");
|
||||||
@@ -476,10 +502,11 @@
|
|||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
pending.delete(selected);
|
pending.delete(selected);
|
||||||
if (!disposed && method() === selected) setFormBusy(form, false);
|
if (!disposed && !expired && method() === selected) setFormBusy(form, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const updateMethod = () => {
|
const updateMethod = () => {
|
||||||
|
if (checkExpiry()) return;
|
||||||
const selected = method();
|
const selected = method();
|
||||||
setFormBusy(form, false);
|
setFormBusy(form, false);
|
||||||
if (challengeInput) challengeInput.value = "";
|
if (challengeInput) challengeInput.value = "";
|
||||||
@@ -524,13 +551,12 @@
|
|||||||
};
|
};
|
||||||
const onError = (event) => {
|
const onError = (event) => {
|
||||||
if (event.target !== form || event.detail?.code !== "mfa-transaction-expired") return;
|
if (event.target !== form || event.detail?.code !== "mfa-transaction-expired") return;
|
||||||
expired = true;
|
expire();
|
||||||
sessionStorage.removeItem(MFA_STORAGE_KEY);
|
|
||||||
if (tokenInput) tokenInput.value = "";
|
|
||||||
const restart = element.querySelector("[data-auth-mfa-restart]");
|
|
||||||
if (restart) restart.hidden = false;
|
|
||||||
};
|
};
|
||||||
updateMethod();
|
updateMethod();
|
||||||
|
if (!expired && Number.isFinite(expiresAt)) {
|
||||||
|
expiryTimer = window.setTimeout(expire, Math.max(0, expiresAt - Date.now()));
|
||||||
|
}
|
||||||
// Capture native change before the shared Radio emits its component output.
|
// Capture native change before the shared Radio emits its component output.
|
||||||
form?.addEventListener("change", onMethodChange, true);
|
form?.addEventListener("change", onMethodChange, true);
|
||||||
form?.addEventListener("submit", onSubmit, true);
|
form?.addEventListener("submit", onSubmit, true);
|
||||||
@@ -538,6 +564,7 @@
|
|||||||
element.addEventListener("wrn:error", onError);
|
element.addEventListener("wrn:error", onError);
|
||||||
flowCleanups.set(element, () => {
|
flowCleanups.set(element, () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
window.clearTimeout(expiryTimer);
|
||||||
form?.removeEventListener("change", onMethodChange, true);
|
form?.removeEventListener("change", onMethodChange, true);
|
||||||
form?.removeEventListener("submit", onSubmit, true);
|
form?.removeEventListener("submit", onSubmit, true);
|
||||||
element.removeEventListener("wrn:success", onSuccess);
|
element.removeEventListener("wrn:success", onSuccess);
|
||||||
|
|||||||
@@ -51,8 +51,15 @@ 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>
|
<nav class="wrn-auth-mfa-links" aria-label="Sign-in help">
|
||||||
<a href='{recoveryHref}' class="mt-4 inline-flex text-sm text-[var(--wrn-color-primary)] hover:underline">Account recovery options</a>
|
<a href='{signInHref}' data-auth-mfa-restart hidden>Back to sign in</a>
|
||||||
|
<a href='{recoveryHref}'>Account recovery options</a>
|
||||||
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
style {
|
||||||
|
.wrn-auth-mfa-links { display: flex; flex-direction: column; align-items: flex-start; gap: 0.75rem; margin-top: 1.25rem; }
|
||||||
|
.wrn-auth-mfa-links a { color: var(--wrn-color-primary); font-size: 0.875rem; }
|
||||||
|
.wrn-auth-mfa-links a:hover { text-decoration: underline; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.22",
|
"version": "0.8.23",
|
||||||
"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,
|
||||||
|
|||||||
@@ -490,6 +490,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
message: "Additional verification is required but no verification method is available",
|
message: "Additional verification is required but no verification method is available",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const mfaExpiresAt = now() + 5 * 60_000;
|
||||||
const mfaToken = await issueToken(user.id, "login-mfa", {
|
const mfaToken = await issueToken(user.id, "login-mfa", {
|
||||||
ttlMs: 5 * 60_000,
|
ttlMs: 5 * 60_000,
|
||||||
metadata,
|
metadata,
|
||||||
@@ -500,6 +501,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
message: "Complete two-step verification to finish signing in.",
|
message: "Complete two-step verification to finish signing in.",
|
||||||
user: publicUser(user),
|
user: publicUser(user),
|
||||||
mfaToken,
|
mfaToken,
|
||||||
|
mfaExpiresAt,
|
||||||
requires: { mfa: methods },
|
requires: { mfa: methods },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1081,6 +1083,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
risk,
|
risk,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const mfaExpiresAt = now() + 5 * 60_000;
|
||||||
const mfaToken = await issueToken(user.id, "login-mfa", {
|
const mfaToken = await issueToken(user.id, "login-mfa", {
|
||||||
ttlMs: 5 * 60_000,
|
ttlMs: 5 * 60_000,
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -1099,6 +1102,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
user: publicUser(user),
|
user: publicUser(user),
|
||||||
risk,
|
risk,
|
||||||
mfaToken,
|
mfaToken,
|
||||||
|
mfaExpiresAt,
|
||||||
requires: { captcha: risk.requireCaptcha, mfa: methods },
|
requires: { captcha: risk.requireCaptcha, mfa: methods },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1961,6 +1965,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens);
|
account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens);
|
||||||
if (await hasMfa(user.id)) {
|
if (await hasMfa(user.id)) {
|
||||||
const methods = await availableMfaMethods(user.id);
|
const methods = await availableMfaMethods(user.id);
|
||||||
|
const mfaExpiresAt = now() + 5 * 60_000;
|
||||||
const mfaToken = await issueToken(user.id, "login-mfa", {
|
const mfaToken = await issueToken(user.id, "login-mfa", {
|
||||||
ttlMs: 5 * 60_000,
|
ttlMs: 5 * 60_000,
|
||||||
metadata: { oauthProvider: providerName },
|
metadata: { oauthProvider: providerName },
|
||||||
@@ -1970,6 +1975,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
code: "mfa-required",
|
code: "mfa-required",
|
||||||
user: publicUser(user),
|
user: publicUser(user),
|
||||||
mfaToken,
|
mfaToken,
|
||||||
|
mfaExpiresAt,
|
||||||
requires: { mfa: methods },
|
requires: { mfa: methods },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,6 +505,8 @@ export interface AuthResult {
|
|||||||
session?: AuthSession;
|
session?: AuthSession;
|
||||||
risk?: AuthRiskDecision;
|
risk?: AuthRiskDecision;
|
||||||
mfaToken?: string;
|
mfaToken?: string;
|
||||||
|
/** Absolute expiry of the pending sign-in challenge, in epoch milliseconds. */
|
||||||
|
mfaExpiresAt?: number;
|
||||||
requires?: {
|
requires?: {
|
||||||
captcha?: boolean;
|
captcha?: boolean;
|
||||||
mfa?: AuthMfaMethod[];
|
mfa?: AuthMfaMethod[];
|
||||||
|
|||||||
@@ -863,6 +863,7 @@ describe("authentication engine", () => {
|
|||||||
requires: { mfa: ["totp"] },
|
requires: { mfa: ["totp"] },
|
||||||
});
|
});
|
||||||
expect(await engine.beginMfaOtp(firstLogin.mfaToken!, "email-otp")).toBeUndefined();
|
expect(await engine.beginMfaOtp(firstLogin.mfaToken!, "email-otp")).toBeUndefined();
|
||||||
|
expect(firstLogin.mfaExpiresAt).toBe(now() + 5 * 60_000);
|
||||||
|
|
||||||
expect((await engine.verifyEmail(verificationToken)).ok).toBe(true);
|
expect((await engine.verifyEmail(verificationToken)).ok).toBe(true);
|
||||||
const secondLogin = await engine.login({
|
const secondLogin = await engine.login({
|
||||||
|
|||||||
@@ -2,12 +2,39 @@ import { expect, test } from "bun:test";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { Window, type HTMLInputElement, type HTMLElement } from "happy-dom";
|
import { Window, type HTMLInputElement, type HTMLElement } from "happy-dom";
|
||||||
|
|
||||||
|
test("expired and legacy MFA requests are rejected on mount before entering a code", async () => {
|
||||||
|
for (const deadline of [undefined, Date.now() - 1]) {
|
||||||
|
const window = new Window({ url: "https://sso.example.test/mfa" });
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
"wrnexus.auth.mfa",
|
||||||
|
JSON.stringify({ mfaToken: "old-challenge", mfaExpiresAt: deadline }),
|
||||||
|
);
|
||||||
|
window.document.body.innerHTML = `<section data-auth-mfa-challenge><form data-auth-mfa-form>
|
||||||
|
<input name="mfaToken"><input type="radio" name="method" value="totp"><input name="code"><p data-auth-mfa-status hidden></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);
|
||||||
|
expect(window.document.querySelector("button")!.disabled).toBe(true);
|
||||||
|
expect(window.document.querySelector<HTMLElement>("[data-auth-mfa-restart]")!.hidden).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(window.document.querySelector("[data-auth-mfa-status]")!.textContent).toContain(
|
||||||
|
"Sign in again",
|
||||||
|
);
|
||||||
|
expect(window.sessionStorage.getItem("wrnexus.auth.mfa")).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await window.happyDOM.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("selecting delivery radios sends once, ignores stale responses, and never sends for local methods", 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" });
|
const window = new Window({ url: "https://sso.example.test/mfa" });
|
||||||
try {
|
try {
|
||||||
window.sessionStorage.setItem(
|
window.sessionStorage.setItem(
|
||||||
"wrnexus.auth.mfa",
|
"wrnexus.auth.mfa",
|
||||||
JSON.stringify({ mfaToken: "synthetic-test-challenge" }),
|
JSON.stringify({ mfaToken: "synthetic-test-challenge", mfaExpiresAt: Date.now() + 300_000 }),
|
||||||
);
|
);
|
||||||
window.document.body.innerHTML = `<section data-auth-mfa-challenge data-mfa-initial-method="totp"><form data-auth-mfa-form>
|
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">
|
<input name="mfaToken"><input name="challengeId"><input name="code">
|
||||||
@@ -63,6 +90,7 @@ test("MFA radios preserve allowed methods and restore continuation without reviv
|
|||||||
"wrnexus.auth.mfa",
|
"wrnexus.auth.mfa",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
mfaToken: "synthetic-test-challenge",
|
mfaToken: "synthetic-test-challenge",
|
||||||
|
mfaExpiresAt: Date.now() + 300_000,
|
||||||
requires: { mfa: ["totp"] },
|
requires: { mfa: ["totp"] },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user