release: WRNexusJS 0.5.0
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const directory = join(import.meta.dir, "..", "components");
|
||||
|
||||
test("all authentication components use theme tokens and Tailwind utilities", () => {
|
||||
const files = readdirSync(directory).filter((file) => file.endsWith(".wrn"));
|
||||
expect(files.length).toBeGreaterThanOrEqual(16);
|
||||
for (const file of files) {
|
||||
const source = readFileSync(join(directory, file), "utf8");
|
||||
expect(source).toContain("component ");
|
||||
expect(source).toContain("--wire-");
|
||||
expect(source).not.toContain("<style");
|
||||
}
|
||||
});
|
||||
|
||||
test("passkey component declares the automatic package runtime", () => {
|
||||
const source = readFileSync(join(directory, "PasskeyButton.wrn"), "utf8");
|
||||
expect(source).toContain('data-wrnexus-runtime="auth"');
|
||||
expect(source).not.toContain("<script");
|
||||
});
|
||||
|
||||
test("SignIn exposes a slot for CAPTCHA and application-specific controls", () => {
|
||||
const source = readFileSync(join(directory, "SignIn.wrn"), "utf8");
|
||||
expect(source).toContain("<slot></slot>");
|
||||
expect(source.indexOf("<slot></slot>")).toBeLessThan(source.indexOf('data-error="_form"'));
|
||||
});
|
||||
|
||||
test("packaged auth forms use built-in schemas instead of native browser validation", () => {
|
||||
const files = readdirSync(directory).filter((file) => file.endsWith(".wrn"));
|
||||
for (const file of files) {
|
||||
const source = readFileSync(join(directory, file), "utf8");
|
||||
if (!source.includes("<form")) continue;
|
||||
expect(source).not.toContain(" required");
|
||||
for (const form of source.matchAll(/<form[\s\S]*?>/g)) {
|
||||
if (!form[0].includes("data-schema")) continue;
|
||||
expect(form[0]).toContain("novalidate");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("passwordless and passkey components preserve MFA continuation metadata", () => {
|
||||
const otp = readFileSync(join(directory, "OtpSignIn.wrn"), "utf8");
|
||||
const passkey = readFileSync(join(directory, "PasskeyButton.wrn"), "utf8");
|
||||
const signIn = readFileSync(join(directory, "SignIn.wrn"), "utf8");
|
||||
expect(otp).toContain("data-mfa-href");
|
||||
expect(passkey).toContain("data-mfa-href");
|
||||
expect(signIn).toContain("mfaHref='{mfaHref}'");
|
||||
expect(signIn).toContain('name="deviceFingerprint"');
|
||||
expect(signIn).toContain('name="deviceName"');
|
||||
});
|
||||
|
||||
test("verification components can resend without application-owned schema files", () => {
|
||||
const email = readFileSync(join(directory, "VerifyEmail.wrn"), "utf8");
|
||||
const phone = readFileSync(join(directory, "VerifyPhone.wrn"), "utf8");
|
||||
for (const source of [email, phone]) {
|
||||
expect(source).toContain('name="identifier"');
|
||||
expect(source).toContain("auth-verification-request");
|
||||
expect(source).toContain("novalidate");
|
||||
}
|
||||
});
|
||||
|
||||
test("auth browser runtime refuses cross-origin navigation targets", () => {
|
||||
const source = readFileSync(join(directory, "..", "assets", "client", "auth.js"), "utf8");
|
||||
expect(source).toContain("if (url.origin !== location.origin) return false");
|
||||
expect(source).toContain("!window.AbortController");
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { base64UrlToBytes, bytesToBase64Url, randomDigits, randomToken } from "../src/crypto.ts";
|
||||
|
||||
test("base64url helpers round-trip canonical values and reject malformed input", () => {
|
||||
const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
|
||||
const encoded = bytesToBase64Url(bytes);
|
||||
expect(base64UrlToBytes(encoded)).toEqual(bytes);
|
||||
expect(() => base64UrlToBytes("a===")).toThrow("Invalid base64url value");
|
||||
expect(() => base64UrlToBytes("a")).toThrow("Invalid base64url value");
|
||||
});
|
||||
|
||||
test("random helpers reject broken random providers instead of looping forever", () => {
|
||||
expect(() => randomToken(() => new Uint8Array(1), 32)).toThrow("exactly 32 bytes");
|
||||
expect(() => randomDigits((length) => new Uint8Array(length).fill(255), 6)).toThrow(
|
||||
"WRN-AUTH-RANDOM-SOURCE-REJECTED",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,861 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createAuthEngine } from "../src/engine.ts";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
import { generateTotp } from "../src/totp/index.ts";
|
||||
import type { AuthDeliveryMessage, PasskeyProvider } from "../src/types.ts";
|
||||
|
||||
function fixture() {
|
||||
let time = 1_720_000_000_000;
|
||||
let seed = 11;
|
||||
const messages: AuthDeliveryMessage[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "test-auth-secret-that-is-longer-than-thirty-two-characters",
|
||||
issuer: "WRNexus Test",
|
||||
clock: { now: () => time },
|
||||
random: {
|
||||
bytes(length) {
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
bytes[index] = seed & 255;
|
||||
}
|
||||
return bytes;
|
||||
},
|
||||
},
|
||||
delivery: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
engine,
|
||||
messages,
|
||||
advance(ms: number) {
|
||||
time += ms;
|
||||
},
|
||||
now: () => time,
|
||||
};
|
||||
}
|
||||
|
||||
describe("authentication engine", () => {
|
||||
test("rejects unsafe numeric authentication configuration", () => {
|
||||
expect(() =>
|
||||
createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "invalid-config-secret-that-is-longer-than-thirty-two-characters",
|
||||
sessionTtlMs: Number.NaN,
|
||||
}),
|
||||
).toThrow("sessionTtlMs");
|
||||
expect(() =>
|
||||
createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "invalid-config-secret-that-is-longer-than-thirty-two-characters",
|
||||
passwordMinLength: 4,
|
||||
}),
|
||||
).toThrow("passwordMinLength");
|
||||
});
|
||||
|
||||
test("registers, verifies email, signs in, and validates the session", async () => {
|
||||
const { engine, messages } = fixture();
|
||||
const registered = await engine.register({
|
||||
email: "Owner@Example.com",
|
||||
username: "owner",
|
||||
displayName: "Owner",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
expect(registered.ok).toBe(true);
|
||||
expect(messages[0]?.template).toBe("verify-email");
|
||||
const verified = await engine.verifyEmail(messages[0]!.token!);
|
||||
expect(verified).toMatchObject({ ok: true, user: { emailVerified: true } });
|
||||
|
||||
const login = await engine.login({
|
||||
identifier: "owner@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
expect(login.ok).toBe(true);
|
||||
expect(login.session).toBeDefined();
|
||||
expect(await engine.validateSession(login.session!.id)).toMatchObject({
|
||||
userId: registered.user!.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("delivery provider failures do not corrupt completed authentication state", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
const engine = createAuthEngine({
|
||||
store,
|
||||
secret: "delivery-failure-secret-that-is-longer-than-thirty-two-characters",
|
||||
sendLoginAlerts: true,
|
||||
delivery: {
|
||||
async send() {
|
||||
throw new Error("provider unavailable");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registered = await engine.register({
|
||||
email: "delivery-failure@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
expect(registered.ok).toBe(true);
|
||||
const user = await store.findUserById(registered.user!.id);
|
||||
user!.status = "active";
|
||||
user!.emailVerified = true;
|
||||
await store.updateUser(user!);
|
||||
|
||||
const login = await engine.login({
|
||||
identifier: "delivery-failure@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
expect(login.ok).toBe(true);
|
||||
expect(
|
||||
(await store.listSecurityEvents(registered.user!.id)).some(
|
||||
(event) => event.type === "delivery.failed",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("engine-level registration rejects malformed identities before persistence", async () => {
|
||||
const { engine } = fixture();
|
||||
expect(
|
||||
await engine.register({ email: "not-an-email", password: "StrongPassword123" }),
|
||||
).toMatchObject({ ok: false, code: "registration-failed" });
|
||||
expect(await engine.register({ phone: "+12", password: "StrongPassword123" })).toMatchObject({
|
||||
ok: false,
|
||||
code: "registration-failed",
|
||||
});
|
||||
expect(await engine.store.listUsers()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("normalizes identities and rejects duplicates", async () => {
|
||||
const { engine } = fixture();
|
||||
expect(
|
||||
(await engine.register({ email: "One@Example.com", password: "StrongPassword123" })).ok,
|
||||
).toBe(true);
|
||||
const duplicate = await engine.register({
|
||||
email: "one@example.com",
|
||||
password: "AnotherStrong123",
|
||||
});
|
||||
expect(duplicate).toMatchObject({ ok: false, code: "registration-failed" });
|
||||
});
|
||||
|
||||
test("issues and consumes password reset links once", async () => {
|
||||
const { engine, messages } = fixture();
|
||||
await engine.register({ email: "reset@example.com", password: "StrongPassword123" });
|
||||
await engine.requestPasswordReset("reset@example.com", "https://example.test");
|
||||
const token = messages.find((message) => message.template === "password-reset")!.token!;
|
||||
expect((await engine.resetPassword(token, "NewStrongPassword456")).ok).toBe(true);
|
||||
expect((await engine.resetPassword(token, "OtherStrongPassword789")).ok).toBe(false);
|
||||
expect(
|
||||
(await engine.login({ identifier: "reset@example.com", password: "NewStrongPassword456" }))
|
||||
.ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("supports email OTP verification with attempt and expiry protection", async () => {
|
||||
const { engine, messages, advance } = fixture();
|
||||
const registered = await engine.register({
|
||||
email: "otp@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const challenge = await engine.issueOtp(registered.user!.id, "email-otp");
|
||||
const code = messages.find((message) => message.template === "email-otp")!.code!;
|
||||
expect((await engine.verifyOtp(challenge.id, "000000")).ok).toBe(false);
|
||||
expect((await engine.verifyOtp(challenge.id, `${code.slice(0, 3)}-${code.slice(3)}`)).ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect((await engine.verifyOtp(challenge.id, code)).ok).toBe(true);
|
||||
expect((await engine.verifyOtp(challenge.id, code)).ok).toBe(false);
|
||||
|
||||
const expired = await engine.issueOtp(registered.user!.id, "email-otp");
|
||||
advance(11 * 60_000);
|
||||
expect((await engine.verifyOtp(expired.id, messages.at(-1)!.code!)).ok).toBe(false);
|
||||
});
|
||||
|
||||
test("binds OTPs to linked destinations and to their intended flow", async () => {
|
||||
const { engine, messages } = fixture();
|
||||
const registered = await engine.register({
|
||||
email: "bound@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
|
||||
let rejected = false;
|
||||
try {
|
||||
await engine.issueOtp(registered.user!.id, "email-otp", "attacker@example.com");
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
expect(rejected).toBe(true);
|
||||
|
||||
const loginChallenge = await engine.requestOtpLogin("bound@example.com", "email-otp");
|
||||
const code = messages.find((message) => message.template === "email-otp")!.code!;
|
||||
expect((await engine.verifyOtp(loginChallenge!.id, code)).ok).toBe(false);
|
||||
expect((await engine.completeOtpLogin(loginChallenge!.id, code)).ok).toBe(true);
|
||||
});
|
||||
|
||||
test("returns an opaque OTP challenge for an unknown account", async () => {
|
||||
const { engine, messages } = fixture();
|
||||
const challenge = await engine.requestOtpLogin("missing@example.com", "email-otp");
|
||||
expect(challenge?.id.startsWith("otp_")).toBe(true);
|
||||
expect(messages).toHaveLength(0);
|
||||
expect((await engine.completeOtpLogin(challenge!.id, "123456")).ok).toBe(false);
|
||||
});
|
||||
|
||||
test("enables replay-safe TOTP and one-use recovery codes", async () => {
|
||||
const { engine, now } = fixture();
|
||||
const registered = await engine.register({
|
||||
email: "mfa@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const setup = await engine.beginTotp(registered.user!.id, "Primary authenticator");
|
||||
const token = await generateTotp(setup.secret, { timestamp: now() });
|
||||
expect(await engine.confirmTotp(registered.user!.id, setup.credentialId, token)).toBe(true);
|
||||
const next = await generateTotp(setup.secret, { timestamp: now() + 30_000 });
|
||||
expect(await engine.verifyTotp(registered.user!.id, next)).toBe(true);
|
||||
expect(await engine.verifyTotp(registered.user!.id, next)).toBe(false);
|
||||
|
||||
const codes = await engine.generateRecoveryCodes(registered.user!.id, 3);
|
||||
expect(codes).toHaveLength(3);
|
||||
expect(await engine.consumeRecoveryCode(registered.user!.id, codes[0]!)).toBe(true);
|
||||
expect(await engine.consumeRecoveryCode(registered.user!.id, codes[0]!)).toBe(false);
|
||||
expect(await engine.listRecoveryCodeStatus(registered.user!.id)).toEqual({
|
||||
total: 3,
|
||||
remaining: 2,
|
||||
});
|
||||
|
||||
const replacement = await engine.generateRecoveryCodes(registered.user!.id, 2);
|
||||
expect(await engine.consumeRecoveryCode(registered.user!.id, codes[1]!)).toBe(false);
|
||||
expect(await engine.consumeRecoveryCode(registered.user!.id, replacement[0]!)).toBe(true);
|
||||
});
|
||||
|
||||
test("creates a trusted session when remember-device is requested", async () => {
|
||||
const { engine } = fixture();
|
||||
await engine.register({ email: "remember@example.com", password: "StrongPassword123" });
|
||||
const result = await engine.login({
|
||||
identifier: "remember@example.com",
|
||||
password: "StrongPassword123",
|
||||
fingerprint: "browser-fingerprint",
|
||||
deviceName: "Work browser",
|
||||
rememberDevice: true,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true, session: { trusted: true } });
|
||||
});
|
||||
|
||||
test("tracks and revokes sessions and trusted devices", async () => {
|
||||
const { engine } = fixture();
|
||||
const registered = await engine.register({
|
||||
email: "devices@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const device = await engine.trustDevice(registered.user!.id, {
|
||||
fingerprint: "browser-device",
|
||||
name: "Work laptop",
|
||||
});
|
||||
expect(device.name).toBe("Work laptop");
|
||||
const session = await engine.createSession(registered.user!.id, {
|
||||
fingerprint: "browser-device",
|
||||
});
|
||||
expect(session.trusted).toBe(true);
|
||||
expect(await engine.revokeSession(registered.user!.id, session.id)).toBe(true);
|
||||
expect(await engine.validateSession(session.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("lists only active sessions and removes expired records", async () => {
|
||||
const { engine, advance } = fixture();
|
||||
const registered = await engine.register({
|
||||
email: "active-sessions@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const expired = await engine.createSession(registered.user!.id);
|
||||
advance(25 * 60 * 60_000);
|
||||
const active = await engine.createSession(registered.user!.id);
|
||||
|
||||
expect(await engine.listSessions(registered.user!.id)).toEqual([active]);
|
||||
expect(await engine.store.findSession(expired.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not reveal account status before password verification", async () => {
|
||||
const { engine } = fixture();
|
||||
const registered = await engine.register({
|
||||
email: "disabled-login@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
await engine.setAccountStatus(registered.user!.id, "disabled");
|
||||
|
||||
const wrongPassword = await engine.login({
|
||||
identifier: "disabled-login@example.com",
|
||||
password: "WrongPassword999",
|
||||
});
|
||||
const unknownAccount = await engine.login({
|
||||
identifier: "missing-login@example.com",
|
||||
password: "WrongPassword999",
|
||||
});
|
||||
expect(wrongPassword).toMatchObject({
|
||||
ok: false,
|
||||
code: "invalid-credentials",
|
||||
message: "The email, phone, username, or password you entered is incorrect.",
|
||||
});
|
||||
expect(unknownAccount).toMatchObject({
|
||||
ok: false,
|
||||
code: "invalid-credentials",
|
||||
message: wrongPassword.message,
|
||||
});
|
||||
expect(
|
||||
await engine.login({
|
||||
identifier: "disabled-login@example.com",
|
||||
password: "StrongPassword123",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
code: "account-disabled",
|
||||
message: "This account is disabled. Contact support for help.",
|
||||
});
|
||||
});
|
||||
|
||||
test("raises CAPTCHA and blocks repeated suspicious sign-in attempts", async () => {
|
||||
const { engine } = fixture();
|
||||
await engine.register({ email: "risk@example.com", password: "StrongPassword123" });
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await engine.login({
|
||||
identifier: "risk@example.com",
|
||||
password: "WrongPassword999",
|
||||
captchaVerified: true,
|
||||
});
|
||||
}
|
||||
const captcha = await engine.login({
|
||||
identifier: "risk@example.com",
|
||||
password: "WrongPassword999",
|
||||
});
|
||||
expect(captcha).toMatchObject({
|
||||
ok: false,
|
||||
code: "captcha-required",
|
||||
requires: { captcha: true },
|
||||
});
|
||||
|
||||
await engine.login({
|
||||
identifier: "risk@example.com",
|
||||
password: "WrongPassword999",
|
||||
captchaVerified: true,
|
||||
});
|
||||
const blocked = await engine.login({
|
||||
identifier: "risk@example.com",
|
||||
password: "WrongPassword999",
|
||||
captchaVerified: true,
|
||||
});
|
||||
expect(blocked.ok).toBe(false);
|
||||
expect(blocked.risk?.block).toBe(true);
|
||||
});
|
||||
|
||||
test("links OAuth accounts and creates users for new provider identities", async () => {
|
||||
const { engine } = fixture();
|
||||
const result = await engine.loginWithOAuth(" GitHub ", {
|
||||
id: "github-123",
|
||||
email: "oauth@example.com",
|
||||
name: "OAuth User",
|
||||
raw: { email_verified: true },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.user?.emailVerified).toBe(true);
|
||||
const accounts = await engine.store.listOAuthAccounts(result.user!.id);
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.provider).toBe("github");
|
||||
});
|
||||
|
||||
test("creates and accepts a single-use invitation", async () => {
|
||||
const { engine, messages } = fixture();
|
||||
const invitation = await engine.createInvitation({
|
||||
email: "invited@example.com",
|
||||
displayName: "Invited User",
|
||||
roles: ["member"],
|
||||
invitedBy: "admin-user",
|
||||
baseUrl: "https://example.test",
|
||||
});
|
||||
expect(messages.at(-1)?.template).toBe("invitation");
|
||||
const accepted = await engine.acceptInvitation(invitation.token, {
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
expect(accepted).toMatchObject({ ok: true, user: { emailVerified: true, status: "active" } });
|
||||
expect(accepted.user?.roles).toContain("member");
|
||||
expect(
|
||||
(await engine.acceptInvitation(invitation.token, { password: "StrongPassword123" })).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("customizes delivered token links without application-owned auth APIs", async () => {
|
||||
const messages: AuthDeliveryMessage[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "token-url-test-secret-that-is-longer-than-thirty-two-characters",
|
||||
delivery: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
tokenUrl({ purpose, token, baseUrl }) {
|
||||
if (!baseUrl) return undefined;
|
||||
const path =
|
||||
purpose === "password-reset"
|
||||
? `/recover/reset?token=${encodeURIComponent(token)}`
|
||||
: purpose === "invite"
|
||||
? `/invitation?token=${encodeURIComponent(token)}`
|
||||
: `/${purpose}?token=${encodeURIComponent(token)}`;
|
||||
return new URL(path, baseUrl).toString();
|
||||
},
|
||||
});
|
||||
|
||||
await engine.register({
|
||||
email: "links@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
messages.length = 0;
|
||||
|
||||
await engine.requestPasswordReset("links@example.com", "https://example.test");
|
||||
expect(messages.at(-1)?.url?.startsWith("https://example.test/recover/reset?token=")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await engine.createInvitation({
|
||||
email: "invited-links@example.com",
|
||||
baseUrl: "https://example.test",
|
||||
});
|
||||
expect(messages.at(-1)?.url?.startsWith("https://example.test/invitation?token=")).toBe(true);
|
||||
});
|
||||
|
||||
test("supports passwordless OTP login and sends a login alert", async () => {
|
||||
const { engine, messages } = fixture();
|
||||
await engine.register({ email: "passwordless@example.com", password: "StrongPassword123" });
|
||||
const challenge = await engine.requestOtpLogin("passwordless@example.com", "email-otp");
|
||||
const code = messages.find((message) => message.template === "email-otp")!.code!;
|
||||
const result = await engine.completeOtpLogin(challenge!.id, code);
|
||||
expect(result).toMatchObject({ ok: true, user: { emailVerified: true } });
|
||||
expect(result.session).toBeDefined();
|
||||
});
|
||||
|
||||
test("protects TOTP and OAuth secrets before persistence", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
const protectedValues: string[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store,
|
||||
secret: "protector-test-secret-that-is-longer-than-thirty-two-characters",
|
||||
secretProtector: {
|
||||
async protect(value, purpose) {
|
||||
const output = `sealed:${purpose}:${value}`;
|
||||
protectedValues.push(output);
|
||||
return output;
|
||||
},
|
||||
async reveal(value) {
|
||||
return value.split(":").slice(2).join(":");
|
||||
},
|
||||
},
|
||||
});
|
||||
const registered = await engine.register({
|
||||
email: "protected@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const setup = await engine.beginTotp(registered.user!.id);
|
||||
expect((await store.listTotp(registered.user!.id))[0]?.secret.startsWith("sealed:totp:")).toBe(
|
||||
true,
|
||||
);
|
||||
await engine.linkOAuth(
|
||||
registered.user!.id,
|
||||
"example",
|
||||
{ id: "provider-id", raw: {} },
|
||||
{
|
||||
access_token: "access",
|
||||
refresh_token: "refresh",
|
||||
token_type: "Bearer",
|
||||
},
|
||||
);
|
||||
expect(protectedValues.some((value) => value.startsWith("sealed:oauth-access:"))).toBe(true);
|
||||
expect(setup.secret.startsWith("sealed:")).toBe(false);
|
||||
});
|
||||
|
||||
test("requires an explicit policy and audits impersonation sessions", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
const engine = createAuthEngine({
|
||||
store,
|
||||
secret: "impersonation-secret-that-is-longer-than-thirty-two-characters",
|
||||
authorizeImpersonation: ({ actor }) => actor.roles.includes("admin"),
|
||||
});
|
||||
const actorResult = await engine.register({
|
||||
email: "admin@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const targetResult = await engine.register({
|
||||
email: "target@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const actor = await store.findUserById(actorResult.user!.id);
|
||||
const target = await store.findUserById(targetResult.user!.id);
|
||||
actor!.roles = ["admin"];
|
||||
actor!.status = "active";
|
||||
target!.status = "active";
|
||||
await store.updateUser(actor!);
|
||||
await store.updateUser(target!);
|
||||
const actorSession = await engine.createSession(actor!.id);
|
||||
const started = await engine.startImpersonation(actor!.id, target!.id, {
|
||||
sessionId: actorSession.id,
|
||||
reason: "Support case",
|
||||
});
|
||||
expect(started).toMatchObject({ ok: true, user: { id: target!.id } });
|
||||
expect(started.session?.metadata).toMatchObject({ impersonated: true, actorUserId: actor!.id });
|
||||
const stopped = await engine.stopImpersonation(started.session!.id);
|
||||
expect(stopped).toMatchObject({
|
||||
ok: true,
|
||||
user: { id: actor!.id },
|
||||
session: { id: actorSession.id },
|
||||
});
|
||||
});
|
||||
|
||||
test("does not auto-link an unverified OAuth email to an existing account", async () => {
|
||||
const { engine } = fixture();
|
||||
await engine.register({ email: "existing@example.com", password: "StrongPassword123" });
|
||||
const result = await engine.loginWithOAuth("unknown", {
|
||||
id: "provider-unverified",
|
||||
email: "existing@example.com",
|
||||
raw: { email_verified: false },
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, code: "oauth-link-required" });
|
||||
});
|
||||
|
||||
test("uses a configured passkey provider for registration and authentication", async () => {
|
||||
let expectedOrigin = "";
|
||||
let expectedRpId = "";
|
||||
const provider: PasskeyProvider = {
|
||||
async registrationOptions() {
|
||||
return {
|
||||
challenge: "AQID",
|
||||
rp: { id: "example.test", name: "Test" },
|
||||
user: { id: "AQID", name: "user", displayName: "User" },
|
||||
timeout: 1,
|
||||
attestation: "none",
|
||||
};
|
||||
},
|
||||
async verifyRegistration() {
|
||||
return {
|
||||
verified: true,
|
||||
credential: {
|
||||
credentialId: "cred-1",
|
||||
publicKey: "public",
|
||||
counter: 0,
|
||||
transports: ["internal"],
|
||||
name: "Device passkey",
|
||||
},
|
||||
};
|
||||
},
|
||||
async authenticationOptions() {
|
||||
return {
|
||||
challenge: "BAUG",
|
||||
rpId: "example.test",
|
||||
timeout: Number.POSITIVE_INFINITY,
|
||||
userVerification: "preferred",
|
||||
};
|
||||
},
|
||||
async verifyAuthentication(input) {
|
||||
expectedOrigin = input.expectedOrigin;
|
||||
expectedRpId = input.expectedRpId;
|
||||
return { verified: true, credentialId: "cred-1", newCounter: 1 };
|
||||
},
|
||||
};
|
||||
let time = 1_720_000_000_000;
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "passkey-test-secret-that-is-longer-than-thirty-two-characters",
|
||||
passkeys: provider,
|
||||
clock: { now: () => time },
|
||||
});
|
||||
const registered = await engine.register({
|
||||
email: "passkey@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const start = await engine.beginPasskeyRegistration(registered.user!.id, {
|
||||
rpId: "example.test",
|
||||
rpName: "Test",
|
||||
origin: "https://example.test",
|
||||
});
|
||||
expect(start.options.timeout).toBe(30_000);
|
||||
expect(
|
||||
await engine.finishPasskeyRegistration(registered.user!.id, {
|
||||
key: start.key,
|
||||
response: {},
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
}),
|
||||
).toBe(true);
|
||||
const auth = await engine.beginPasskeyAuthentication({
|
||||
identifier: "passkey@example.com",
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
});
|
||||
expect(auth.options.timeout).toBe(5 * 60_000);
|
||||
time += 1;
|
||||
expect(
|
||||
(
|
||||
await engine.finishPasskeyAuthentication({
|
||||
key: auth.key,
|
||||
response: { id: "cred-1" },
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(expectedOrigin).toBe("https://example.test");
|
||||
expect(expectedRpId).toBe("example.test");
|
||||
|
||||
const repeatedCounter = await engine.beginPasskeyAuthentication({
|
||||
identifier: "passkey@example.com",
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
});
|
||||
expect(
|
||||
await engine.finishPasskeyAuthentication({
|
||||
key: repeatedCounter.key,
|
||||
response: { id: "cred-1" },
|
||||
}),
|
||||
).toMatchObject({ ok: false, code: "passkey-counter-regression" });
|
||||
|
||||
await engine.setAccountStatus(registered.user!.id, "disabled");
|
||||
const disabledAuth = await engine.beginPasskeyAuthentication({
|
||||
identifier: "passkey@example.com",
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
});
|
||||
expect(
|
||||
await engine.finishPasskeyAuthentication({
|
||||
key: disabledAuth.key,
|
||||
response: { id: "cred-1" },
|
||||
}),
|
||||
).toMatchObject({ ok: false, code: "account-disabled" });
|
||||
});
|
||||
|
||||
test("rejects a passkey result that switches away from the identified user", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
const provider: PasskeyProvider = {
|
||||
async registrationOptions() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async verifyRegistration() {
|
||||
return { verified: false };
|
||||
},
|
||||
async authenticationOptions() {
|
||||
return {
|
||||
challenge: "BAUG",
|
||||
rpId: "example.test",
|
||||
timeout: 60_000,
|
||||
userVerification: "required",
|
||||
};
|
||||
},
|
||||
async verifyAuthentication() {
|
||||
return { verified: true, userId: "different-user" };
|
||||
},
|
||||
};
|
||||
const engine = createAuthEngine({
|
||||
store,
|
||||
secret: "passkey-switch-secret-that-is-longer-than-thirty-two-characters",
|
||||
passkeys: provider,
|
||||
});
|
||||
const registered = await engine.register({
|
||||
email: "identified@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const auth = await engine.beginPasskeyAuthentication({
|
||||
identifier: "identified@example.com",
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
});
|
||||
const result = await engine.finishPasskeyAuthentication({ key: auth.key, response: {} });
|
||||
expect(result).toMatchObject({ ok: false, code: "passkey-user-mismatch" });
|
||||
expect(registered.user?.id).toBeDefined();
|
||||
});
|
||||
|
||||
test("passwordless flows respect account status and enrolled MFA", async () => {
|
||||
const { engine, messages, now } = fixture();
|
||||
const registered = await engine.register({
|
||||
email: "passwordless-mfa@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const setup = await engine.beginTotp(registered.user!.id);
|
||||
const token = await generateTotp(setup.secret, { timestamp: now() });
|
||||
expect(await engine.confirmTotp(registered.user!.id, setup.credentialId, token)).toBe(true);
|
||||
|
||||
await engine.requestMagicLink("passwordless-mfa@example.com", "https://example.test");
|
||||
const magicToken = messages.find((message) => message.template === "magic-link")!.token!;
|
||||
expect(await engine.consumeMagicLink(magicToken)).toMatchObject({
|
||||
ok: false,
|
||||
code: "mfa-required",
|
||||
requires: { mfa: expect.any(Array) },
|
||||
});
|
||||
|
||||
const otp = await engine.requestOtpLogin("passwordless-mfa@example.com", "email-otp");
|
||||
const otpCode = messages.filter((message) => message.template === "email-otp").at(-1)!.code!;
|
||||
expect(await engine.completeOtpLogin(otp.id, otpCode)).toMatchObject({
|
||||
ok: false,
|
||||
code: "mfa-required",
|
||||
});
|
||||
|
||||
await engine.setAccountStatus(registered.user!.id, "disabled");
|
||||
const second = await engine.requestOtpLogin("passwordless-mfa@example.com", "email-otp");
|
||||
expect(await engine.completeOtpLogin(second.id, "000000")).toMatchObject({
|
||||
ok: false,
|
||||
code: "invalid-otp",
|
||||
});
|
||||
});
|
||||
|
||||
test("temporary login locks do not activate a pending account when they expire", async () => {
|
||||
let time = 1_720_000_000_000;
|
||||
const store = new MemoryAuthStore();
|
||||
const engine = createAuthEngine({
|
||||
store,
|
||||
secret: "pending-lock-secret-that-is-longer-than-thirty-two-characters",
|
||||
requireVerifiedEmail: true,
|
||||
maxFailedLogins: 1,
|
||||
lockDurationMs: 60_000,
|
||||
clock: { now: () => time },
|
||||
});
|
||||
const registered = await engine.register({
|
||||
email: "pending-lock@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
await engine.login({
|
||||
identifier: "PENDING-LOCK@example.com",
|
||||
password: "WrongPassword999",
|
||||
captchaVerified: true,
|
||||
});
|
||||
expect((await store.findUserById(registered.user!.id))?.status).toBe("locked");
|
||||
time += 61_000;
|
||||
const login = await engine.login({
|
||||
identifier: "pending-lock@example.com",
|
||||
password: "StrongPassword123",
|
||||
captchaVerified: true,
|
||||
});
|
||||
expect(login).toMatchObject({ ok: false, code: "email-unverified" });
|
||||
expect((await store.findUserById(registered.user!.id))?.status).toBe("pending");
|
||||
});
|
||||
|
||||
test("OAuth refreshes preserve stored tokens when a provider omits them", async () => {
|
||||
const { engine } = fixture();
|
||||
const registered = await engine.register({
|
||||
email: "oauth-refresh@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const profile = { id: "oauth-refresh-id", email: "oauth-refresh@example.com", raw: {} };
|
||||
const first = await engine.linkOAuth(registered.user!.id, "example", profile, {
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
token_type: "Bearer",
|
||||
scope: "openid profile",
|
||||
});
|
||||
const second = await engine.linkOAuth(registered.user!.id, "example", profile);
|
||||
expect(second.accessToken).toBe(first.accessToken);
|
||||
expect(second.refreshToken).toBe(first.refreshToken);
|
||||
expect(second.scope).toBe(first.scope);
|
||||
});
|
||||
|
||||
test("disabled accounts cannot use previously issued recovery or verification tokens", async () => {
|
||||
const { engine, messages } = fixture();
|
||||
const registered = await engine.register({
|
||||
email: "disabled-recovery@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const verificationToken = messages.find(
|
||||
(message) => message.template === "verify-email",
|
||||
)!.token!;
|
||||
await engine.requestPasswordReset("disabled-recovery@example.com", "https://example.test");
|
||||
const resetToken = messages.find((message) => message.template === "password-reset")!.token!;
|
||||
|
||||
await engine.setAccountStatus(registered.user!.id, "disabled");
|
||||
expect(await engine.verifyEmail(verificationToken)).toMatchObject({
|
||||
ok: false,
|
||||
code: "account-disabled",
|
||||
});
|
||||
expect(await engine.resetPassword(resetToken, "ReplacementPassword456")).toMatchObject({
|
||||
ok: false,
|
||||
code: "account-disabled",
|
||||
});
|
||||
const before = messages.length;
|
||||
await engine.requestPasswordReset("disabled-recovery@example.com", "https://example.test");
|
||||
expect(messages).toHaveLength(before);
|
||||
|
||||
// Administrative re-enablement leaves the original one-time credentials
|
||||
// available because the disabled-account checks did not consume them.
|
||||
await engine.setAccountStatus(registered.user!.id, "active");
|
||||
expect((await engine.verifyEmail(verificationToken)).ok).toBe(true);
|
||||
expect((await engine.resetPassword(resetToken, "ReplacementPassword456")).ok).toBe(true);
|
||||
});
|
||||
|
||||
test("a valid password reset clears a failed-login lock without activating pending users", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
const messages: AuthDeliveryMessage[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store,
|
||||
secret: "password-reset-lock-secret-that-is-longer-than-thirty-two-characters",
|
||||
maxFailedLogins: 1,
|
||||
delivery: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
const registered = await engine.register({
|
||||
email: "locked-reset@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
await engine.login({
|
||||
identifier: "locked-reset@example.com",
|
||||
password: "WrongPassword123",
|
||||
captchaVerified: true,
|
||||
});
|
||||
expect((await store.findUserById(registered.user!.id))?.status).toBe("locked");
|
||||
|
||||
await engine.requestPasswordReset("locked-reset@example.com", "https://example.test");
|
||||
const token = messages
|
||||
.filter((message) => message.template === "password-reset")
|
||||
.at(-1)!.token!;
|
||||
expect((await engine.resetPassword(token, "ReplacementPassword456")).ok).toBe(true);
|
||||
expect((await store.findUserById(registered.user!.id))?.status).toBe("pending");
|
||||
expect(
|
||||
(
|
||||
await engine.login({
|
||||
identifier: "locked-reset@example.com",
|
||||
password: "ReplacementPassword456",
|
||||
captchaVerified: true,
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("MFA email and SMS challenges require verified linked identities", async () => {
|
||||
const { engine, messages, now } = fixture();
|
||||
const registered = await engine.register({
|
||||
email: "unverified-mfa@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const verificationToken = messages.find(
|
||||
(message) => message.template === "verify-email",
|
||||
)!.token!;
|
||||
const setup = await engine.beginTotp(registered.user!.id);
|
||||
const token = await generateTotp(setup.secret, { timestamp: now() });
|
||||
expect(await engine.confirmTotp(registered.user!.id, setup.credentialId, token)).toBe(true);
|
||||
|
||||
const firstLogin = await engine.login({
|
||||
identifier: "unverified-mfa@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
expect(firstLogin).toMatchObject({
|
||||
ok: false,
|
||||
code: "mfa-required",
|
||||
requires: { mfa: ["totp"] },
|
||||
});
|
||||
expect(await engine.beginMfaOtp(firstLogin.mfaToken!, "email-otp")).toBeUndefined();
|
||||
|
||||
expect((await engine.verifyEmail(verificationToken)).ok).toBe(true);
|
||||
const secondLogin = await engine.login({
|
||||
identifier: "unverified-mfa@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
expect(secondLogin.requires?.mfa).toContain("email-otp");
|
||||
expect(secondLogin.requires?.mfa).toContain("totp");
|
||||
expect(await engine.beginMfaOtp(secondLogin.mfaToken!, "email-otp")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { createAuthEngine } from "../src/engine.ts";
|
||||
import { createAuthHttpHandlers } from "../src/http/index.ts";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
|
||||
function context(request: Request): Context {
|
||||
const values = new Map<string, unknown>();
|
||||
return {
|
||||
req: request,
|
||||
url: new URL(request.url),
|
||||
params: {},
|
||||
locals: {},
|
||||
lang: "en",
|
||||
t: (key: string) => key,
|
||||
ip: "127.0.0.1",
|
||||
user: null,
|
||||
cookies: {} as Context["cookies"],
|
||||
localStorage: {} as Context["localStorage"],
|
||||
session: {
|
||||
id: () => "http-test-session",
|
||||
get: <T>(key: string) => values.get(key) as T | undefined,
|
||||
getAll: () => Object.fromEntries(values),
|
||||
set: (key: string, value: unknown) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
delete: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
regenerate: () => {},
|
||||
clear: () => {
|
||||
values.clear();
|
||||
},
|
||||
},
|
||||
} as Context;
|
||||
}
|
||||
|
||||
test("login handler never trusts a browser captchaVerified field", async () => {
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "http-handler-secret-that-is-longer-than-thirty-two-characters",
|
||||
captchaThreshold: 0,
|
||||
});
|
||||
await engine.register({ email: "captcha@example.com", password: "StrongPassword123" });
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const ctx = context(
|
||||
new Request("https://example.test/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
identifier: "captcha@example.com",
|
||||
password: "StrongPassword123",
|
||||
captchaVerified: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const response = await handlers.login(ctx);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({ code: "captcha-required" });
|
||||
});
|
||||
|
||||
test("server-populated CAPTCHA verification permits the login attempt", async () => {
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "http-handler-secret-that-is-longer-than-thirty-two-characters",
|
||||
captchaThreshold: 0,
|
||||
});
|
||||
await engine.register({ email: "verified@example.com", password: "StrongPassword123" });
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const ctx = context(
|
||||
new Request("https://example.test/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ identifier: "verified@example.com", password: "StrongPassword123" }),
|
||||
}),
|
||||
);
|
||||
ctx.locals.captcha = { success: true };
|
||||
const response = await handlers.login(ctx);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
test("login API returns a safe actionable invalid-credentials message", async () => {
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "http-login-message-secret-longer-than-thirty-two-characters",
|
||||
});
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const response = await handlers.login(
|
||||
context(
|
||||
new Request("https://example.test/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
identifier: "missing@example.com",
|
||||
password: "WrongPassword999",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: false,
|
||||
code: "invalid-credentials",
|
||||
message: "The email, phone, username, or password you entered is incorrect.",
|
||||
});
|
||||
});
|
||||
|
||||
test("HTTP handlers use navigation hooks configured on the auth engine", async () => {
|
||||
const calls: string[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "engine-navigation-hooks-secret-longer-than-thirty-two-characters",
|
||||
onSignedIn(ctx, returnTo) {
|
||||
calls.push(`in:${returnTo}`);
|
||||
return Response.redirect(new URL(returnTo ?? "/account", ctx.url), 303);
|
||||
},
|
||||
onSignedOut(ctx) {
|
||||
calls.push("out");
|
||||
return Response.redirect(new URL("/sign-in", ctx.url), 303);
|
||||
},
|
||||
});
|
||||
await engine.register({
|
||||
email: "navigation@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const loginResponse = await handlers.login(
|
||||
context(
|
||||
new Request("https://example.test/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
identifier: "navigation@example.com",
|
||||
password: "StrongPassword123",
|
||||
returnTo: "/dashboard",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(loginResponse.status).toBe(303);
|
||||
expect(loginResponse.headers.get("location")).toBe("https://example.test/dashboard");
|
||||
|
||||
const logoutResponse = await handlers.logout(
|
||||
context(new Request("https://example.test/api/auth/logout", { method: "POST" })),
|
||||
);
|
||||
expect(logoutResponse.status).toBe(303);
|
||||
expect(logoutResponse.headers.get("location")).toBe("https://example.test/sign-in");
|
||||
expect(calls).toEqual(["in:/dashboard", "out"]);
|
||||
});
|
||||
|
||||
test("register handler returns validation field errors before calling the engine", async () => {
|
||||
let registerCalls = 0;
|
||||
const engine = {
|
||||
register: async () => {
|
||||
registerCalls += 1;
|
||||
return { ok: true };
|
||||
},
|
||||
} as unknown as ReturnType<typeof createAuthEngine>;
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const ctx = context(
|
||||
new Request("https://example.test/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ displayName: "A", email: "bad", password: "short" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await handlers.register(ctx);
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: false,
|
||||
errors: {
|
||||
displayName: expect.any(String),
|
||||
email: expect.any(String),
|
||||
password: expect.any(String),
|
||||
},
|
||||
});
|
||||
expect(registerCalls).toBe(0);
|
||||
});
|
||||
|
||||
test("successful signup redirects to sign-in by default", async () => {
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "default-signup-redirect-secret-longer-than-thirty-two-characters",
|
||||
});
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const response = await handlers.register(
|
||||
context(
|
||||
new Request("https://example.test/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
displayName: "Default Redirect",
|
||||
email: "default-signup@example.com",
|
||||
password: "StrongPassword123",
|
||||
consent: true,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get("location")).toBe("https://example.test/sign-in");
|
||||
});
|
||||
|
||||
test("onSuccessfulSignUp can safely auto-sign-in and redirect the new user", async () => {
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "automatic-signup-login-secret-longer-than-thirty-two-characters",
|
||||
onSuccessfulSignUp() {
|
||||
return { autoSignIn: true, redirectTo: "/welcome" };
|
||||
},
|
||||
});
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const ctx = context(
|
||||
new Request("https://example.test/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
displayName: "Automatic Login",
|
||||
email: "automatic-signup@example.com",
|
||||
password: "StrongPassword123",
|
||||
consent: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const response = await handlers.register(ctx);
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get("location")).toBe("https://example.test/welcome");
|
||||
expect(ctx.locals.authUser).toMatchObject({ displayName: "Automatic Login" });
|
||||
expect(ctx.locals.authSession).toBeDefined();
|
||||
});
|
||||
|
||||
test("signup auto-sign-in does not bypass verification policy", async () => {
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "verified-signup-policy-secret-longer-than-thirty-two-characters",
|
||||
requireVerifiedEmail: true,
|
||||
onSuccessfulSignUp() {
|
||||
return { autoSignIn: true, redirectTo: "/account" };
|
||||
},
|
||||
});
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const ctx = context(
|
||||
new Request("https://example.test/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
displayName: "Verification Required",
|
||||
email: "verify-signup@example.com",
|
||||
password: "StrongPassword123",
|
||||
consent: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const response = await handlers.register(ctx);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: false,
|
||||
code: "email-unverified",
|
||||
message: "Verify your email address before signing in.",
|
||||
});
|
||||
expect(ctx.locals.authSession).toBeUndefined();
|
||||
});
|
||||
|
||||
test("authenticated OTP issue rejects an unlinked destination without throwing", async () => {
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "http-otp-secret-that-is-longer-than-thirty-two-characters",
|
||||
});
|
||||
const registered = await engine.register({
|
||||
email: "owner@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const ctx = context(
|
||||
new Request("https://example.test/api/auth/otp", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ method: "email-otp", destination: "other@example.com" }),
|
||||
}),
|
||||
);
|
||||
ctx.user = registered.user!;
|
||||
ctx.locals.authUser = registered.user!;
|
||||
const response = await handlers.issueOtp(ctx);
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
test("verification resend is generic and can resolve an unauthenticated identifier", async () => {
|
||||
const messages: Array<{ template: string }> = [];
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "verification-resend-secret-that-is-longer-than-thirty-two-characters",
|
||||
delivery: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
await engine.register({
|
||||
email: "resend@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const before = messages.length;
|
||||
const known = await handlers.requestVerification(
|
||||
context(
|
||||
new Request("https://example.test/api/auth/verification/request", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "email", identifier: "resend@example.com" }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(known.status).toBe(200);
|
||||
expect(await known.json()).toEqual({ ok: true });
|
||||
expect(messages).toHaveLength(before + 1);
|
||||
|
||||
const unknown = await handlers.requestVerification(
|
||||
context(
|
||||
new Request("https://example.test/api/auth/verification/request", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "email", identifier: "missing@example.com" }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(unknown.status).toBe(200);
|
||||
expect(await unknown.json()).toEqual({ ok: true });
|
||||
expect(messages).toHaveLength(before + 1);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { publicKeyCreationOptions, publicKeyRequestOptions } from "../src/passkeys/index.ts";
|
||||
|
||||
test("passkey option conversion accepts canonical Base64URL values", () => {
|
||||
const creation = publicKeyCreationOptions({
|
||||
challenge: "AQID",
|
||||
rp: { id: "example.test", name: "Example" },
|
||||
user: { id: "BAUG", name: "user", displayName: "User" },
|
||||
timeout: 60_000,
|
||||
attestation: "none",
|
||||
});
|
||||
expect(Array.from(creation.challenge as Uint8Array)).toEqual([1, 2, 3]);
|
||||
|
||||
const request = publicKeyRequestOptions({
|
||||
challenge: "BAUG",
|
||||
rpId: "example.test",
|
||||
timeout: 60_000,
|
||||
userVerification: "preferred",
|
||||
});
|
||||
expect(Array.from(request.challenge as Uint8Array)).toEqual([4, 5, 6]);
|
||||
});
|
||||
|
||||
test("passkey option conversion rejects malformed Base64URL values", () => {
|
||||
expect(() =>
|
||||
publicKeyRequestOptions({
|
||||
challenge: "AQID=",
|
||||
rpId: "example.test",
|
||||
timeout: 60_000,
|
||||
userVerification: "required",
|
||||
}),
|
||||
).toThrow("canonical Base64URL");
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createPluginRunner } from "@wrnexus/plugin";
|
||||
import { authPlugin } from "../src/plugin.ts";
|
||||
import { AUTH_ROUTE_DEFINITIONS } from "../src/routes/definitions.ts";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { getDefaultAuthRouteOptions } from "../src/runtime.ts";
|
||||
|
||||
test("plugin contributes components, runtime, styles, migration, and toolbar", async () => {
|
||||
const metadata = new Map<string, unknown>();
|
||||
const runner = createPluginRunner(
|
||||
authPlugin({ includeRoutes: true, includeMigrations: true, includeMiddleware: true }),
|
||||
{
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata,
|
||||
warn() {},
|
||||
},
|
||||
);
|
||||
const contributions = await runner.contributions();
|
||||
expect(contributions.componentDirs).toHaveLength(1);
|
||||
expect(contributions.clientRuntimes[0]).toMatchObject({ id: "auth", singleton: true });
|
||||
expect(contributions.styles[0]?.id).toBe("auth-components");
|
||||
expect(contributions.migrations.map((migration) => migration.id)).toEqual([
|
||||
"wrnexus-auth-001",
|
||||
"wrnexus-auth-002-otp-purpose",
|
||||
]);
|
||||
expect(contributions.routes.length).toBeGreaterThanOrEqual(30);
|
||||
expect(contributions.middleware).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("unconfigured automatic discovery fails closed for routes, middleware, and migrations", async () => {
|
||||
const metadata = new Map<string, unknown>();
|
||||
const runner = createPluginRunner(authPlugin(), {
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata,
|
||||
warn() {},
|
||||
});
|
||||
|
||||
const contributions = await runner.contributions();
|
||||
expect(contributions.routes).toHaveLength(0);
|
||||
expect(contributions.middleware).toHaveLength(0);
|
||||
expect(contributions.migrations).toHaveLength(0);
|
||||
expect(contributions.componentDirs).toHaveLength(1);
|
||||
expect(contributions.clientRuntimes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("config.auth controls route groups and migrations without explicit plugin options", async () => {
|
||||
const metadata = new Map<string, unknown>();
|
||||
const runner = createPluginRunner(authPlugin(), {
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata,
|
||||
warn() {},
|
||||
});
|
||||
await runner.configure({
|
||||
auth: {
|
||||
engine: {} as never,
|
||||
migrations: false,
|
||||
routes: { enabled: true, registration: false, passkeys: false },
|
||||
},
|
||||
});
|
||||
const contributions = await runner.contributions();
|
||||
expect(contributions.migrations).toHaveLength(0);
|
||||
expect(contributions.routes.some((route) => route.path === "/api/auth/register")).toBe(false);
|
||||
expect(contributions.routes.some((route) => route.path.includes("/passkeys/"))).toBe(false);
|
||||
expect(contributions.routes.some((route) => route.path === "/api/auth/login")).toBe(true);
|
||||
});
|
||||
|
||||
test("config.auth resolves navigation hooks from the configured engine", async () => {
|
||||
const onSignedIn = () => new Response(null, { status: 204 });
|
||||
const onSignedOut = () => new Response(null, { status: 204 });
|
||||
const runner = createPluginRunner(authPlugin(), {
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata: new Map(),
|
||||
warn() {},
|
||||
});
|
||||
|
||||
await runner.configure({
|
||||
auth: {
|
||||
engine: { onSignedIn, onSignedOut } as never,
|
||||
routes: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getDefaultAuthRouteOptions().onSignedIn).toBe(onSignedIn);
|
||||
expect(getDefaultAuthRouteOptions().onSignedOut).toBe(onSignedOut);
|
||||
});
|
||||
|
||||
test("auth runtime contains built-in browser schemas", async () => {
|
||||
const metadata = new Map<string, unknown>();
|
||||
const runner = createPluginRunner(authPlugin({ includeMigrations: false }), {
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata,
|
||||
warn() {},
|
||||
});
|
||||
const contributions = await runner.contributions();
|
||||
expect(contributions.clientRuntimes[0]?.source).toContain("auth-password-request");
|
||||
expect(contributions.clientRuntimes[0]?.source).toContain("auth-register");
|
||||
});
|
||||
|
||||
test("each package auth route uses a route-specific entry module", async () => {
|
||||
const metadata = new Map<string, unknown>();
|
||||
const runner = createPluginRunner(
|
||||
authPlugin({
|
||||
includeRoutes: true,
|
||||
includeMigrations: false,
|
||||
includeMiddleware: false,
|
||||
}),
|
||||
{
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata,
|
||||
warn() {},
|
||||
},
|
||||
);
|
||||
|
||||
const contributions = await runner.contributions();
|
||||
const entries = contributions.routes.map((route) => route.entry);
|
||||
const passwordRequest = contributions.routes.find(
|
||||
(route) => route.path === "/api/auth/password/request",
|
||||
);
|
||||
|
||||
expect(new Set(entries).size).toBe(entries.length);
|
||||
expect(
|
||||
passwordRequest?.entry.replace(/\\/g, "/").endsWith("/src/routes/api/password-request.ts"),
|
||||
).toBe(true);
|
||||
for (const definition of AUTH_ROUTE_DEFINITIONS) {
|
||||
const route = contributions.routes.find((item) => item.path === definition.path);
|
||||
expect(route).toBeDefined();
|
||||
const source = readFileSync(route!.entry, "utf8");
|
||||
expect(source).toContain(`invokeAuthHandler("${definition.handler}"`);
|
||||
expect(source.includes("dispatchAuthRoute")).toBe(false);
|
||||
for (const method of definition.methods) {
|
||||
expect(source).toContain(`export function ${method}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
test("config.auth registers package routes", async () => {
|
||||
const metadata = new Map<string, unknown>();
|
||||
|
||||
const runner = createPluginRunner(authPlugin(), {
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata,
|
||||
warn() {},
|
||||
});
|
||||
|
||||
await runner.configure({
|
||||
auth: {
|
||||
engine: {} as never,
|
||||
routes: true,
|
||||
middleware: true,
|
||||
migrations: false,
|
||||
},
|
||||
});
|
||||
|
||||
const contributions = await runner.contributions();
|
||||
|
||||
expect(contributions.routes.some((route) => route.path === "/api/auth/password/request")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(contributions.routes.some((route) => route.path === "/api/auth/register")).toBe(true);
|
||||
|
||||
expect(contributions.routes.some((route) => route.path === "/api/auth/login")).toBe(true);
|
||||
|
||||
expect(contributions.middleware).toHaveLength(1);
|
||||
|
||||
expect(contributions.migrations).toHaveLength(0);
|
||||
});
|
||||
test("config.auth can disable route groups", async () => {
|
||||
const metadata = new Map<string, unknown>();
|
||||
|
||||
const runner = createPluginRunner(authPlugin(), {
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata,
|
||||
warn() {},
|
||||
});
|
||||
|
||||
await runner.configure({
|
||||
auth: {
|
||||
engine: {} as never,
|
||||
migrations: false,
|
||||
routes: {
|
||||
enabled: true,
|
||||
password: true,
|
||||
passkeys: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const contributions = await runner.contributions();
|
||||
|
||||
expect(contributions.routes.some((route) => route.path === "/api/auth/password/request")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(contributions.routes.some((route) => route.path.includes("/passkeys/"))).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createKeyring, generateKey, seal } from "@wrnexus/encryption";
|
||||
import { createAuthSecretProtector } from "../src/protector.ts";
|
||||
|
||||
test("secret protector binds encrypted values to their auth purpose", async () => {
|
||||
const keyring = createKeyring([{ id: "primary", secret: await generateKey(), active: true }]);
|
||||
const protector = createAuthSecretProtector(keyring);
|
||||
const protectedValue = await protector.protect("secret-value", "totp");
|
||||
|
||||
expect(await protector.reveal(protectedValue, "totp")).toBe("secret-value");
|
||||
await expect(protector.reveal(protectedValue, "oauth-access")).rejects.toThrow(
|
||||
"WRN-AUTH-SECRET-PURPOSE",
|
||||
);
|
||||
});
|
||||
|
||||
test("secret protector can read legacy unbound ciphertext", async () => {
|
||||
const keyring = createKeyring([{ id: "primary", secret: await generateKey(), active: true }]);
|
||||
const protector = createAuthSecretProtector(keyring);
|
||||
const legacy = await seal("legacy-secret", keyring);
|
||||
expect(await protector.reveal(legacy, "oauth-refresh")).toBe("legacy-secret");
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { evaluateAuthRisk } from "../src/risk.ts";
|
||||
|
||||
test("risk scoring escalates CAPTCHA, MFA, and blocking", () => {
|
||||
expect(evaluateAuthRisk({ failedAttempts: 2 })).toMatchObject({
|
||||
level: "low",
|
||||
requireCaptcha: false,
|
||||
});
|
||||
expect(evaluateAuthRisk({ failedAttempts: 3, unfamiliarDevice: true })).toMatchObject({
|
||||
requireCaptcha: true,
|
||||
});
|
||||
expect(
|
||||
evaluateAuthRisk({ unusualIp: true, impossibleTravel: true, unfamiliarDevice: true }),
|
||||
).toMatchObject({ requireMfa: true });
|
||||
expect(evaluateAuthRisk({ accountLocked: true })).toMatchObject({
|
||||
block: true,
|
||||
level: "critical",
|
||||
});
|
||||
});
|
||||
|
||||
test("risk scoring remains finite for malformed numeric signals and policy", () => {
|
||||
const result = evaluateAuthRisk(
|
||||
{ customScore: Number.NaN, failedAttempts: Number.POSITIVE_INFINITY },
|
||||
{
|
||||
captchaThreshold: Number.NaN,
|
||||
mfaThreshold: Number.POSITIVE_INFINITY,
|
||||
blockThreshold: -10,
|
||||
},
|
||||
);
|
||||
expect(result.score).toBe(0);
|
||||
expect(result.level).toBe("low");
|
||||
expect(result.requireCaptcha).toBe(false);
|
||||
expect(result.requireMfa).toBe(false);
|
||||
expect(result.block).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { createAuthEngine } from "../src/engine.ts";
|
||||
import { setDefaultAuthEngine } from "../src/runtime.ts";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
import { POST as requestPasswordReset } from "../src/routes/api/password-request.ts";
|
||||
import { GET as listSessions } from "../src/routes/api/sessions.ts";
|
||||
import { POST as passkeyLoginOptions } from "../src/routes/api/passkeys-login-options.ts";
|
||||
|
||||
function context(request: Request): Context {
|
||||
const values = new Map<string, unknown>();
|
||||
return {
|
||||
req: request,
|
||||
// Deliberately use unrelated values. A route-specific package entry must
|
||||
// not infer its endpoint from ctx.url, ctx.req.url, params, or locals.
|
||||
url: new URL("https://example.test/__wrnexus/rewritten"),
|
||||
params: {},
|
||||
locals: {},
|
||||
lang: "en",
|
||||
t: (key: string) => key,
|
||||
ip: "127.0.0.1",
|
||||
user: null,
|
||||
cookies: {
|
||||
get: (name: string) => (name === "wire-csrf" ? "route-csrf-token" : undefined),
|
||||
} as Context["cookies"],
|
||||
localStorage: {} as Context["localStorage"],
|
||||
session: {
|
||||
id: () => "route-test-session",
|
||||
get: <T>(key: string) => values.get(key) as T | undefined,
|
||||
getAll: () => Object.fromEntries(values),
|
||||
set: (key: string, value: unknown) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
delete: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
regenerate: () => {},
|
||||
clear: () => {
|
||||
values.clear();
|
||||
},
|
||||
},
|
||||
} as Context;
|
||||
}
|
||||
|
||||
test("route-specific password recovery entry cannot fall through to Not Found", async () => {
|
||||
setDefaultAuthEngine(
|
||||
createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "route-specific-entry-secret-longer-than-thirty-two-characters",
|
||||
}),
|
||||
);
|
||||
|
||||
const request = new Request("https://example.test/completely/unrelated", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-csrf-token": "route-csrf-token",
|
||||
},
|
||||
body: JSON.stringify({ identifier: "missing@example.test" }),
|
||||
});
|
||||
|
||||
const response = await requestPasswordReset(context(request));
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("package auth routes reject unsafe requests without CSRF verification", async () => {
|
||||
const request = new Request("https://example.test/api/auth/password/request", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ identifier: "missing@example.test" }),
|
||||
});
|
||||
const response = await requestPasswordReset(context(request));
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toMatchObject({ ok: false, error: "Invalid CSRF token" });
|
||||
});
|
||||
|
||||
test("safe package auth routes do not require a CSRF token", async () => {
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "safe-route-secret-that-is-longer-than-thirty-two-characters",
|
||||
});
|
||||
const registered = await engine.register({
|
||||
email: "sessions@example.test",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
setDefaultAuthEngine(engine);
|
||||
const ctx = context(new Request("https://example.test/api/auth/sessions", { method: "GET" }));
|
||||
ctx.user = registered.user!;
|
||||
ctx.locals.authUser = registered.user!;
|
||||
|
||||
const response = await listSessions(ctx);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ ok: true, sessions: [] });
|
||||
});
|
||||
|
||||
test("passkey routes return a controlled response when no provider is configured", async () => {
|
||||
setDefaultAuthEngine(
|
||||
createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "missing-passkey-provider-secret-longer-than-thirty-two-characters",
|
||||
}),
|
||||
);
|
||||
const request = new Request("https://example.test/api/auth/passkeys/login/options", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-csrf-token": "route-csrf-token",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const response = await passkeyLoginOptions(context(request));
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: false,
|
||||
code: "passkey-provider-not-configured",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
import type { AuthIdentity, OAuthAccount, PasskeyCredential } from "../src/types.ts";
|
||||
|
||||
function identity(overrides: Partial<AuthIdentity> = {}): AuthIdentity {
|
||||
return {
|
||||
id: "identity-1",
|
||||
userId: "user-1",
|
||||
type: "email",
|
||||
value: "first@example.com",
|
||||
normalizedValue: "first@example.com",
|
||||
primary: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function passkey(overrides: Partial<PasskeyCredential> = {}): PasskeyCredential {
|
||||
return {
|
||||
id: "passkey-1",
|
||||
userId: "user-1",
|
||||
credentialId: "credential-1",
|
||||
publicKey: "public-key",
|
||||
counter: 0,
|
||||
transports: ["internal"],
|
||||
name: "Passkey",
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function oauth(overrides: Partial<OAuthAccount> = {}): OAuthAccount {
|
||||
return {
|
||||
id: "oauth-1",
|
||||
userId: "user-1",
|
||||
provider: "example",
|
||||
providerAccountId: "provider-account-1",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("memory identity updates re-key lookups and reject collisions", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
const first = identity();
|
||||
const second = identity({
|
||||
id: "identity-2",
|
||||
userId: "user-2",
|
||||
value: "second@example.com",
|
||||
normalizedValue: "second@example.com",
|
||||
});
|
||||
await store.createIdentity(first);
|
||||
await store.createIdentity(second);
|
||||
|
||||
first.value = "renamed@example.com";
|
||||
first.normalizedValue = "renamed@example.com";
|
||||
first.updatedAt = 2;
|
||||
await store.updateIdentity(first);
|
||||
|
||||
expect(await store.findIdentity("email", "first@example.com")).toBeUndefined();
|
||||
expect(await store.findIdentity("email", "renamed@example.com")).toMatchObject({
|
||||
id: "identity-1",
|
||||
});
|
||||
|
||||
first.value = second.value;
|
||||
first.normalizedValue = second.normalizedValue;
|
||||
await expect(store.updateIdentity(first)).rejects.toThrow("WRN-AUTH-IDENTITY-EXISTS");
|
||||
});
|
||||
|
||||
test("memory store enforces passkey credential uniqueness", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.createPasskey(passkey());
|
||||
await expect(
|
||||
store.createPasskey(
|
||||
passkey({ id: "passkey-2", userId: "user-2", credentialId: "credential-1" }),
|
||||
),
|
||||
).rejects.toThrow("WRN-AUTH-PASSKEY-EXISTS");
|
||||
|
||||
await store.createPasskey(
|
||||
passkey({ id: "passkey-2", userId: "user-2", credentialId: "credential-2" }),
|
||||
);
|
||||
await expect(
|
||||
store.updatePasskey(
|
||||
passkey({ id: "passkey-2", userId: "user-2", credentialId: "credential-1" }),
|
||||
),
|
||||
).rejects.toThrow("WRN-AUTH-PASSKEY-IMMUTABLE");
|
||||
});
|
||||
|
||||
test("memory store enforces OAuth provider-account uniqueness", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.createOAuthAccount(oauth());
|
||||
await expect(
|
||||
store.createOAuthAccount(
|
||||
oauth({ id: "oauth-2", userId: "user-2", providerAccountId: "provider-account-1" }),
|
||||
),
|
||||
).rejects.toThrow("WRN-AUTH-OAUTH-ACCOUNT-EXISTS");
|
||||
|
||||
await store.createOAuthAccount(
|
||||
oauth({ id: "oauth-2", userId: "user-2", providerAccountId: "provider-account-2" }),
|
||||
);
|
||||
await expect(
|
||||
store.updateOAuthAccount(
|
||||
oauth({ id: "oauth-2", userId: "user-2", providerAccountId: "provider-account-1" }),
|
||||
),
|
||||
).rejects.toThrow("WRN-AUTH-OAUTH-ACCOUNT-IMMUTABLE");
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
decodeBase32,
|
||||
generateTotp,
|
||||
generateTotpSecret,
|
||||
totpUri,
|
||||
verifyTotp,
|
||||
} from "../src/totp/index.ts";
|
||||
|
||||
test("TOTP matches the RFC 6238 SHA-1 vector", async () => {
|
||||
const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
|
||||
expect(await generateTotp(secret, { timestamp: 59_000, digits: 8 })).toBe("94287082");
|
||||
expect(await verifyTotp(secret, "94287082", { timestamp: 59_000, digits: 8, window: 0 })).toEqual(
|
||||
{ valid: true, counter: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("TOTP rejects malformed secrets, tokens, and unsafe options", async () => {
|
||||
expect(() => decodeBase32("JBSW0Y3P")).toThrow("Invalid base32 secret");
|
||||
expect(() => decodeBase32("====")).toThrow("Invalid base32 secret");
|
||||
expect(() => generateTotpSecret(() => new Uint8Array(19))).toThrow(
|
||||
"must return exactly 20 bytes",
|
||||
);
|
||||
await expect(generateTotp("JBSWY3DPEHPK3PXP", { period: 0 })).rejects.toThrow("TOTP period");
|
||||
expect(await verifyTotp("JBSWY3DPEHPK3PXP", "12ab56", { timestamp: 59_000 })).toEqual({
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("TOTP URI validates and normalizes configuration", () => {
|
||||
const uri = totpUri({
|
||||
issuer: " WorkRoot ",
|
||||
accountName: " user@example.com ",
|
||||
secret: "JBSW Y3DP-EHPK3PXP",
|
||||
});
|
||||
expect(uri).toContain("secret=JBSWY3DPEHPK3PXP");
|
||||
expect(uri).toContain("issuer=WorkRoot");
|
||||
expect(() =>
|
||||
totpUri({ issuer: "", accountName: "user@example.com", secret: "JBSWY3DPEHPK3PXP" }),
|
||||
).toThrow("issuer and account name");
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
invitationAcceptSchema,
|
||||
loginSchema,
|
||||
mfaSchema,
|
||||
otpLoginRequestSchema,
|
||||
registerSchema,
|
||||
signUpSchema,
|
||||
} from "../src/validation.ts";
|
||||
|
||||
test("authentication schemas reject malformed input", () => {
|
||||
expect(registerSchema.parse({ email: "bad", password: "short", displayName: "A" }).ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect(loginSchema.parse({ identifier: "", password: "" }).ok).toBe(false);
|
||||
expect(
|
||||
otpLoginRequestSchema.parse({ identifier: "person@example.com", method: "voice" }).ok,
|
||||
).toBe(false);
|
||||
expect(invitationAcceptSchema.parse({ token: "short" }).ok).toBe(false);
|
||||
expect(mfaSchema.parse({ mfaToken: "short", method: "unknown", code: "1" }).ok).toBe(false);
|
||||
});
|
||||
|
||||
test("sign-up schema is shared by browser and server registration", () => {
|
||||
const invalid = signUpSchema.parse({
|
||||
displayName: "A",
|
||||
email: "bad",
|
||||
password: "weak",
|
||||
consent: false,
|
||||
});
|
||||
expect(invalid.ok).toBe(false);
|
||||
expect(invalid.errors).toMatchObject({
|
||||
displayName: "Enter your full name",
|
||||
email: "Enter a valid email address",
|
||||
consent: "Accept the terms and privacy policy to continue",
|
||||
});
|
||||
|
||||
expect(
|
||||
signUpSchema.parse({
|
||||
displayName: "Ada Lovelace",
|
||||
email: "ada@example.com",
|
||||
password: "StrongPassword123",
|
||||
consent: true,
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("built-in browser schema registry covers packaged auth forms", async () => {
|
||||
const { authBrowserSchemaDescriptors, authSchemas } = await import("../src/validation.ts");
|
||||
const descriptors = authBrowserSchemaDescriptors(authSchemas);
|
||||
expect(descriptors["auth-register"]).toBeDefined();
|
||||
expect(descriptors["auth-password-request"]).toBeDefined();
|
||||
expect(descriptors["auth-session-revoke"]).toBeDefined();
|
||||
expect(descriptors["auth-authenticator-setup"]).toBeDefined();
|
||||
expect(descriptors["auth-authenticator-confirm"]).toBeDefined();
|
||||
expect(descriptors["auth-authenticator-disable"]).toBeDefined();
|
||||
expect(descriptors["auth-recovery-codes"]).toBeDefined();
|
||||
expect(descriptors["auth-empty"]).toBeDefined();
|
||||
});
|
||||
Reference in New Issue
Block a user