mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
1113 lines
32 KiB
TypeScript
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([]);
|
|
});
|
|
});
|