feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user