mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: add auth extension points and one ssh connect pipeline
This commit is contained in:
1 parent
d3c209af13
commit
d774df8b41
159 files changed
+43243
-7451
No files matched your search
@@ -0,0 +1,267 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export interface FakeUser {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
oidcIdentifier?: string | null;
|
||||
ssoProviderId?: number | null;
|
||||
totpEnabled?: boolean;
|
||||
totpSecret?: string | null;
|
||||
totpBackupCodes?: string | null;
|
||||
}
|
||||
|
||||
/** In-memory stand-ins for the repositories the login pipeline touches. */
|
||||
export function createAuthState() {
|
||||
return {
|
||||
users: new Map<string, FakeUser>(),
|
||||
identities: [] as Array<{
|
||||
id: number;
|
||||
userId: string;
|
||||
providerId: string;
|
||||
subject: string;
|
||||
email: string | null;
|
||||
}>,
|
||||
factors: [] as Array<{
|
||||
userId: string;
|
||||
pluginId: string;
|
||||
factorId: string;
|
||||
}>,
|
||||
roles: [] as Array<{ userId: string; roleName: string }>,
|
||||
settings: new Map<string, string>(),
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
trusted: new Set<string>(),
|
||||
trustedAdded: [] as string[],
|
||||
pendingTokens: new Map<string, string>(),
|
||||
};
|
||||
}
|
||||
|
||||
export type AuthState = ReturnType<typeof createAuthState>;
|
||||
|
||||
export function fakeFactory(state: AuthState) {
|
||||
return {
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => state.users.get(id) ?? null,
|
||||
findByUsername: async (username: string) =>
|
||||
[...state.users.values()].find((u) => u.username === username) ?? null,
|
||||
findByOidcIdentifier: async (identifier: string) =>
|
||||
[...state.users.values()].find(
|
||||
(u) => u.oidcIdentifier === identifier,
|
||||
) ?? null,
|
||||
countAll: async () => state.users.size,
|
||||
listAll: async () => [...state.users.values()],
|
||||
createFirstSsoUser: async (user: FakeUser) => {
|
||||
const isFirstUser = state.users.size === 0;
|
||||
const created = {
|
||||
...user,
|
||||
isAdmin: isFirstUser || !!user.isAdmin,
|
||||
};
|
||||
state.users.set(user.id, created);
|
||||
return { user: created, isFirstUser };
|
||||
},
|
||||
update: async (id: string, changes: Partial<FakeUser>) => {
|
||||
const user = state.users.get(id);
|
||||
if (!user) return null;
|
||||
Object.assign(user, changes);
|
||||
return user;
|
||||
},
|
||||
delete: async (id: string) => state.users.delete(id),
|
||||
}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
findIdentity: async (providerId: string, subject: string) =>
|
||||
state.identities.find(
|
||||
(row) => row.providerId === providerId && row.subject === subject,
|
||||
) ?? null,
|
||||
linkIdentity: async (input: {
|
||||
userId: string;
|
||||
providerId: string;
|
||||
subject: string;
|
||||
email?: string | null;
|
||||
}) => {
|
||||
const existing = state.identities.find(
|
||||
(row) =>
|
||||
row.providerId === input.providerId &&
|
||||
row.subject === input.subject,
|
||||
);
|
||||
if (existing) {
|
||||
existing.userId = input.userId;
|
||||
return;
|
||||
}
|
||||
state.identities.push({
|
||||
id: state.identities.length + 1,
|
||||
userId: input.userId,
|
||||
providerId: input.providerId,
|
||||
subject: input.subject,
|
||||
email: input.email ?? null,
|
||||
});
|
||||
},
|
||||
listSecondFactors: async (userId: string) =>
|
||||
state.factors.filter((row) => row.userId === userId),
|
||||
recordSecondFactor: async (
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
) => {
|
||||
if (
|
||||
!state.factors.some(
|
||||
(row) =>
|
||||
row.userId === userId &&
|
||||
row.pluginId === pluginId &&
|
||||
row.factorId === factorId,
|
||||
)
|
||||
) {
|
||||
state.factors.push({ userId, pluginId, factorId });
|
||||
}
|
||||
},
|
||||
removeSecondFactor: async (
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
) => {
|
||||
state.factors = state.factors.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.userId === userId &&
|
||||
row.pluginId === pluginId &&
|
||||
row.factorId === factorId
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
clearSecondFactors: async (userId: string) => {
|
||||
state.factors = state.factors.filter((row) => row.userId !== userId);
|
||||
return 1;
|
||||
},
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
assignRoleNameToUser: async (input: {
|
||||
userId: string;
|
||||
roleName: string;
|
||||
}) => {
|
||||
state.roles.push({ userId: input.userId, roleName: input.roleName });
|
||||
return true;
|
||||
},
|
||||
switchUserRoleName: async (input: {
|
||||
userId: string;
|
||||
addRoleName: string;
|
||||
removeRoleName: string;
|
||||
}) => {
|
||||
state.roles = state.roles.filter(
|
||||
(role) =>
|
||||
!(
|
||||
role.userId === input.userId &&
|
||||
role.roleName === input.removeRoleName
|
||||
),
|
||||
);
|
||||
state.roles.push({ userId: input.userId, roleName: input.addRoleName });
|
||||
},
|
||||
listUserRoles: async (userId: string) =>
|
||||
state.roles
|
||||
.filter((role) => role.userId === userId)
|
||||
.map((role, index) => ({ roleId: index, roleName: role.roleName })),
|
||||
removeRoleFromUser: async () => true,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings.get(key) ?? null,
|
||||
getBoolean: async (key: string, fallback: boolean) =>
|
||||
state.settings.has(key) ? state.settings.get(key) === "true" : fallback,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings.set(key, value);
|
||||
},
|
||||
delete: async (key: string) => state.settings.delete(key),
|
||||
}),
|
||||
getCurrentSettingValue: (key: string) => state.settings.get(key) ?? null,
|
||||
createCurrentTrustedDeviceRepository: () => ({
|
||||
deleteByUserId: async () => {},
|
||||
}),
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
listEnabledPublic: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeAuthManager(state: AuthState) {
|
||||
let tokenCount = 0;
|
||||
return {
|
||||
generateJWTToken: vi.fn(
|
||||
async (userId: string, options: { pendingTOTP?: boolean } = {}) => {
|
||||
if (!options.pendingTOTP) return `jwt-${++tokenCount}`;
|
||||
const token = `pending-${++tokenCount}`;
|
||||
state.pendingTokens.set(token, userId);
|
||||
return token;
|
||||
},
|
||||
),
|
||||
verifyJWTToken: vi.fn(async (token: string) => {
|
||||
const userId = state.pendingTokens.get(token);
|
||||
return userId ? { userId, pendingTOTP: true } : null;
|
||||
}),
|
||||
authenticateUser: vi.fn(async () => true),
|
||||
authenticateOIDCUser: vi.fn(async () => true),
|
||||
authenticateWebAuthnUser: vi.fn(async () => true),
|
||||
registerOIDCUser: vi.fn(async () => {}),
|
||||
isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) =>
|
||||
state.trusted.has(`${userId}:${fingerprint}`),
|
||||
),
|
||||
addTrustedDevice: vi.fn(async (userId: string, fingerprint: string) => {
|
||||
state.trustedAdded.push(`${userId}:${fingerprint}`);
|
||||
}),
|
||||
getSecureCookieOptions: vi.fn((_req: unknown, maxAge: number) => ({
|
||||
httpOnly: true,
|
||||
maxAge,
|
||||
})),
|
||||
getClearCookieOptions: vi.fn(() => ({ httpOnly: true })),
|
||||
getUserDataKey: vi.fn(() => Buffer.alloc(32, 7)),
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeRequest(overrides: Record<string, unknown> = {}) {
|
||||
const headers: Record<string, string> = {
|
||||
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0",
|
||||
"x-termix-device-id": "a".repeat(64),
|
||||
...(overrides.headers as Record<string, string> | undefined),
|
||||
};
|
||||
return {
|
||||
body: {},
|
||||
query: {},
|
||||
params: {},
|
||||
cookies: {},
|
||||
ip: "10.0.0.1",
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
...overrides,
|
||||
headers,
|
||||
get: (name: string) => headers[name.toLowerCase()],
|
||||
} as Record<string, unknown> & { headers: Record<string, string> };
|
||||
}
|
||||
|
||||
export function fakeResponse() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
cookies: [] as Array<{ name: string; value: string; options: unknown }>,
|
||||
cleared: [] as string[],
|
||||
redirectedTo: null as string | null,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.body = body;
|
||||
return res;
|
||||
},
|
||||
cookie(name: string, value: string, options: unknown) {
|
||||
res.cookies.push({ name, value, options });
|
||||
return res;
|
||||
},
|
||||
clearCookie(name: string) {
|
||||
res.cleared.push(name);
|
||||
return res;
|
||||
},
|
||||
redirect(url: string) {
|
||||
res.redirectedTo = url;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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
|
||||
>,
|
||||
providerConfig: null as unknown,
|
||||
providerType: "oidc",
|
||||
idTokenClaims: null as unknown,
|
||||
ldap: {
|
||||
users: {} as Record<
|
||||
string,
|
||||
{ dn: string; password: string; attrs: Record<string, string> }
|
||||
>,
|
||||
adminDns: [] as string[],
|
||||
},
|
||||
ssoProvider: { id: 4, type: "ldap", enabled: true },
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", async () => {
|
||||
const helpers = await import("./auth-test-helpers.js");
|
||||
const names = [
|
||||
...Object.keys(helpers.fakeFactory(helpers.createAuthState())),
|
||||
];
|
||||
const mocked = Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
(...args: unknown[]) =>
|
||||
(
|
||||
helpers.fakeFactory(h.state) as Record<
|
||||
string,
|
||||
(...a: unknown[]) => unknown
|
||||
>
|
||||
)[name](...args),
|
||||
]),
|
||||
);
|
||||
return {
|
||||
...mocked,
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
findById: async () => h.ssoProvider,
|
||||
listEnabledPublic: async () => [],
|
||||
}),
|
||||
};
|
||||
});
|
||||
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/automation-events.js", () => ({
|
||||
notifyAutomationInternalEvent: 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 };
|
||||
});
|
||||
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/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
|
||||
SharedCredentialSecretsManager: {
|
||||
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/routes/user-oidc-utils.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("../../database/routes/user-oidc-utils.js")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
loadProviderConfig: async () =>
|
||||
h.providerConfig
|
||||
? {
|
||||
config: h.providerConfig,
|
||||
providerType: h.providerType,
|
||||
providerDbId: 3,
|
||||
}
|
||||
: null,
|
||||
verifyOIDCToken: async () => h.idTokenClaims,
|
||||
};
|
||||
});
|
||||
vi.mock("ldapjs", () => {
|
||||
function createClient() {
|
||||
let bound: string | null = null;
|
||||
return {
|
||||
bind: (dn: string, password: string, cb: (err?: Error) => void) => {
|
||||
if (dn === "cn=service") {
|
||||
bound = dn;
|
||||
return cb();
|
||||
}
|
||||
const user = Object.values(h.ldap.users).find((u) => u.dn === dn);
|
||||
if (user && user.password === password) {
|
||||
bound = dn;
|
||||
return cb();
|
||||
}
|
||||
cb(new Error("invalid credentials"));
|
||||
},
|
||||
search: (
|
||||
base: string,
|
||||
options: { filter: string },
|
||||
cb: (err: Error | null, res: unknown) => void,
|
||||
) => {
|
||||
const listeners: Record<string, (value?: unknown) => void> = {};
|
||||
const res = {
|
||||
on: (event: string, fn: (value?: unknown) => void) => {
|
||||
listeners[event] = fn;
|
||||
},
|
||||
};
|
||||
cb(null, res);
|
||||
queueMicrotask(() => {
|
||||
if (base === "ou=groups") {
|
||||
for (const dn of h.ldap.adminDns) {
|
||||
if (options.filter.includes(dn)) {
|
||||
listeners.searchEntry?.({
|
||||
dn: { toString: () => "cn=admins,ou=groups" },
|
||||
attributes: [{ type: "cn", values: ["admins"] }],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const match = /uid=([^)]+)/.exec(options.filter);
|
||||
const user = match ? h.ldap.users[match[1]] : undefined;
|
||||
if (user) {
|
||||
listeners.searchEntry?.({
|
||||
dn: { toString: () => user.dn },
|
||||
attributes: Object.entries(user.attrs).map(([type, value]) => ({
|
||||
type,
|
||||
values: [value],
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
listeners.end?.();
|
||||
});
|
||||
void bound;
|
||||
},
|
||||
unbind: () => {},
|
||||
};
|
||||
}
|
||||
return { default: { createClient }, createClient };
|
||||
});
|
||||
|
||||
const { handleOidcCallback } = await import("../../auth/legacy/oidc-login.js");
|
||||
const { verifyLdapLogin } = await import("../../auth/legacy/ldap-login.js");
|
||||
const { respondWithLogin, respondWithRedirectLogin, runLogin } =
|
||||
await import("../../auth/login-pipeline.js");
|
||||
|
||||
beforeEach(() => {
|
||||
h.state = createAuthState();
|
||||
h.manager = fakeAuthManager(h.state);
|
||||
h.providerType = "oidc";
|
||||
h.providerConfig = {
|
||||
client_id: "client",
|
||||
client_secret: "secret",
|
||||
issuer_url: "https://idp.example",
|
||||
authorization_url: "https://idp.example/auth",
|
||||
token_url: "https://idp.example/token",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid",
|
||||
admin_group: "termix-admins",
|
||||
group_claim: "groups",
|
||||
};
|
||||
h.idTokenClaims = {
|
||||
sub: "sub-1",
|
||||
sid: "sid-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
nonce: "nonce-1",
|
||||
groups: ["termix-admins"],
|
||||
};
|
||||
for (const [key, value] of Object.entries({
|
||||
oidc_backend_callback_state1: "https://termix.example/users/oidc/callback",
|
||||
oidc_frontend_origin_state1: "https://termix.example",
|
||||
oidc_remember_me_state1: "false",
|
||||
oidc_state_state1: "nonce-1",
|
||||
oidc_provider_state1: "3",
|
||||
})) {
|
||||
h.state.settings.set(key, value);
|
||||
}
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (url === "https://idp.example/token") {
|
||||
return new Response(
|
||||
JSON.stringify({ id_token: "jwt", access_token: "at" }),
|
||||
);
|
||||
}
|
||||
return new Response("{}", { status: 404 });
|
||||
}),
|
||||
);
|
||||
h.ldap.users = {
|
||||
bob: {
|
||||
dn: "uid=bob,ou=people",
|
||||
password: "hunter2",
|
||||
attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" },
|
||||
},
|
||||
};
|
||||
h.ldap.adminDns = [];
|
||||
});
|
||||
|
||||
describe("OIDC through the pipeline", () => {
|
||||
it("turns the callback into an identity with the old identifier and claims", async () => {
|
||||
const identity = await handleOidcCallback(
|
||||
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
|
||||
);
|
||||
expect(identity).toMatchObject({
|
||||
kind: "external",
|
||||
provider: "3",
|
||||
subject: "sub-1",
|
||||
legacyIdentifier: "sub-1",
|
||||
name: "Alice",
|
||||
isAdmin: true,
|
||||
oidcSub: "sub-1",
|
||||
oidcSid: "sid-1",
|
||||
ssoProviderId: 3,
|
||||
returnTo: "https://termix.example",
|
||||
});
|
||||
// The one-time state is consumed.
|
||||
expect(h.state.settings.has("oidc_state_state1")).toBe(false);
|
||||
});
|
||||
|
||||
it("signs in and redirects with a cookie, like the old callback", async () => {
|
||||
const req = fakeRequest({ query: { code: "c", state: "state1" } });
|
||||
const identity = await handleOidcCallback(req as never);
|
||||
const res = fakeResponse();
|
||||
await respondWithRedirectLogin(
|
||||
req as never,
|
||||
res as never,
|
||||
identity,
|
||||
{ methodId: "oidc", rememberMe: false },
|
||||
() => false,
|
||||
);
|
||||
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
|
||||
expect(res.cleared).toContain("jwt");
|
||||
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
|
||||
const [user] = [...h.state.users.values()];
|
||||
expect(user).toMatchObject({ oidcIdentifier: "sub-1", isAdmin: true });
|
||||
expect(h.state.audits).toContainEqual(
|
||||
expect.objectContaining({ action: "login", userId: user.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a nonce mismatch without a session", async () => {
|
||||
h.idTokenClaims = { ...(h.idTokenClaims as object), nonce: "other" };
|
||||
await expect(
|
||||
handleOidcCallback(
|
||||
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it("namespaces GitHub users the way they were stored", async () => {
|
||||
h.providerType = "github";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (url === "https://idp.example/token") {
|
||||
return new Response(JSON.stringify({ access_token: "at" }));
|
||||
}
|
||||
if (url === "https://api.github.com/user") {
|
||||
return new Response(JSON.stringify({ id: 42, login: "octo" }));
|
||||
}
|
||||
return new Response("[]");
|
||||
}),
|
||||
);
|
||||
const identity = await handleOidcCallback(
|
||||
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
|
||||
);
|
||||
expect(identity).toMatchObject({
|
||||
provider: "3",
|
||||
subject: "42",
|
||||
legacyIdentifier: "github:3:42",
|
||||
name: "octo",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LDAP through the pipeline", () => {
|
||||
const providerConfig = {
|
||||
host: "ldap.example",
|
||||
bindDN: "cn=service",
|
||||
bindPassword: "svc",
|
||||
userSearchBase: "ou=people",
|
||||
userSearchFilter: "(uid={{username}})",
|
||||
groupSearchBase: "ou=groups",
|
||||
adminGroup: "admins",
|
||||
};
|
||||
|
||||
it("binds, finds the user and signs in with the old identifier", async () => {
|
||||
h.providerConfig = providerConfig;
|
||||
h.ldap.adminDns = ["uid=bob,ou=people"];
|
||||
const req = fakeRequest({
|
||||
body: { providerId: 4, username: "bob", password: "hunter2" },
|
||||
headers: { "x-electron-app": "true" },
|
||||
});
|
||||
const identity = await verifyLdapLogin({
|
||||
body: req.body as never,
|
||||
ip: "10.0.0.1",
|
||||
});
|
||||
expect(identity).toMatchObject({
|
||||
provider: "4",
|
||||
subject: "bob",
|
||||
legacyIdentifier: "ldap:4:bob",
|
||||
isAdmin: true,
|
||||
name: "Bob Builder",
|
||||
});
|
||||
|
||||
const res = fakeResponse();
|
||||
await respondWithLogin(req as never, res as never, identity, {
|
||||
methodId: "ldap",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
|
||||
// Native clients now get the token for LDAP too.
|
||||
expect(res.body).toMatchObject({
|
||||
success: true,
|
||||
token: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a wrong password", async () => {
|
||||
h.providerConfig = providerConfig;
|
||||
await expect(
|
||||
verifyLdapLogin({
|
||||
body: { providerId: 4, username: "bob", password: "wrong" },
|
||||
ip: "10.0.0.2",
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it("runs TOTP for an LDAP user who enrolled", async () => {
|
||||
h.providerConfig = providerConfig;
|
||||
h.state.users.set("u-bob", {
|
||||
id: "u-bob",
|
||||
username: "Bob Builder",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
totpEnabled: true,
|
||||
totpSecret: "JBSWY3DPEHPK3PXP",
|
||||
});
|
||||
const identity = await verifyLdapLogin({
|
||||
body: { providerId: 4, username: "bob", password: "hunter2" },
|
||||
ip: "10.0.0.3",
|
||||
});
|
||||
const result = await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "ldap",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("second-factor");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,729 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import speakeasy from "speakeasy";
|
||||
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/automation-events.js", () => ({
|
||||
notifyAutomationInternalEvent: 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,
|
||||
} = 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 PASSWORD = "correct-horse";
|
||||
const totpSecret = speakeasy.generateSecret({ name: "test" }).base32;
|
||||
|
||||
function addUser(
|
||||
overrides: Partial<import("./auth-test-helpers.js").FakeUser> = {},
|
||||
) {
|
||||
const user = {
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
passwordHash: bcrypt.hashSync(PASSWORD, 4),
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
totpEnabled: 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;
|
||||
});
|
||||
|
||||
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({
|
||||
totpEnabled: true,
|
||||
totpSecret,
|
||||
totpBackupCodes: JSON.stringify(["BACKUP01"]),
|
||||
});
|
||||
h.state.factors.push({ userId: "u1", pluginId: "core", 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: "core" }),
|
||||
],
|
||||
});
|
||||
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: speakeasy.totp({ secret: totpSecret, encoding: "base32" }),
|
||||
rememberMe: true,
|
||||
},
|
||||
});
|
||||
const res = fakeResponse();
|
||||
await verifySecondFactorAndRespond(req as never, res as never, "totp");
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
|
||||
expect(res.body).toMatchObject({
|
||||
success: true,
|
||||
username: "alice",
|
||||
totp_enabled: true,
|
||||
});
|
||||
expect(h.state.trustedAdded).toHaveLength(1);
|
||||
expect(h.state.audits.at(-1)).toMatchObject({ action: "login" });
|
||||
});
|
||||
|
||||
it("accepts a backup code once, stored encrypted", async () => {
|
||||
enrolTotp();
|
||||
const first = await passwordLogin({
|
||||
username: "alice",
|
||||
password: PASSWORD,
|
||||
});
|
||||
const tempToken = (first.body as { temp_token: string }).temp_token;
|
||||
const res = fakeResponse();
|
||||
await verifySecondFactorAndRespond(
|
||||
fakeRequest({
|
||||
body: { temp_token: tempToken, totp_code: "BACKUP01" },
|
||||
}) as never,
|
||||
res as never,
|
||||
"totp",
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
// Re-encrypted after use, and the code is gone.
|
||||
expect(h.state.users.get("u1")!.totpBackupCodes).not.toContain("BACKUP01");
|
||||
});
|
||||
|
||||
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 on a trusted device", async () => {
|
||||
enrolTotp();
|
||||
h.manager.isTrustedDevice.mockResolvedValueOnce(true);
|
||||
const res = await passwordLogin({ username: "alice", password: PASSWORD });
|
||||
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
|
||||
});
|
||||
|
||||
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",
|
||||
ssoProviderId: 3,
|
||||
};
|
||||
|
||||
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,
|
||||
isOidc: 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("oidc_auto_provision", "true");
|
||||
await expect(
|
||||
runLogin(
|
||||
fakeRequest() as never,
|
||||
{ ...external, allowedUsers: "@other.org" },
|
||||
{ methodId: "oidc", rememberMe: false },
|
||||
),
|
||||
).rejects.toMatchObject({ code: "user_not_allowed" });
|
||||
});
|
||||
|
||||
it("re-checks the allowed list for existing users", async () => {
|
||||
addUser({ id: "u9", username: "Alice", isOidc: true, 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("finds an account from before the identity table by its old identifier", async () => {
|
||||
addUser({
|
||||
id: "old",
|
||||
username: "ldap-bob",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
});
|
||||
const result = await runLogin(
|
||||
fakeRequest() as never,
|
||||
{
|
||||
kind: "external",
|
||||
provider: "4",
|
||||
subject: "bob",
|
||||
name: "Bob",
|
||||
legacyIdentifier: "ldap:4:bob",
|
||||
ssoProviderId: 4,
|
||||
},
|
||||
{ methodId: "ldap", rememberMe: false },
|
||||
);
|
||||
expect(result.kind).toBe("session");
|
||||
expect(h.state.users.size).toBe(1);
|
||||
expect(h.state.identities).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: "old",
|
||||
providerId: "4",
|
||||
subject: "bob",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps admin in step with the provider's admin group", async () => {
|
||||
addUser({
|
||||
id: "u9",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
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("runs enrolled second factors for SSO users too", async () => {
|
||||
addUser({
|
||||
id: "u9",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
totpEnabled: true,
|
||||
totpSecret,
|
||||
});
|
||||
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",
|
||||
ssoProviderId: 3,
|
||||
oidcSub: "sub-1",
|
||||
oidcSid: "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({
|
||||
oidcSub: "sub-1",
|
||||
oidcSid: "sid-1",
|
||||
ssoProviderId: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
addUser({
|
||||
id: "u9",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
totpEnabled: true,
|
||||
totpSecret,
|
||||
});
|
||||
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: speakeasy.totp({ secret: totpSecret, encoding: "base32" }),
|
||||
},
|
||||
}) as never,
|
||||
verify as never,
|
||||
"totp",
|
||||
);
|
||||
expect(verify.statusCode).toBe(200);
|
||||
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
|
||||
"u9",
|
||||
expect.objectContaining({ oidcSid: "sid-1", ssoProviderId: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user