Files
Termix/src/backend/tests/auth/login-pipeline.test.ts
T

1113 lines
32 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import bcrypt from "bcryptjs";
import {
createAuthState,
fakeAuthManager,
fakeRequest,
fakeResponse,
type AuthState,
} from "./auth-test-helpers.js";
const h = vi.hoisted(() => ({
state: null as unknown as AuthState,
manager: null as unknown as ReturnType<
typeof import("./auth-test-helpers.js").fakeAuthManager
>,
trustedProxy: false,
}));
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("./auth-test-helpers.js");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => h.manager },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
h.state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return {
authLogger: log,
databaseLogger: log,
sshLogger: log,
logger: log,
systemLogger: log,
};
});
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
},
}));
vi.mock("../../utils/trusted-proxy-auth.js", () => ({
isTrustedProxyAuthEnabled: () => h.trustedProxy,
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
const {
runLogin,
respondWithLogin,
respondWithRedirectLogin,
verifySecondFactorAndRespond,
resetPendingLoginsForTests,
TRUST_DEVICE_COOKIE,
hashTrustToken,
} = await import("../../auth/login-pipeline.js");
const { verifyPasswordLogin } =
await import("../../auth/builtin-login-methods.js");
const { getPasswordLoginStatus } = await import("../../auth/core-auth.js");
const { registerLoginMethod, registerSecondFactor } =
await import("../../auth/registry.js");
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
const { provideAuthPolicy, clearAuthPolicies, SECOND_FACTOR_SETUP } =
await import("../../auth/policy.js");
const PASSWORD = "correct-horse";
// Stands in for the totp plugin's factor: core only sees the registration.
const TOTP_CODE = "123456";
let disposeTotp: (() => void) | null = null;
function registerTotpFixture() {
disposeTotp = registerSecondFactor({
id: "totp",
pluginId: "totp",
labelKey: "totp:factor",
isEnrolled: async () => false,
verify: async (_userId, body) => body.totp_code === TOTP_CODE,
reset: async () => {},
});
}
function addUser(
overrides: Partial<import("./auth-test-helpers.js").FakeUser> = {},
) {
const user = {
id: "u1",
username: "alice",
passwordHash: bcrypt.hashSync(PASSWORD, 4),
isAdmin: false,
...overrides,
};
h.state.users.set(user.id, user);
return user;
}
beforeEach(() => {
h.state = createAuthState();
h.manager = fakeAuthManager(h.state);
h.trustedProxy = false;
resetPendingLoginsForTests();
loginRateLimiter.resetAttempts("10.0.0.1", "alice");
loginRateLimiter.resetTOTPAttempts("u1");
delete process.env.ALLOW_PASSWORD_LOGIN;
disposeTotp?.();
registerTotpFixture();
clearAuthPolicies();
});
async function passwordLogin(body: Record<string, unknown>, headers = {}) {
const req = fakeRequest({ body, headers });
const res = fakeResponse();
const identity = await verifyPasswordLogin(req as never);
await respondWithLogin(req as never, res as never, identity, {
methodId: "password",
rememberMe: !!body.rememberMe,
});
return res;
}
describe("password login through the pipeline", () => {
it("issues the same JWT, cookie, audit and body the old route did", async () => {
addUser({ isAdmin: true });
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.authenticateUser).toHaveBeenCalledWith(
"u1",
PASSWORD,
"web",
);
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({
rememberMe: false,
deviceType: "web",
deviceInfo: expect.any(String),
}),
);
// Session timeout default: 24 hours.
expect(res.cookies).toEqual([
{
name: "jwt",
value: "jwt-1",
options: { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 },
},
]);
expect(res.body).toMatchObject({
success: true,
is_admin: true,
username: "alice",
});
expect(res.body).not.toHaveProperty("token");
expect(h.state.audits).toEqual([
expect.objectContaining({
userId: "u1",
username: "alice",
action: "login",
resourceType: "session",
success: true,
}),
]);
});
it("remembers the session for 30 days and follows session_timeout_hours otherwise", async () => {
addUser();
const remembered = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
expect((remembered.cookies[0].options as { maxAge: number }).maxAge).toBe(
30 * 24 * 60 * 60 * 1000,
);
h.state.settings.set("session_timeout_hours", "2");
const short = await passwordLogin({
username: "alice",
password: PASSWORD,
});
expect((short.cookies[0].options as { maxAge: number }).maxAge).toBe(
2 * 60 * 60 * 1000,
);
});
it("returns the token in the body for the desktop and mobile apps", async () => {
addUser();
const res = await passwordLogin(
{ username: "alice", password: PASSWORD },
{ "x-electron-app": "true" },
);
expect(res.body).toMatchObject({ token: "jwt-1" });
});
it("rejects a wrong password with the old status and message", async () => {
addUser();
await expect(
verifyPasswordLogin(
fakeRequest({ body: { username: "alice", password: "nope" } }) as never,
),
).rejects.toMatchObject({
status: 401,
message: "Invalid username or password",
});
expect(h.state.audits).toEqual([]);
});
it("refuses a user whose data key does not open with the password", async () => {
addUser();
h.manager.authenticateUser.mockResolvedValueOnce(false);
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
});
const identity = await verifyPasswordLogin(req as never);
await expect(
runLogin(req as never, identity, {
methodId: "password",
rememberMe: false,
}),
).rejects.toMatchObject({ status: 401, message: "Incorrect password" });
});
});
describe("second factors", () => {
function enrolTotp() {
addUser();
h.state.factors.push({ userId: "u1", pluginId: "totp", factorId: "totp" });
}
it("stops for TOTP with the pending token the old route returned", async () => {
enrolTotp();
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(res.cookies).toEqual([]);
expect(res.body).toMatchObject({
success: true,
requires_totp: true,
temp_token: "pending-1",
rememberMe: false,
second_factors: [
expect.objectContaining({ id: "totp", pluginId: "totp" }),
],
});
expect(h.manager.generateJWTToken).toHaveBeenCalledWith("u1", {
pendingTOTP: true,
expiresIn: "10m",
});
expect(h.state.audits).toEqual([]);
});
it("finishes the login with a valid code and trusts the device when remembered", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const tempToken = (first.body as { temp_token: string }).temp_token;
const req = fakeRequest({
body: {
temp_token: tempToken,
totp_code: TOTP_CODE,
rememberMe: true,
},
});
const res = fakeResponse();
await verifySecondFactorAndRespond(req as never, res as never, "totp");
expect(res.statusCode).toBe(200);
const trust = res.cookies.find((c) => c.name === TRUST_DEVICE_COOKIE);
expect(trust?.value).toMatch(/^[a-f0-9]{64}$/);
expect(res.cookies.find((c) => c.name === "jwt")).toBeDefined();
expect(res.body).toMatchObject({
success: true,
username: "alice",
totp_enabled: true,
});
expect(res.body).not.toHaveProperty("trustToken");
expect(h.state.trustedAdded).toEqual([
`u1:${hashTrustToken(trust!.value)}`,
]);
expect(h.state.audits.at(-1)).toMatchObject({ action: "login" });
});
it("hands the trust token to the desktop app in the body", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
headers: { "x-electron-app": "true" },
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: TOTP_CODE,
rememberMe: true,
},
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(200);
expect((res.body as { trustToken: string }).trustToken).toMatch(
/^[a-f0-9]{64}$/,
);
});
it("answers the 2.8 route with the user's first factor when none is named", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: TOTP_CODE,
},
}) as never,
res as never,
);
expect(res.statusCode).toBe(200);
});
it("fails closed for a 2.8 TOTP user while the totp plugin is disabled", async () => {
enrolTotp();
disposeTotp?.();
disposeTotp = null;
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
});
const identity = await verifyPasswordLogin(req as never);
await expect(
runLogin(req as never, identity, {
methodId: "password",
rememberMe: false,
}),
).rejects.toMatchObject({ status: 403, code: "second_factor_unavailable" });
expect(h.manager.generateJWTToken).not.toHaveBeenCalled();
});
it("still stops password login for TOTP when the external-login setting is off", async () => {
enrolTotp();
h.state.settings.set("second_factor_after_external_login", "false");
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(res.body).toMatchObject({ requires_totp: true });
});
it("rejects a wrong code without a session", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: "000000",
},
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(401);
expect(res.cookies).toEqual([]);
});
it("skips the factor with a server-issued trust cookie", async () => {
enrolTotp();
const token = "c".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
cookies: { [TRUST_DEVICE_COOKIE]: token },
});
const res = fakeResponse();
const identity = await verifyPasswordLogin(req as never);
await respondWithLogin(req as never, res as never, identity, {
methodId: "password",
rememberMe: false,
});
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
});
it("never skips the factor on a client-made device id", async () => {
enrolTotp();
const deviceId = "a".repeat(64);
h.state.trusted.add(`u1:${deviceId}`);
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(res.body).toMatchObject({ requires_totp: true });
expect(h.manager.isTrustedDevice).not.toHaveBeenCalled();
});
it("fails closed when an enrolled factor's plugin is gone", async () => {
addUser();
h.state.factors.push({
userId: "u1",
pluginId: "gone",
factorId: "yubikey",
});
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
});
const identity = await verifyPasswordLogin(req as never);
await expect(
runLogin(req as never, identity, {
methodId: "password",
rememberMe: false,
}),
).rejects.toMatchObject({ status: 403, code: "second_factor_unavailable" });
expect(h.manager.generateJWTToken).not.toHaveBeenCalled();
expect(h.state.audits).toEqual([
expect.objectContaining({
action: "login_blocked_second_factor",
success: false,
}),
]);
});
it("runs a plugin's second factor", async () => {
addUser();
h.state.factors.push({
userId: "u1",
pluginId: "fixture",
factorId: "pin",
});
const dispose = registerSecondFactor({
id: "pin",
pluginId: "fixture",
labelKey: "fixture.pin",
isEnrolled: async () => true,
verify: async (_userId, body) => body.pin === "1234",
});
try {
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
expect(first.body).toMatchObject({
second_factors: [expect.objectContaining({ id: "pin" })],
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
pin: "1234",
},
}) as never,
res as never,
"pin",
);
expect(res.statusCode).toBe(200);
} finally {
dispose();
}
});
it("lets a method that already proved a factor skip the step", async () => {
enrolTotp();
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
);
expect(result.kind).toBe("session");
});
});
describe("external identities", () => {
const external = {
kind: "external" as const,
provider: "3",
subject: "sub-1",
email: "a@example.com",
name: "Alice",
legacyIdentifier: "sub-1",
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
};
// The sso plugin's method, which marks itself external.
let disposeOidc: () => void = () => {};
beforeEach(() => {
disposeOidc = registerLoginMethod({
id: "oidc",
pluginId: "sso",
labelKey: "loginWithSso",
kind: "redirect",
external: true,
});
});
afterEach(() => disposeOidc());
function addSsoUser(passwordHash = "") {
addUser({ passwordHash });
h.state.identities.push({
userId: "u1",
providerId: "3",
subject: "sub-1",
} as never);
}
it("refuses a passkey sign in for an account that only uses SSO", async () => {
addSsoUser();
await expect(
runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
),
).rejects.toMatchObject({ status: 403, code: "external_login_required" });
});
it("still lets an SSO account with a password use a passkey", async () => {
addSsoUser(bcrypt.hashSync(PASSWORD, 4));
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
);
expect(result.kind).toBe("session");
});
it("lets an external method sign an SSO account in by user id", async () => {
addSsoUser();
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1" },
{ methodId: "oidc", rememberMe: false },
);
expect(result.kind).toBe("session");
});
it("provisions the first user as admin and links the identity", async () => {
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
const [user] = [...h.state.users.values()];
expect(user).toMatchObject({
username: "Alice",
isAdmin: true,
});
expect(h.state.identities).toEqual([
expect.objectContaining({
userId: user.id,
providerId: "3",
subject: "sub-1",
}),
]);
expect(h.state.roles).toContainEqual({
userId: user.id,
roleName: "admin",
});
});
it("refuses a new user when provisioning is off, and one outside the allowed list", async () => {
addUser({ id: "someone", username: "someone" });
await expect(
runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
}),
).rejects.toMatchObject({ code: "registration_disabled" });
h.state.settings.set("external_auto_provision", "true");
await expect(
runLogin(
fakeRequest() as never,
{ ...external, allowedUsers: "@other.org" },
{ methodId: "oidc", rememberMe: false },
),
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it("keeps equal subjects on explicitly different providers separate", async () => {
addUser({
id: "other",
username: "Other Alice",
passwordHash: "",
});
h.state.identities.push({
id: 1,
userId: "other",
providerId: "9",
subject: "sub-1",
email: null,
});
h.state.settings.set("external_auto_provision", "true");
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(2);
expect(
h.state.identities.find((link) => link.providerId === "3")?.userId,
).not.toBe("other");
});
it("uses a verified provider link even when the old provider column is empty", async () => {
addUser({
id: "old",
username: "Alice",
passwordHash: "",
});
h.state.identities.push({
id: 1,
userId: "old",
providerId: "3",
subject: "sub-1",
email: null,
});
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(1);
});
it("re-checks the allowed list for existing users", async () => {
addUser({ id: "u9", username: "Alice", passwordHash: "" });
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
await expect(
runLogin(
fakeRequest() as never,
{ ...external, allowedUsers: "@other.org" },
{ methodId: "oidc", rememberMe: false },
),
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it("keeps admin in step with the provider's admin group", async () => {
addUser({
id: "u9",
username: "Alice",
passwordHash: "",
isAdmin: true,
});
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
await runLogin(
fakeRequest() as never,
{ ...external, isAdmin: false },
{ methodId: "oidc", rememberMe: false },
);
expect(h.state.users.get("u9")!.isAdmin).toBe(false);
});
it("skips enrolled second factors for SSO users when the setting is off", async () => {
addUser({
id: "u9",
username: "Alice",
passwordHash: "",
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
});
it("runs enrolled second factors for SSO users when the setting is on", async () => {
h.state.settings.set("second_factor_after_external_login", "true");
addUser({
id: "u9",
username: "Alice",
passwordHash: "",
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("second-factor");
});
});
describe("redirect logins", () => {
const identity = {
kind: "external" as const,
provider: "3",
subject: "sub-1",
name: "Alice",
returnTo: "https://termix.example",
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
};
it("sets the cookie and redirects with success, carrying the SSO claims", async () => {
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
}),
);
});
it("puts the token in the URL for app callbacks", async () => {
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
{ ...identity, returnTo: "http://localhost:4567/oidc-callback" },
{ methodId: "oidc", rememberMe: false },
() => true,
);
expect(res.redirectedTo).toMatch(/success=true/);
expect(res.redirectedTo).toMatch(/token=jwt-/);
expect(res.cookies).toEqual([]);
});
it("sends a second-factor step back with a pending cookie", async () => {
h.state.settings.set("second_factor_after_external_login", "true");
addUser({
id: "u9",
username: "Alice",
passwordHash: "",
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toContain("second_factor=1");
expect(res.redirectedTo).toContain("second_factors=totp");
expect(res.cookies[0]).toMatchObject({ name: "termix_pending_login" });
// The pending login keeps the SSO claims for the session.
const verify = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
cookies: { termix_pending_login: res.cookies[0].value },
body: {
totp_code: TOTP_CODE,
},
}) as never,
verify as never,
"totp",
);
expect(verify.statusCode).toBe(200);
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
"u9",
expect.objectContaining({
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
}),
);
});
it("redirects provisioning refusals with the old error codes", async () => {
addUser({ id: "someone", username: "someone" });
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toBe(
"https://termix.example/?error=registration_disabled",
);
});
});
describe("lockout guard", () => {
it("keeps password login on, flagged forced, when nothing else can sign anyone in", async () => {
h.state.settings.set("allow_password_login", "false");
expect(await getPasswordLoginStatus()).toEqual({
allowed: true,
forced: true,
});
});
it("honours the setting when another method is enabled", async () => {
h.state.settings.set("allow_password_login", "false");
const dispose = registerLoginMethod({
id: "fixture-sso",
pluginId: "fixture",
labelKey: "fixture",
kind: "redirect",
describe: async () => [{ id: "1", label: "Fixture", enabled: true }],
});
try {
expect(await getPasswordLoginStatus()).toEqual({
allowed: false,
forced: false,
});
await expect(
verifyPasswordLogin(
fakeRequest({
body: { username: "alice", password: PASSWORD },
}) as never,
),
).rejects.toMatchObject({ status: 403 });
} finally {
dispose();
}
});
it("counts trusted proxy login as another way in", async () => {
h.state.settings.set("allow_password_login", "false");
h.trustedProxy = true;
expect(await getPasswordLoginStatus()).toEqual({
allowed: false,
forced: false,
});
});
});
describe("login policy", () => {
function enrolTotp() {
h.state.factors.push({ userId: "u1", pluginId: "totp", factorId: "totp" });
}
function trustedLogin(token: string) {
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
cookies: { [TRUST_DEVICE_COOKIE]: token },
});
return runLogin(
req as never,
{ kind: "user", userId: "u1" },
{ methodId: "password", rememberMe: true },
);
}
it("hands the provider the user's factors and the device", async () => {
addUser();
enrolTotp();
const evaluate = vi.fn(() => ({}));
provideAuthPolicy("policy", { evaluate });
await passwordLogin({ username: "alice", password: PASSWORD });
expect(evaluate).toHaveBeenCalledWith({
userId: "u1",
methodId: "password",
external: false,
deviceType: "web",
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [{ id: "totp", pluginId: "totp", enrolled: true }],
});
});
it("turns remember me off and caps the session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 600 }),
});
const res = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 600 }),
);
expect((res.cookies[0].options as { maxAge: number }).maxAge).toBe(
600 * 1000,
);
});
it("stores the idle timeout on the session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ idleTimeoutSeconds: 900 }),
});
provideAuthPolicy("other", {
evaluate: () => ({ idleTimeoutSeconds: 300 }),
});
await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ idleTimeoutSeconds: 300 }),
);
});
it("ignores a remembered device when remember me is off", async () => {
addUser();
enrolTotp();
const token = "c".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false }),
});
const result = await trustedLogin(token);
expect(result.kind).toBe("second-factor");
});
it("asks for the factor on a remembered device with always, not with new-device", async () => {
addUser();
enrolTotp();
const token = "d".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
const remove = provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
expect((await trustedLogin(token)).kind).toBe("second-factor");
remove();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "new-device" }),
});
expect((await trustedLogin(token)).kind).toBe("session");
});
it("gives a user without a factor a setup only session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ restriction: SECOND_FACTOR_SETUP }),
);
expect(res.body).toMatchObject({ restriction: SECOND_FACTOR_SETUP });
});
it("lets a verified passkey satisfy a required factor", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
);
expect(result.kind).toBe("session");
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ restriction: null }),
);
});
it("runs factors for SSO logins when the policy requires one", async () => {
const dispose = registerLoginMethod({
id: "oidc",
pluginId: "sso",
labelKey: "loginWithSso",
kind: "redirect",
external: true,
});
try {
addUser({ passwordHash: "" });
enrolTotp();
h.state.identities.push({
id: 1,
userId: "u1",
providerId: "3",
subject: "sub-1",
email: null,
});
provideAuthPolicy("policy", {
evaluate: (input) => (input.external ? { secondFactor: "always" } : {}),
});
const result = await runLogin(
fakeRequest() as never,
{
kind: "external",
provider: "3",
subject: "sub-1",
email: null,
name: "Alice",
} as never,
{ methodId: "oidc", rememberMe: false },
);
expect(result.kind).toBe("second-factor");
} finally {
dispose();
}
});
it("keeps the policy for the factor step: no trusted device, same cap", async () => {
addUser();
enrolTotp();
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 1200 }),
});
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: TOTP_CODE,
rememberMe: true,
},
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(200);
expect(res.cookies.map((c) => c.name)).toEqual(["jwt"]);
expect(h.state.trustedAdded).toEqual([]);
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
"u1",
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 1200 }),
);
});
it("refuses the login when a provider throws", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => {
throw new Error("boom");
},
});
await expect(
runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1" },
{ methodId: "password", rememberMe: false },
),
).rejects.toMatchObject({ status: 503, code: "auth_policy_unavailable" });
expect(h.state.audits).toEqual([]);
});
});