877 lines
32 KiB
TypeScript
877 lines
32 KiB
TypeScript
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";
|
|
|
|
test("authentication storage factories resolve lazily and only once", async () => {
|
|
let calls = 0;
|
|
const backing = new MemoryAuthStore();
|
|
const engine = createAuthEngine({
|
|
store: () => {
|
|
calls++;
|
|
return backing;
|
|
},
|
|
secret: "a secure test secret that is longer than thirty-two characters",
|
|
});
|
|
|
|
expect(calls).toBe(0);
|
|
expect(engine.store).toBeDefined();
|
|
expect(calls).toBe(0);
|
|
await engine.getUser("missing");
|
|
await engine.getUser("still-missing");
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
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: {},
|
|
}),
|
|
).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" },
|
|
})
|
|
).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();
|
|
});
|
|
});
|