feat: release design system engine, studio and component contracts

This commit is contained in:
Clintchiz committed 2026-10-19 13:09:03 +05:30
1 parent ff1065fb27
commit 3943b946d6
693 files changed
+175546 -3894

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.8.34
- Add a Resend code control to email and SMS MFA challenges with a 60-second cooldown.
- Explicit resend requests create a fresh challenge and invalidate the previous code while retaining sign-in expiry and device binding.
- Clear entered digits after resend and keep resend hidden for authenticator and recovery-code methods.
## 0.8.19
- Pass the trusted HTTP origin into registration delivery, so signup verification links are populated without custom URL callbacks.
+45 -5
View File
@@ -415,6 +415,10 @@
const challengeInput = form?.querySelector("[name=challengeId]");
const radios = Array.from(form?.querySelectorAll("input[type=radio][name=method]") || []);
const status = form?.querySelector("[data-auth-mfa-status]");
const resendGroup = form?.querySelector("[data-auth-mfa-resend-group]");
const resendButton = form?.querySelector("button[data-auth-mfa-resend]");
const resendDeadlines = new Map();
let resendTimer;
const saved = readMfaState();
const allowed = Array.isArray(saved?.requires?.mfa) ? saved.requires.mfa : [];
const challenges = new Map();
@@ -457,6 +461,19 @@
};
const method = () => radios.find((radio) => radio.checked && !radio.disabled)?.value;
const isOtp = (value) => ["email-otp", "sms-otp"].includes(value);
const updateResend = () => {
window.clearTimeout(resendTimer);
const selected = method();
if (resendGroup) resendGroup.hidden = !isOtp(selected) || expired;
if (!resendButton || !isOtp(selected) || expired || disposed) return;
const seconds = Math.max(
0,
Math.ceil(((resendDeadlines.get(selected) || 0) - Date.now()) / 1000),
);
resendButton.disabled = pending.has(selected) || seconds > 0;
resendButton.textContent = seconds > 0 ? "Resend code in " + seconds + "s" : "Resend code";
if (seconds > 0) resendTimer = window.setTimeout(updateResend, 1000);
};
for (const radio of radios) {
radio.disabled = allowed.length > 0 && !allowed.includes(radio.value);
const label = radio.closest("label");
@@ -468,8 +485,10 @@
for (const radio of radios) radio.checked = radio === initial;
restoreToken();
const sendCode = async (selected) => {
const sendCode = async (selected, resend = false) => {
if (disposed || expired || !isOtp(selected)) return;
if (resend && (pending.has(selected) || (resendDeadlines.get(selected) || 0) > Date.now()))
return;
restoreToken();
if (expired) return;
if (!tokenInput?.value) {
@@ -481,12 +500,13 @@
const previous = challenges.get(selected);
if (previous?.expiresAt && Number(previous.expiresAt) <= Date.now())
challenges.delete(selected);
if (!pending.has(selected) && !challenges.has(selected)) {
if (!pending.has(selected) && (resend || !challenges.has(selected))) {
pending.set(
selected,
json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", {
mfaToken: tokenInput.value,
method: selected,
resend,
}),
);
}
@@ -495,16 +515,27 @@
setMessage(status, "Sending a one-time code…");
}
try {
const result = challenges.get(selected) || (await pending.get(selected));
const result = (!resend && challenges.get(selected)) || (await pending.get(selected));
if (disposed || expired) return;
challenges.set(selected, result);
if (!resendDeadlines.has(selected) || resend)
resendDeadlines.set(selected, Date.now() + (Number(result.retryAfter) || 60) * 1000);
if (method() !== selected) return;
if (challengeInput) challengeInput.value = String(result.id || "");
if (resend) {
form?.querySelectorAll("[data-auth-mfa-pin] input, [name=code]").forEach((input) => {
input.value = "";
input.dispatchEvent(new window.Event("input", { bubbles: true }));
});
form?.querySelectorAll("[data-error]").forEach((error) => {
error.textContent = "";
});
}
setMessage(
status,
selected === "email-otp"
? "A code has been sent to your email."
: "A code has been sent to your mobile number.",
? "An email code has been requested. Check your Inbox and Spam folder."
: "An SMS code has been requested. It may take a moment to arrive.",
);
} catch (error) {
if (!disposed && !expired && method() === selected)
@@ -516,6 +547,7 @@
} finally {
pending.delete(selected);
if (!disposed && !expired && method() === selected) setFormBusy(form, false);
updateResend();
}
};
const updateMethod = () => {
@@ -528,6 +560,7 @@
const error = form?.querySelector("[data-error=code]");
if (error) error.textContent = "";
setMessage(status, "");
updateResend();
for (const [selector, active] of [
["[data-auth-mfa-pin]", selected !== "recovery-code"],
["[data-auth-mfa-recovery]", selected === "recovery-code"],
@@ -546,6 +579,10 @@
const onMethodChange = (event) => {
if (radios.includes(event.target)) updateMethod();
};
const onResend = (event) => {
event.preventDefault();
void sendCode(method(), true);
};
const onSubmit = (event) => {
restoreToken();
const code = form?.querySelector("[name=code]");
@@ -573,11 +610,14 @@
// Capture native change before the shared Radio emits its component output.
form?.addEventListener("change", onMethodChange, true);
form?.addEventListener("submit", onSubmit, true);
resendButton?.addEventListener("click", onResend);
element.addEventListener("wrn:success", onSuccess);
element.addEventListener("wrn:error", onError);
flowCleanups.set(element, () => {
disposed = true;
window.clearTimeout(expiryTimer);
window.clearTimeout(resendTimer);
resendButton?.removeEventListener("click", onResend);
form?.removeEventListener("change", onMethodChange, true);
form?.removeEventListener("submit", onSubmit, true);
element.removeEventListener("wrn:success", onSuccess);
@@ -47,6 +47,10 @@ component TwoFactorChallenge {
<p data-error="code" class="m-0 text-sm text-[var(--wrn-color-danger)]" aria-live="polite"></p>
<Checkbox name="rememberDevice" label="Trust this device for 30 days" value="true" />
<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>
<div data-auth-mfa-resend-group hidden class="space-y-2">
<Button type="button" data-auth-mfa-resend label="Resend code" variant="outline" color="{color}" size="{size}" />
<p class="m-0 text-xs text-[var(--wrn-color-muted)]">Check your Spam or Junk folder. After requesting a new code, use the latest email.</p>
</div>
<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>
<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>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.8.33",
"version": "0.8.36",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
+21 -9
View File
@@ -132,6 +132,7 @@ export interface AuthEngine {
beginMfaOtp(
mfaToken: string,
method: "email-otp" | "sms-otp",
options?: { resend?: boolean },
): Promise<{ id: string; expiresAt: number } | undefined>;
completeMfa(input: {
rememberDevice?: boolean;
@@ -1711,7 +1712,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
return { ok: true, user: publicUser(user), session };
},
async beginMfaOtp(mfaToken, method) {
async beginMfaOtp(mfaToken, method, request = {}) {
const token = await inspectToken(mfaToken, "login-mfa");
if (!token) return undefined;
const user = await store.findUserById(token.userId);
@@ -1724,12 +1725,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const previousId = token.metadata?.[key];
if (typeof previousId === "string") {
const previous = await store.findOtp(previousId);
if (previous && !previous.usedAt && previous.expiresAt > now())
return { id: previous.id, expiresAt: previous.expiresAt };
if (request.resend && previous && now() - previous.createdAt < 60_000) return undefined;
if (previous && !previous.usedAt && previous.expiresAt > now()) {
if (!request.resend) return { id: previous.id, expiresAt: previous.expiresAt };
}
}
const challenge = await issueOtp(token.userId, method, undefined, "mfa");
token.metadata = { ...token.metadata, [key]: challenge.id };
await store.updateToken(token);
if (request.resend && typeof previousId === "string") {
const previous = await store.findOtp(previousId);
if (previous && !previous.usedAt) await store.updateOtp({ ...previous, usedAt: now() });
}
return challenge;
} catch {
return undefined;
@@ -2081,12 +2088,17 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const verification = verificationUnavailable(user);
if (verification) return verification;
account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens);
const providerVerifiesDevice = verifiedEmail &&
options.verifiedOAuthDeviceProviders?.some((name) => normalizedOAuthProvider(name) === providerName);
const deviceCheck = providerVerifiesDevice ? undefined : await newDeviceRequirement(user, {
...sessionInput,
oauthProvider: providerName,
});
const providerVerifiesDevice =
verifiedEmail &&
options.verifiedOAuthDeviceProviders?.some(
(name) => normalizedOAuthProvider(name) === providerName,
);
const deviceCheck = providerVerifiesDevice
? undefined
: await newDeviceRequirement(user, {
...sessionInput,
oauthProvider: providerName,
});
if (deviceCheck) return deviceCheck;
if (await hasMfa(user.id)) {
const methods = await availableMfaMethods(user.id);
+12 -3
View File
@@ -505,10 +505,19 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!validation.ok) return validation.response;
const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
const challenge = await engine.beginMfaOtp(text(input.mfaToken), method);
const challenge = await engine.beginMfaOtp(text(input.mfaToken), method, {
resend: input.resend === true,
});
return challenge
? json({ ok: true, ...challenge })
: json({ ok: false, error: "MFA transaction is invalid or expired" }, 400);
? json({ ok: true, ...challenge, retryAfter: 60 })
: json(
{
ok: false,
error:
"Unable to request a code. Wait 60 seconds before retrying, or sign in again if this request has expired.",
},
400,
);
},
async completeMfa(ctx: Context): Promise<Response> {
+1
View File
@@ -148,6 +148,7 @@ export const otpSchema = v.object({
});
export const mfaOtpRequestSchema = v.object({
resend: v.boolean().optional(),
mfaToken: v
.string()
.required("MFA transaction is missing")
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "bun:test";
import { createContext } from "@wrnexus/core";
import { createAuthEngine } from "../src/engine.ts";
import { createAuthHttpHandlers } from "../src/http/index.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import type { AuthDeliveryMessage } from "../src/types.ts";
test("MFA resend creates a fresh code after cooldown and invalidates the previous code", async () => {
let now = 1_790_000_000_000;
const messages: AuthDeliveryMessage[] = [];
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "mfa-resend-test-secret-longer-than-thirty-two-characters",
clock: { now: () => now },
requireTrustedDeviceVerification: true,
delivery: {
async send(message) {
messages.push(message);
},
},
});
await engine.register({ email: "resend@example.test", password: "StrongPassword123!" });
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
const login = await engine.login({
identifier: "resend@example.test",
password: "StrongPassword123!",
fingerprint: "browser",
});
const handlers = createAuthHttpHandlers({ engine });
const request = (resend: boolean) => {
const req = new Request("https://sso.example.test/api/auth/mfa/otp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mfaToken: login.mfaToken, method: "email-otp", resend }),
});
return handlers.beginMfaOtp(createContext(req, new URL(req.url)));
};
const first = await (await request(false)).json();
const firstCode = messages.at(-1)!.code!;
const count = messages.length;
expect((await request(true)).status).toBe(400);
expect(messages.length).toBe(count);
expect((await (await request(false)).json()).id).toBe(first.id);
now += 60_000;
const next = await (await request(true)).json();
expect(next.ok).toBe(true);
expect(next.id).not.toBe(first.id);
expect(next.retryAfter).toBe(60);
expect(messages.length).toBe(count + 1);
expect((await engine.verifyOtp(first.id, firstCode, "mfa")).ok).toBe(false);
expect(
(
await engine.completeMfa({
mfaToken: login.mfaToken!,
method: "email-otp",
challengeId: next.id,
code: messages.at(-1)!.code!,
fingerprint: "browser",
})
).ok,
).toBe(true);
now += 60_000;
expect((await request(true)).status).toBe(400);
});
+55 -1
View File
@@ -1,6 +1,60 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { Window, type HTMLInputElement, type HTMLElement } from "happy-dom";
import { Window, type HTMLInputElement, type HTMLButtonElement, type HTMLElement } from "happy-dom";
test("MFA resend waits for cooldown, clears old code and replaces the active challenge", async () => {
const window = new Window({ url: "https://sso.example.test/mfa" });
try {
let now = Date.now();
window.Date.now = () => now;
window.sessionStorage.setItem(
"wrnexus.auth.mfa",
JSON.stringify({
mfaToken: "synthetic-test-challenge",
mfaExpiresAt: now + 300_000,
requires: { mfa: ["email-otp"] },
}),
);
window.document.body.innerHTML = `<section data-auth-mfa-challenge><form data-auth-mfa-form>
<input name="mfaToken"><input name="challengeId"><input name="code"><input type="radio" name="method" value="email-otp">
<div data-auth-mfa-pin><input name="otpCode"></div><p data-auth-mfa-status></p><p data-error="_form">Verification failed</p>
<div data-auth-mfa-resend-group hidden><button type="button" data-auth-mfa-resend>Resend code</button></div><button type="submit">Verify</button>
</form></section>`;
const requests: any[] = [];
window.fetch = (async (_: unknown, init: RequestInit) => {
requests.push(JSON.parse(String(init.body)));
return Response.json({
id: "challenge-" + requests.length,
retryAfter: 60,
expiresAt: now + 300_000,
});
}) as any;
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
(window as any).WRNexusAuth.mount(window.document);
await new Promise((resolve) => setTimeout(resolve, 0));
const button = window.document.querySelector<HTMLButtonElement>("[data-auth-mfa-resend]")!;
expect(button.disabled).toBe(true);
expect(button.textContent).toContain("60s");
button.dispatchEvent(new window.Event("click"));
expect(requests.length).toBe(1);
const code = window.document.querySelector<HTMLInputElement>("[name=otpCode]")!;
code.value = "123456";
now += 60_001;
button.dispatchEvent(new window.Event("click"));
button.dispatchEvent(new window.Event("click"));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests.length).toBe(2);
expect(requests[1].resend).toBe(true);
expect(window.document.querySelector<HTMLInputElement>("[name=challengeId]")!.value).toBe(
"challenge-2",
);
expect(code.value).toBe("");
expect(window.document.querySelector("[data-error=_form]")!.textContent).toBe("");
expect(button.disabled).toBe(true);
} finally {
await window.happyDOM.close();
}
});
test("expired and legacy MFA requests are rejected on mount before entering a code", async () => {
for (const deadline of [undefined, Date.now() - 1]) {
+15 -5
View File
@@ -5,8 +5,11 @@ import { MemoryAuthStore } from "../src/stores/memory.ts";
const profile = { id: "google-user", email: "google@example.test", raw: { email_verified: true } };
function setup(providers?: string[]) {
const store = new MemoryAuthStore();
const engine = createAuthEngine({ store, secret: "oauth-device-policy-test-at-least-32-characters",
requireVerifiedEmail: true, requireTrustedDeviceVerification: true,
const engine = createAuthEngine({
store,
secret: "oauth-device-policy-test-at-least-32-characters",
requireVerifiedEmail: true,
requireTrustedDeviceVerification: true,
verifiedOAuthDeviceProviders: providers,
isOAuthEmailVerified: (_provider, value) => value.raw.email_verified === true,
});
@@ -14,18 +17,25 @@ function setup(providers?: string[]) {
}
test("explicit verified Google policy satisfies the device check without trusting future password logins", async () => {
const { engine } = setup(["google"]);
const result = await engine.loginWithOAuth("google", profile, undefined, { fingerprint: "new-browser" });
const result = await engine.loginWithOAuth("google", profile, undefined, {
fingerprint: "new-browser",
});
expect(result.ok).toBe(true);
expect(result.session).toBeDefined();
expect(await engine.listTrustedDevices(result.user!.id)).toHaveLength(0);
});
test("device verification remains the default and applies to other providers", async () => {
expect((await setup().engine.loginWithOAuth("google", profile)).code).toBe("mfa-required");
expect((await setup(["google"]).engine.loginWithOAuth("other", profile)).code).toBe("mfa-required");
expect((await setup(["google"]).engine.loginWithOAuth("other", profile)).code).toBe(
"mfa-required",
);
});
test("unverified provider emails cannot satisfy verification", async () => {
const { engine } = setup(["google"]);
const result = await engine.loginWithOAuth("google", { ...profile, raw: { email_verified: false } });
const result = await engine.loginWithOAuth("google", {
...profile,
raw: { email_verified: false },
});
expect(result.ok).toBe(false);
expect(result.session).toBeUndefined();
});
+51 -14
View File
@@ -3,39 +3,76 @@ import { createContext } from "@wrnexus/core";
import { createAuthEngine } from "../src/engine.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import { configureAuthRuntime } from "../src/plugin.ts";
import { clearDefaultAuthEngine, getDefaultAuthRouteOptions, setDefaultAuthRouteOptions } from "../src/runtime.ts";
import {
clearDefaultAuthEngine,
getDefaultAuthRouteOptions,
setDefaultAuthRouteOptions,
} from "../src/runtime.ts";
import { invokeAuthHandler } from "../src/routes/api.ts";
afterEach(() => { clearDefaultAuthEngine(); setDefaultAuthRouteOptions({}); });
afterEach(() => {
clearDefaultAuthEngine();
setDefaultAuthRouteOptions({});
});
test("production startup restores runtime-only providers and passkey settings for generated routes", async () => {
const engine = createAuthEngine({
store: new MemoryAuthStore(), secret: "production-runtime-test-secret-at-least-32-characters",
store: new MemoryAuthStore(),
secret: "production-runtime-test-secret-at-least-32-characters",
passkeys: {
async authenticationOptions({ rpId }) { return { challenge: "test-challenge", rpId, timeout: 60000, userVerification: "required" }; },
async registrationOptions() { throw new Error("not used"); },
async verifyRegistration() { return { verified: false }; },
async verifyAuthentication() { return { verified: false }; },
async authenticationOptions({ rpId }) {
return { challenge: "test-challenge", rpId, timeout: 60000, userVerification: "required" };
},
async registrationOptions() {
throw new Error("not used");
},
async verifyRegistration() {
return { verified: false };
},
async verifyAuthentication() {
return { verified: false };
},
},
});
// Build-time credentials absent, followed by server-startup runtime secrets.
configureAuthRuntime({ engine, oauth: {} });
const ctx = createContext(new Request("https://sso.example/api/auth/oauth/google"), new URL("https://sso.example/api/auth/oauth/google"));
const ctx = createContext(
new Request("https://sso.example/api/auth/oauth/google"),
new URL("https://sso.example/api/auth/oauth/google"),
);
ctx.params = { provider: "google" };
expect((await invokeAuthHandler("startOAuth", ctx)).status).toBe(404);
configureAuthRuntime({ engine, baseUrl: "https://sso.example", sessionCookieName: "custom_session",
configureAuthRuntime({
engine,
baseUrl: "https://sso.example",
sessionCookieName: "custom_session",
oauth: { google: { clientId: "runtime-client", clientSecret: "runtime-secret" } },
passkey: { rpId: "sso.example", origin: "https://sso.example" }, oauthMfaPath: "/mfa" });
passkey: { rpId: "sso.example", origin: "https://sso.example" },
oauthMfaPath: "/mfa",
});
const response = await invokeAuthHandler("startOAuth", ctx);
expect(response.status).toBe(302);
const location = new URL(response.headers.get("location")!);
expect(location.hostname).toBe("accounts.google.com");
expect(location.searchParams.get("client_id")).toBe("runtime-client");
expect(location.searchParams.get("redirect_uri")).toBe("https://sso.example/api/auth/oauth/google/callback");
expect(location.searchParams.get("redirect_uri")).toBe(
"https://sso.example/api/auth/oauth/google/callback",
);
expect(getDefaultAuthRouteOptions().sessionCookieName).toBe("custom_session");
const request = new Request("https://sso.example/api/auth/passkeys/login/options", { method: "POST",
headers: { "content-type": "application/json", origin: "https://sso.example", cookie: "wrn-csrf=runtime-test-csrf", "x-csrf-token": "runtime-test-csrf" }, body: "{}" });
const result = await invokeAuthHandler("passkeyAuthenticationOptions", createContext(request, new URL(request.url)));
const request = new Request("https://sso.example/api/auth/passkeys/login/options", {
method: "POST",
headers: {
"content-type": "application/json",
origin: "https://sso.example",
cookie: "wrn-csrf=runtime-test-csrf",
"x-csrf-token": "runtime-test-csrf",
},
body: "{}",
});
const result = await invokeAuthHandler(
"passkeyAuthenticationOptions",
createContext(request, new URL(request.url)),
);
expect(result.status).toBe(200);
expect((await result.json()).options.rpId).toBe("sso.example");
configureAuthRuntime({ enabled: false, engine });