mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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,116 @@
|
||||
/**
|
||||
* Password login, core's one built-in method. It only proves who the user is;
|
||||
* the pipeline does second factors and the session.
|
||||
*/
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js";
|
||||
import { createCurrentUserRepository } from "../database/repositories/factory.js";
|
||||
import { getPasswordLoginStatus } from "./core-auth.js";
|
||||
import { registerLoginMethod, type LoginMethod } from "./registry.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
|
||||
|
||||
function nonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export async function verifyPasswordLogin(request: {
|
||||
body: Record<string, unknown>;
|
||||
ip?: string;
|
||||
socket?: { remoteAddress?: string };
|
||||
}): Promise<VerifiedIdentity & { rateLimitUsername: string }> {
|
||||
if (isTrustedProxyAuthEnabled()) {
|
||||
throw new LoginMethodError(
|
||||
"Password login is disabled while trusted proxy authentication is enabled",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const { username, password } = request.body;
|
||||
const clientIp = request.ip || request.socket?.remoteAddress || "unknown";
|
||||
|
||||
if (!nonEmpty(username) || !nonEmpty(password)) {
|
||||
throw new LoginMethodError("Invalid username or password", 400);
|
||||
}
|
||||
|
||||
const lockStatus = loginRateLimiter.isLocked(clientIp, username);
|
||||
if (lockStatus.locked) {
|
||||
authLogger.warn("Login attempt blocked due to rate limiting", {
|
||||
operation: "user_login_blocked",
|
||||
username,
|
||||
ip: clientIp,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
});
|
||||
const error = new LoginMethodError(
|
||||
"Too many login attempts. Please try again later.",
|
||||
429,
|
||||
);
|
||||
Object.assign(error, { remainingTime: lockStatus.remainingTime });
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!(await getPasswordLoginStatus()).allowed) {
|
||||
throw new LoginMethodError(
|
||||
"Password authentication is currently disabled",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await createCurrentUserRepository().findByUsername(username);
|
||||
if (!user) {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, username);
|
||||
authLogger.warn("Login failed: user not found", {
|
||||
operation: "user_login",
|
||||
username,
|
||||
ip: clientIp,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
|
||||
if (user.isOidc && (!user.passwordHash || user.passwordHash.trim() === "")) {
|
||||
authLogger.warn("OIDC-only user attempted traditional login", {
|
||||
operation: "user_login",
|
||||
username,
|
||||
userId: user.id,
|
||||
});
|
||||
throw new LoginMethodError("This user uses external authentication", 403);
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isMatch) {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, username);
|
||||
authLogger.warn("Login failed: incorrect password", {
|
||||
operation: "user_login",
|
||||
username,
|
||||
userId: user.id,
|
||||
ip: clientIp,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "user",
|
||||
userId: user.id,
|
||||
password,
|
||||
rememberMe: !!request.body.rememberMe,
|
||||
rateLimitUsername: username,
|
||||
};
|
||||
}
|
||||
|
||||
export const passwordLoginMethod: LoginMethod = {
|
||||
id: "password",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.password",
|
||||
kind: "form",
|
||||
describe: async () => {
|
||||
const status = await getPasswordLoginStatus();
|
||||
return [{ id: "password", label: "Password", enabled: status.allowed }];
|
||||
},
|
||||
verify: (request) => verifyPasswordLogin(request as never),
|
||||
};
|
||||
|
||||
export function registerPasswordLoginMethod(): () => void {
|
||||
return registerLoginMethod(passwordLoginMethod);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Core's own login method (password) and the lockout guard.
|
||||
*/
|
||||
|
||||
import { getCurrentSettingValue } from "../database/repositories/factory.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js";
|
||||
import { registerPasswordLoginMethod } from "./builtin-login-methods.js";
|
||||
import { registerLegacyLoginProviders } from "./legacy-providers.js";
|
||||
import { listLoginMethods } from "./registry.js";
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Registers core's login methods and factors once. */
|
||||
export function ensureCoreLoginProviders(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerPasswordLoginMethod();
|
||||
registerLegacyLoginProviders();
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetCoreLoginProvidersForTests(): void {
|
||||
registered = false;
|
||||
}
|
||||
|
||||
/** The admin setting (or ALLOW_PASSWORD_LOGIN), before the lockout guard. */
|
||||
export function isPasswordLoginSettingOn(): boolean {
|
||||
const envVal = process.env.ALLOW_PASSWORD_LOGIN;
|
||||
if (envVal !== undefined) return envVal.trim().toLowerCase() === "true";
|
||||
try {
|
||||
const value = getCurrentSettingValue("allow_password_login");
|
||||
return value ? value === "true" : true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether anyone could still sign in without a password: trusted proxy auth,
|
||||
* or a login method reporting an enabled instance (an SSO provider).
|
||||
*/
|
||||
export async function hasOtherEnabledLoginMethod(): Promise<boolean> {
|
||||
if (isTrustedProxyAuthEnabled()) return true;
|
||||
ensureCoreLoginProviders();
|
||||
for (const method of listLoginMethods()) {
|
||||
if (method.id === "password" || !method.describe) continue;
|
||||
try {
|
||||
const instances = await method.describe();
|
||||
if (instances.some((instance) => instance.enabled)) return true;
|
||||
} catch {
|
||||
// A method that cannot describe itself does not count.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface PasswordLoginStatus {
|
||||
allowed: boolean;
|
||||
/** Turned off by the admin but kept on so nobody is locked out. */
|
||||
forced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password login is off only when the admin turned it off AND another way to
|
||||
* sign in exists. Otherwise it stays on, with a warning, so that disabling it
|
||||
* (or losing the only SSO provider) can never lock everyone out.
|
||||
*/
|
||||
export async function getPasswordLoginStatus(): Promise<PasswordLoginStatus> {
|
||||
if (isPasswordLoginSettingOn()) return { allowed: true, forced: false };
|
||||
if (await hasOtherEnabledLoginMethod()) {
|
||||
return { allowed: false, forced: false };
|
||||
}
|
||||
authLogger.warn(
|
||||
"Password login is disabled but no other login method is enabled; keeping it on",
|
||||
{ operation: "password_login_lockout_guard" },
|
||||
);
|
||||
return { allowed: true, forced: true };
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Auth methods that still live in core for 2.9.0, registered through the same
|
||||
* interfaces a plugin uses. Phase C moves each one into its own plugin by
|
||||
* relocating its block from here; D1 deletes this file once it is empty.
|
||||
*
|
||||
* Nothing outside this file should branch on these type names.
|
||||
*/
|
||||
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import {
|
||||
registerKeyboardInteractiveInterceptor,
|
||||
registerSshAuthProvider,
|
||||
} from "../hosts/connect/auth-provider-registry.js";
|
||||
import type {
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
} from "../hosts/connect/types.js";
|
||||
import {
|
||||
registerLoginMethod,
|
||||
registerSecondFactor,
|
||||
type LoginMethod,
|
||||
} from "./registry.js";
|
||||
import { totpSecondFactor } from "./legacy/totp-factor.js";
|
||||
|
||||
const OPKSSH_REQUIRED_MESSAGE =
|
||||
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.";
|
||||
|
||||
const VAULT_REQUIRED_MESSAGE =
|
||||
"Vault SSH signer authentication required. Please open a Terminal connection first.";
|
||||
|
||||
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
|
||||
|
||||
/** How long a Tailscale check-mode login may sit waiting for approval. */
|
||||
export const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000;
|
||||
|
||||
function issuedCertificateProvider(
|
||||
type: "opkssh" | "stepca",
|
||||
labelKey: string,
|
||||
): SshAuthProvider {
|
||||
return {
|
||||
type,
|
||||
pluginId: "core",
|
||||
labelKey,
|
||||
needsUserInteraction: true,
|
||||
supportsBackground: false,
|
||||
interaction: "opkssh",
|
||||
prepare: async (config, host, env) => {
|
||||
const { getOPKSSHToken } = await import("../hosts/opkssh-auth.js");
|
||||
const token = await getOPKSSHToken(env.userId, env.hostId);
|
||||
if (!token) {
|
||||
env.log("info", "No valid certificate found, requesting sign-in");
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "opkssh",
|
||||
message: OPKSSH_REQUIRED_MESSAGE,
|
||||
flag: "requiresOPKSSHAuth",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { setupOPKSSHCertAuth } =
|
||||
await import("../hosts/opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(config, env.client, token, host.username);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message: "OPKSSH authentication failed: " + getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
env.log("info", "Using cached SSH certificate");
|
||||
return { status: "ready" };
|
||||
},
|
||||
onAuthFailed: (_host, env, context) => {
|
||||
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
|
||||
sshLogger.warn("OPKSSH authentication failed - invalidating token", {
|
||||
operation: "opkssh_auth_failed",
|
||||
hostId: env.hostId,
|
||||
userId: env.userId,
|
||||
});
|
||||
void import("../hosts/opkssh-auth.js")
|
||||
.then(({ invalidateOPKSSHToken }) =>
|
||||
invalidateOPKSSHToken(env.userId, env.hostId, "SSH auth failed"),
|
||||
)
|
||||
.catch(() => {});
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "opkssh",
|
||||
message:
|
||||
"OPKSSH authentication failed or expired. Please authenticate again.",
|
||||
};
|
||||
},
|
||||
startInteraction: async (request) => {
|
||||
if (type === "stepca") {
|
||||
const { startStepCaAuth } = await import("../hosts/step-ca-auth.js");
|
||||
await startStepCaAuth(
|
||||
request.userId,
|
||||
request.hostId,
|
||||
request.host.username,
|
||||
request.socket,
|
||||
request.requestOrigin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { startOPKSSHAuth } = await import("../hosts/opkssh-auth.js");
|
||||
await startOPKSSHAuth(
|
||||
request.userId,
|
||||
request.hostId,
|
||||
request.host.name || request.host.ip,
|
||||
request.socket,
|
||||
request.requestOrigin,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function vaultProfileId(host: SshConnectHost): number | undefined {
|
||||
return host.vaultProfile?.id ?? undefined;
|
||||
}
|
||||
|
||||
const vaultProvider: SshAuthProvider = {
|
||||
type: "vault",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthVault",
|
||||
needsUserInteraction: true,
|
||||
interaction: "vault",
|
||||
prepare: async (config, host, env) => {
|
||||
const profileId = vaultProfileId(host);
|
||||
if (!profileId) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message: "Host has no Vault signer profile configured",
|
||||
};
|
||||
}
|
||||
const { getVaultCert } = await import("../hosts/vault-signer-auth.js");
|
||||
const cert = await getVaultCert(env.userId, profileId);
|
||||
if (!cert) {
|
||||
env.log(
|
||||
"info",
|
||||
"No valid Vault certificate found, requesting authentication",
|
||||
);
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "vault",
|
||||
message: VAULT_REQUIRED_MESSAGE,
|
||||
flag: "requiresVaultAuth",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { setupOPKSSHCertAuth } =
|
||||
await import("../hosts/opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(
|
||||
config,
|
||||
env.client,
|
||||
{ privateKey: cert.privateKey, sshCert: cert.sshCert },
|
||||
host.username,
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message:
|
||||
"Vault SSH signer authentication failed: " + getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
env.log("info", "Using cached Vault-signed certificate");
|
||||
return { status: "ready" };
|
||||
},
|
||||
onAuthFailed: (host, env, context) => {
|
||||
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
|
||||
sshLogger.warn("Vault certificate authentication failed", {
|
||||
operation: "vault_auth_failed",
|
||||
hostId: env.hostId,
|
||||
userId: env.userId,
|
||||
});
|
||||
const profileId = vaultProfileId(host);
|
||||
if (profileId) {
|
||||
void import("../hosts/vault-signer-auth.js")
|
||||
.then(({ deleteVaultCert }) => deleteVaultCert(env.userId, profileId))
|
||||
.catch(() => {});
|
||||
}
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "vault",
|
||||
message:
|
||||
"Vault authentication failed or expired. Please authenticate again.",
|
||||
};
|
||||
},
|
||||
startInteraction: async (request) => {
|
||||
const { loadVaultProfileForHost, startVaultAuth } =
|
||||
await import("../hosts/vault-oidc-auth.js");
|
||||
const profile = await loadVaultProfileForHost(
|
||||
request.hostId,
|
||||
request.userId,
|
||||
);
|
||||
if (!profile) {
|
||||
throw new Error("No Vault signer profile configured for this host");
|
||||
}
|
||||
await startVaultAuth(
|
||||
request.userId,
|
||||
request.hostId,
|
||||
profile,
|
||||
request.socket,
|
||||
request.requestOrigin,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const tailscaleProvider: SshAuthProvider = {
|
||||
type: "tailscale",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthTailscale",
|
||||
connectOptions: (_host, purpose) =>
|
||||
purpose === "terminal"
|
||||
? {
|
||||
tryKeyboard: false,
|
||||
readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS,
|
||||
// The socket sits idle while a check-mode login is pending.
|
||||
timeout: TAILSCALE_CHECK_TIMEOUT_MS,
|
||||
}
|
||||
: { tryKeyboard: false },
|
||||
prepare: async (_config, _host, env) => {
|
||||
env.log("info", "Using Tailscale SSH");
|
||||
return { status: "ready" };
|
||||
},
|
||||
onAuthFailed: (host, _env, context) => {
|
||||
const failed =
|
||||
context.methodNotAvailable ||
|
||||
AUTH_FAILED_PATTERN.test(context.error.message);
|
||||
if (!failed) return;
|
||||
|
||||
// Tailscale documents the "+password" suffix for clients that mishandle a
|
||||
// successful "none" reply. The password value is ignored.
|
||||
if (context.retries === 0 && context.canRetry) {
|
||||
return {
|
||||
status: "retry",
|
||||
message: "Retrying Tailscale SSH in forced password mode",
|
||||
patch: {
|
||||
username: `${host.username}+password`,
|
||||
password: "termix",
|
||||
tryKeyboard: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message: `Tailscale SSH authentication failed for user "${host.username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${host.username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const WARPGATE_PATTERN = /warpgate\s+authentication/i;
|
||||
|
||||
let sshRegistered = false;
|
||||
|
||||
export function registerLegacySshAuthProviders(): void {
|
||||
if (sshRegistered) return;
|
||||
sshRegistered = true;
|
||||
|
||||
registerSshAuthProvider(
|
||||
issuedCertificateProvider("opkssh", "hosts.filterAuthOpkssh"),
|
||||
);
|
||||
registerSshAuthProvider(
|
||||
issuedCertificateProvider("stepca", "hosts.filterAuthStepca"),
|
||||
);
|
||||
registerSshAuthProvider(vaultProvider);
|
||||
registerSshAuthProvider(tailscaleProvider);
|
||||
|
||||
// Warpgate is a flag on a host, not an auth type: it shows up as a
|
||||
// keyboard-interactive round with a sign-in URL.
|
||||
registerKeyboardInteractiveInterceptor({
|
||||
id: "warpgate",
|
||||
pluginId: "core",
|
||||
detect: ({ name, instructions, prompts }) => {
|
||||
const texts = prompts.map((p) => p.prompt);
|
||||
const isWarpgate =
|
||||
WARPGATE_PATTERN.test(name) ||
|
||||
WARPGATE_PATTERN.test(instructions) ||
|
||||
texts.some((p) => WARPGATE_PATTERN.test(p));
|
||||
if (!isWarpgate) return null;
|
||||
|
||||
const fullText = `${name}\n${instructions}\n${texts.join("\n")}`;
|
||||
const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i);
|
||||
if (!urlMatch) return null;
|
||||
const keyMatch = fullText.match(
|
||||
/security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i,
|
||||
);
|
||||
return {
|
||||
kind: "warpgate",
|
||||
url: urlMatch[0],
|
||||
securityKey: keyMatch ? keyMatch[1] : "N/A",
|
||||
instructions,
|
||||
};
|
||||
},
|
||||
// Warpgate asks for the password before its browser round; answer it
|
||||
// silently so the only thing the user sees is the Warpgate dialog.
|
||||
autoAnswerPasswords: (host) => host.useWarpgate === true,
|
||||
});
|
||||
}
|
||||
|
||||
// Login methods and second factors -------------------------------------------
|
||||
|
||||
async function listPublicProviders(types: string[]) {
|
||||
const { listPublicSsoProviders } =
|
||||
await import("../database/routes/sso-provider-routes.js");
|
||||
return (await listPublicSsoProviders()).filter((provider) =>
|
||||
types.includes(provider.type),
|
||||
);
|
||||
}
|
||||
|
||||
const oidcLoginMethod: LoginMethod = {
|
||||
id: "oidc",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.loginWithSso",
|
||||
icon: "key-round",
|
||||
kind: "redirect",
|
||||
describe: async () =>
|
||||
(await listPublicProviders(["oidc", "github", "google"])).map(
|
||||
(provider) => ({
|
||||
id: String(provider.id),
|
||||
label: provider.name,
|
||||
enabled: true,
|
||||
}),
|
||||
),
|
||||
start: async (request, instanceId) => {
|
||||
const { startOidcLogin } = await import("./legacy/oidc-login.js");
|
||||
// Instance "0" is the provider synthesized from environment variables.
|
||||
const result = await startOidcLogin(request as never, {
|
||||
providerId: instanceId && instanceId !== "0" ? instanceId : undefined,
|
||||
});
|
||||
return { redirectUrl: result.auth_url };
|
||||
},
|
||||
callback: async (request) => {
|
||||
const { handleOidcCallback } = await import("./legacy/oidc-login.js");
|
||||
return handleOidcCallback(request as never);
|
||||
},
|
||||
};
|
||||
|
||||
const ldapLoginMethod: LoginMethod = {
|
||||
id: "ldap",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.loginWithLdap",
|
||||
icon: "server",
|
||||
kind: "form",
|
||||
describe: async () =>
|
||||
(await listPublicProviders(["ldap"])).map((provider) => ({
|
||||
id: String(provider.id),
|
||||
label: provider.name,
|
||||
enabled: true,
|
||||
})),
|
||||
verify: async (request, instanceId) => {
|
||||
const { verifyLdapLogin } = await import("./legacy/ldap-login.js");
|
||||
const body = { ...request.body };
|
||||
if (instanceId && !body.providerId) body.providerId = Number(instanceId);
|
||||
return verifyLdapLogin({ body, ip: request.ip });
|
||||
},
|
||||
};
|
||||
|
||||
// Passkeys prove the user as well as possession, so a passkey login can
|
||||
// stand in for the second factor. No describe: a passkey only helps a user
|
||||
// who already registered one, so it never counts for the lockout guard.
|
||||
const passkeyLoginMethod: LoginMethod = {
|
||||
id: "passkey",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.loginWithPasskey",
|
||||
icon: "fingerprint",
|
||||
kind: "form",
|
||||
verify: async (request) => {
|
||||
const { verifyPasskeyLogin } =
|
||||
await import("../database/routes/user-webauthn-routes.js");
|
||||
return verifyPasskeyLogin(request as never);
|
||||
},
|
||||
};
|
||||
|
||||
let loginRegistered = false;
|
||||
|
||||
export function registerLegacyLoginProviders(): void {
|
||||
if (loginRegistered) return;
|
||||
loginRegistered = true;
|
||||
registerLoginMethod(oidcLoginMethod);
|
||||
registerLoginMethod(ldapLoginMethod);
|
||||
registerLoginMethod(passkeyLoginMethod);
|
||||
registerSecondFactor(totpSecondFactor);
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetLegacyProvidersForTests(): void {
|
||||
sshRegistered = false;
|
||||
loginRegistered = false;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* LDAP sign-in as a form login method. Moved out of the LDAP route so the
|
||||
* pipeline issues the session; Phase C moves it into the LDAP plugin.
|
||||
*/
|
||||
|
||||
import ldap from "ldapjs";
|
||||
import type { LDAPProviderConfig } from "../../../types/index.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
import { createCurrentSsoProviderRepository } from "../../database/repositories/factory.js";
|
||||
import { loadProviderConfig } from "../../database/routes/user-oidc-utils.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../types.js";
|
||||
|
||||
export function ldapEscapeFilter(value: string): string {
|
||||
return value.replace(
|
||||
/[\\*()\x00]/g,
|
||||
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createLDAPClient(
|
||||
host: string,
|
||||
port: number,
|
||||
useTLS: boolean,
|
||||
): ldap.Client {
|
||||
const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`;
|
||||
return ldap.createClient({
|
||||
url,
|
||||
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function ldapBind(
|
||||
client: ldap.Client,
|
||||
dn: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.bind(dn, password, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapSearch(
|
||||
client: ldap.Client,
|
||||
base: string,
|
||||
filter: string,
|
||||
attributes: string[],
|
||||
): Promise<ldap.SearchEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: ldap.SearchEntry[] = [];
|
||||
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
|
||||
if (err) return reject(err);
|
||||
res.on("searchEntry", (entry) => entries.push(entry));
|
||||
res.on("error", reject);
|
||||
res.on("end", () => resolve(entries));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapUnbind(client: ldap.Client): void {
|
||||
try {
|
||||
client.unbind();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
function firstValue(entry: ldap.SearchEntry, key: string): string {
|
||||
const attr = entry.attributes.find((a) => a.type === key);
|
||||
if (!attr) return "";
|
||||
return Array.isArray(attr.values) ? attr.values[0] : String(attr.values);
|
||||
}
|
||||
|
||||
export async function verifyLdapLogin(request: {
|
||||
body: Record<string, unknown>;
|
||||
ip?: string;
|
||||
}): Promise<VerifiedIdentity> {
|
||||
const { providerId, username, password, rememberMe } = request.body as {
|
||||
providerId?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
rememberMe?: boolean;
|
||||
};
|
||||
|
||||
if (!providerId || !username || !password) {
|
||||
throw new LoginMethodError(
|
||||
"providerId, username, and password are required",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Same limits as password login: an LDAP bind is a password guess.
|
||||
const clientIp = request.ip || "unknown";
|
||||
const rateLimitKey = `ldap:${providerId}:${username}`;
|
||||
const lockStatus = loginRateLimiter.isLocked(clientIp, rateLimitKey);
|
||||
if (lockStatus.locked) {
|
||||
const error = new LoginMethodError(
|
||||
"Too many login attempts. Please try again later.",
|
||||
429,
|
||||
);
|
||||
Object.assign(error, { remainingTime: lockStatus.remainingTime });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const provider =
|
||||
await createCurrentSsoProviderRepository().findById(providerId);
|
||||
if (!provider || provider.type !== "ldap" || !provider.enabled) {
|
||||
throw new LoginMethodError("LDAP provider not found", 404);
|
||||
}
|
||||
|
||||
const providerResult = await loadProviderConfig(providerId);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("LDAP provider not found", 404);
|
||||
}
|
||||
const config = providerResult.config as unknown as LDAPProviderConfig;
|
||||
if (
|
||||
!config.host ||
|
||||
!config.bindDN ||
|
||||
!config.userSearchBase ||
|
||||
!config.userSearchFilter
|
||||
) {
|
||||
throw new LoginMethodError("LDAP provider is misconfigured", 500);
|
||||
}
|
||||
|
||||
const serviceClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(serviceClient, config.bindDN, config.bindPassword);
|
||||
|
||||
const filter = config.userSearchFilter.replace(
|
||||
/\{\{username\}\}/g,
|
||||
ldapEscapeFilter(username),
|
||||
);
|
||||
const entries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.userSearchBase,
|
||||
filter,
|
||||
[
|
||||
config.usernameAttribute || "uid",
|
||||
config.displayNameAttribute || "cn",
|
||||
"mail",
|
||||
"email",
|
||||
],
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey);
|
||||
authLogger.warn("LDAP user not found", {
|
||||
operation: "ldap_login",
|
||||
username,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
|
||||
const userEntry = entries[0];
|
||||
const userDN = userEntry.dn.toString();
|
||||
const ldapIdentifier =
|
||||
firstValue(userEntry, config.usernameAttribute || "uid") || username;
|
||||
const displayName =
|
||||
firstValue(userEntry, config.displayNameAttribute || "cn") || username;
|
||||
const email =
|
||||
firstValue(userEntry, "mail") || firstValue(userEntry, "email") || "";
|
||||
|
||||
const userClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(userClient, userDN, password);
|
||||
} catch {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey);
|
||||
authLogger.warn("LDAP bind failed - wrong password", {
|
||||
operation: "ldap_login",
|
||||
ldapIdentifier,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
} finally {
|
||||
ldapUnbind(userClient);
|
||||
}
|
||||
|
||||
let isAdmin: boolean | undefined;
|
||||
if (config.adminGroup && config.groupSearchBase) {
|
||||
isAdmin = false;
|
||||
try {
|
||||
const groupEntries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.groupSearchBase,
|
||||
`(member=${ldapEscapeFilter(userDN)})`,
|
||||
["cn", "dn"],
|
||||
);
|
||||
isAdmin = groupEntries.some(
|
||||
(group) =>
|
||||
firstValue(group, "cn") === config.adminGroup ||
|
||||
group.dn.toString() === config.adminGroup,
|
||||
);
|
||||
} catch (groupErr) {
|
||||
authLogger.warn("LDAP group check failed", {
|
||||
operation: "ldap_group_check",
|
||||
error: groupErr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerId),
|
||||
subject: ldapIdentifier,
|
||||
email: email || null,
|
||||
name: displayName,
|
||||
isAdmin,
|
||||
allowedUsers: config.allowedUsers ?? null,
|
||||
legacyIdentifier: `ldap:${providerId}:${ldapIdentifier}`,
|
||||
ssoProviderId: providerId,
|
||||
rememberMe: !!rememberMe,
|
||||
rateLimitUsername: rateLimitKey,
|
||||
};
|
||||
} finally {
|
||||
ldapUnbind(serviceClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* OIDC, GitHub and Google sign-in, moved out of the users routes so they can
|
||||
* register as a login method. Phase C moves this into the OIDC plugin.
|
||||
*
|
||||
* start builds the provider's authorize URL; callback turns the provider's
|
||||
* answer into a verified identity. Neither touches sessions.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import type { Request } from "express";
|
||||
import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js";
|
||||
import { getDesktopOidcCallbackUrl } from "../../utils/oidc-desktop-callback.js";
|
||||
import { createCurrentSettingsRepository } from "../../database/repositories/factory.js";
|
||||
import {
|
||||
OIDCTokenFormatError,
|
||||
buildFetchOptions,
|
||||
extractOidcGroupsFromSources,
|
||||
loadProviderConfig,
|
||||
parseOidcRoleMap,
|
||||
resolveOidcMappedRoles,
|
||||
verifyOIDCToken,
|
||||
} from "../../database/routes/user-oidc-utils.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../types.js";
|
||||
|
||||
/** An error raised after the return address is known redirects there. */
|
||||
export class RedirectLoginError extends LoginMethodError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly returnTo: string,
|
||||
status = 400,
|
||||
code?: string,
|
||||
) {
|
||||
super(message, status, code);
|
||||
this.name = "RedirectLoginError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider id used for OIDC identities that came from env configuration. */
|
||||
export const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
|
||||
|
||||
function nonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
// RFC 7636 PKCE: 43-128 char unreserved-character string.
|
||||
function generatePkceCodeVerifier(): string {
|
||||
return crypto.randomBytes(64).toString("base64url");
|
||||
}
|
||||
|
||||
function generatePkceCodeChallenge(verifier: string): string {
|
||||
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
export async function deleteOIDCStateSettings(state: string): Promise<void> {
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
await settingsRepository.delete(`oidc_state_${state}`);
|
||||
await settingsRepository.delete(`oidc_backend_callback_${state}`);
|
||||
await settingsRepository.delete(`oidc_frontend_origin_${state}`);
|
||||
await settingsRepository.delete(`oidc_remember_me_${state}`);
|
||||
await settingsRepository.delete(`oidc_provider_${state}`);
|
||||
await settingsRepository.delete(`oidc_pkce_verifier_${state}`);
|
||||
}
|
||||
|
||||
export async function startOidcLogin(
|
||||
req: Request,
|
||||
overrides: { providerId?: string | null } = {},
|
||||
): Promise<{ auth_url: string; state: string; nonce: string }> {
|
||||
const { rememberMe, desktopCallbackPort, appCallbackUrl } = req.query;
|
||||
const providerIdStr = overrides.providerId ?? req.query.providerId;
|
||||
const origin = getRequestOriginWithForceHTTPS(req);
|
||||
const basePath = (process.env.BASE_PATH || "").replace(/\/+$/, "");
|
||||
const backendCallbackUri = `${origin}${basePath}/users/oidc/callback`;
|
||||
|
||||
const resolvedProviderId = providerIdStr
|
||||
? parseInt(providerIdStr as string, 10)
|
||||
: null;
|
||||
const providerResult = await loadProviderConfig(
|
||||
resolvedProviderId || undefined,
|
||||
);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("OIDC not configured", 404);
|
||||
}
|
||||
const { config, providerDbId } = providerResult;
|
||||
const state = nanoid();
|
||||
const nonce = nanoid();
|
||||
|
||||
const referer = req.get("Referer");
|
||||
let frontendOrigin: string;
|
||||
if (desktopCallbackPort) {
|
||||
frontendOrigin = getDesktopOidcCallbackUrl(desktopCallbackPort);
|
||||
if (!frontendOrigin) {
|
||||
throw new LoginMethodError("Invalid desktop callback port", 400);
|
||||
}
|
||||
} else if (typeof appCallbackUrl === "string" && appCallbackUrl) {
|
||||
let callbackUrl: URL;
|
||||
try {
|
||||
callbackUrl = new URL(appCallbackUrl);
|
||||
} catch {
|
||||
throw new LoginMethodError("Invalid app callback URL", 400);
|
||||
}
|
||||
if (callbackUrl.protocol !== "termix-mobile:") {
|
||||
throw new LoginMethodError("Unsupported app callback URL", 400);
|
||||
}
|
||||
frontendOrigin = callbackUrl.toString();
|
||||
} else if (referer) {
|
||||
const refererUrl = new URL(referer);
|
||||
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
|
||||
} else {
|
||||
frontendOrigin = origin;
|
||||
}
|
||||
|
||||
const codeVerifier = generatePkceCodeVerifier();
|
||||
const codeChallenge = generatePkceCodeChallenge(codeVerifier);
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
await settingsRepository.set(`oidc_state_${state}`, nonce);
|
||||
await settingsRepository.set(
|
||||
`oidc_backend_callback_${state}`,
|
||||
backendCallbackUri,
|
||||
);
|
||||
await settingsRepository.set(`oidc_frontend_origin_${state}`, frontendOrigin);
|
||||
await settingsRepository.set(
|
||||
`oidc_remember_me_${state}`,
|
||||
rememberMe === "true" ? "true" : "false",
|
||||
);
|
||||
await settingsRepository.set(`oidc_pkce_verifier_${state}`, codeVerifier);
|
||||
if (providerDbId != null) {
|
||||
await settingsRepository.set(
|
||||
`oidc_provider_${state}`,
|
||||
String(providerDbId),
|
||||
);
|
||||
}
|
||||
|
||||
const authUrl = new URL(config.authorization_url);
|
||||
authUrl.searchParams.set("client_id", config.client_id);
|
||||
authUrl.searchParams.set("redirect_uri", backendCallbackUri);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("scope", config.scopes);
|
||||
authUrl.searchParams.set("state", state);
|
||||
authUrl.searchParams.set("nonce", nonce);
|
||||
authUrl.searchParams.set("code_challenge", codeChallenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
return { auth_url: authUrl.toString(), state, nonce };
|
||||
}
|
||||
|
||||
type ProviderConfig = NonNullable<
|
||||
Awaited<ReturnType<typeof loadProviderConfig>>
|
||||
>["config"];
|
||||
|
||||
async function githubIdentity(
|
||||
code: string,
|
||||
state: string,
|
||||
config: ProviderConfig,
|
||||
providerDbId: number | null,
|
||||
backendCallbackUri: string,
|
||||
frontendOrigin: string,
|
||||
rememberMe: boolean,
|
||||
): Promise<VerifiedIdentity> {
|
||||
const fetchOptions = buildFetchOptions(config.ca_cert);
|
||||
const tokenResponse = await fetch(config.token_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
code,
|
||||
redirect_uri: backendCallbackUri,
|
||||
}),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
authLogger.error("GitHub token exchange failed", {
|
||||
operation: "github_token_exchange_failed",
|
||||
status: tokenResponse.status,
|
||||
errorResponse: errorText,
|
||||
});
|
||||
throw new LoginMethodError("Failed to exchange authorization code", 400);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
|
||||
await deleteOIDCStateSettings(state);
|
||||
|
||||
const userInfoResponse = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Termix",
|
||||
},
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!userInfoResponse.ok) {
|
||||
throw new LoginMethodError("Failed to get GitHub user information", 400);
|
||||
}
|
||||
const userInfo = (await userInfoResponse.json()) as Record<string, unknown>;
|
||||
|
||||
const emailResponse = await fetch("https://api.github.com/user/emails", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Termix",
|
||||
},
|
||||
});
|
||||
if (emailResponse.ok) {
|
||||
const emails = (await emailResponse.json()) as Array<{
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
}>;
|
||||
const primary = emails.find((e) => e.primary && e.verified);
|
||||
if (primary) userInfo.email = primary.email;
|
||||
}
|
||||
|
||||
const subject = String(userInfo.id ?? userInfo.login);
|
||||
const legacyIdentifier = `github:${providerDbId}:${subject}`;
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID),
|
||||
subject,
|
||||
email: (userInfo.email as string | undefined) ?? null,
|
||||
name: ((userInfo.name || userInfo.login) as string) || legacyIdentifier,
|
||||
allowedUsers: config.allowed_users ?? null,
|
||||
legacyIdentifier,
|
||||
ssoProviderId: providerDbId,
|
||||
returnTo: frontendOrigin,
|
||||
rememberMe,
|
||||
};
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
if (!path || !obj) return null;
|
||||
return path
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(current, key) => (current as Record<string, unknown>)?.[key],
|
||||
obj,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's callback. Throws RedirectLoginError once the return address
|
||||
* is known, and a plain LoginMethodError (answered as JSON) before that.
|
||||
*/
|
||||
export async function handleOidcCallback(
|
||||
req: Request,
|
||||
): Promise<VerifiedIdentity> {
|
||||
const { code, state } = req.query;
|
||||
if (!nonEmpty(code) || !nonEmpty(state)) {
|
||||
throw new LoginMethodError("Code and state are required", 400);
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const backendCallbackUri = await settingsRepository.get(
|
||||
`oidc_backend_callback_${state}`,
|
||||
);
|
||||
const frontendOrigin = await settingsRepository.get(
|
||||
`oidc_frontend_origin_${state}`,
|
||||
);
|
||||
const rememberMe =
|
||||
(await settingsRepository.get(`oidc_remember_me_${state}`)) === "true";
|
||||
const codeVerifier = await settingsRepository.get(
|
||||
`oidc_pkce_verifier_${state}`,
|
||||
);
|
||||
|
||||
if (!backendCallbackUri || !frontendOrigin) {
|
||||
throw new LoginMethodError(
|
||||
"Invalid state parameter - redirect URIs not found",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const storedNonce = await settingsRepository.get(`oidc_state_${state}`);
|
||||
if (!storedNonce) {
|
||||
throw new LoginMethodError("Invalid state parameter", 400);
|
||||
}
|
||||
|
||||
const storedProviderId = await settingsRepository.get(
|
||||
`oidc_provider_${state}`,
|
||||
);
|
||||
const callbackProviderId = storedProviderId
|
||||
? parseInt(storedProviderId, 10)
|
||||
: null;
|
||||
const providerResult = await loadProviderConfig(
|
||||
callbackProviderId || undefined,
|
||||
);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("OIDC not configured", 500);
|
||||
}
|
||||
const { config, providerType, providerDbId } = providerResult;
|
||||
await settingsRepository.delete(`oidc_provider_${state}`);
|
||||
|
||||
// GitHub does not issue OIDC id_tokens; it has its own token exchange.
|
||||
if (providerType === "github") {
|
||||
return await githubIdentity(
|
||||
code,
|
||||
state,
|
||||
config,
|
||||
providerDbId ?? null,
|
||||
backendCallbackUri,
|
||||
frontendOrigin,
|
||||
rememberMe,
|
||||
);
|
||||
}
|
||||
|
||||
const caCert = config.ca_cert;
|
||||
const fetchOptions = buildFetchOptions(caCert);
|
||||
const tokenResponse = await fetch(config.token_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
code,
|
||||
redirect_uri: backendCallbackUri,
|
||||
...(codeVerifier ? { code_verifier: codeVerifier } : {}),
|
||||
}),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
authLogger.error("OIDC token exchange failed", {
|
||||
operation: "oidc_token_exchange_failed",
|
||||
status: tokenResponse.status,
|
||||
statusText: tokenResponse.statusText,
|
||||
backendCallbackUri,
|
||||
frontendOrigin,
|
||||
errorResponse: errorText,
|
||||
});
|
||||
throw new LoginMethodError("Failed to exchange authorization code", 400);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
|
||||
await deleteOIDCStateSettings(state);
|
||||
|
||||
let userInfo: Record<string, unknown> | null = null;
|
||||
const claimSources: Record<string, unknown>[] = [];
|
||||
const userInfoUrls: string[] = [];
|
||||
|
||||
const normalizedIssuerUrl = config.issuer_url.endsWith("/")
|
||||
? config.issuer_url.slice(0, -1)
|
||||
: config.issuer_url;
|
||||
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "");
|
||||
|
||||
try {
|
||||
const discoveryResponse = await fetch(
|
||||
`${normalizedIssuerUrl}/.well-known/openid-configuration`,
|
||||
fetchOptions,
|
||||
);
|
||||
if (discoveryResponse.ok) {
|
||||
const discovery = (await discoveryResponse.json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (discovery.userinfo_endpoint) {
|
||||
userInfoUrls.push(discovery.userinfo_endpoint as string);
|
||||
}
|
||||
}
|
||||
} catch (discoveryError) {
|
||||
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
|
||||
}
|
||||
|
||||
if (config.userinfo_url) userInfoUrls.unshift(config.userinfo_url);
|
||||
userInfoUrls.push(
|
||||
`${baseUrl}/userinfo/`,
|
||||
`${baseUrl}/userinfo`,
|
||||
`${normalizedIssuerUrl}/userinfo/`,
|
||||
`${normalizedIssuerUrl}/userinfo`,
|
||||
`${baseUrl}/oauth2/userinfo/`,
|
||||
`${baseUrl}/oauth2/userinfo`,
|
||||
`${normalizedIssuerUrl}/oauth2/userinfo/`,
|
||||
`${normalizedIssuerUrl}/oauth2/userinfo`,
|
||||
);
|
||||
|
||||
if (tokenData.id_token) {
|
||||
try {
|
||||
userInfo = await verifyOIDCToken(
|
||||
tokenData.id_token as string,
|
||||
config.issuer_url,
|
||||
config.client_id,
|
||||
caCert,
|
||||
);
|
||||
if (userInfo.nonce !== storedNonce) {
|
||||
authLogger.warn("OIDC ID token nonce mismatch", {
|
||||
operation: "oidc_nonce_mismatch",
|
||||
providerId: callbackProviderId,
|
||||
});
|
||||
throw new LoginMethodError("Invalid OIDC token nonce", 401);
|
||||
}
|
||||
claimSources.push(userInfo);
|
||||
} catch (error) {
|
||||
// A token that is not a JWS carries no claims we could trust; fall
|
||||
// through to userinfo. Signature and claim failures still reject.
|
||||
if (!(error instanceof OIDCTokenFormatError)) throw error;
|
||||
userInfo = null;
|
||||
authLogger.warn(
|
||||
"OIDC ID token cannot be verified, falling back to userinfo endpoint",
|
||||
{
|
||||
operation: "oidc_id_token_unverifiable",
|
||||
providerId: callbackProviderId,
|
||||
reason: error.message,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenData.access_token) {
|
||||
for (const userInfoUrl of userInfoUrls) {
|
||||
try {
|
||||
const response = await fetch(userInfoUrl, {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
...fetchOptions,
|
||||
});
|
||||
if (response.ok) {
|
||||
const fetched = (await response.json()) as Record<string, unknown>;
|
||||
claimSources.push(fetched);
|
||||
userInfo = { ...userInfo, ...fetched };
|
||||
break;
|
||||
}
|
||||
authLogger.error(
|
||||
`Userinfo endpoint ${userInfoUrl} failed with status: ${response.status}`,
|
||||
);
|
||||
} catch (error) {
|
||||
authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!userInfo) {
|
||||
authLogger.error("Failed to get user information from all sources");
|
||||
throw new LoginMethodError("Failed to get user information", 400);
|
||||
}
|
||||
|
||||
const identifier = (getNestedValue(userInfo, config.identifier_path) ||
|
||||
userInfo[config.identifier_path] ||
|
||||
userInfo.sub ||
|
||||
userInfo.email ||
|
||||
userInfo.preferred_username) as string;
|
||||
const name = (getNestedValue(userInfo, config.name_path) ||
|
||||
userInfo[config.name_path] ||
|
||||
userInfo.name ||
|
||||
userInfo.given_name ||
|
||||
identifier) as string;
|
||||
|
||||
if (!identifier) {
|
||||
throw new LoginMethodError(
|
||||
`User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(", ")}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
let isAdmin: boolean | undefined;
|
||||
if (config.admin_group) {
|
||||
const groups = extractOidcGroupsFromSources(
|
||||
claimSources,
|
||||
config.group_claim,
|
||||
);
|
||||
isAdmin = groups.includes(config.admin_group);
|
||||
}
|
||||
|
||||
let roleSync: { desired: string[]; managed: string[] } | undefined;
|
||||
try {
|
||||
const roleMap = parseOidcRoleMap(
|
||||
config.role_map ?? process.env.OIDC_ROLE_MAP,
|
||||
);
|
||||
if (roleMap.size > 0) {
|
||||
const groups = extractOidcGroupsFromSources(
|
||||
claimSources,
|
||||
config.group_claim,
|
||||
);
|
||||
const { desired, managed } = resolveOidcMappedRoles(groups, roleMap);
|
||||
roleSync = { desired: [...desired], managed: [...managed] };
|
||||
}
|
||||
} catch (roleMapError) {
|
||||
authLogger.error("Failed to read the OIDC role map", roleMapError, {
|
||||
operation: "oidc_role_map_sync_failed",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID),
|
||||
subject: identifier,
|
||||
email: (userInfo.email as string | undefined) ?? null,
|
||||
name,
|
||||
isAdmin,
|
||||
allowedUsers: config.allowed_users ?? null,
|
||||
legacyIdentifier: identifier,
|
||||
ssoProviderId: providerDbId ?? null,
|
||||
oidcSub: typeof userInfo.sub === "string" ? userInfo.sub : null,
|
||||
oidcSid: typeof userInfo.sid === "string" ? userInfo.sid : null,
|
||||
roleSync,
|
||||
returnTo: frontendOrigin,
|
||||
rememberMe,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof LoginMethodError) throw error;
|
||||
authLogger.error("OIDC callback failed", error);
|
||||
throw new RedirectLoginError(
|
||||
"OIDC authentication failed",
|
||||
frontendOrigin,
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* TOTP as a second factor. Enrolment stays in the TOTP routes; this is the
|
||||
* login half. Phase C moves both into the TOTP plugin.
|
||||
*/
|
||||
|
||||
import speakeasy from "speakeasy";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||
import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import type { SecondFactor } from "../registry.js";
|
||||
|
||||
export const TOTP_FACTOR_ID = "totp";
|
||||
|
||||
/**
|
||||
* Checks a backup code and burns it. Codes are stored encrypted under the
|
||||
* user's data key when it was available at enrolment, so both forms are read.
|
||||
*/
|
||||
async function consumeBackupCode(
|
||||
userId: string,
|
||||
storedCodes: string | null,
|
||||
code: string,
|
||||
userDataKey: Buffer,
|
||||
): Promise<boolean> {
|
||||
if (!storedCodes) return false;
|
||||
const raw = LazyFieldEncryption.safeGetFieldValue(
|
||||
storedCodes,
|
||||
userDataKey,
|
||||
userId,
|
||||
"totpBackupCodes",
|
||||
);
|
||||
let codes: unknown = [];
|
||||
try {
|
||||
codes = raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
codes = [];
|
||||
}
|
||||
if (!Array.isArray(codes)) return false;
|
||||
const index = codes.indexOf(code);
|
||||
if (index === -1) return false;
|
||||
|
||||
codes.splice(index, 1);
|
||||
await createCurrentUserRepository().update(userId, {
|
||||
totpBackupCodes: FieldCrypto.encryptField(
|
||||
JSON.stringify(codes),
|
||||
userDataKey,
|
||||
userId,
|
||||
"totpBackupCodes",
|
||||
),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export const totpSecondFactor: SecondFactor = {
|
||||
id: TOTP_FACTOR_ID,
|
||||
pluginId: "core",
|
||||
labelKey: "auth.totpFactor",
|
||||
|
||||
isEnrolled: async (userId) => {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
return !!user?.totpEnabled;
|
||||
},
|
||||
|
||||
verify: async (userId, body) => {
|
||||
const code = String(body.totp_code ?? body.code ?? "").trim();
|
||||
if (!code) return false;
|
||||
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
if (!user?.totpEnabled || !user.totpSecret) {
|
||||
return { ok: false, error: "TOTP not enabled for this user" };
|
||||
}
|
||||
|
||||
const userDataKey = AuthManager.getInstance().getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
};
|
||||
}
|
||||
|
||||
const secret = LazyFieldEncryption.safeGetFieldValue(
|
||||
user.totpSecret,
|
||||
userDataKey,
|
||||
userId,
|
||||
"totp_secret",
|
||||
);
|
||||
if (!secret) {
|
||||
// The secret was encrypted under a key a password reset replaced.
|
||||
await createCurrentUserRepository().update(userId, {
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
await createCurrentUserAuthRepository().removeSecondFactor(
|
||||
userId,
|
||||
"core",
|
||||
TOTP_FACTOR_ID,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"TOTP has been disabled due to password reset. Please set up TOTP again.",
|
||||
};
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret,
|
||||
encoding: "base32",
|
||||
token: code,
|
||||
window: 2,
|
||||
});
|
||||
if (verified) return true;
|
||||
|
||||
return consumeBackupCode(userId, user.totpBackupCodes, code, userDataKey);
|
||||
},
|
||||
|
||||
reset: async (userId) => {
|
||||
await createCurrentUserRepository().update(userId, {
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* Every login runs through here:
|
||||
*
|
||||
* method -> verified identity -> find or provision the user -> unlock the
|
||||
* data key -> second factors (fail closed) -> session
|
||||
*
|
||||
* A method never mints a session. It says who the user is and core does the
|
||||
* rest, so password, passkey, OIDC, LDAP and any plugin method get the same
|
||||
* second factors, the same cookie and the same audit line.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import {
|
||||
generateDeviceFingerprint,
|
||||
getDeviceId,
|
||||
parseUserAgent,
|
||||
} from "../utils/user-agent-parser.js";
|
||||
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
import { ensureCoreLoginProviders } from "./core-auth.js";
|
||||
import { findOrProvisionExternalUser } from "./provisioning.js";
|
||||
import {
|
||||
getSecondFactor,
|
||||
listSecondFactors,
|
||||
type SecondFactor,
|
||||
} from "./registry.js";
|
||||
import {
|
||||
issueSession,
|
||||
sendSession,
|
||||
syncSharedCredentialsForUserRoles,
|
||||
} from "./session-issuer.js";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PendingLogin,
|
||||
type VerifiedIdentity,
|
||||
} from "./types.js";
|
||||
|
||||
const PENDING_TTL_MS = 10 * 60 * 1000;
|
||||
export const PENDING_LOGIN_COOKIE = "termix_pending_login";
|
||||
|
||||
const pendingLogins = new Map<string, PendingLogin>();
|
||||
|
||||
function prunePending(now = Date.now()): void {
|
||||
for (const [key, pending] of pendingLogins) {
|
||||
if (now - pending.createdAt > PENDING_TTL_MS) pendingLogins.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function pendingKey(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetPendingLoginsForTests(): void {
|
||||
pendingLogins.clear();
|
||||
}
|
||||
|
||||
export interface SecondFactorState {
|
||||
/** Factors the user must pass, all with an active provider. */
|
||||
required: SecondFactor[];
|
||||
/** Enrolled in a factor whose plugin is disabled or missing. */
|
||||
blocked: { pluginId: string; factorId: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Which second factors apply to a user. A factor recorded in
|
||||
* user_second_factors whose plugin is not running blocks the login: skipping
|
||||
* it would turn "disable the plugin" into "remove everyone's 2FA".
|
||||
*/
|
||||
export async function evaluateSecondFactors(
|
||||
userId: string,
|
||||
): Promise<SecondFactorState> {
|
||||
ensureCoreLoginProviders();
|
||||
const rows =
|
||||
await createCurrentUserAuthRepository().listSecondFactors(userId);
|
||||
const required = new Map<string, SecondFactor>();
|
||||
const blocked: SecondFactorState["blocked"] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const factor = getSecondFactor(row.pluginId, row.factorId);
|
||||
if (!factor) {
|
||||
blocked.push({ pluginId: row.pluginId, factorId: row.factorId });
|
||||
continue;
|
||||
}
|
||||
required.set(`${factor.pluginId}:${factor.id}`, factor);
|
||||
}
|
||||
|
||||
// A factor can also report enrolment itself, for data from before
|
||||
// user_second_factors existed.
|
||||
for (const factor of listSecondFactors()) {
|
||||
const key = `${factor.pluginId}:${factor.id}`;
|
||||
if (required.has(key)) continue;
|
||||
try {
|
||||
if (await factor.isEnrolled(userId)) required.set(key, factor);
|
||||
} catch (error) {
|
||||
authLogger.warn("Second factor enrolment check failed", {
|
||||
operation: "second_factor_enrolment_check",
|
||||
factorId: factor.id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { required: [...required.values()], blocked };
|
||||
}
|
||||
|
||||
async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
|
||||
if (!fingerprint) return false;
|
||||
const trusted = await AuthManager.getInstance().isTrustedDevice(
|
||||
userId,
|
||||
fingerprint,
|
||||
);
|
||||
if (trusted) {
|
||||
authLogger.info("Second factor bypassed for trusted device", {
|
||||
operation: "totp_bypass",
|
||||
userId,
|
||||
deviceFingerprint: fingerprint,
|
||||
});
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
|
||||
type DeviceType = ReturnType<typeof parseUserAgent>["type"];
|
||||
|
||||
async function resolveUser(
|
||||
identity: VerifiedIdentity,
|
||||
deviceType: DeviceType,
|
||||
): Promise<UserRecord> {
|
||||
if (identity.kind === "external") {
|
||||
return findOrProvisionExternalUser(identity, deviceType);
|
||||
}
|
||||
const user = await createCurrentUserRepository().findById(identity.userId);
|
||||
if (!user)
|
||||
throw new LoginMethodError("User not found", 404, "user_not_found");
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks the user's data key. A password login migrates and opens the key
|
||||
* with the password; everything else uses the server-held wrapping.
|
||||
*/
|
||||
async function unlockUser(
|
||||
user: UserRecord,
|
||||
identity: VerifiedIdentity,
|
||||
deviceType: DeviceType,
|
||||
): Promise<void> {
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
if (identity.kind === "external" || user.isOidc) {
|
||||
try {
|
||||
await authManager.authenticateOIDCUser(user.id, deviceType);
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to set up external user encryption", error, {
|
||||
operation: "external_user_encryption_setup_failed",
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = identity.password
|
||||
? await authManager.authenticateUser(user.id, identity.password, deviceType)
|
||||
: await authManager.authenticateWebAuthnUser(user.id, deviceType);
|
||||
if (!ok) {
|
||||
throw new LoginMethodError(
|
||||
identity.password
|
||||
? "Incorrect password"
|
||||
: (identity.unlockError ?? "This account could not be unlocked"),
|
||||
401,
|
||||
"unlock_failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoginContext {
|
||||
methodId: string;
|
||||
rememberMe: boolean;
|
||||
/** Username the rate limiter counted, cleared on success. */
|
||||
rateLimitUsername?: string;
|
||||
}
|
||||
|
||||
export type LoginResult =
|
||||
| {
|
||||
kind: "session";
|
||||
token: string;
|
||||
maxAge: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
kind: "second-factor";
|
||||
tempToken: string;
|
||||
factors: Array<{ id: string; pluginId: string; labelKey: string }>;
|
||||
rememberMe: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes a verified identity to either a session or a second-factor step.
|
||||
* Throws LoginMethodError for anything the user should be told.
|
||||
*/
|
||||
export async function runLogin(
|
||||
req: Request,
|
||||
identity: VerifiedIdentity,
|
||||
context: LoginContext,
|
||||
): Promise<LoginResult> {
|
||||
const deviceType = parseUserAgent(req).type;
|
||||
const user = await resolveUser(identity, deviceType);
|
||||
|
||||
await unlockUser(user, identity, deviceType);
|
||||
await syncSharedCredentialsForUserRoles(
|
||||
user.id,
|
||||
`${context.methodId}_role_shared_credentials`,
|
||||
);
|
||||
|
||||
const factors = await evaluateSecondFactors(user.id);
|
||||
if (factors.blocked.length > 0) {
|
||||
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
|
||||
operation: "second_factor_unavailable",
|
||||
userId: user.id,
|
||||
factors: factors.blocked,
|
||||
});
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: "login_blocked_second_factor",
|
||||
resourceType: "session",
|
||||
details: JSON.stringify({ unavailable: factors.blocked }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
errorMessage: "Enrolled second factor unavailable",
|
||||
});
|
||||
throw new LoginMethodError(
|
||||
"Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
||||
403,
|
||||
"second_factor_unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
const ssoClaims = {
|
||||
ssoProviderId: identity.ssoProviderId ?? null,
|
||||
oidcSub: identity.oidcSub ?? null,
|
||||
oidcSid: identity.oidcSid ?? null,
|
||||
};
|
||||
|
||||
if (
|
||||
factors.required.length > 0 &&
|
||||
!identity.mfaSatisfied &&
|
||||
!(await isTrustedDevice(req, user.id))
|
||||
) {
|
||||
const tempToken = await AuthManager.getInstance().generateJWTToken(
|
||||
user.id,
|
||||
{ pendingTOTP: true, expiresIn: "10m" },
|
||||
);
|
||||
prunePending();
|
||||
pendingLogins.set(pendingKey(tempToken), {
|
||||
userId: user.id,
|
||||
methodId: context.methodId,
|
||||
rememberMe: context.rememberMe,
|
||||
...ssoClaims,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return {
|
||||
kind: "second-factor",
|
||||
tempToken,
|
||||
rememberMe: context.rememberMe,
|
||||
factors: factors.required.map((factor) => ({
|
||||
id: factor.id,
|
||||
pluginId: factor.pluginId,
|
||||
labelKey: factor.labelKey,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const session = await issueSession(req, user, {
|
||||
methodId: context.methodId,
|
||||
rememberMe: context.rememberMe,
|
||||
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
|
||||
longLivedForApps: identity.kind === "external",
|
||||
...ssoClaims,
|
||||
});
|
||||
return { kind: "session", ...session };
|
||||
}
|
||||
|
||||
/** JSON form of a login result, the shape the login screen expects. */
|
||||
export function secondFactorBody(
|
||||
result: Extract<LoginResult, { kind: "second-factor" }>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
success: true,
|
||||
requires_totp: true,
|
||||
requires_second_factor: true,
|
||||
temp_token: result.tempToken,
|
||||
rememberMe: result.rememberMe,
|
||||
second_factors: result.factors,
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs a form login and writes the response. */
|
||||
export async function respondWithLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
identity: VerifiedIdentity,
|
||||
context: LoginContext,
|
||||
): Promise<Response> {
|
||||
const result = await runLogin(req, identity, context);
|
||||
if (result.kind === "second-factor") {
|
||||
return res.json(secondFactorBody(result));
|
||||
}
|
||||
return sendSession(req, res, result);
|
||||
}
|
||||
|
||||
export function sendLoginError(res: Response, error: unknown): Response {
|
||||
if (error instanceof LoginMethodError) {
|
||||
const remainingTime = (error as { remainingTime?: number }).remainingTime;
|
||||
return res.status(error.status).json({
|
||||
error: error.message,
|
||||
...(error.code ? { code: error.code } : {}),
|
||||
...(remainingTime !== undefined ? { remainingTime } : {}),
|
||||
});
|
||||
}
|
||||
authLogger.error("Login failed", error);
|
||||
return res.status(500).json({ error: "Login failed" });
|
||||
}
|
||||
|
||||
export interface PendingLoginLookup {
|
||||
userId: string;
|
||||
token: string;
|
||||
pending: PendingLogin | null;
|
||||
}
|
||||
|
||||
/** Checks a pending token from the body or the redirect cookie. */
|
||||
export async function readPendingLogin(
|
||||
req: Request,
|
||||
): Promise<PendingLoginLookup | null> {
|
||||
const token =
|
||||
(typeof req.body?.temp_token === "string" && req.body.temp_token) ||
|
||||
(req.cookies?.[PENDING_LOGIN_COOKIE] as string | undefined);
|
||||
if (!token) return null;
|
||||
const decoded = await AuthManager.getInstance().verifyJWTToken(token);
|
||||
if (!decoded || !decoded.pendingTOTP) return null;
|
||||
prunePending();
|
||||
const pending = pendingLogins.get(pendingKey(token)) ?? null;
|
||||
return { userId: decoded.userId, token, pending };
|
||||
}
|
||||
|
||||
export function consumePendingLogin(token: string): void {
|
||||
pendingLogins.delete(pendingKey(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes a login that stopped for a second factor. The pending token comes
|
||||
* from the body, or from the cookie a redirect method set.
|
||||
*/
|
||||
export async function verifySecondFactorAndRespond(
|
||||
req: Request,
|
||||
res: Response,
|
||||
factorId: string,
|
||||
): Promise<Response> {
|
||||
const lookup = await readPendingLogin(req);
|
||||
if (!lookup) {
|
||||
return res.status(401).json({ error: "Invalid temporary token" });
|
||||
}
|
||||
|
||||
const user = await createCurrentUserRepository().findById(lookup.userId);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
|
||||
const lockStatus = loginRateLimiter.isTOTPLocked(user.id);
|
||||
if (lockStatus.locked) {
|
||||
authLogger.warn("Second factor blocked due to rate limiting", {
|
||||
operation: "totp_verify_blocked",
|
||||
userId: user.id,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
});
|
||||
return res.status(429).json({
|
||||
error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
code: "TOTP_RATE_LIMITED",
|
||||
});
|
||||
}
|
||||
loginRateLimiter.recordFailedTOTPAttempt(user.id);
|
||||
|
||||
const factors = await evaluateSecondFactors(user.id);
|
||||
if (factors.blocked.length > 0) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
||||
code: "second_factor_unavailable",
|
||||
});
|
||||
}
|
||||
const factor = factors.required.find(
|
||||
(candidate) => candidate.id === factorId,
|
||||
);
|
||||
if (!factor) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "That second factor is not enabled for this user" });
|
||||
}
|
||||
|
||||
const result = await factor.verify(user.id, req.body ?? {});
|
||||
if (result !== true) {
|
||||
if (result && typeof result === "object") {
|
||||
return res.status(result.code === "SESSION_EXPIRED" ? 401 : 400).json({
|
||||
error: result.error,
|
||||
...(result.code ? { code: result.code } : {}),
|
||||
});
|
||||
}
|
||||
authLogger.warn("Second factor verification failed", {
|
||||
operation: "totp_verify_failed",
|
||||
userId: user.id,
|
||||
factorId,
|
||||
});
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: "login_second_factor_failed",
|
||||
resourceType: "session",
|
||||
details: JSON.stringify({ factor: factorId }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Invalid TOTP code",
|
||||
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(user.id),
|
||||
});
|
||||
}
|
||||
loginRateLimiter.resetTOTPAttempts(user.id);
|
||||
|
||||
const rememberMe =
|
||||
typeof req.body?.rememberMe === "boolean"
|
||||
? req.body.rememberMe
|
||||
: (lookup.pending?.rememberMe ?? false);
|
||||
|
||||
if (rememberMe) {
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
|
||||
if (fingerprint) {
|
||||
await AuthManager.getInstance().addTrustedDevice(
|
||||
user.id,
|
||||
fingerprint,
|
||||
deviceInfo.type,
|
||||
deviceInfo.deviceInfo,
|
||||
);
|
||||
authLogger.info("Device automatically trusted via Remember Me", {
|
||||
operation: "totp_auto_trust",
|
||||
userId: user.id,
|
||||
deviceType: deviceInfo.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pending = lookup.pending;
|
||||
const session = await issueSession(req, user, {
|
||||
methodId: pending?.methodId ?? "password",
|
||||
rememberMe,
|
||||
ssoProviderId: pending?.ssoProviderId ?? null,
|
||||
oidcSub: pending?.oidcSub ?? null,
|
||||
oidcSid: pending?.oidcSid ?? null,
|
||||
});
|
||||
|
||||
consumePendingLogin(lookup.token);
|
||||
res.clearCookie(
|
||||
PENDING_LOGIN_COOKIE,
|
||||
AuthManager.getInstance().getClearCookieOptions(req),
|
||||
);
|
||||
return sendSession(req, res, session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes a redirect login (OIDC and friends): back to where the browser
|
||||
* came from, with a cookie, or with the token in the URL for the desktop and
|
||||
* mobile app callbacks that cannot read one.
|
||||
*/
|
||||
export async function respondWithRedirectLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
identity: VerifiedIdentity,
|
||||
context: LoginContext,
|
||||
isTokenCallback: (url: string) => boolean,
|
||||
): Promise<void> {
|
||||
const returnTo = identity.returnTo;
|
||||
if (!returnTo) {
|
||||
res.status(500).json({ error: "Login method did not say where to return" });
|
||||
return;
|
||||
}
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
let result: LoginResult;
|
||||
try {
|
||||
result = await runLogin(req, identity, context);
|
||||
} catch (error) {
|
||||
redirectWithLoginError(res, returnTo, error);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(returnTo);
|
||||
const tokenCallback = isTokenCallback(returnTo);
|
||||
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
||||
|
||||
if (result.kind === "second-factor") {
|
||||
url.searchParams.set("second_factor", "1");
|
||||
url.searchParams.set(
|
||||
"second_factors",
|
||||
result.factors.map((factor) => factor.id).join(","),
|
||||
);
|
||||
if (tokenCallback) {
|
||||
url.searchParams.set("temp_token", result.tempToken);
|
||||
res.redirect(url.toString());
|
||||
return;
|
||||
}
|
||||
res
|
||||
.cookie(
|
||||
PENDING_LOGIN_COOKIE,
|
||||
result.tempToken,
|
||||
authManager.getSecureCookieOptions(req, PENDING_TTL_MS),
|
||||
)
|
||||
.redirect(url.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
url.searchParams.set("success", "true");
|
||||
if (tokenCallback) {
|
||||
url.searchParams.set("token", result.token);
|
||||
res.redirect(url.toString());
|
||||
return;
|
||||
}
|
||||
res
|
||||
.cookie(
|
||||
"jwt",
|
||||
result.token,
|
||||
authManager.getSecureCookieOptions(req, result.maxAge),
|
||||
)
|
||||
.redirect(url.toString());
|
||||
}
|
||||
|
||||
export function redirectWithLoginError(
|
||||
res: Response,
|
||||
returnTo: string,
|
||||
error: unknown,
|
||||
): void {
|
||||
if (!(error instanceof LoginMethodError)) {
|
||||
authLogger.error("Redirect login failed", error);
|
||||
}
|
||||
const url = new URL(returnTo);
|
||||
url.searchParams.set(
|
||||
"error",
|
||||
error instanceof LoginMethodError
|
||||
? (error.code ?? error.message)
|
||||
: "OIDC authentication failed",
|
||||
);
|
||||
res.redirect(url.toString());
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Finds or creates the user behind an external sign-in (OIDC, LDAP, a
|
||||
* plugin's provider). The rules used to be copied into every SSO route; they
|
||||
* live here once so every login method gets the same ones:
|
||||
*
|
||||
* - the allowed-users list is checked on every sign-in, new user or not
|
||||
* - the first user ever becomes admin and skips the list
|
||||
* - anyone else is only created when SSO auto-provisioning is on
|
||||
* - an admin group, when the provider reports one, keeps admin in step
|
||||
* - a mapped role set, when the provider reports one, keeps roles in step
|
||||
*/
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
import { isOIDCUserAllowed } from "../database/routes/user-oidc-utils.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
|
||||
|
||||
type ExternalIdentity = Extract<VerifiedIdentity, { kind: "external" }>;
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** SSO auto-provisioning: the admin setting, or OIDC_ALLOW_REGISTRATION. */
|
||||
export async function isExternalProvisioningAllowed(): Promise<boolean> {
|
||||
try {
|
||||
if (
|
||||
await createCurrentSettingsRepository().getBoolean(
|
||||
"oidc_auto_provision",
|
||||
false,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the environment
|
||||
}
|
||||
return (
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === "true"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a newly provisioned user must wait for an admin. There is no
|
||||
* approval queue yet, so nobody does; a plugin or a later release can change
|
||||
* that here without touching any login method.
|
||||
*/
|
||||
export async function isApprovalRequired(
|
||||
_identity: ExternalIdentity,
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllowed(identity: ExternalIdentity): boolean {
|
||||
if (!identity.allowedUsers) return true;
|
||||
const email = identity.email ?? undefined;
|
||||
return (
|
||||
isOIDCUserAllowed(identity.allowedUsers, identity.subject, email) ||
|
||||
(!!identity.legacyIdentifier &&
|
||||
isOIDCUserAllowed(
|
||||
identity.allowedUsers,
|
||||
identity.legacyIdentifier,
|
||||
email,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
async function syncAdmin(
|
||||
user: UserRecord,
|
||||
isAdmin: boolean,
|
||||
): Promise<UserRecord> {
|
||||
if (!!user.isAdmin === isAdmin) return user;
|
||||
const updated =
|
||||
(await createCurrentUserRepository().update(user.id, { isAdmin })) ?? user;
|
||||
try {
|
||||
await createCurrentRoleRepository().switchUserRoleName({
|
||||
userId: user.id,
|
||||
addRoleName: isAdmin ? "admin" : "user",
|
||||
removeRoleName: isAdmin ? "user" : "admin",
|
||||
grantedBy: user.id,
|
||||
});
|
||||
} catch {
|
||||
// Roles follow on the next sign-in; the admin flag itself is saved.
|
||||
}
|
||||
authLogger.info("Admin status synced from the identity provider", {
|
||||
operation: "external_admin_sync",
|
||||
userId: user.id,
|
||||
isAdmin,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds and removes the roles a provider's group map manages. Roles assigned
|
||||
* by hand, and roles outside the map, are never touched.
|
||||
*/
|
||||
export async function applyRoleSync(
|
||||
userId: string,
|
||||
roleSync: { desired: string[]; managed: string[] },
|
||||
): Promise<void> {
|
||||
const desired = new Set(roleSync.desired);
|
||||
const managed = new Set(roleSync.managed);
|
||||
const roleRepository = createCurrentRoleRepository();
|
||||
const currentRoles = await roleRepository.listUserRoles(userId);
|
||||
const currentNames = new Set(currentRoles.map((role) => role.roleName));
|
||||
|
||||
const toAdd = [...desired].filter((name) => !currentNames.has(name));
|
||||
const toRemove = currentRoles.filter(
|
||||
(role) => managed.has(role.roleName) && !desired.has(role.roleName),
|
||||
);
|
||||
|
||||
for (const roleName of toAdd) {
|
||||
const assigned = await roleRepository.assignRoleNameToUser({
|
||||
userId,
|
||||
roleName,
|
||||
grantedBy: userId,
|
||||
});
|
||||
if (!assigned) {
|
||||
authLogger.warn("Role map references a role that does not exist", {
|
||||
operation: "external_role_map_missing_role",
|
||||
userId,
|
||||
roleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const role of toRemove) {
|
||||
await roleRepository.removeRoleFromUser(userId, role.roleId);
|
||||
}
|
||||
|
||||
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||
authLogger.info("Roles synced from provider group membership", {
|
||||
operation: "external_role_map_sync",
|
||||
userId,
|
||||
added: toAdd,
|
||||
removed: toRemove.map((role) => role.roleName),
|
||||
});
|
||||
PermissionManager.getInstance().invalidateUserPermissionCache(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async function findLinkedUser(
|
||||
identity: ExternalIdentity,
|
||||
): Promise<UserRecord | null> {
|
||||
const identities = createCurrentUserAuthRepository();
|
||||
const users = createCurrentUserRepository();
|
||||
|
||||
const linked = await identities.findIdentity(
|
||||
identity.provider,
|
||||
identity.subject,
|
||||
);
|
||||
if (linked) {
|
||||
const user = await users.findById(linked.userId);
|
||||
if (user) return user;
|
||||
}
|
||||
|
||||
// Accounts from before user_external_identities matched on the raw
|
||||
// identifier column; link them the first time they sign in.
|
||||
if (identity.legacyIdentifier) {
|
||||
const legacy = await users.findByOidcIdentifier(identity.legacyIdentifier);
|
||||
if (legacy) {
|
||||
await identities.linkIdentity({
|
||||
userId: legacy.id,
|
||||
providerId: identity.provider,
|
||||
subject: identity.subject,
|
||||
email: identity.email,
|
||||
});
|
||||
return legacy;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createUser(
|
||||
identity: ExternalIdentity,
|
||||
deviceType: string,
|
||||
): Promise<UserRecord> {
|
||||
const users = createCurrentUserRepository();
|
||||
const id = nanoid();
|
||||
const created = await users.createFirstSsoUser({
|
||||
id,
|
||||
username: identity.name || identity.subject,
|
||||
passwordHash: "",
|
||||
isAdmin: !!identity.isAdmin,
|
||||
isOidc: true,
|
||||
oidcIdentifier:
|
||||
identity.legacyIdentifier ?? `${identity.provider}:${identity.subject}`,
|
||||
ssoProviderId: identity.ssoProviderId ?? null,
|
||||
});
|
||||
|
||||
try {
|
||||
const assigned = await createCurrentRoleRepository().assignRoleNameToUser({
|
||||
userId: id,
|
||||
roleName: created.user.isAdmin ? "admin" : "user",
|
||||
grantedBy: id,
|
||||
});
|
||||
if (!assigned) {
|
||||
authLogger.warn("Default role not found for a provisioned user", {
|
||||
operation: "external_default_role",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
} catch (roleError) {
|
||||
authLogger.error("Failed to assign a default role", roleError, {
|
||||
operation: "external_default_role",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionDurationMs =
|
||||
deviceType === "desktop" || deviceType === "mobile"
|
||||
? THIRTY_DAYS_MS
|
||||
: ONE_DAY_MS;
|
||||
await AuthManager.getInstance().registerOIDCUser(id, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await users.delete(id);
|
||||
authLogger.error(
|
||||
"Failed to set up a provisioned user's encryption, user removed",
|
||||
encryptionError,
|
||||
{ operation: "external_user_create_failed", userId: id },
|
||||
);
|
||||
throw new LoginMethodError(
|
||||
"Failed to setup user security - user creation cancelled",
|
||||
500,
|
||||
"setup_failed",
|
||||
);
|
||||
}
|
||||
|
||||
await createCurrentUserAuthRepository().linkIdentity({
|
||||
userId: id,
|
||||
providerId: identity.provider,
|
||||
subject: identity.subject,
|
||||
email: identity.email,
|
||||
});
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("external_user_create");
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist a provisioned user", saveError, {
|
||||
operation: "external_user_create_save_failed",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
return created.user;
|
||||
}
|
||||
|
||||
export async function findOrProvisionExternalUser(
|
||||
identity: ExternalIdentity,
|
||||
deviceType: string,
|
||||
): Promise<UserRecord> {
|
||||
let user = await findLinkedUser(identity);
|
||||
|
||||
if (!user) {
|
||||
const isFirstUser = (await createCurrentUserRepository().countAll()) === 0;
|
||||
if (!isFirstUser) {
|
||||
if (!isAllowed(identity)) {
|
||||
authLogger.warn("External user not in the allowed list", {
|
||||
operation: "external_user_not_allowed",
|
||||
provider: identity.provider,
|
||||
});
|
||||
throw new LoginMethodError("User not allowed", 403, "user_not_allowed");
|
||||
}
|
||||
if (!(await isExternalProvisioningAllowed())) {
|
||||
authLogger.warn("External sign-in refused, provisioning is off", {
|
||||
operation: "external_registration_disabled",
|
||||
provider: identity.provider,
|
||||
});
|
||||
throw new LoginMethodError(
|
||||
"Registration is disabled",
|
||||
403,
|
||||
"registration_disabled",
|
||||
);
|
||||
}
|
||||
if (await isApprovalRequired(identity)) {
|
||||
throw new LoginMethodError(
|
||||
"Your account is waiting for an admin to approve it",
|
||||
403,
|
||||
"approval_required",
|
||||
);
|
||||
}
|
||||
}
|
||||
user = await createUser(identity, deviceType);
|
||||
} else {
|
||||
if (!isAllowed(identity)) {
|
||||
authLogger.warn("External user not in the allowed list", {
|
||||
operation: "external_user_not_allowed_existing",
|
||||
provider: identity.provider,
|
||||
userId: user.id,
|
||||
});
|
||||
throw new LoginMethodError("User not allowed", 403, "user_not_allowed");
|
||||
}
|
||||
|
||||
// A user who also has a password picked their own username; leave it.
|
||||
const isDualAuth = !!user.passwordHash && user.passwordHash.trim() !== "";
|
||||
if (!isDualAuth && identity.name && user.username !== identity.name) {
|
||||
user =
|
||||
(await createCurrentUserRepository().update(user.id, {
|
||||
username: identity.name,
|
||||
})) ?? user;
|
||||
}
|
||||
|
||||
if (identity.isAdmin !== undefined) {
|
||||
user = await syncAdmin(user, identity.isAdmin);
|
||||
}
|
||||
}
|
||||
|
||||
if (identity.roleSync) {
|
||||
try {
|
||||
await applyRoleSync(user.id, identity.roleSync);
|
||||
} catch (roleSyncError) {
|
||||
authLogger.error("Failed to sync roles from provider", roleSyncError, {
|
||||
operation: "external_role_map_sync_failed",
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Login methods and second factors, from core and from plugins.
|
||||
*
|
||||
* Password login is registered by core and always present. Everything else
|
||||
* (OIDC, LDAP, passkeys, TOTP, ...) registers here, from a plugin through
|
||||
* ctx.auth or, in 2.9.0, from legacy-providers.ts.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginLoginMethod,
|
||||
PluginSecondFactor,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type LoginMethod = PluginLoginMethod & { pluginId: string };
|
||||
export type SecondFactor = PluginSecondFactor & { pluginId: string };
|
||||
|
||||
const loginMethods = new Map<string, LoginMethod>();
|
||||
const secondFactors = new Map<string, SecondFactor>();
|
||||
|
||||
function register<T extends { id: string; pluginId: string }>(
|
||||
map: Map<string, T>,
|
||||
kind: string,
|
||||
entry: T,
|
||||
): () => void {
|
||||
const existing = map.get(entry.id);
|
||||
if (existing && existing.pluginId !== entry.pluginId) {
|
||||
throw new Error(
|
||||
`${kind} "${entry.id}" is already provided by ${existing.pluginId}`,
|
||||
);
|
||||
}
|
||||
map.set(entry.id, entry);
|
||||
return () => {
|
||||
if (map.get(entry.id) === entry) map.delete(entry.id);
|
||||
};
|
||||
}
|
||||
|
||||
export function registerLoginMethod(method: LoginMethod): () => void {
|
||||
return register(loginMethods, "Login method", method);
|
||||
}
|
||||
|
||||
export function getLoginMethod(id: string): LoginMethod | undefined {
|
||||
return loginMethods.get(id);
|
||||
}
|
||||
|
||||
export function listLoginMethods(): LoginMethod[] {
|
||||
return [...loginMethods.values()];
|
||||
}
|
||||
|
||||
export function registerSecondFactor(factor: SecondFactor): () => void {
|
||||
return register(secondFactors, "Second factor", factor);
|
||||
}
|
||||
|
||||
export function getSecondFactor(
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
): SecondFactor | undefined {
|
||||
const factor = secondFactors.get(factorId);
|
||||
return factor && factor.pluginId === pluginId ? factor : undefined;
|
||||
}
|
||||
|
||||
export function listSecondFactors(): SecondFactor[] {
|
||||
return [...secondFactors.values()];
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetAuthRegistryForTests(): void {
|
||||
loginMethods.clear();
|
||||
secondFactors.clear();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Admin view and reset of a user's second factors. Covers factors whose
|
||||
* plugin is disabled or gone, which is exactly when a user gets locked out
|
||||
* and an admin has to step in.
|
||||
*/
|
||||
|
||||
import {
|
||||
createCurrentTrustedDeviceRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { ensureCoreLoginProviders } from "./core-auth.js";
|
||||
import { getSecondFactor, listSecondFactors } from "./registry.js";
|
||||
|
||||
export interface UserSecondFactorSummary {
|
||||
pluginId: string;
|
||||
factorId: string;
|
||||
labelKey: string | null;
|
||||
/** False when the plugin behind it is not running. */
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export async function listUserSecondFactors(
|
||||
userId: string,
|
||||
): Promise<UserSecondFactorSummary[]> {
|
||||
ensureCoreLoginProviders();
|
||||
const rows =
|
||||
await createCurrentUserAuthRepository().listSecondFactors(userId);
|
||||
const summaries: UserSecondFactorSummary[] = rows.map((row) => {
|
||||
const factor = getSecondFactor(row.pluginId, row.factorId);
|
||||
return {
|
||||
pluginId: row.pluginId,
|
||||
factorId: row.factorId,
|
||||
labelKey: factor?.labelKey ?? null,
|
||||
available: !!factor,
|
||||
};
|
||||
});
|
||||
|
||||
for (const factor of listSecondFactors()) {
|
||||
if (
|
||||
summaries.some(
|
||||
(summary) =>
|
||||
summary.pluginId === factor.pluginId &&
|
||||
summary.factorId === factor.id,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (await factor.isEnrolled(userId).catch(() => false)) {
|
||||
summaries.push({
|
||||
pluginId: factor.pluginId,
|
||||
factorId: factor.id,
|
||||
labelKey: factor.labelKey,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every factor: runs each running factor's own reset (so TOTP clears
|
||||
* its secret), drops the enrolment rows including orphaned ones, and forgets
|
||||
* trusted devices so an old "remember me" cannot skip the new factor.
|
||||
*/
|
||||
export async function resetUserSecondFactors(
|
||||
userId: string,
|
||||
): Promise<Array<{ pluginId: string; factorId: string }>> {
|
||||
const factors = await listUserSecondFactors(userId);
|
||||
for (const summary of factors) {
|
||||
const factor = getSecondFactor(summary.pluginId, summary.factorId);
|
||||
if (!factor?.reset) continue;
|
||||
try {
|
||||
await factor.reset(userId);
|
||||
} catch (error) {
|
||||
authLogger.error("Second factor reset failed", error, {
|
||||
operation: "admin_reset_second_factor",
|
||||
userId,
|
||||
factorId: summary.factorId,
|
||||
});
|
||||
}
|
||||
}
|
||||
await createCurrentUserAuthRepository().clearSecondFactors(userId);
|
||||
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
||||
return factors.map(({ pluginId, factorId }) => ({ pluginId, factorId }));
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* The one place a login becomes a session: JWT, cookie, response body,
|
||||
* audit line and the automation event. Every login method ends here, so
|
||||
* they all behave the same way.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
||||
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
||||
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||
import { notifyAutomationInternalEvent } from "../hosts/automation-events.js";
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function isNativeAppRequest(req: Request): boolean {
|
||||
return (
|
||||
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
||||
req.get("X-Electron-App") === "true"
|
||||
);
|
||||
}
|
||||
|
||||
/** Cookie lifetime: 30 days when remembered, else session_timeout_hours. */
|
||||
export async function sessionCookieMaxAge(
|
||||
rememberMe: boolean,
|
||||
): Promise<number> {
|
||||
if (rememberMe) return THIRTY_DAYS_MS;
|
||||
const value = await createCurrentSettingsRepository().get(
|
||||
"session_timeout_hours",
|
||||
);
|
||||
const hours = value ? parseInt(value, 10) || 24 : 24;
|
||||
return hours * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export async function syncSharedCredentialsForUserRoles(
|
||||
userId: string,
|
||||
operation: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
|
||||
const { SharedCredentialSecretsManager } =
|
||||
await import("../utils/shared-credential-secrets-manager.js");
|
||||
await SharedCredentialSecretsManager.getInstance().snapshotForUserRoles(
|
||||
userId,
|
||||
);
|
||||
} catch (error) {
|
||||
authLogger.warn("Failed to sync role shared host secrets", {
|
||||
operation,
|
||||
userId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface IssueSessionOptions {
|
||||
methodId: string;
|
||||
rememberMe: boolean;
|
||||
/** Username the rate limiter counted attempts under, to clear them. */
|
||||
rateLimitUsername?: string;
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
|
||||
longLivedForApps?: boolean;
|
||||
}
|
||||
|
||||
export interface IssuedSession {
|
||||
token: string;
|
||||
maxAge: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints the JWT and writes the audit line. The caller decides how the token
|
||||
* reaches the client: a JSON body with a cookie, or a redirect.
|
||||
*/
|
||||
export async function issueSession(
|
||||
req: Request,
|
||||
user: UserRecord,
|
||||
options: IssueSessionOptions,
|
||||
): Promise<IssuedSession> {
|
||||
const authManager = AuthManager.getInstance();
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
const token = await authManager.generateJWTToken(user.id, {
|
||||
rememberMe: options.rememberMe,
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
oidcSub: options.oidcSub ?? null,
|
||||
oidcSid: options.oidcSid ?? null,
|
||||
ssoProviderId: options.ssoProviderId ?? null,
|
||||
});
|
||||
|
||||
if (options.rateLimitUsername) {
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
|
||||
loginRateLimiter.resetAttempts(clientIp, options.rateLimitUsername);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: "login",
|
||||
resourceType: "session",
|
||||
details: JSON.stringify({ method: options.methodId }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
notifyAutomationInternalEvent("user_login", user.id, undefined, {
|
||||
username: user.username,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
authLogger.success("User login successful", {
|
||||
operation: "user_login_complete",
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
method: options.methodId,
|
||||
});
|
||||
|
||||
const maxAge =
|
||||
options.longLivedForApps &&
|
||||
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
|
||||
? THIRTY_DAYS_MS
|
||||
: await sessionCookieMaxAge(options.rememberMe);
|
||||
|
||||
return {
|
||||
token,
|
||||
maxAge,
|
||||
body: {
|
||||
success: true,
|
||||
is_admin: !!user.isAdmin,
|
||||
username: user.username,
|
||||
userId: user.id,
|
||||
is_oidc: !!user.isOidc,
|
||||
totp_enabled: !!user.totpEnabled,
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Sets the cookie and sends the body. */
|
||||
export function sendSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
session: IssuedSession,
|
||||
): Response {
|
||||
const authManager = AuthManager.getInstance();
|
||||
return res
|
||||
.cookie(
|
||||
"jwt",
|
||||
session.token,
|
||||
authManager.getSecureCookieOptions(req, session.maxAge),
|
||||
)
|
||||
.json(session.body);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PluginVerifiedIdentity } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export { LoginMethodError } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/** Extra fields core's own methods may set. Plugins never see these. */
|
||||
interface CoreIdentityExtras {
|
||||
/**
|
||||
* The identifier string the user row carried before user_external_identities
|
||||
* existed ("ldap:<provider>:<id>", "github:<provider>:<id>" or a bare OIDC
|
||||
* subject). Found users get their identity row linked lazily; new users keep
|
||||
* the column filled so older code that reads it still works.
|
||||
*/
|
||||
legacyIdentifier?: string;
|
||||
/** SSO provider row the session came from, for back-channel logout. */
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
/** Provider group to role mapping, applied on every login. */
|
||||
roleSync?: { desired: string[]; managed: string[] };
|
||||
/** Shown when the user's data key cannot be unlocked by this method. */
|
||||
unlockError?: string;
|
||||
/** Username the login rate limiter counted, cleared on success. */
|
||||
rateLimitUsername?: string;
|
||||
}
|
||||
|
||||
export type VerifiedIdentity = PluginVerifiedIdentity & CoreIdentityExtras;
|
||||
|
||||
/** What a login needs to remember while it waits for a second factor. */
|
||||
export interface PendingLogin {
|
||||
userId: string;
|
||||
methodId: string;
|
||||
rememberMe: boolean;
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import userRoutes from "./routes/users.js";
|
||||
import hostRoutes from "./routes/host.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
@@ -27,7 +28,7 @@ import vaultRoutes from "./routes/vault.js";
|
||||
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import pluginApiRoutes from "./routes/plugin-api-routes.js";
|
||||
import { mountPluginApi } from "./routes/plugin-api-routes.js";
|
||||
import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
||||
@@ -1762,6 +1763,7 @@ app.use("/users", userRoutes);
|
||||
app.use("/host", hostRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/ssh-auth", sshAuthRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
@@ -1785,7 +1787,7 @@ app.use(
|
||||
"/plugin-assets",
|
||||
createPluginAssetsRouter((id) => getPluginRuntime().loader.get(id)),
|
||||
);
|
||||
app.use("/plugin-api", authenticateJWT, pluginApiRoutes);
|
||||
mountPluginApi(app);
|
||||
|
||||
const frontendDistPaths = [
|
||||
path.join(__dirname, "../../../dist"),
|
||||
|
||||
@@ -269,6 +269,29 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_external_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
email TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (provider_id, subject),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_external_identities_user ON user_external_identities (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_second_factors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
plugin_id TEXT NOT NULL,
|
||||
factor_id TEXT NOT NULL,
|
||||
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, plugin_id, factor_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
|
||||
@@ -120,6 +120,60 @@ export const trustedDevices = mysqlTable(
|
||||
(table) => [index("idx_trusted_devices_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* A sign-in identity from an external provider (OIDC, LDAP, GitHub, ...).
|
||||
* Replaces identifier strings like "ldap:<provider>:<id>" on users.
|
||||
*/
|
||||
export const userExternalIdentities = mysqlTable(
|
||||
"user_external_identities",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
providerId: varchar("provider_id", { length: 255 }).notNull(),
|
||||
/** The provider's id for the user, at most 255 characters. */
|
||||
subject: varchar("subject", { length: 255 }).notNull(),
|
||||
email: text("email"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_user_external_identities_provider_subject").on(
|
||||
table.providerId,
|
||||
table.subject,
|
||||
),
|
||||
index("idx_user_external_identities_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which second factors a user enrolled in, by plugin. Kept when the plugin is
|
||||
* disabled or removed, so login fails closed instead of skipping the factor.
|
||||
*/
|
||||
export const userSecondFactors = mysqlTable(
|
||||
"user_second_factors",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||
factorId: varchar("factor_id", { length: 255 }).notNull(),
|
||||
enrolledAt: text("enrolled_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_user_second_factors_user_factor").on(
|
||||
table.userId,
|
||||
table.pluginId,
|
||||
table.factorId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const webauthnCredentials = mysqlTable("webauthn_credentials", {
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
|
||||
@@ -121,6 +121,60 @@ export const trustedDevices = pgTable(
|
||||
(table) => [index("idx_trusted_devices_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* A sign-in identity from an external provider (OIDC, LDAP, GitHub, ...).
|
||||
* Replaces identifier strings like "ldap:<provider>:<id>" on users.
|
||||
*/
|
||||
export const userExternalIdentities = pgTable(
|
||||
"user_external_identities",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
providerId: varchar("provider_id", { length: 255 }).notNull(),
|
||||
/** The provider's id for the user, at most 255 characters. */
|
||||
subject: varchar("subject", { length: 255 }).notNull(),
|
||||
email: text("email"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_user_external_identities_provider_subject").on(
|
||||
table.providerId,
|
||||
table.subject,
|
||||
),
|
||||
index("idx_user_external_identities_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which second factors a user enrolled in, by plugin. Kept when the plugin is
|
||||
* disabled or removed, so login fails closed instead of skipping the factor.
|
||||
*/
|
||||
export const userSecondFactors = pgTable(
|
||||
"user_second_factors",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||
factorId: varchar("factor_id", { length: 255 }).notNull(),
|
||||
enrolledAt: text("enrolled_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_user_second_factors_user_factor").on(
|
||||
table.userId,
|
||||
table.pluginId,
|
||||
table.factorId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const webauthnCredentials = pgTable("webauthn_credentials", {
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
|
||||
@@ -111,6 +111,60 @@ export const trustedDevices = sqliteTable(
|
||||
(table) => [index("idx_trusted_devices_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* A sign-in identity from an external provider (OIDC, LDAP, GitHub, ...).
|
||||
* Replaces identifier strings like "ldap:<provider>:<id>" on users.
|
||||
*/
|
||||
export const userExternalIdentities = sqliteTable(
|
||||
"user_external_identities",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
providerId: text("provider_id").notNull(),
|
||||
/** The provider's id for the user, at most 255 characters. */
|
||||
subject: text("subject").notNull(),
|
||||
email: text("email"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_user_external_identities_provider_subject").on(
|
||||
table.providerId,
|
||||
table.subject,
|
||||
),
|
||||
index("idx_user_external_identities_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which second factors a user enrolled in, by plugin. Kept when the plugin is
|
||||
* disabled or removed, so login fails closed instead of skipping the factor.
|
||||
*/
|
||||
export const userSecondFactors = sqliteTable(
|
||||
"user_second_factors",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
pluginId: text("plugin_id").notNull(),
|
||||
factorId: text("factor_id").notNull(),
|
||||
enrolledAt: text("enrolled_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_user_second_factors_user_factor").on(
|
||||
table.userId,
|
||||
table.pluginId,
|
||||
table.factorId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const webauthnCredentials = sqliteTable("webauthn_credentials", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
|
||||
@@ -37,6 +37,7 @@ import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
||||
import { UserAuthRepository } from "./user-auth-repository.js";
|
||||
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
|
||||
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
|
||||
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
||||
@@ -380,6 +381,13 @@ export function createCurrentPluginRepository(): PluginRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentUserAuthRepository(): UserAuthRepository {
|
||||
return new UserAuthRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("user_auth_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginPermissionGrantRepository(): PluginPermissionGrantRepository {
|
||||
return new PluginPermissionGrantRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import crypto from "crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { userExternalIdentities, userSecondFactors } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type UserExternalIdentityRecord =
|
||||
typeof userExternalIdentities.$inferSelect;
|
||||
export type UserSecondFactorRecord = typeof userSecondFactors.$inferSelect;
|
||||
|
||||
/** Subjects are indexed, so they stay within the portable key length. */
|
||||
export const MAX_EXTERNAL_SUBJECT_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Keeps a subject within the index length. Longer ones are hashed, which is
|
||||
* stable, so the same provider answer always finds the same row.
|
||||
*/
|
||||
export function normalizeExternalSubject(subject: string): string {
|
||||
if (subject.length <= MAX_EXTERNAL_SUBJECT_LENGTH) return subject;
|
||||
return `sha256:${crypto.createHash("sha256").update(subject).digest("hex")}`;
|
||||
}
|
||||
|
||||
/** External sign-in identities and second-factor enrolments. */
|
||||
export class UserAuthRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findIdentity(
|
||||
providerId: string,
|
||||
rawSubject: string,
|
||||
): Promise<UserExternalIdentityRecord | null> {
|
||||
const subject = normalizeExternalSubject(rawSubject);
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userExternalIdentities)
|
||||
.where(
|
||||
and(
|
||||
eq(userExternalIdentities.providerId, providerId),
|
||||
eq(userExternalIdentities.subject, subject),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listIdentitiesForUser(
|
||||
userId: string,
|
||||
): Promise<UserExternalIdentityRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(userExternalIdentities)
|
||||
.where(eq(userExternalIdentities.userId, userId));
|
||||
}
|
||||
|
||||
async listAllIdentities(): Promise<UserExternalIdentityRecord[]> {
|
||||
return this.context.drizzle.select().from(userExternalIdentities);
|
||||
}
|
||||
|
||||
/** Links or moves an identity to a user. Idempotent. */
|
||||
async linkIdentity(input: {
|
||||
userId: string;
|
||||
providerId: string;
|
||||
subject: string;
|
||||
email?: string | null;
|
||||
}): Promise<void> {
|
||||
const subject = normalizeExternalSubject(input.subject);
|
||||
const existing = await this.findIdentity(input.providerId, subject);
|
||||
if (existing) {
|
||||
if (existing.userId === input.userId) return;
|
||||
await this.context.drizzle
|
||||
.update(userExternalIdentities)
|
||||
.set({ userId: input.userId, email: input.email ?? existing.email })
|
||||
.where(eq(userExternalIdentities.id, existing.id));
|
||||
} else {
|
||||
await this.context.drizzle.insert(userExternalIdentities).values({
|
||||
userId: input.userId,
|
||||
providerId: input.providerId,
|
||||
subject,
|
||||
email: input.email ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async unlinkIdentitiesForUser(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(userExternalIdentities)
|
||||
.where(eq(userExternalIdentities.userId, userId));
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async moveIdentities(fromUserId: string, toUserId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(userExternalIdentities)
|
||||
.set({ userId: toUserId })
|
||||
.where(eq(userExternalIdentities.userId, fromUserId));
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async listSecondFactors(userId: string): Promise<UserSecondFactorRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(userSecondFactors)
|
||||
.where(eq(userSecondFactors.userId, userId));
|
||||
}
|
||||
|
||||
async listAllSecondFactors(): Promise<UserSecondFactorRecord[]> {
|
||||
return this.context.drizzle.select().from(userSecondFactors);
|
||||
}
|
||||
|
||||
async recordSecondFactor(
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
): Promise<void> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userSecondFactors)
|
||||
.where(
|
||||
and(
|
||||
eq(userSecondFactors.userId, userId),
|
||||
eq(userSecondFactors.pluginId, pluginId),
|
||||
eq(userSecondFactors.factorId, factorId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (rows[0]) return;
|
||||
await this.context.drizzle.insert(userSecondFactors).values({
|
||||
userId,
|
||||
pluginId,
|
||||
factorId,
|
||||
enrolledAt: new Date().toISOString(),
|
||||
});
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async removeSecondFactor(
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(userSecondFactors)
|
||||
.where(
|
||||
and(
|
||||
eq(userSecondFactors.userId, userId),
|
||||
eq(userSecondFactors.pluginId, pluginId),
|
||||
eq(userSecondFactors.factorId, factorId),
|
||||
),
|
||||
);
|
||||
const affected = rowsAffected(result) > 0;
|
||||
if (affected) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async clearSecondFactors(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(userSecondFactors)
|
||||
.where(eq(userSecondFactors.userId, userId));
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* The generic login surface: every registered login method and second factor
|
||||
* is reachable here, core's and plugins' alike. The older per-method routes
|
||||
* (/users/login, /users/oidc/*, /users/ldap/login, ...) stay as thin wrappers
|
||||
* so existing clients keep working.
|
||||
*/
|
||||
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
|
||||
import { ensureCoreLoginProviders } from "../../auth/core-auth.js";
|
||||
import {
|
||||
readPendingLogin,
|
||||
redirectWithLoginError,
|
||||
respondWithLogin,
|
||||
respondWithRedirectLogin,
|
||||
sendLoginError,
|
||||
verifySecondFactorAndRespond,
|
||||
evaluateSecondFactors,
|
||||
} from "../../auth/login-pipeline.js";
|
||||
import { getLoginMethod, listLoginMethods } from "../../auth/registry.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
||||
|
||||
export interface PublicLoginMethod {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
kind: "redirect" | "form";
|
||||
labelKey: string;
|
||||
icon?: string;
|
||||
instances: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
/** What the login screen may know: ids, labels and enabled instances. */
|
||||
export async function listPublicLoginMethods(): Promise<PublicLoginMethod[]> {
|
||||
ensureCoreLoginProviders();
|
||||
const methods: PublicLoginMethod[] = [];
|
||||
for (const method of listLoginMethods()) {
|
||||
let instances: Array<{ id: string; label: string }> = [];
|
||||
if (method.describe) {
|
||||
try {
|
||||
instances = (await method.describe())
|
||||
.filter((instance) => instance.enabled)
|
||||
.map((instance) => ({ id: instance.id, label: instance.label }));
|
||||
} catch (error) {
|
||||
authLogger.warn("Login method could not describe itself", {
|
||||
operation: "login_method_describe",
|
||||
methodId: method.id,
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (instances.length === 0) continue;
|
||||
}
|
||||
methods.push({
|
||||
id: method.id,
|
||||
pluginId: method.pluginId,
|
||||
kind: method.kind,
|
||||
labelKey: method.labelKey,
|
||||
icon: method.icon,
|
||||
instances,
|
||||
});
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
|
||||
function instanceParam(req: Request): string | null {
|
||||
const value = req.query.instance ?? req.body?.instanceId;
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function methodOr404(req: Request, res: Response) {
|
||||
ensureCoreLoginProviders();
|
||||
const method = getLoginMethod(String(req.params.methodId));
|
||||
if (!method) {
|
||||
res.status(404).json({ error: "Unknown login method" });
|
||||
return null;
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/methods:
|
||||
* get:
|
||||
* summary: List login methods
|
||||
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login methods.
|
||||
*/
|
||||
router.get("/auth/methods", async (_req, res) => {
|
||||
try {
|
||||
res.json({ methods: await listPublicLoginMethods() });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to list login methods", error);
|
||||
res.status(500).json({ error: "Failed to list login methods" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/second-factor/{factorId}/challenge:
|
||||
* post:
|
||||
* summary: Start a second factor
|
||||
* description: Returns whatever the factor needs the client to have before the user answers, for a login waiting on a second factor.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: factorId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Challenge data.
|
||||
* 401:
|
||||
* description: No pending login.
|
||||
* 404:
|
||||
* description: Factor not enabled for this user.
|
||||
*/
|
||||
router.post("/auth/second-factor/:factorId/challenge", async (req, res) => {
|
||||
try {
|
||||
const lookup = await readPendingLogin(req);
|
||||
if (!lookup) {
|
||||
return res.status(401).json({ error: "Invalid temporary token" });
|
||||
}
|
||||
const { required } = await evaluateSecondFactors(lookup.userId);
|
||||
const factor = required.find(
|
||||
(candidate) => candidate.id === req.params.factorId,
|
||||
);
|
||||
if (!factor) {
|
||||
return res.status(404).json({ error: "Second factor not enabled" });
|
||||
}
|
||||
res.json({
|
||||
challenge: (await factor.challenge?.(lookup.userId)) ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/second-factor/{factorId}/verify:
|
||||
* post:
|
||||
* summary: Finish a second factor
|
||||
* description: Checks the user's answer for a login waiting on a second factor and issues the session. The pending token comes from temp_token in the body or the pending-login cookie.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: factorId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful.
|
||||
* 401:
|
||||
* description: Wrong answer or no pending login.
|
||||
* 403:
|
||||
* description: An enrolled factor is unavailable.
|
||||
* 429:
|
||||
* description: Too many attempts.
|
||||
*/
|
||||
router.post("/auth/second-factor/:factorId/verify", async (req, res) => {
|
||||
try {
|
||||
await verifySecondFactorAndRespond(req, res, String(req.params.factorId));
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/{methodId}/start:
|
||||
* get:
|
||||
* summary: Start a redirect login
|
||||
* description: Returns the URL to send the browser to for a redirect login method, such as an SSO provider.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: methodId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: instance
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Redirect URL.
|
||||
* 404:
|
||||
* description: Unknown login method.
|
||||
*/
|
||||
router.get("/auth/:methodId/start", async (req, res) => {
|
||||
const method = methodOr404(req, res);
|
||||
if (!method) return;
|
||||
if (method.kind !== "redirect" || !method.start) {
|
||||
return res.status(400).json({ error: "Not a redirect login method" });
|
||||
}
|
||||
try {
|
||||
res.json(await method.start(req as never, instanceParam(req)));
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
const callback = async (req: Request, res: Response) => {
|
||||
const method = methodOr404(req, res);
|
||||
if (!method) return;
|
||||
if (!method.callback) {
|
||||
return res.status(400).json({ error: "Not a redirect login method" });
|
||||
}
|
||||
let identity: VerifiedIdentity;
|
||||
try {
|
||||
identity = await method.callback(req as never);
|
||||
} catch (error) {
|
||||
const returnTo = (error as { returnTo?: string }).returnTo;
|
||||
if (returnTo) return redirectWithLoginError(res, returnTo, error);
|
||||
return sendLoginError(res, error);
|
||||
}
|
||||
await respondWithRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
identity,
|
||||
{ methodId: method.id, rememberMe: !!identity.rememberMe },
|
||||
isOidcTokenCallback,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/{methodId}/callback:
|
||||
* get:
|
||||
* summary: Redirect login callback
|
||||
* description: Where a redirect login method's provider sends the browser back. Redirects to the app with a session, a second-factor step, or an error.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: methodId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Back to the app.
|
||||
* post:
|
||||
* summary: Redirect login callback (form post)
|
||||
* description: Same as the GET form, for providers that post their answer.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: methodId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Back to the app.
|
||||
*/
|
||||
router.get("/auth/:methodId/callback", callback);
|
||||
router.post("/auth/:methodId/callback", callback);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/{methodId}/verify:
|
||||
* post:
|
||||
* summary: Form login
|
||||
* description: Checks what the user typed for a form login method, then issues a session or asks for a second factor.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: methodId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful, or a second factor is required.
|
||||
* 401:
|
||||
* description: Wrong credentials.
|
||||
* 404:
|
||||
* description: Unknown login method.
|
||||
*/
|
||||
router.post("/auth/:methodId/verify", async (req, res) => {
|
||||
const method = methodOr404(req, res);
|
||||
if (!method) return;
|
||||
if (method.kind !== "form" || !method.verify) {
|
||||
return res.status(400).json({ error: "Not a form login method" });
|
||||
}
|
||||
try {
|
||||
const identity = await method.verify(req as never, instanceParam(req));
|
||||
await respondWithLogin(req, res, identity, {
|
||||
methodId: method.id,
|
||||
rememberMe: !!identity.rememberMe || !!req.body?.rememberMe,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof LoginMethodError)) {
|
||||
authLogger.error("Form login failed", error, { methodId: method.id });
|
||||
}
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,28 +4,56 @@ import type {
|
||||
CredentialBackend,
|
||||
} from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import ssh2Pkg from "ssh2";
|
||||
import type { Client as SshClient } from "ssh2";
|
||||
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||
import { connectHost } from "../../hosts/connect/connect-host.js";
|
||||
import type { SshConnectHost } from "../../hosts/connect/types.js";
|
||||
import { resolveHostById } from "../../hosts/host-resolver.js";
|
||||
|
||||
const { Client } = ssh2Pkg;
|
||||
function describeDeployError(err: unknown): string {
|
||||
const message = getErrorMessage(err, "Connection failed");
|
||||
if (message.includes("All configured authentication methods failed")) {
|
||||
return "Authentication failed. Please check your credentials and ensure the SSH service is running.";
|
||||
}
|
||||
if (message.includes("ENOTFOUND") || message.includes("ENOENT")) {
|
||||
return "Could not resolve hostname or connect to server.";
|
||||
}
|
||||
if (message.includes("ECONNREFUSED")) {
|
||||
return "Connection refused. The server may not be running or the port may be incorrect.";
|
||||
}
|
||||
if (message.includes("ETIMEDOUT")) {
|
||||
return "Connection timed out. Check your network connection and server availability.";
|
||||
}
|
||||
if (
|
||||
message.includes("authentication failed") ||
|
||||
message.includes("Permission denied")
|
||||
) {
|
||||
return "Authentication failed. Please check your username and password/key.";
|
||||
}
|
||||
if (message === "SSH connection timeout") return "Connection timeout";
|
||||
return message;
|
||||
}
|
||||
|
||||
async function deploySSHKeyToHost(
|
||||
hostConfig: Record<string, unknown>,
|
||||
host: SshConnectHost,
|
||||
userId: string,
|
||||
credData: CredentialBackend,
|
||||
): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
const publicKey = credData.publicKey as string;
|
||||
|
||||
let conn: SshClient;
|
||||
try {
|
||||
({ client: conn } = await connectHost(host, {
|
||||
userId,
|
||||
purpose: "credential-deploy",
|
||||
timeoutMs: 120000,
|
||||
}));
|
||||
} catch (error) {
|
||||
return { success: false, error: describeDeployError(error) };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const conn = new Client();
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
conn.destroy();
|
||||
resolve({ success: false, error: "Connection timeout" });
|
||||
}, 120000);
|
||||
|
||||
conn.on("ready", async () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await new Promise<void>((resolveCmd, rejectCmd) => {
|
||||
const cmdTimeout = setTimeout(() => {
|
||||
@@ -238,121 +266,7 @@ async function deploySSHKeyToHost(
|
||||
error: getErrorMessage(error, "Deployment failed"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
let errorMessage = err.message;
|
||||
|
||||
if (
|
||||
err.message.includes("All configured authentication methods failed")
|
||||
) {
|
||||
errorMessage =
|
||||
"Authentication failed. Please check your credentials and ensure the SSH service is running.";
|
||||
} else if (
|
||||
err.message.includes("ENOTFOUND") ||
|
||||
err.message.includes("ENOENT")
|
||||
) {
|
||||
errorMessage = "Could not resolve hostname or connect to server.";
|
||||
} else if (err.message.includes("ECONNREFUSED")) {
|
||||
errorMessage =
|
||||
"Connection refused. The server may not be running or the port may be incorrect.";
|
||||
} else if (err.message.includes("ETIMEDOUT")) {
|
||||
errorMessage =
|
||||
"Connection timed out. Check your network connection and server availability.";
|
||||
} else if (
|
||||
err.message.includes("authentication failed") ||
|
||||
err.message.includes("Permission denied")
|
||||
) {
|
||||
errorMessage =
|
||||
"Authentication failed. Please check your username and password/key.";
|
||||
}
|
||||
|
||||
resolve({ success: false, error: errorMessage });
|
||||
});
|
||||
|
||||
try {
|
||||
const connectionConfig: Record<string, unknown> = {
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
],
|
||||
cipher: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (hostConfig.authType === "password" && hostConfig.password) {
|
||||
connectionConfig.password = hostConfig.password;
|
||||
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
|
||||
try {
|
||||
const privateKey = hostConfig.privateKey as string;
|
||||
connectionConfig.privateKey = preparePrivateKeyForSSH2(
|
||||
privateKey,
|
||||
hostConfig.keyPassword as string | undefined,
|
||||
);
|
||||
|
||||
if (hostConfig.keyPassword) {
|
||||
connectionConfig.passphrase = hostConfig.keyPassword;
|
||||
}
|
||||
} catch (keyError) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Invalid SSH key format: ${getErrorMessage(keyError)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
conn.connect(connectionConfig);
|
||||
} catch (error) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: getErrorMessage(error, "Connection failed"),
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -456,6 +370,8 @@ export function registerCredentialDeployRoutes(
|
||||
error: "Public key is required for deployment",
|
||||
});
|
||||
}
|
||||
// Writing authorized_keys is an owner action, so ownership is checked
|
||||
// before the shared resolver fills in the credentials.
|
||||
const hostData = await repository.findHostByIdForUser(
|
||||
targetHostId,
|
||||
userId,
|
||||
@@ -468,57 +384,19 @@ export function registerCredentialDeployRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
const hostConfig = {
|
||||
ip: hostData.ip,
|
||||
port: hostData.port,
|
||||
username: hostData.username,
|
||||
authType: hostData.authType,
|
||||
password: hostData.password,
|
||||
privateKey: hostData.key,
|
||||
keyPassword: hostData.keyPassword,
|
||||
};
|
||||
|
||||
if (hostData.authType === "credential" && hostData.credentialId) {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Authentication required for credential resolution",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostCredential = await repository.findCredentialByIdForUser(
|
||||
hostData.credentialId as number,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostCredential) {
|
||||
hostConfig.authType = hostCredential.authType;
|
||||
hostConfig.username = hostCredential.username;
|
||||
|
||||
if (hostCredential.authType === "password") {
|
||||
hostConfig.password = hostCredential.password;
|
||||
} else if (hostCredential.authType === "key") {
|
||||
hostConfig.privateKey =
|
||||
hostCredential.privateKey || hostCredential.key;
|
||||
hostConfig.keyPassword = hostCredential.keyPassword;
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Host credential not found",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to resolve host credentials",
|
||||
});
|
||||
}
|
||||
const resolvedHost = await resolveHostById(targetHostId, userId);
|
||||
if (!resolvedHost) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Host credential not found",
|
||||
});
|
||||
}
|
||||
|
||||
const deployResult = await deploySSHKeyToHost(hostConfig, credData);
|
||||
const deployResult = await deploySSHKeyToHost(
|
||||
resolvedHost as unknown as SshConnectHost,
|
||||
userId,
|
||||
credData,
|
||||
);
|
||||
|
||||
if (deployResult.success) {
|
||||
res.json({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { listCredentialTypes } from "../../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
@@ -23,6 +25,12 @@ import {
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
/** Built-in password and key, plus any type a plugin offers for credentials. */
|
||||
function getCredentialTypes(): string[] {
|
||||
ensureCoreSshAuthProviders();
|
||||
return listCredentialTypes();
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
@@ -110,16 +118,17 @@ router.post(
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
|
||||
if (!["password", "key"].includes(authType)) {
|
||||
const credentialTypes = getCredentialTypes();
|
||||
if (!credentialTypes.includes(authType)) {
|
||||
authLogger.warn("Invalid auth type provided", {
|
||||
operation: "credential_create",
|
||||
userId,
|
||||
name,
|
||||
authType,
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Auth type must be "password" or "key"' });
|
||||
return res.status(400).json({
|
||||
error: `Auth type must be one of: ${credentialTypes.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -659,8 +668,14 @@ router.put(
|
||||
}
|
||||
if (updateData.username !== undefined)
|
||||
updateFields.username = updateData.username?.trim() || null;
|
||||
if (updateData.authType !== undefined)
|
||||
if (updateData.authType !== undefined) {
|
||||
if (!getCredentialTypes().includes(updateData.authType)) {
|
||||
return res.status(400).json({
|
||||
error: `Auth type must be one of: ${getCredentialTypes().join(", ")}`,
|
||||
});
|
||||
}
|
||||
updateFields.authType = updateData.authType;
|
||||
}
|
||||
if (updateData.keyType !== undefined)
|
||||
updateFields.keyType = updateData.keyType;
|
||||
|
||||
|
||||
@@ -1,76 +1,6 @@
|
||||
import type { Router } from "express";
|
||||
import type { LDAPProviderConfig } from "../../../types/index.js";
|
||||
import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { parseUserAgent } from "../../utils/user-agent-parser.js";
|
||||
import { isOIDCUserAllowed, loadProviderConfig } from "./user-oidc-utils.js";
|
||||
import ldap from "ldapjs";
|
||||
import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentSsoProviderRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
function ldapEscapeFilter(value: string): string {
|
||||
return value.replace(
|
||||
/[\\*()\x00]/g,
|
||||
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createLDAPClient(
|
||||
host: string,
|
||||
port: number,
|
||||
useTLS: boolean,
|
||||
): ldap.Client {
|
||||
const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`;
|
||||
return ldap.createClient({
|
||||
url,
|
||||
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function ldapBind(
|
||||
client: ldap.Client,
|
||||
dn: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.bind(dn, password, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapSearch(
|
||||
client: ldap.Client,
|
||||
base: string,
|
||||
filter: string,
|
||||
attributes: string[],
|
||||
): Promise<ldap.SearchEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: ldap.SearchEntry[] = [];
|
||||
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
|
||||
if (err) return reject(err);
|
||||
res.on("searchEntry", (entry) => entries.push(entry));
|
||||
res.on("error", reject);
|
||||
res.on("end", () => resolve(entries));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapUnbind(client: ldap.Client): void {
|
||||
try {
|
||||
client.unbind();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
import { verifyLdapLogin } from "../../auth/legacy/ldap-login.js";
|
||||
import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js";
|
||||
|
||||
export function registerLDAPAuthRoutes(router: Router): void {
|
||||
/**
|
||||
@@ -78,7 +8,7 @@ export function registerLDAPAuthRoutes(router: Router): void {
|
||||
* /users/ldap/login:
|
||||
* post:
|
||||
* summary: LDAP login
|
||||
* description: Authenticates a user against an LDAP server.
|
||||
* description: Authenticates a user against an LDAP server, then runs the same second factors and session issuing as every other login.
|
||||
* tags:
|
||||
* - SSO
|
||||
* requestBody:
|
||||
@@ -98,312 +28,27 @@ export function registerLDAPAuthRoutes(router: Router): void {
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful.
|
||||
* description: Login successful, or a second factor is required.
|
||||
* 400:
|
||||
* description: Missing fields.
|
||||
* 401:
|
||||
* description: Invalid credentials.
|
||||
* 403:
|
||||
* description: User not allowed.
|
||||
* description: User not allowed or registration disabled.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
* 429:
|
||||
* description: Too many login attempts.
|
||||
*/
|
||||
router.post("/ldap/login", async (req, res) => {
|
||||
const { providerId, username, password, rememberMe } = req.body as {
|
||||
providerId: number;
|
||||
username: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
};
|
||||
|
||||
if (!providerId || !username || !password) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "providerId, username, and password are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const provider =
|
||||
await createCurrentSsoProviderRepository().findById(providerId);
|
||||
if (!provider || provider.type !== "ldap" || !provider.enabled) {
|
||||
return res.status(404).json({ error: "LDAP provider not found" });
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to load LDAP provider", err);
|
||||
return res.status(500).json({ error: "Failed to load LDAP provider" });
|
||||
}
|
||||
|
||||
const providerResult = await loadProviderConfig(providerId);
|
||||
if (!providerResult) {
|
||||
return res.status(404).json({ error: "LDAP provider not found" });
|
||||
}
|
||||
const config = providerResult.config as unknown as LDAPProviderConfig;
|
||||
|
||||
if (
|
||||
!config.host ||
|
||||
!config.bindDN ||
|
||||
!config.userSearchBase ||
|
||||
!config.userSearchFilter
|
||||
) {
|
||||
return res.status(500).json({ error: "LDAP provider is misconfigured" });
|
||||
}
|
||||
|
||||
const serviceClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(serviceClient, config.bindDN, config.bindPassword);
|
||||
|
||||
const filter = config.userSearchFilter.replace(
|
||||
/\{\{username\}\}/g,
|
||||
ldapEscapeFilter(username),
|
||||
);
|
||||
const attrList = [
|
||||
config.usernameAttribute || "uid",
|
||||
config.displayNameAttribute || "cn",
|
||||
"mail",
|
||||
"email",
|
||||
];
|
||||
const entries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.userSearchBase,
|
||||
filter,
|
||||
attrList,
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
authLogger.warn("LDAP user not found", {
|
||||
operation: "ldap_login",
|
||||
username,
|
||||
});
|
||||
return res.status(401).json({ error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
const userEntry = entries[0];
|
||||
const userDN = userEntry.dn.toString();
|
||||
|
||||
const getAttr = (key: string): string => {
|
||||
const attr = userEntry.attributes.find((a) => a.type === key);
|
||||
return attr
|
||||
? Array.isArray(attr.values)
|
||||
? attr.values[0]
|
||||
: String(attr.values)
|
||||
: "";
|
||||
};
|
||||
|
||||
const ldapIdentifier =
|
||||
getAttr(config.usernameAttribute || "uid") || username;
|
||||
const displayName =
|
||||
getAttr(config.displayNameAttribute || "cn") || username;
|
||||
const email = getAttr("mail") || getAttr("email") || "";
|
||||
|
||||
if (config.allowedUsers) {
|
||||
if (
|
||||
!isOIDCUserAllowed(
|
||||
config.allowedUsers,
|
||||
ldapIdentifier,
|
||||
email || undefined,
|
||||
)
|
||||
) {
|
||||
authLogger.warn("LDAP user not in allowed list", {
|
||||
operation: "ldap_user_not_allowed",
|
||||
ldapIdentifier,
|
||||
});
|
||||
return res.status(403).json({ error: "User not allowed" });
|
||||
}
|
||||
}
|
||||
|
||||
// Re-bind with user's own credentials to verify password
|
||||
const userClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(userClient, userDN, password);
|
||||
} catch {
|
||||
authLogger.warn("LDAP bind failed - wrong password", {
|
||||
operation: "ldap_login",
|
||||
ldapIdentifier,
|
||||
});
|
||||
return res.status(401).json({ error: "Invalid username or password" });
|
||||
} finally {
|
||||
ldapUnbind(userClient);
|
||||
}
|
||||
|
||||
// Admin group check
|
||||
let isAdmin = false;
|
||||
if (config.adminGroup && config.groupSearchBase) {
|
||||
try {
|
||||
const groupFilter = `(member=${ldapEscapeFilter(userDN)})`;
|
||||
const groupEntries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.groupSearchBase,
|
||||
groupFilter,
|
||||
["cn", "dn"],
|
||||
);
|
||||
isAdmin = groupEntries.some((g) => {
|
||||
const cn = g.attributes.find((a) => a.type === "cn");
|
||||
const cnVal = cn
|
||||
? Array.isArray(cn.values)
|
||||
? cn.values[0]
|
||||
: String(cn.values)
|
||||
: "";
|
||||
return (
|
||||
cnVal === config.adminGroup ||
|
||||
g.dn.toString() === config.adminGroup
|
||||
);
|
||||
});
|
||||
} catch (groupErr) {
|
||||
authLogger.warn("LDAP group check failed", {
|
||||
operation: "ldap_group_check",
|
||||
error: groupErr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ldapUnbind(serviceClient);
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const oidcIdentifier = `ldap:${providerId}:${ldapIdentifier}`;
|
||||
const userRepository = createCurrentUserRepository();
|
||||
|
||||
let existingUser =
|
||||
await userRepository.findByOidcIdentifier(oidcIdentifier);
|
||||
|
||||
let userId: string;
|
||||
if (!existingUser) {
|
||||
let autoProvision = false;
|
||||
try {
|
||||
autoProvision = await createCurrentSettingsRepository().getBoolean(
|
||||
"oidc_auto_provision",
|
||||
false,
|
||||
);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
if (!autoProvision)
|
||||
autoProvision =
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
|
||||
"true";
|
||||
|
||||
const isFirst = (await userRepository.countAll()) === 0;
|
||||
|
||||
if (!isFirst && !autoProvision) {
|
||||
return res.status(403).json({ error: "Registration is disabled" });
|
||||
}
|
||||
|
||||
userId = nanoid();
|
||||
const createdUser = await userRepository.createFirstSsoUser({
|
||||
id: userId,
|
||||
username: displayName,
|
||||
passwordHash: "",
|
||||
isAdmin,
|
||||
isOidc: true,
|
||||
oidcIdentifier,
|
||||
ssoProviderId: providerId,
|
||||
});
|
||||
const isFirstFinal = createdUser.isFirstUser;
|
||||
|
||||
try {
|
||||
const defaultRoleName = isFirstFinal || isAdmin ? "admin" : "user";
|
||||
await createCurrentRoleRepository().assignRoleNameToUser({
|
||||
userId,
|
||||
roleName: defaultRoleName,
|
||||
grantedBy: userId,
|
||||
});
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionDurationMs =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
await authManager.registerOIDCUser(userId, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await userRepository.delete(userId);
|
||||
authLogger.error(
|
||||
"Failed to setup LDAP user encryption",
|
||||
encryptionError,
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to setup user security" });
|
||||
}
|
||||
|
||||
existingUser = await userRepository.findById(userId);
|
||||
if (!existingUser) {
|
||||
throw new Error("Created LDAP user could not be loaded");
|
||||
}
|
||||
} else {
|
||||
userId = existingUser.id;
|
||||
|
||||
// Sync admin status from group membership
|
||||
if (config.adminGroup && !!existingUser.isAdmin !== isAdmin) {
|
||||
existingUser =
|
||||
(await userRepository.update(userId, { isAdmin })) ?? existingUser;
|
||||
try {
|
||||
const newRoleName = isAdmin ? "admin" : "user";
|
||||
const oldRoleName = isAdmin ? "user" : "admin";
|
||||
await createCurrentRoleRepository().switchUserRoleName({
|
||||
userId,
|
||||
addRoleName: newRoleName,
|
||||
removeRoleName: oldRoleName,
|
||||
grantedBy: userId,
|
||||
});
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// Update display name if not dual-auth
|
||||
const isDualAuth =
|
||||
existingUser.passwordHash && existingUser.passwordHash.trim() !== "";
|
||||
if (!isDualAuth && existingUser.username !== displayName) {
|
||||
existingUser =
|
||||
(await userRepository.update(userId, { username: displayName })) ??
|
||||
existingUser;
|
||||
}
|
||||
}
|
||||
|
||||
const userRecord = existingUser;
|
||||
try {
|
||||
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
rememberMe: rememberMe || false,
|
||||
const identity = await verifyLdapLogin({ body: req.body, ip: req.ip });
|
||||
await respondWithLogin(req, res, identity, {
|
||||
methodId: "ldap",
|
||||
rememberMe: !!req.body?.rememberMe,
|
||||
});
|
||||
|
||||
authLogger.success("LDAP login successful", {
|
||||
operation: "ldap_login_complete",
|
||||
userId: userRecord.id,
|
||||
username: userRecord.username,
|
||||
});
|
||||
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
|
||||
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
.json({ success: true, message: "Login successful" });
|
||||
} catch (err) {
|
||||
ldapUnbind(serviceClient);
|
||||
authLogger.error("LDAP login failed", err);
|
||||
return res.status(500).json({ error: "LDAP authentication failed" });
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -69,4 +69,13 @@ router.use("/:pluginId", (req: Request, res: Response, next) => {
|
||||
pluginRouter(req, res, next);
|
||||
});
|
||||
|
||||
/**
|
||||
* Mounts the dispatcher. No auth in front of it: each plugin router runs
|
||||
* core auth itself and skips it only for the paths it declared public, so a
|
||||
* webhook or OIDC callback a plugin serves can actually be reached.
|
||||
*/
|
||||
export function mountPluginApi(app: express.Express): void {
|
||||
app.use("/plugin-api", router);
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -104,6 +104,72 @@ router.get("/public", async (_req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/public-manifest:
|
||||
* get:
|
||||
* summary: List the plugin frontends the login screen needs
|
||||
* description: >
|
||||
* No auth. The login screen draws login methods and second-factor steps
|
||||
* that plugins provide, before anyone has signed in. Returns only
|
||||
* enabled plugins that contribute login methods or second factors, with
|
||||
* the fields the browser loader needs and the ids they contribute.
|
||||
* Nothing operational: no capabilities, grants, settings or errors.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login-capable plugins.
|
||||
*/
|
||||
router.get("/public-manifest", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await listPreLoginPlugins());
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to list login plugins",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_list_public_manifest" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to list plugins" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Enabled plugins with login or second-factor UI, without admin data. */
|
||||
export async function listPreLoginPlugins(): Promise<
|
||||
Array<Record<string, unknown>>
|
||||
> {
|
||||
const records = await createCurrentPluginRepository().listAll();
|
||||
const { loader } = getPluginRuntime();
|
||||
return records.flatMap((record) => {
|
||||
if (record.state !== "enabled") return [];
|
||||
const loaded = loader.get(record.id);
|
||||
if (loaded?.state !== "active") return [];
|
||||
let manifest: PluginManifest;
|
||||
try {
|
||||
manifest = JSON.parse(record.manifestJson) as PluginManifest;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const auth = manifest?.contributes?.auth;
|
||||
const loginMethods = auth?.loginMethods ?? [];
|
||||
const secondFactors = auth?.secondFactors ?? [];
|
||||
if (loginMethods.length === 0 && secondFactors.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
enabled: true,
|
||||
state: loaded.state,
|
||||
contributes: { auth: { loginMethods, secondFactors } },
|
||||
dependencies: manifest.dependencies ?? {},
|
||||
optionalDependencies: manifest.optionalDependencies ?? {},
|
||||
...describePluginFrontend(loaded),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins:
|
||||
|
||||
@@ -4,7 +4,8 @@ import express, { type Request, type Response } from "express";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { connectHost } from "../../hosts/connect/connect-host.js";
|
||||
import type { SshConnectHost } from "../../hosts/connect/types.js";
|
||||
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
|
||||
import {
|
||||
createSnippetExecutionResult,
|
||||
@@ -586,22 +587,12 @@ router.post(
|
||||
.json({ error: "Notes cannot be executed on a host" });
|
||||
}
|
||||
|
||||
const { Client } = await import("ssh2");
|
||||
const host = await resolveHostById(parseInt(hostId), userId);
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const password = host.password;
|
||||
const privateKey = host.key;
|
||||
const passphrase = host.keyPassword;
|
||||
const authType = host.authType;
|
||||
|
||||
const conn = new Client();
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
const resolvedCommand = resolveSnippetCommand(
|
||||
snippet.content,
|
||||
{
|
||||
@@ -613,6 +604,13 @@ router.post(
|
||||
inputValues && typeof inputValues === "object" ? inputValues : {},
|
||||
);
|
||||
|
||||
const { client: conn } = await connectHost(
|
||||
host as unknown as SshConnectHost,
|
||||
{ userId, purpose: "snippet", timeoutMs: 30000 },
|
||||
);
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
const executePromise = new Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
@@ -621,130 +619,42 @@ router.post(
|
||||
const timeoutMs = getSnippetExecutionTimeoutMs();
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
|
||||
conn.on("ready", () => {
|
||||
conn.exec(resolvedCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
if (timeoutMs) {
|
||||
timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(
|
||||
new Error(`Command execution timeout (${timeoutMs / 1000}s)`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
stream.on("close", (exitCode: number | null) => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
resolve(
|
||||
createSnippetExecutionResult(exitCode, output, errorOutput),
|
||||
);
|
||||
});
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
host: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 30000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
timeout: 30000,
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
LC_CTYPE: "en_US.UTF-8",
|
||||
LC_MESSAGES: "en_US.UTF-8",
|
||||
LC_MONETARY: "en_US.UTF-8",
|
||||
LC_NUMERIC: "en_US.UTF-8",
|
||||
LC_TIME: "en_US.UTF-8",
|
||||
LC_COLLATE: "en_US.UTF-8",
|
||||
COLORTERM: "truecolor",
|
||||
},
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (authType === "password" && password) {
|
||||
config.password = password;
|
||||
} else if (authType === "key" && privateKey) {
|
||||
const cleanKey = (privateKey as string)
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (passphrase) {
|
||||
config.passphrase = passphrase;
|
||||
conn.exec(resolvedCommand, (err, stream) => {
|
||||
if (err) {
|
||||
conn.end();
|
||||
return reject(err);
|
||||
}
|
||||
} else if (password) {
|
||||
config.password = password;
|
||||
} else if (privateKey) {
|
||||
const cleanKey = (privateKey as string)
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (passphrase) {
|
||||
config.passphrase = passphrase;
|
||||
}
|
||||
}
|
||||
|
||||
conn.connect(config);
|
||||
if (timeoutMs) {
|
||||
timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(
|
||||
new Error(`Command execution timeout (${timeoutMs / 1000}s)`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
stream.on("close", (exitCode: number | null) => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
resolve(
|
||||
createSnippetExecutionResult(exitCode, output, errorOutput),
|
||||
);
|
||||
});
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const result = await executePromise;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import {
|
||||
listSshAuthProviders,
|
||||
listSshAuthTypeOwners,
|
||||
} from "../../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
|
||||
|
||||
export interface SshAuthProviderSummary {
|
||||
type: string;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
pluginId: string;
|
||||
fields: unknown[];
|
||||
credentialType: boolean;
|
||||
needsUserInteraction: boolean;
|
||||
supportsBackground: boolean;
|
||||
available: boolean;
|
||||
/** Set when the type belongs to a plugin that is disabled or missing. */
|
||||
missingPlugin?: { id: string; name: string };
|
||||
}
|
||||
|
||||
export function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
|
||||
ensureCoreSshAuthProviders();
|
||||
const summaries: SshAuthProviderSummary[] = listSshAuthProviders().map(
|
||||
(provider) => ({
|
||||
type: provider.type,
|
||||
labelKey: provider.labelKey,
|
||||
descriptionKey: provider.descriptionKey,
|
||||
pluginId: provider.pluginId,
|
||||
fields: provider.fields ?? [],
|
||||
credentialType: !!provider.credentialType,
|
||||
needsUserInteraction: !!provider.needsUserInteraction,
|
||||
supportsBackground: provider.supportsBackground !== false,
|
||||
available: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const known = new Set(summaries.map((summary) => summary.type));
|
||||
for (const owner of listSshAuthTypeOwners()) {
|
||||
if (known.has(owner.type)) continue;
|
||||
summaries.push({
|
||||
type: owner.type,
|
||||
labelKey: "",
|
||||
pluginId: owner.pluginId,
|
||||
fields: [],
|
||||
credentialType: false,
|
||||
needsUserInteraction: false,
|
||||
supportsBackground: false,
|
||||
available: false,
|
||||
missingPlugin: { id: owner.pluginId, name: owner.pluginName },
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh-auth/providers:
|
||||
* get:
|
||||
* summary: List SSH auth types
|
||||
* description: Every SSH auth type the server can connect with, from core and from plugins, plus types declared by plugins that are disabled so the host editor can explain them.
|
||||
* tags:
|
||||
* - SSH Auth
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Auth type list.
|
||||
* 401:
|
||||
* description: Not authenticated.
|
||||
*/
|
||||
router.get("/providers", authenticateJWT, (_req: Request, res: Response) => {
|
||||
res.json({ providers: summarizeSshAuthProviders() });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -94,6 +94,26 @@ function applyProviderDefaults(
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Enabled providers as the login screen sees them: no secrets. */
|
||||
export async function listPublicSsoProviders(): Promise<
|
||||
Array<{ id: number; name: string; type: string; displayOrder: number }>
|
||||
> {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||
return [{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 }];
|
||||
}
|
||||
|
||||
const providers =
|
||||
await createCurrentSsoProviderRepository().listEnabledPublic();
|
||||
|
||||
// With no provider rows, synthesize one from env vars so SSO login stays
|
||||
// available when it is configured purely through the environment.
|
||||
if (providers.length === 0 && envConfig) {
|
||||
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function registerSSOProviderRoutes(router: Router): void {
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
@@ -111,25 +131,7 @@ export function registerSSOProviderRoutes(router: Router): void {
|
||||
*/
|
||||
router.get("/sso-providers", async (_req, res) => {
|
||||
try {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||
return res.json([
|
||||
{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 },
|
||||
]);
|
||||
}
|
||||
|
||||
const providers =
|
||||
await createCurrentSsoProviderRepository().listEnabledPublic();
|
||||
|
||||
// If no DB providers exist, synthesize one from env vars so SSO login
|
||||
// remains available when configured purely via environment variables.
|
||||
if (providers.length === 0) {
|
||||
if (envConfig) {
|
||||
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(providers);
|
||||
res.json(await listPublicSsoProviders());
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list SSO providers", err);
|
||||
res.status(500).json({ error: "Failed to list SSO providers" });
|
||||
|
||||
@@ -9,8 +9,13 @@ import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import {
|
||||
listUserSecondFactors,
|
||||
resetUserSecondFactors,
|
||||
} from "../../auth/second-factor-admin.js";
|
||||
import type {
|
||||
UserRecord,
|
||||
UserRepository,
|
||||
@@ -730,6 +735,11 @@ export function registerUserAdminRoutes(
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
await createCurrentUserAuthRepository().removeSecondFactor(
|
||||
targetUser.id,
|
||||
"core",
|
||||
"totp",
|
||||
);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("admin_disable_totp");
|
||||
@@ -760,6 +770,126 @@ export function registerUserAdminRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/admin/{userId}/second-factors:
|
||||
* get:
|
||||
* summary: List a user's second factors (admin only)
|
||||
* description: Every second factor the user is enrolled in, and whether the plugin behind it is running.
|
||||
* tags:
|
||||
* - Users
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: userId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Enrolled factors.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
*/
|
||||
router.get(
|
||||
"/admin/:userId/second-factors",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const adminId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const adminUser = await userRepository.findById(adminId);
|
||||
if (!adminUser?.isAdmin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
const targetUser = await userRepository.findById(
|
||||
String(req.params.userId),
|
||||
);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
res.json({ factors: await listUserSecondFactors(targetUser.id) });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list second factors", err);
|
||||
res.status(500).json({ error: "Failed to list second factors" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/admin/{userId}/second-factors:
|
||||
* delete:
|
||||
* summary: Reset a user's second factors (admin only)
|
||||
* description: Removes every second factor the user enrolled in, including ones whose plugin is disabled or missing, and forgets their trusted devices. The user can sign in with their first factor and enrol again.
|
||||
* tags:
|
||||
* - Users
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: userId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Second factors reset.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
*/
|
||||
router.delete(
|
||||
"/admin/:userId/second-factors",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const adminId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const adminUser = await userRepository.findById(adminId);
|
||||
if (!adminUser?.isAdmin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
const targetUser = await userRepository.findById(
|
||||
String(req.params.userId),
|
||||
);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const removed = await resetUserSecondFactors(targetUser.id);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("admin_reset_second_factors");
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist second factor reset", saveError, {
|
||||
operation: "admin_reset_second_factors_save_failed",
|
||||
userId: targetUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: adminId,
|
||||
username: adminUser.username ?? adminId,
|
||||
action: "admin_reset_second_factors",
|
||||
resourceType: "user",
|
||||
resourceId: targetUser.id,
|
||||
resourceName: targetUser.username,
|
||||
details: JSON.stringify({ removed }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ message: "Second factors reset", removed });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to reset second factors", err);
|
||||
res.status(500).json({ error: "Failed to reset second factors" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/admin/export/{userId}:
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { RequestHandler, Router } from "express";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { createCurrentUserRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
createCurrentUserRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
|
||||
type UserOidcAccountRoutesDeps = {
|
||||
@@ -120,6 +123,12 @@ export function registerUserOidcAccountRoutes(
|
||||
scopes: oidcUser.scopes || "openid email profile",
|
||||
});
|
||||
|
||||
// Identities hang off the user row and would go with it.
|
||||
await createCurrentUserAuthRepository().moveIdentities(
|
||||
oidcUserId,
|
||||
targetUser.id,
|
||||
);
|
||||
|
||||
await authManager.revokeAllUserSessions(oidcUserId);
|
||||
authManager.logoutUser(oidcUserId);
|
||||
|
||||
@@ -265,6 +274,9 @@ export function registerUserOidcAccountRoutes(
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
});
|
||||
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
|
||||
targetUser.id,
|
||||
);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("unlink_oidc_explicit_save");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Router } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import QRCode from "qrcode";
|
||||
import speakeasy from "speakeasy";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
@@ -8,16 +9,14 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||
|
||||
import { isPasswordLoginSettingOn } from "../../auth/core-auth.js";
|
||||
import { verifySecondFactorAndRespond } from "../../auth/login-pipeline.js";
|
||||
import { TOTP_FACTOR_ID } from "../../auth/legacy/totp-factor.js";
|
||||
import {
|
||||
generateDeviceFingerprint,
|
||||
getDeviceId,
|
||||
parseUserAgent,
|
||||
} from "../../utils/user-agent-parser.js";
|
||||
import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentSessionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentTrustedDeviceRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
@@ -31,6 +30,17 @@ interface UserTotpRoutesDeps {
|
||||
isNativeAppRequest: NativeAppRequestChecker;
|
||||
}
|
||||
|
||||
const BACKUP_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
/** Eight characters from a CSPRNG; Math.random is not fit for secrets. */
|
||||
export function generateBackupCode(): string {
|
||||
let code = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += BACKUP_CODE_ALPHABET[crypto.randomInt(BACKUP_CODE_ALPHABET.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function verifyTotpReauth(
|
||||
userRecord: UserRecord,
|
||||
credential: string,
|
||||
@@ -245,11 +255,7 @@ export function registerUserTotpRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordLoginAllowed =
|
||||
await createCurrentSettingsRepository().getBoolean(
|
||||
"allow_password_login",
|
||||
true,
|
||||
);
|
||||
const passwordLoginAllowed = isPasswordLoginSettingOn();
|
||||
if (!passwordLoginAllowed) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
@@ -291,9 +297,7 @@ export function registerUserTotpRoutes(
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({ length: 8 }, () =>
|
||||
Math.random().toString(36).substring(2, 10).toUpperCase(),
|
||||
);
|
||||
const backupCodes = Array.from({ length: 8 }, () => generateBackupCode());
|
||||
|
||||
const backupCodesJson = JSON.stringify(backupCodes);
|
||||
const storedBackupCodes = userDataKey
|
||||
@@ -309,6 +313,11 @@ export function registerUserTotpRoutes(
|
||||
totpEnabled: true,
|
||||
totpBackupCodes: storedBackupCodes,
|
||||
});
|
||||
await createCurrentUserAuthRepository().recordSecondFactor(
|
||||
userId,
|
||||
"core",
|
||||
TOTP_FACTOR_ID,
|
||||
);
|
||||
|
||||
await createCurrentSessionRepository().revokeAllForUser(
|
||||
userId,
|
||||
@@ -419,6 +428,11 @@ export function registerUserTotpRoutes(
|
||||
totpSecret: null,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
await createCurrentUserAuthRepository().removeSecondFactor(
|
||||
userId,
|
||||
"core",
|
||||
TOTP_FACTOR_ID,
|
||||
);
|
||||
authLogger.info("Two-factor authentication disabled", {
|
||||
operation: "totp_disable",
|
||||
userId,
|
||||
@@ -503,9 +517,7 @@ export function registerUserTotpRoutes(
|
||||
.json({ error: "Incorrect password or invalid TOTP code" });
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({ length: 8 }, () =>
|
||||
Math.random().toString(36).substring(2, 10).toUpperCase(),
|
||||
);
|
||||
const backupCodes = Array.from({ length: 8 }, () => generateBackupCode());
|
||||
|
||||
const backupCodesJson = JSON.stringify(backupCodes);
|
||||
const storedBackupCodes = userDataKey
|
||||
@@ -560,180 +572,13 @@ export function registerUserTotpRoutes(
|
||||
* description: TOTP verification failed.
|
||||
*/
|
||||
router.post("/totp/verify-login", async (req, res) => {
|
||||
const { temp_token, totp_code, rememberMe } = req.body;
|
||||
|
||||
if (!temp_token || !totp_code) {
|
||||
if (!req.body?.temp_token || !req.body?.totp_code) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Token and TOTP code are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = await authManager.verifyJWTToken(temp_token);
|
||||
if (!decoded || !decoded.pendingTOTP) {
|
||||
return res.status(401).json({ error: "Invalid temporary token" });
|
||||
}
|
||||
|
||||
const userRecord = await createCurrentUserRepository().findById(
|
||||
decoded.userId,
|
||||
);
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const lockStatus = loginRateLimiter.isTOTPLocked(userRecord.id);
|
||||
if (lockStatus.locked) {
|
||||
authLogger.warn("TOTP verification blocked due to rate limiting", {
|
||||
operation: "totp_verify_blocked",
|
||||
userId: userRecord.id,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
});
|
||||
return res.status(429).json({
|
||||
error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
code: "TOTP_RATE_LIMITED",
|
||||
});
|
||||
}
|
||||
|
||||
loginRateLimiter.recordFailedTOTPAttempt(userRecord.id);
|
||||
|
||||
if (!userRecord.totpEnabled || !userRecord.totpSecret) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "TOTP not enabled for this user" });
|
||||
}
|
||||
|
||||
const userDataKey = authManager.getUserDataKey(userRecord.id);
|
||||
if (!userDataKey) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const totpSecret = LazyFieldEncryption.safeGetFieldValue(
|
||||
userRecord.totpSecret,
|
||||
userDataKey,
|
||||
userRecord.id,
|
||||
"totp_secret",
|
||||
);
|
||||
|
||||
if (!totpSecret) {
|
||||
await createCurrentUserRepository().update(userRecord.id, {
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"TOTP has been disabled due to password reset. Please set up TOTP again.",
|
||||
});
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: totpSecret,
|
||||
encoding: "base32",
|
||||
token: totp_code,
|
||||
window: 2,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
let backupCodes = [];
|
||||
try {
|
||||
backupCodes = userRecord.totpBackupCodes
|
||||
? JSON.parse(userRecord.totpBackupCodes)
|
||||
: [];
|
||||
} catch {
|
||||
backupCodes = [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(backupCodes)) {
|
||||
backupCodes = [];
|
||||
}
|
||||
|
||||
const backupIndex = backupCodes.indexOf(totp_code);
|
||||
|
||||
if (backupIndex === -1) {
|
||||
authLogger.warn("TOTP verification failed - invalid code", {
|
||||
operation: "totp_verify_failed",
|
||||
userId: userRecord.id,
|
||||
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(
|
||||
userRecord.id,
|
||||
),
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Invalid TOTP code",
|
||||
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(
|
||||
userRecord.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
backupCodes.splice(backupIndex, 1);
|
||||
await createCurrentUserRepository().update(userRecord.id, {
|
||||
totpBackupCodes: JSON.stringify(backupCodes),
|
||||
});
|
||||
}
|
||||
|
||||
loginRateLimiter.resetTOTPAttempts(userRecord.id);
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
if (rememberMe) {
|
||||
const deviceFingerprint = generateDeviceFingerprint(
|
||||
deviceInfo,
|
||||
getDeviceId(req),
|
||||
);
|
||||
if (deviceFingerprint) {
|
||||
await authManager.addTrustedDevice(
|
||||
userRecord.id,
|
||||
deviceFingerprint,
|
||||
deviceInfo.type,
|
||||
deviceInfo.deviceInfo,
|
||||
);
|
||||
authLogger.info("Device automatically trusted via Remember Me", {
|
||||
operation: "totp_auto_trust",
|
||||
userId: userRecord.id,
|
||||
deviceType: deviceInfo.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
rememberMe: !!rememberMe,
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
|
||||
authLogger.success("TOTP verification successful", {
|
||||
operation: "totp_verify_success",
|
||||
userId: userRecord.id,
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
success: true,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
username: userRecord.username,
|
||||
userId: userRecord.id,
|
||||
is_oidc: !!userRecord.isOidc,
|
||||
totp_enabled: !!userRecord.totpEnabled,
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
};
|
||||
|
||||
const timeoutValue = await createCurrentSettingsRepository().get(
|
||||
"session_timeout_hours",
|
||||
);
|
||||
const timeoutHours = timeoutValue ? parseInt(timeoutValue, 10) || 24 : 24;
|
||||
const maxAge = rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: timeoutHours * 60 * 60 * 1000;
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
.json(response);
|
||||
await verifySecondFactorAndRespond(req, res, TOTP_FACTOR_ID);
|
||||
} catch (err) {
|
||||
authLogger.error("TOTP verification failed", err);
|
||||
return res.status(500).json({ error: "TOTP verification failed" });
|
||||
|
||||
@@ -17,17 +17,14 @@ import { nanoid } from "nanoid";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
generateDeviceFingerprint,
|
||||
getDeviceId,
|
||||
parseUserAgent,
|
||||
} from "../../utils/user-agent-parser.js";
|
||||
|
||||
import {
|
||||
createCurrentUserRepository,
|
||||
createCurrentWebauthnCredentialRepository,
|
||||
getCurrentSettingValue,
|
||||
} from "../repositories/factory.js";
|
||||
import type { WebauthnCredentialRecord } from "../repositories/webauthn-credential-repository.js";
|
||||
import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
||||
|
||||
type UserVerification = "discouraged" | "preferred" | "required";
|
||||
type NativeAppRequestChecker = (req: Request) => boolean;
|
||||
@@ -130,6 +127,82 @@ function getCredentialForVerification(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a passkey assertion and says who signed in. A passkey that verified
|
||||
* the user (PIN or biometric) counts as the second factor too.
|
||||
*/
|
||||
export async function verifyPasskeyLogin(
|
||||
req: Request,
|
||||
): Promise<VerifiedIdentity> {
|
||||
const challenge = takeChallenge(
|
||||
authenticationChallenges,
|
||||
req.body?.challengeId,
|
||||
);
|
||||
if (!challenge) {
|
||||
throw new LoginMethodError("Authentication challenge expired", 400);
|
||||
}
|
||||
|
||||
const response = req.body?.response as AuthenticationResponseJSON | undefined;
|
||||
if (!response?.id) {
|
||||
throw new LoginMethodError("Invalid passkey response", 400);
|
||||
}
|
||||
|
||||
const credential =
|
||||
await createCurrentWebauthnCredentialRepository().findByCredentialId(
|
||||
response.id,
|
||||
);
|
||||
if (!credential) {
|
||||
throw new LoginMethodError("Passkey not recognized", 401);
|
||||
}
|
||||
if (challenge.userId && challenge.userId !== credential.userId) {
|
||||
throw new LoginMethodError("Passkey not recognized", 401);
|
||||
}
|
||||
|
||||
let verification: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rpID,
|
||||
credential: getCredentialForVerification(credential),
|
||||
requireUserVerification: challenge.userVerification === "required",
|
||||
advancedFIDOConfig: { userVerification: challenge.userVerification },
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.warn("WebAuthn authentication failed", {
|
||||
operation: "webauthn_auth_verify",
|
||||
credentialId: credential.id,
|
||||
userId: credential.userId,
|
||||
error: getErrorMessage(error, "Unknown"),
|
||||
});
|
||||
throw new LoginMethodError("Passkey authentication failed", 401);
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new LoginMethodError("Passkey authentication failed", 401);
|
||||
}
|
||||
|
||||
await createCurrentWebauthnCredentialRepository().updateAuthState(
|
||||
credential.id,
|
||||
{
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
backedUp: verification.authenticationInfo.credentialBackedUp,
|
||||
deviceType: verification.authenticationInfo.credentialDeviceType,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
kind: "user",
|
||||
userId: credential.userId,
|
||||
mfaSatisfied: verification.authenticationInfo.userVerified === true,
|
||||
rememberMe: !!req.body?.rememberMe,
|
||||
unlockError:
|
||||
"Passkey cannot unlock this account. Log in with password and register the passkey again.",
|
||||
};
|
||||
}
|
||||
|
||||
export function registerUserWebAuthnRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps,
|
||||
@@ -412,140 +485,14 @@ export function registerUserWebAuthnRoutes(
|
||||
* description: Passkey not recognized or authentication failed.
|
||||
*/
|
||||
router.post("/webauthn/authenticate/verify", async (req, res) => {
|
||||
const challenge = takeChallenge(
|
||||
authenticationChallenges,
|
||||
req.body?.challengeId,
|
||||
);
|
||||
if (!challenge) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Authentication challenge expired" });
|
||||
}
|
||||
|
||||
const response = req.body?.response as
|
||||
AuthenticationResponseJSON | undefined;
|
||||
if (!response?.id) {
|
||||
return res.status(400).json({ error: "Invalid passkey response" });
|
||||
}
|
||||
|
||||
const credential =
|
||||
await createCurrentWebauthnCredentialRepository().findByCredentialId(
|
||||
response.id,
|
||||
);
|
||||
|
||||
if (!credential) {
|
||||
return res.status(401).json({ error: "Passkey not recognized" });
|
||||
}
|
||||
|
||||
if (challenge.userId && challenge.userId !== credential.userId) {
|
||||
return res.status(401).json({ error: "Passkey not recognized" });
|
||||
}
|
||||
|
||||
try {
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rpID,
|
||||
credential: getCredentialForVerification(credential),
|
||||
requireUserVerification: challenge.userVerification === "required",
|
||||
advancedFIDOConfig: {
|
||||
userVerification: challenge.userVerification,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
return res.status(401).json({ error: "Passkey authentication failed" });
|
||||
}
|
||||
|
||||
const userRecord = await createCurrentUserRepository().findById(
|
||||
credential.userId,
|
||||
);
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const authenticated = await authManager.authenticateWebAuthnUser(
|
||||
userRecord.id,
|
||||
deviceInfo.type,
|
||||
);
|
||||
|
||||
if (!authenticated) {
|
||||
return res.status(401).json({
|
||||
error:
|
||||
"Passkey cannot unlock this account. Log in with password and register the passkey again.",
|
||||
});
|
||||
}
|
||||
|
||||
await createCurrentWebauthnCredentialRepository().updateAuthState(
|
||||
credential.id,
|
||||
{
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
backedUp: verification.authenticationInfo.credentialBackedUp,
|
||||
deviceType: verification.authenticationInfo.credentialDeviceType,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
userRecord.totpEnabled &&
|
||||
verification.authenticationInfo.userVerified !== true
|
||||
) {
|
||||
const deviceFingerprint = generateDeviceFingerprint(
|
||||
deviceInfo,
|
||||
getDeviceId(req),
|
||||
);
|
||||
const isTrusted = deviceFingerprint
|
||||
? await authManager.isTrustedDevice(userRecord.id, deviceFingerprint)
|
||||
: false;
|
||||
|
||||
if (!isTrusted) {
|
||||
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
||||
pendingTOTP: true,
|
||||
expiresIn: "10m",
|
||||
});
|
||||
return res.json({
|
||||
success: true,
|
||||
requires_totp: true,
|
||||
temp_token: tempToken,
|
||||
rememberMe: !!req.body?.rememberMe,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
const identity = await verifyPasskeyLogin(req);
|
||||
await respondWithLogin(req, res, identity, {
|
||||
methodId: "passkey",
|
||||
rememberMe: !!req.body?.rememberMe,
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
|
||||
const timeoutSetting = getCurrentSettingValue("session_timeout_hours");
|
||||
const timeoutHours = timeoutSetting
|
||||
? parseInt(timeoutSetting, 10) || 24
|
||||
: 24;
|
||||
const maxAge = req.body?.rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: timeoutHours * 60 * 60 * 1000;
|
||||
|
||||
res.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge));
|
||||
res.json({
|
||||
success: true,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
username: userRecord.username,
|
||||
userId: userRecord.id,
|
||||
is_oidc: !!userRecord.isOidc,
|
||||
totp_enabled: !!userRecord.totpEnabled,
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.warn("WebAuthn authentication failed", {
|
||||
operation: "webauthn_auth_verify",
|
||||
credentialId: credential.id,
|
||||
userId: credential.userId,
|
||||
error: getErrorMessage(error, "Unknown"),
|
||||
});
|
||||
res.status(401).json({ error: "Passkey authentication failed" });
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,452 +0,0 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { resolveExternalSecretRefs } from "./external-secrets.js";
|
||||
import { sshLogger, authLogger } from "../utils/logger.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
|
||||
interface ResolvedCredentials {
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: Buffer;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
certPublicKey?: string;
|
||||
}
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
useWarpgate?: boolean;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
}
|
||||
|
||||
interface AuthContext {
|
||||
userId: string;
|
||||
ws: WebSocket;
|
||||
hostId: number;
|
||||
isKeyboardInteractive: boolean;
|
||||
keyboardInteractiveResponded: boolean;
|
||||
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
|
||||
totpPromptSent: boolean;
|
||||
warpgateAuthPromptSent: boolean;
|
||||
totpTimeout: NodeJS.Timeout | null;
|
||||
warpgateAuthTimeout: NodeJS.Timeout | null;
|
||||
totpAttempts: number;
|
||||
}
|
||||
|
||||
export class SSHAuthManager {
|
||||
public context: AuthContext;
|
||||
|
||||
constructor(context: AuthContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async resolveCredentials(
|
||||
hostConfig: HostConfig,
|
||||
): Promise<ResolvedCredentials> {
|
||||
let resolvedCredentials: ResolvedCredentials = {
|
||||
username: hostConfig.username,
|
||||
authType: hostConfig.authType || "none",
|
||||
};
|
||||
|
||||
if (hostConfig.credentialId) {
|
||||
const cred =
|
||||
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||
hostConfig.credentialId,
|
||||
this.context.userId,
|
||||
);
|
||||
|
||||
if (cred) {
|
||||
// Credentials may hold secret references instead of secrets.
|
||||
await resolveExternalSecretRefs(
|
||||
cred as unknown as Record<string, unknown>,
|
||||
this.context.userId,
|
||||
);
|
||||
resolvedCredentials = {
|
||||
username: (cred.username as string) || hostConfig.username,
|
||||
password: (cred.password as string) || undefined,
|
||||
key:
|
||||
cred.key || cred.privateKey
|
||||
? Buffer.from((cred.key || cred.privateKey) as string)
|
||||
: undefined,
|
||||
keyPassword: (cred.keyPassword as string) || undefined,
|
||||
authType: (cred.authType as string) || "none",
|
||||
certPublicKey: (cred.certPublicKey as string) || undefined,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (hostConfig.password) {
|
||||
resolvedCredentials.password = hostConfig.password;
|
||||
resolvedCredentials.authType = "password";
|
||||
}
|
||||
|
||||
if (hostConfig.key) {
|
||||
resolvedCredentials.key = Buffer.from(hostConfig.key, "utf8");
|
||||
resolvedCredentials.authType = "key";
|
||||
|
||||
if (hostConfig.keyPassword) {
|
||||
resolvedCredentials.keyPassword = hostConfig.keyPassword;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedCredentials;
|
||||
}
|
||||
|
||||
handleKeyboardInteractive(
|
||||
name: string,
|
||||
instructions: string,
|
||||
instructionsLang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
hostConfig?: HostConfig,
|
||||
): void {
|
||||
this.context.isKeyboardInteractive = true;
|
||||
const promptTexts = prompts.map((p) => p.prompt);
|
||||
|
||||
const warpgatePattern = /warpgate\s+authentication/i;
|
||||
const isWarpgate =
|
||||
warpgatePattern.test(name) ||
|
||||
warpgatePattern.test(instructions) ||
|
||||
promptTexts.some((p) => warpgatePattern.test(p));
|
||||
|
||||
if (isWarpgate) {
|
||||
this.handleWarpgateAuth(name, instructions, promptTexts, finish);
|
||||
return;
|
||||
}
|
||||
|
||||
// FortiToken-style prompt: asks the user to type a code OR the literal
|
||||
// word "push" (e.g. "Enter your Token or type 'push' to receive a push
|
||||
// notification:"). Unlike JumpCloud/DUO's confirm-only push, this needs a
|
||||
// text field, so it must not fall into the empty-answerable push pattern
|
||||
// below. Checked first since its text also contains "push notification".
|
||||
const fortiTokenPattern = /type\s+['"]?push['"]?/i;
|
||||
const isFortiTokenPrompt = promptTexts.some((p) =>
|
||||
fortiTokenPattern.test(p),
|
||||
);
|
||||
|
||||
// JumpCloud Protect / DUO-style push MFA: a menu choice ("Choose [1] Push,
|
||||
// or [2] TOTP:") followed by an empty-answerable confirm ("Press enter to
|
||||
// send Push request:"). Checked before the TOTP regex because the menu
|
||||
// prompt's own text ("...or [2] TOTP:") would otherwise match it and get
|
||||
// misrouted into the numeric-code flow.
|
||||
const pushPromptPattern =
|
||||
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
|
||||
const isPushPrompt =
|
||||
!isFortiTokenPrompt && promptTexts.some((p) => pushPromptPattern.test(p));
|
||||
|
||||
if (isPushPrompt) {
|
||||
sshLogger.info("Push/menu MFA prompt detected", {
|
||||
operation: "ssh_keyboard_interactive_push",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
const totpPromptIndex = prompts.findIndex((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
),
|
||||
);
|
||||
|
||||
if (totpPromptIndex !== -1) {
|
||||
this.handleTotpAuth(
|
||||
prompts,
|
||||
totpPromptIndex,
|
||||
finish,
|
||||
resolvedCredentials,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
|
||||
}
|
||||
|
||||
private handleWarpgateAuth(
|
||||
name: string,
|
||||
instructions: string,
|
||||
promptTexts: string[],
|
||||
finish: (responses: string[]) => void,
|
||||
): void {
|
||||
const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`;
|
||||
|
||||
const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i);
|
||||
const keyMatch = fullText.match(
|
||||
/security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i,
|
||||
);
|
||||
|
||||
if (urlMatch) {
|
||||
this.context.keyboardInteractiveFinish = () => {
|
||||
finish([""]);
|
||||
};
|
||||
|
||||
this.context.warpgateAuthPromptSent = true;
|
||||
|
||||
this.sendLog("auth", "info", "Warpgate authentication required");
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "warpgate_auth_required",
|
||||
url: urlMatch[0],
|
||||
securityKey: keyMatch ? keyMatch[1] : "N/A",
|
||||
instructions: instructions,
|
||||
}),
|
||||
);
|
||||
|
||||
this.context.warpgateAuthTimeout = setTimeout(() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.warpgateAuthPromptSent = false;
|
||||
sshLogger.warn("Warpgate authentication timeout", {
|
||||
operation: "warpgate_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Warpgate authentication timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 300000);
|
||||
}
|
||||
}
|
||||
|
||||
private handleTotpAuth(
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
totpPromptIndex: number,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
): void {
|
||||
if (this.context.totpPromptSent) {
|
||||
sshLogger.warn("TOTP prompt asked again - invalid code", {
|
||||
operation: "ssh_keyboard_interactive_totp_retry",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
authLogger.warn("TOTP verification failed for SSH session", {
|
||||
operation: "terminal_totp_failed",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
|
||||
this.sendLog("auth", "warning", "Invalid TOTP code");
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_retry",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.totpPromptSent = true;
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
|
||||
this.context.keyboardInteractiveFinish = (totpResponses: string[]) => {
|
||||
const totpCode = (totpResponses[0] || "").trim();
|
||||
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === totpPromptIndex) {
|
||||
return totpCode;
|
||||
}
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
};
|
||||
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
}
|
||||
|
||||
this.context.totpTimeout = setTimeout(() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.totpPromptSent = false;
|
||||
sshLogger.warn("TOTP prompt timeout", {
|
||||
operation: "totp_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "TOTP verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
this.sendLog("auth", "info", "TOTP verification required");
|
||||
authLogger.info("TOTP verification prompt sent to client", {
|
||||
operation: "terminal_totp_prompt",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_required",
|
||||
prompt: prompts[totpPromptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handlePasswordAuth(
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
hostConfig?: HostConfig,
|
||||
): void {
|
||||
// For Warpgate hosts: auto-answer password prompts silently using stored credentials.
|
||||
// Warpgate sends a password prompt before its browser-verification round; we must
|
||||
// not show a UI prompt here -- the WarpgateDialog handles user interaction later.
|
||||
if (hostConfig?.useWarpgate) {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasStoredPassword =
|
||||
resolvedCredentials.password && resolvedCredentials.authType !== "none";
|
||||
|
||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
// Find the first prompt we can't auto-answer. This handles DUO/PAM challenges
|
||||
// that don't say "password" (e.g. "Passcode or option (1-N):").
|
||||
const firstUnansweredIndex = prompts.findIndex((p) => {
|
||||
if (/password/i.test(p.prompt) && hasStoredPassword) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (firstUnansweredIndex !== -1) {
|
||||
if (this.context.keyboardInteractiveResponded) {
|
||||
return;
|
||||
}
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
|
||||
const promptIndex =
|
||||
passwordPromptIndex !== -1 && !hasStoredPassword
|
||||
? passwordPromptIndex
|
||||
: firstUnansweredIndex;
|
||||
|
||||
const pushPromptPattern =
|
||||
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
|
||||
const isPushPrompt = pushPromptPattern.test(prompts[promptIndex].prompt);
|
||||
|
||||
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||
const userInput = (userResponses[0] || "").trim();
|
||||
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === promptIndex) {
|
||||
return userInput;
|
||||
}
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
};
|
||||
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
}
|
||||
|
||||
this.context.totpTimeout = setTimeout(
|
||||
() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
sshLogger.warn("Password prompt timeout", {
|
||||
operation: "password_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Password verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
isPushPrompt ? 300000 : 180000,
|
||||
);
|
||||
|
||||
this.sendLog("auth", "info", "Password authentication required");
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "password_required",
|
||||
prompt: prompts[promptIndex].prompt,
|
||||
echo: prompts[promptIndex].echo,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
}
|
||||
|
||||
sendLog(
|
||||
stage: string,
|
||||
level: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage,
|
||||
level,
|
||||
message,
|
||||
details,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
this.context.totpTimeout = null;
|
||||
}
|
||||
|
||||
if (this.context.warpgateAuthTimeout) {
|
||||
clearTimeout(this.context.warpgateAuthTimeout);
|
||||
this.context.warpgateAuthTimeout = null;
|
||||
}
|
||||
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.totpPromptSent = false;
|
||||
this.context.warpgateAuthPromptSent = false;
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Every SSH auth type the server knows how to connect with.
|
||||
*
|
||||
* Core registers password, key, credential, agent and none. The rest come from
|
||||
* plugins through ctx.auth.registerSshAuthProvider, or for 2.9.0 from
|
||||
* src/backend/auth/legacy-providers.ts. A type with no provider fails the
|
||||
* connect with a message naming the plugin that would provide it.
|
||||
*/
|
||||
|
||||
import type {
|
||||
KeyboardInteractiveInterceptor,
|
||||
SshAuthProvider,
|
||||
} from "./types.js";
|
||||
|
||||
const providers = new Map<string, SshAuthProvider>();
|
||||
const interceptors = new Map<string, KeyboardInteractiveInterceptor>();
|
||||
|
||||
export interface SshAuthTypeOwner {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which plugin declares which auth type, from every manifest on disk
|
||||
* including disabled ones. Set by the plugin runtime.
|
||||
*/
|
||||
let ownerSource: () => Array<SshAuthTypeOwner & { type: string }> = () => [];
|
||||
|
||||
export function setSshAuthTypeOwnerSource(
|
||||
source: () => Array<SshAuthTypeOwner & { type: string }>,
|
||||
): void {
|
||||
ownerSource = source;
|
||||
}
|
||||
|
||||
export function listSshAuthTypeOwners(): Array<
|
||||
SshAuthTypeOwner & { type: string }
|
||||
> {
|
||||
return ownerSource();
|
||||
}
|
||||
|
||||
export function findSshAuthTypeOwner(type: string): SshAuthTypeOwner | null {
|
||||
const owner = ownerSource().find((entry) => entry.type === type);
|
||||
return owner
|
||||
? { pluginId: owner.pluginId, pluginName: owner.pluginName }
|
||||
: null;
|
||||
}
|
||||
|
||||
export class SshAuthProviderMissingError extends Error {
|
||||
readonly code = "SSH_AUTH_PROVIDER_MISSING";
|
||||
constructor(
|
||||
readonly authType: string,
|
||||
readonly owner: SshAuthTypeOwner | null,
|
||||
) {
|
||||
super(
|
||||
owner
|
||||
? `This host uses ${authType}, which needs the ${owner.pluginName} plugin`
|
||||
: `This host uses ${authType}, which no enabled plugin provides`,
|
||||
);
|
||||
this.name = "SshAuthProviderMissingError";
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSshAuthProvider(provider: SshAuthProvider): () => void {
|
||||
const existing = providers.get(provider.type);
|
||||
if (existing && existing.pluginId !== provider.pluginId) {
|
||||
throw new Error(
|
||||
`SSH auth type "${provider.type}" is already provided by ${existing.pluginId}`,
|
||||
);
|
||||
}
|
||||
providers.set(provider.type, provider);
|
||||
return () => {
|
||||
if (providers.get(provider.type) === provider) {
|
||||
providers.delete(provider.type);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getSshAuthProvider(type: string): SshAuthProvider | undefined {
|
||||
return providers.get(type);
|
||||
}
|
||||
|
||||
/** Throws SshAuthProviderMissingError when nothing provides the type. */
|
||||
export function requireSshAuthProvider(type: string): SshAuthProvider {
|
||||
const provider = providers.get(type);
|
||||
if (!provider) {
|
||||
throw new SshAuthProviderMissingError(type, findSshAuthTypeOwner(type));
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
export function listSshAuthProviders(): SshAuthProvider[] {
|
||||
return [...providers.values()];
|
||||
}
|
||||
|
||||
/** Types a stored credential may use. */
|
||||
export function listCredentialTypes(): string[] {
|
||||
return listSshAuthProviders()
|
||||
.filter((provider) => provider.credentialType)
|
||||
.map((provider) => provider.type);
|
||||
}
|
||||
|
||||
export function registerKeyboardInteractiveInterceptor(
|
||||
interceptor: KeyboardInteractiveInterceptor,
|
||||
): () => void {
|
||||
interceptors.set(interceptor.id, interceptor);
|
||||
return () => {
|
||||
if (interceptors.get(interceptor.id) === interceptor) {
|
||||
interceptors.delete(interceptor.id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function listKeyboardInteractiveInterceptors(): KeyboardInteractiveInterceptor[] {
|
||||
return [...interceptors.values()];
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetSshAuthRegistryForTests(): void {
|
||||
providers.clear();
|
||||
interceptors.clear();
|
||||
ownerSource = () => [];
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
import { buildSSHAlgorithms } from "../../utils/ssh-algorithms.js";
|
||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
||||
import { resolveSshKeepalive } from "../ssh-keepalive.js";
|
||||
import {
|
||||
SshAuthProviderMissingError,
|
||||
requireSshAuthProvider,
|
||||
} from "./auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "./core-providers.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthEnv,
|
||||
SshAuthLog,
|
||||
SshAuthOutcome,
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
SshConnectPurpose,
|
||||
} from "./types.js";
|
||||
|
||||
interface PurposeDefaults {
|
||||
keepaliveIntervalMs: number;
|
||||
keepaliveCountMax: number;
|
||||
/** Let terminalConfig.keepalive* override the defaults. */
|
||||
hostKeepalive: boolean;
|
||||
readyTimeout: number;
|
||||
/** Socket idle timeout, when the transport set one. */
|
||||
socketTimeout?: number;
|
||||
env: Record<string, string> | null;
|
||||
}
|
||||
|
||||
const TERMINAL_ENV: Record<string, string> = {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
LC_CTYPE: "en_US.UTF-8",
|
||||
LC_MESSAGES: "en_US.UTF-8",
|
||||
LC_MONETARY: "en_US.UTF-8",
|
||||
LC_NUMERIC: "en_US.UTF-8",
|
||||
LC_TIME: "en_US.UTF-8",
|
||||
LC_COLLATE: "en_US.UTF-8",
|
||||
COLORTERM: "truecolor",
|
||||
};
|
||||
|
||||
const BASIC_ENV: Record<string, string> = {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
};
|
||||
|
||||
// The values each transport used before it moved onto the pipeline.
|
||||
const PURPOSE_DEFAULTS: Record<SshConnectPurpose, PurposeDefaults> = {
|
||||
terminal: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 5,
|
||||
hostKeepalive: true,
|
||||
readyTimeout: 120000,
|
||||
socketTimeout: 120000,
|
||||
env: TERMINAL_ENV,
|
||||
},
|
||||
"file-manager": {
|
||||
keepaliveIntervalMs: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
hostKeepalive: true,
|
||||
readyTimeout: 60000,
|
||||
env: TERMINAL_ENV,
|
||||
},
|
||||
"file-transfer": {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
tmux: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
metrics: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 30000,
|
||||
env: BASIC_ENV,
|
||||
},
|
||||
fleet: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 30000,
|
||||
env: BASIC_ENV,
|
||||
},
|
||||
tunnel: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: true,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
docker: {
|
||||
keepaliveIntervalMs: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
hostKeepalive: true,
|
||||
readyTimeout: 60000,
|
||||
env: TERMINAL_ENV,
|
||||
},
|
||||
"docker-console": {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
proxmox: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 20000,
|
||||
env: null,
|
||||
},
|
||||
snippet: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 30000,
|
||||
env: null,
|
||||
},
|
||||
"credential-deploy": {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 30000,
|
||||
env: null,
|
||||
},
|
||||
"jump-host": {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
"remote-desktop": {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
plugin: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 30000,
|
||||
env: BASIC_ENV,
|
||||
},
|
||||
};
|
||||
|
||||
export function getPurposeDefaults(
|
||||
purpose: SshConnectPurpose,
|
||||
): PurposeDefaults {
|
||||
return PURPOSE_DEFAULTS[purpose];
|
||||
}
|
||||
|
||||
export interface BuildConnectConfigOptions {
|
||||
/** Acting user: host key trust, certificates and tokens are per user. */
|
||||
userId: string;
|
||||
purpose: SshConnectPurpose;
|
||||
client: Client;
|
||||
/** Server-side host id when it differs from host.id (sync-id lookups). */
|
||||
serverHostId?: number;
|
||||
/** Terminal socket for the changed-host-key prompt. */
|
||||
hostKeySocket?: WebSocket | null;
|
||||
interactive?: boolean;
|
||||
log?: SshAuthLog;
|
||||
/** Applied after the purpose defaults and before auth is prepared. */
|
||||
overrides?: Partial<MutableConnectConfig>;
|
||||
}
|
||||
|
||||
export interface BuiltConnectConfig {
|
||||
config: MutableConnectConfig;
|
||||
provider: SshAuthProvider | null;
|
||||
outcome: SshAuthOutcome;
|
||||
env: SshAuthEnv;
|
||||
}
|
||||
|
||||
const noopLog: SshAuthLog = () => {};
|
||||
|
||||
export function stripIpv6Brackets(ip: string): string {
|
||||
return ip?.replace(/^\[|\]$/g, "") || ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ssh2 config for one host: base settings for the purpose, the
|
||||
* host verifier, then the auth provider's prepare step. Never connects.
|
||||
*/
|
||||
export async function buildConnectConfig(
|
||||
host: SshConnectHost,
|
||||
options: BuildConnectConfigOptions,
|
||||
): Promise<BuiltConnectConfig> {
|
||||
ensureCoreSshAuthProviders();
|
||||
|
||||
const defaults = PURPOSE_DEFAULTS[options.purpose];
|
||||
const authType = host.authType || "none";
|
||||
const serverHostId = options.serverHostId ?? host.id;
|
||||
const isJumpHost = options.purpose === "jump-host";
|
||||
const terminalConfig = (host.terminalConfig ?? undefined) as
|
||||
Record<string, unknown> | undefined;
|
||||
|
||||
const keepalive = defaults.hostKeepalive
|
||||
? resolveSshKeepalive(
|
||||
terminalConfig?.keepaliveInterval as number | undefined,
|
||||
terminalConfig?.keepaliveCountMax as number | undefined,
|
||||
defaults.keepaliveIntervalMs,
|
||||
defaults.keepaliveCountMax,
|
||||
)
|
||||
: {
|
||||
keepaliveInterval: defaults.keepaliveIntervalMs,
|
||||
keepaliveCountMax: defaults.keepaliveCountMax,
|
||||
};
|
||||
|
||||
const preloaded = serverHostId
|
||||
? await SSHHostKeyVerifier.preloadHostData(serverHostId)
|
||||
: null;
|
||||
|
||||
const config: MutableConnectConfig = {
|
||||
host: stripIpv6Brackets(host.ip),
|
||||
port: host.port || 22,
|
||||
username: host.username,
|
||||
// A jump hop with no auth never answered prompts before either.
|
||||
tryKeyboard: isJumpHost ? authType !== "none" : true,
|
||||
...keepalive,
|
||||
readyTimeout: defaults.readyTimeout,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
|
||||
serverHostId,
|
||||
host.ip,
|
||||
host.port || 22,
|
||||
options.hostKeySocket ?? null,
|
||||
options.userId,
|
||||
isJumpHost,
|
||||
preloaded,
|
||||
),
|
||||
algorithms: buildSSHAlgorithms(
|
||||
terminalConfig?.allowLegacyAlgorithms !== false,
|
||||
),
|
||||
};
|
||||
if (defaults.env) config.env = { ...defaults.env };
|
||||
if (defaults.socketTimeout) config.timeout = defaults.socketTimeout;
|
||||
|
||||
const env: SshAuthEnv = {
|
||||
client: options.client,
|
||||
userId: options.userId,
|
||||
hostId: serverHostId,
|
||||
purpose: options.purpose,
|
||||
interactive: options.interactive ?? false,
|
||||
log: options.log ?? noopLog,
|
||||
};
|
||||
|
||||
let provider: SshAuthProvider;
|
||||
try {
|
||||
provider = requireSshAuthProvider(authType);
|
||||
} catch (error) {
|
||||
if (error instanceof SshAuthProviderMissingError) {
|
||||
return {
|
||||
config,
|
||||
provider: null,
|
||||
env,
|
||||
outcome: {
|
||||
status: "error",
|
||||
code: "provider-missing",
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (provider.connectOptions) {
|
||||
Object.assign(config, provider.connectOptions(host, options.purpose));
|
||||
}
|
||||
if (options.overrides) {
|
||||
Object.assign(config, options.overrides);
|
||||
}
|
||||
|
||||
const outcome = await provider.prepare(config, host, env);
|
||||
return { config, provider, outcome, env };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* The SSH auth types core always provides: password, key, credential, agent
|
||||
* and none. Everything else is a plugin (or, in 2.9.0, a legacy provider).
|
||||
*/
|
||||
|
||||
import ssh2 from "ssh2";
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import {
|
||||
isPrivateKeyPassphraseError,
|
||||
preparePrivateKeyForSSH2,
|
||||
} from "../../utils/ssh-key-utils.js";
|
||||
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
||||
import { registerSshAuthProvider } from "./auth-provider-registry.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthEnv,
|
||||
SshAuthOutcome,
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
} from "./types.js";
|
||||
|
||||
function keyText(key: SshConnectHost["key"]): string {
|
||||
if (!key) return "";
|
||||
return Buffer.isBuffer(key) ? key.toString("utf8") : key;
|
||||
}
|
||||
|
||||
async function preparePassword(
|
||||
config: MutableConnectConfig,
|
||||
host: SshConnectHost,
|
||||
env: SshAuthEnv,
|
||||
): Promise<SshAuthOutcome> {
|
||||
if (!host.password) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "missing-secret",
|
||||
message: "Password authentication requested but no password provided",
|
||||
};
|
||||
}
|
||||
// Forced keyboard-interactive answers the password prompt from the same
|
||||
// value instead of offering it up front.
|
||||
if (!host.forceKeyboardInteractive) {
|
||||
config.password = host.password;
|
||||
}
|
||||
env.log("info", "Using password authentication");
|
||||
return { status: "ready" };
|
||||
}
|
||||
|
||||
async function prepareKey(
|
||||
config: MutableConnectConfig,
|
||||
host: SshConnectHost,
|
||||
env: SshAuthEnv,
|
||||
): Promise<SshAuthOutcome> {
|
||||
const key = keyText(host.key);
|
||||
if (!key.trim()) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "missing-secret",
|
||||
message: "SSH key authentication requested but no key provided",
|
||||
};
|
||||
}
|
||||
|
||||
const passphrase = host.keyPassword || undefined;
|
||||
try {
|
||||
config.privateKey = preparePrivateKeyForSSH2(key, passphrase);
|
||||
} catch (error) {
|
||||
if (isPrivateKeyPassphraseError(error)) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "passphrase-required",
|
||||
message:
|
||||
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error",
|
||||
code: "invalid-key",
|
||||
message: `SSH key format error: ${getErrorMessage(error, "Invalid private key format")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// A key that parses in our own helper can still be encrypted with no
|
||||
// passphrase given; ssh2 only notices at connect time otherwise.
|
||||
const parsed = ssh2.utils.parseKey(config.privateKey as Buffer, passphrase);
|
||||
if (parsed instanceof Error) {
|
||||
if (isPrivateKeyPassphraseError(parsed)) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "passphrase-required",
|
||||
message:
|
||||
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error",
|
||||
code: "invalid-key",
|
||||
message: `SSH key format error: ${parsed.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (passphrase) config.passphrase = passphrase;
|
||||
// Lets a server that wants key then password (or password only) still work.
|
||||
if (host.password) config.password = host.password;
|
||||
|
||||
const certPublicKey = host.certPublicKey;
|
||||
if (certPublicKey && certPublicKey.trim()) {
|
||||
try {
|
||||
const { setupCACertAuth } = await import("../opkssh-cert-auth.js");
|
||||
await setupCACertAuth(
|
||||
config,
|
||||
env.client,
|
||||
config.privateKey as Buffer,
|
||||
certPublicKey,
|
||||
host.username,
|
||||
passphrase,
|
||||
);
|
||||
env.log("info", "Using SSH key authentication with CA certificate");
|
||||
return { status: "ready" };
|
||||
} catch (certError) {
|
||||
env.log(
|
||||
"warning",
|
||||
`CA certificate setup failed, falling back to key only: ${getErrorMessage(certError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
env.log("info", "Using SSH key authentication");
|
||||
return { status: "ready" };
|
||||
}
|
||||
|
||||
export const passwordProvider: SshAuthProvider = {
|
||||
type: "password",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthPassword",
|
||||
credentialType: true,
|
||||
requiresSecret: true,
|
||||
prepare: preparePassword,
|
||||
};
|
||||
|
||||
export const keyProvider: SshAuthProvider = {
|
||||
type: "key",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthKey",
|
||||
credentialType: true,
|
||||
requiresSecret: true,
|
||||
prepare: prepareKey,
|
||||
};
|
||||
|
||||
/**
|
||||
* The resolver expands a credential into key or password before connect, so
|
||||
* this only runs when that expansion found something odd, like an unresolved
|
||||
* reference. It behaves like whichever secret is present.
|
||||
*/
|
||||
export const credentialProvider: SshAuthProvider = {
|
||||
type: "credential",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthCredential",
|
||||
requiresSecret: true,
|
||||
prepare: async (config, host, env) => {
|
||||
if (keyText(host.key).trim()) return prepareKey(config, host, env);
|
||||
if (host.password) return preparePassword(config, host, env);
|
||||
return {
|
||||
status: "error",
|
||||
code: "missing-secret",
|
||||
message: "The stored credential for this host could not be resolved",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const agentProvider: SshAuthProvider = {
|
||||
type: "agent",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthAgent",
|
||||
requiresSecret: true,
|
||||
prepare: async (config, host, env) => {
|
||||
const result = await applyAgentAuth(
|
||||
config,
|
||||
host.terminalConfig as Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
return { status: "error", code: "failed", message: result.error };
|
||||
}
|
||||
env.log("info", `SSH agent configured (socket: ${result.socketPath})`);
|
||||
return { status: "ready" };
|
||||
},
|
||||
};
|
||||
|
||||
export const noneProvider: SshAuthProvider = {
|
||||
type: "none",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthNone",
|
||||
// Keyboard-interactive only: a background poll would fire a live prompt
|
||||
// (a RADIUS or Duo push) with nobody there to answer it.
|
||||
supportsBackground: false,
|
||||
prepare: async (_config, _host, env) => {
|
||||
env.log("info", "Using keyboard-interactive authentication");
|
||||
return { status: "ready" };
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_SSH_AUTH_PROVIDERS: readonly SshAuthProvider[] = [
|
||||
passwordProvider,
|
||||
keyProvider,
|
||||
credentialProvider,
|
||||
agentProvider,
|
||||
noneProvider,
|
||||
];
|
||||
|
||||
export function registerBuiltinSshAuthProviders(): () => void {
|
||||
const disposers = BUILTIN_SSH_AUTH_PROVIDERS.map((provider) =>
|
||||
registerSshAuthProvider(provider),
|
||||
);
|
||||
return () => disposers.forEach((dispose) => dispose());
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* connectHost: the one way core and plugins open an SSH connection.
|
||||
*
|
||||
* Resolve the host (shared overrides, external secrets and owner decryption
|
||||
* all happen in resolveHostById), build the config through the host's auth
|
||||
* provider, open the transport, then connect with a keyboard-interactive
|
||||
* handler that either asks through a prompt channel or fills in the stored
|
||||
* password.
|
||||
*/
|
||||
|
||||
import { Client } from "ssh2";
|
||||
import { withConnection } from "../ssh-connection-pool.js";
|
||||
import { resolveHostById } from "../host-resolver.js";
|
||||
import { buildConnectConfig } from "./build-connect-config.js";
|
||||
import {
|
||||
createAutoKeyboardInteractiveHandler,
|
||||
createPromptKeyboardInteractiveHandler,
|
||||
type KeyboardInteractiveListener,
|
||||
} from "./keyboard-interactive.js";
|
||||
import { openSshTransport, type OpenTransportOptions } from "./transport.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthLog,
|
||||
SshAuthOutcome,
|
||||
SshConnectHost,
|
||||
SshConnectPurpose,
|
||||
SshPromptChannel,
|
||||
} from "./types.js";
|
||||
|
||||
export class SshConnectError extends Error {
|
||||
readonly code: string;
|
||||
constructor(readonly outcome: Exclude<SshAuthOutcome, { status: "ready" }>) {
|
||||
super(outcome.message);
|
||||
this.name = "SshConnectError";
|
||||
this.code =
|
||||
outcome.status === "error"
|
||||
? outcome.code
|
||||
: outcome.status === "interaction-required"
|
||||
? `${outcome.interaction}-required`
|
||||
: "retry";
|
||||
}
|
||||
}
|
||||
|
||||
export class SshHostNotFoundError extends Error {
|
||||
constructor(readonly hostId: number) {
|
||||
super("Host not found or access denied");
|
||||
this.name = "SshHostNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConnectHostOptions {
|
||||
/** Acting user. Access and credentials are resolved for them. */
|
||||
userId: string;
|
||||
purpose: SshConnectPurpose;
|
||||
/** Ask a person for keyboard-interactive answers. Without it, auto-fill. */
|
||||
prompt?: SshPromptChannel;
|
||||
/** Replaces the keyboard-interactive handler entirely. */
|
||||
keyboardInteractive?: KeyboardInteractiveListener;
|
||||
/** Config fields to force, e.g. a longer readyTimeout. */
|
||||
overrides?: Partial<MutableConnectConfig>;
|
||||
/** Overall timeout for reaching "ready". Defaults to 30s. */
|
||||
timeoutMs?: number;
|
||||
/** Use this client instead of a new one. */
|
||||
client?: Client;
|
||||
transport?: OpenTransportOptions;
|
||||
log?: SshAuthLog;
|
||||
}
|
||||
|
||||
export interface SshConnection {
|
||||
client: Client;
|
||||
jumpClient: Client | null;
|
||||
host: SshConnectHost;
|
||||
config: MutableConnectConfig;
|
||||
/** Ends the connection and its jump chain. Safe to call twice. */
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export async function resolveConnectHost(
|
||||
target: number | SshConnectHost,
|
||||
userId: string,
|
||||
): Promise<SshConnectHost> {
|
||||
if (typeof target !== "number") return target;
|
||||
const host = await resolveHostById(target, userId);
|
||||
if (!host) throw new SshHostNotFoundError(target);
|
||||
return host as unknown as SshConnectHost;
|
||||
}
|
||||
|
||||
export async function connectHost(
|
||||
target: number | SshConnectHost,
|
||||
options: ConnectHostOptions,
|
||||
): Promise<SshConnection> {
|
||||
const host = await resolveConnectHost(target, options.userId);
|
||||
const client = options.client ?? new Client();
|
||||
|
||||
const { config, provider, outcome, env } = await buildConnectConfig(host, {
|
||||
userId: options.userId,
|
||||
purpose: options.purpose,
|
||||
client,
|
||||
hostKeySocket: options.prompt?.hostKeySocket ?? null,
|
||||
interactive: !!options.prompt,
|
||||
log: options.log,
|
||||
overrides: options.overrides,
|
||||
});
|
||||
if (outcome.status !== "ready") {
|
||||
throw new SshConnectError(outcome);
|
||||
}
|
||||
|
||||
const { jumpClient } = await openSshTransport(host, config, {
|
||||
log: options.log,
|
||||
...options.transport,
|
||||
});
|
||||
|
||||
const keyboardInteractive =
|
||||
options.keyboardInteractive ??
|
||||
(options.prompt
|
||||
? createPromptKeyboardInteractiveHandler(host, options.prompt)
|
||||
: createAutoKeyboardInteractiveHandler(host));
|
||||
|
||||
let disposed = false;
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try {
|
||||
client.end();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
jumpClient?.end();
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
dispose();
|
||||
reject(new Error("SSH connection timeout"));
|
||||
}, options.timeoutMs ?? 30000);
|
||||
|
||||
client.on("ready", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on("error", (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
jumpClient?.end();
|
||||
const authFailed = /All configured authentication methods failed/i.test(
|
||||
error.message,
|
||||
);
|
||||
if (authFailed && provider?.onAuthFailed) {
|
||||
try {
|
||||
provider.onAuthFailed(host, env, {
|
||||
error,
|
||||
retries: 0,
|
||||
canRetry: false,
|
||||
methodNotAvailable: false,
|
||||
});
|
||||
} catch {
|
||||
// cleanup hooks never mask the connect error
|
||||
}
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
jumpClient?.end();
|
||||
});
|
||||
|
||||
client.on("keyboard-interactive", keyboardInteractive);
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
return { client, jumpClient, host, config, dispose };
|
||||
}
|
||||
|
||||
/** Pool key for a host and purpose, matching the keys the transports used. */
|
||||
export function getConnectionPoolKey(
|
||||
prefix: string,
|
||||
host: SshConnectHost,
|
||||
): string {
|
||||
const socks5Key = host.useSocks5
|
||||
? `:socks5:${host.socks5Host}:${host.socks5Port}`
|
||||
: "";
|
||||
return `${prefix}:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs fn on a pooled connection. The pool calls the factory only when it
|
||||
* has no idle connection for the key.
|
||||
*/
|
||||
export async function withHostConnection<T>(
|
||||
poolKey: string,
|
||||
target: number | SshConnectHost,
|
||||
options: ConnectHostOptions,
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const host = await resolveConnectHost(target, options.userId);
|
||||
return withConnection(
|
||||
poolKey,
|
||||
async () => (await connectHost(host, options)).client,
|
||||
fn,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { registerLegacySshAuthProviders } from "../../auth/legacy-providers.js";
|
||||
import { registerBuiltinSshAuthProviders } from "./builtin-providers.js";
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Registers core's providers once. Called lazily by the pipeline so tests and
|
||||
* scripts get the same set as a running server without a start-up hook.
|
||||
*/
|
||||
export function ensureCoreSshAuthProviders(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerBuiltinSshAuthProviders();
|
||||
registerLegacySshAuthProviders();
|
||||
}
|
||||
|
||||
/** Test helper, pairs with resetSshAuthRegistryForTests. */
|
||||
export function resetCoreSshAuthProvidersForTests(): void {
|
||||
registered = false;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* One classifier for keyboard-interactive rounds, shared by every transport.
|
||||
*
|
||||
* It only decides what a round is. Answering is up to the transport: the
|
||||
* terminal asks over its socket, the file manager parks the connection, and
|
||||
* background work fills in the stored password and nothing else.
|
||||
*/
|
||||
|
||||
import { listKeyboardInteractiveInterceptors } from "./auth-provider-registry.js";
|
||||
import type {
|
||||
KeyboardInteractiveDecision,
|
||||
KeyboardInteractivePrompt,
|
||||
SshConnectHost,
|
||||
SshPromptChannel,
|
||||
} from "./types.js";
|
||||
|
||||
const PASSWORD_PATTERN = /password/i;
|
||||
|
||||
// FortiToken asks for a code OR the word "push", so it needs a text field and
|
||||
// must not be treated as a confirm-only push. Checked before the push pattern
|
||||
// because its text also says "push notification".
|
||||
const FORTI_TOKEN_PATTERN = /type\s+['"]?push['"]?/i;
|
||||
|
||||
// JumpCloud Protect / DUO: a menu choice, then an empty-answerable confirm.
|
||||
// Checked before TOTP because "...or [2] TOTP:" would match that pattern.
|
||||
export const PUSH_PROMPT_PATTERN =
|
||||
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
|
||||
|
||||
const TOTP_PATTERN =
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i;
|
||||
|
||||
export interface KeyboardInteractiveRound {
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
}
|
||||
|
||||
/** Stored password for every password prompt, empty for the rest. */
|
||||
export function autoResponses(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
password: string | null | undefined,
|
||||
): string[] {
|
||||
return prompts.map((p) =>
|
||||
PASSWORD_PATTERN.test(p.prompt) && password ? password : "",
|
||||
);
|
||||
}
|
||||
|
||||
/** Puts the user's answer at one index and auto-fills the rest. */
|
||||
export function responsesWithAnswer(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
answerIndex: number,
|
||||
answer: string,
|
||||
password: string | null | undefined,
|
||||
): string[] {
|
||||
return prompts.map((p, index) => {
|
||||
if (index === answerIndex) return answer;
|
||||
if (PASSWORD_PATTERN.test(p.prompt) && password) return password;
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
function shouldAutoAnswerPasswords(host: SshConnectHost): boolean {
|
||||
return listKeyboardInteractiveInterceptors().some(
|
||||
(interceptor) => interceptor.autoAnswerPasswords?.(host) === true,
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyKeyboardInteractive(
|
||||
round: KeyboardInteractiveRound,
|
||||
host: SshConnectHost,
|
||||
): KeyboardInteractiveDecision {
|
||||
for (const interceptor of listKeyboardInteractiveInterceptors()) {
|
||||
const decision = interceptor.detect(round, host);
|
||||
if (decision) return decision;
|
||||
}
|
||||
|
||||
const { prompts } = round;
|
||||
const texts = prompts.map((p) => p.prompt);
|
||||
const isFortiToken = texts.some((p) => FORTI_TOKEN_PATTERN.test(p));
|
||||
const isPush =
|
||||
!isFortiToken && texts.some((p) => PUSH_PROMPT_PATTERN.test(p));
|
||||
|
||||
if (!isPush) {
|
||||
const totpIndex = prompts.findIndex((p) => TOTP_PATTERN.test(p.prompt));
|
||||
if (totpIndex !== -1) return { kind: "totp", promptIndex: totpIndex };
|
||||
}
|
||||
|
||||
if (shouldAutoAnswerPasswords(host)) {
|
||||
return { kind: "auto", responses: autoResponses(prompts, host.password) };
|
||||
}
|
||||
|
||||
const hasStoredPassword = !!host.password && host.authType !== "none";
|
||||
const passwordIndex = prompts.findIndex((p) =>
|
||||
PASSWORD_PATTERN.test(p.prompt),
|
||||
);
|
||||
// DUO/PAM challenges often don't say "password" at all.
|
||||
const firstUnanswered = prompts.findIndex(
|
||||
(p) => !(PASSWORD_PATTERN.test(p.prompt) && hasStoredPassword),
|
||||
);
|
||||
|
||||
if (firstUnanswered === -1) {
|
||||
return { kind: "auto", responses: autoResponses(prompts, host.password) };
|
||||
}
|
||||
|
||||
const promptIndex =
|
||||
passwordIndex !== -1 && !hasStoredPassword
|
||||
? passwordIndex
|
||||
: firstUnanswered;
|
||||
return {
|
||||
kind: "input",
|
||||
promptIndex,
|
||||
isPush: PUSH_PROMPT_PATTERN.test(prompts[promptIndex].prompt),
|
||||
};
|
||||
}
|
||||
|
||||
export type KeyboardInteractiveListener = (
|
||||
name: string,
|
||||
instructions: string,
|
||||
lang: string,
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
finish: (responses: string[]) => void,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* The handler for connections with nobody to ask: the stored password goes
|
||||
* to password prompts, everything else gets an empty answer. That is what
|
||||
* every background path did before the pipeline.
|
||||
*/
|
||||
export function createAutoKeyboardInteractiveHandler(
|
||||
host: SshConnectHost,
|
||||
): KeyboardInteractiveListener {
|
||||
return (_name, _instructions, _lang, prompts, finish) => {
|
||||
finish(autoResponses(prompts, host.password));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The handler for connections with a prompt channel. One question per round;
|
||||
* a second TOTP round means the last code was wrong.
|
||||
*/
|
||||
export function createPromptKeyboardInteractiveHandler(
|
||||
host: SshConnectHost,
|
||||
channel: SshPromptChannel,
|
||||
): KeyboardInteractiveListener {
|
||||
let totpAsked = false;
|
||||
|
||||
return (name, instructions, _lang, prompts, finish) => {
|
||||
const decision = classifyKeyboardInteractive(
|
||||
{ name, instructions, prompts },
|
||||
host,
|
||||
);
|
||||
|
||||
const ask = async (): Promise<string[]> => {
|
||||
switch (decision.kind) {
|
||||
case "auto":
|
||||
return decision.responses;
|
||||
case "warpgate": {
|
||||
await channel.ask({
|
||||
kind: "warpgate",
|
||||
url: decision.url,
|
||||
securityKey: decision.securityKey,
|
||||
instructions: decision.instructions,
|
||||
});
|
||||
return prompts.map(() => "");
|
||||
}
|
||||
case "totp": {
|
||||
const answer = await channel.ask({
|
||||
kind: "totp",
|
||||
prompt: prompts[decision.promptIndex].prompt,
|
||||
retry: totpAsked,
|
||||
});
|
||||
totpAsked = true;
|
||||
return responsesWithAnswer(
|
||||
prompts,
|
||||
decision.promptIndex,
|
||||
(answer ?? "").trim(),
|
||||
host.password,
|
||||
);
|
||||
}
|
||||
case "input": {
|
||||
const prompt = prompts[decision.promptIndex];
|
||||
const answer = await channel.ask({
|
||||
kind: "input",
|
||||
prompt: prompt.prompt,
|
||||
echo: prompt.echo,
|
||||
isPush: decision.isPush,
|
||||
});
|
||||
return responsesWithAnswer(
|
||||
prompts,
|
||||
decision.promptIndex,
|
||||
(answer ?? "").trim(),
|
||||
host.password,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ask().then(finish, () => finish(prompts.map(() => "")));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Keyboard-interactive answering over the terminal WebSocket.
|
||||
*
|
||||
* The classifier decides what a round is; this keeps the terminal's message
|
||||
* protocol (totp_required, totp_retry, password_required,
|
||||
* warpgate_auth_required) and its timeouts exactly as the client expects.
|
||||
*/
|
||||
|
||||
import type { WebSocket } from "ws";
|
||||
import { sshLogger, authLogger } from "../../utils/logger.js";
|
||||
import { ensureCoreSshAuthProviders } from "./core-providers.js";
|
||||
import {
|
||||
classifyKeyboardInteractive,
|
||||
responsesWithAnswer,
|
||||
} from "./keyboard-interactive.js";
|
||||
import type { KeyboardInteractivePrompt, SshConnectHost } from "./types.js";
|
||||
|
||||
interface ResolvedCredentials {
|
||||
username: string;
|
||||
password?: string;
|
||||
authType?: string;
|
||||
}
|
||||
|
||||
interface HostFlags {
|
||||
useWarpgate?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContext {
|
||||
userId: string;
|
||||
ws: WebSocket;
|
||||
hostId: number;
|
||||
isKeyboardInteractive: boolean;
|
||||
keyboardInteractiveResponded: boolean;
|
||||
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
|
||||
totpPromptSent: boolean;
|
||||
warpgateAuthPromptSent: boolean;
|
||||
totpTimeout: NodeJS.Timeout | null;
|
||||
warpgateAuthTimeout: NodeJS.Timeout | null;
|
||||
totpAttempts: number;
|
||||
}
|
||||
|
||||
const TOTP_TIMEOUT_MS = 180000;
|
||||
const PUSH_TIMEOUT_MS = 300000;
|
||||
const WARPGATE_TIMEOUT_MS = 300000;
|
||||
|
||||
export class SSHAuthManager {
|
||||
public context: AuthContext;
|
||||
|
||||
constructor(context: AuthContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
handleKeyboardInteractive(
|
||||
name: string,
|
||||
instructions: string,
|
||||
_instructionsLang: string,
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
hostConfig?: HostFlags,
|
||||
): void {
|
||||
ensureCoreSshAuthProviders();
|
||||
this.context.isKeyboardInteractive = true;
|
||||
|
||||
const host = {
|
||||
...(hostConfig ?? {}),
|
||||
id: this.context.hostId,
|
||||
ip: "",
|
||||
port: 22,
|
||||
username: resolvedCredentials.username,
|
||||
password: resolvedCredentials.password,
|
||||
authType: resolvedCredentials.authType,
|
||||
} as SshConnectHost;
|
||||
|
||||
const decision = classifyKeyboardInteractive(
|
||||
{ name, instructions, prompts },
|
||||
host,
|
||||
);
|
||||
|
||||
switch (decision.kind) {
|
||||
case "auto":
|
||||
finish(decision.responses);
|
||||
return;
|
||||
case "warpgate":
|
||||
this.handleWarpgate(decision, finish);
|
||||
return;
|
||||
case "totp":
|
||||
this.handleTotp(prompts, decision.promptIndex, finish, host.password);
|
||||
return;
|
||||
case "input":
|
||||
this.handleInput(
|
||||
prompts,
|
||||
decision.promptIndex,
|
||||
decision.isPush,
|
||||
finish,
|
||||
host.password,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleWarpgate(
|
||||
decision: { url: string | null; securityKey: string; instructions: string },
|
||||
finish: (responses: string[]) => void,
|
||||
): void {
|
||||
this.context.keyboardInteractiveFinish = () => {
|
||||
finish([""]);
|
||||
};
|
||||
this.context.warpgateAuthPromptSent = true;
|
||||
this.sendLog("auth", "info", "Warpgate authentication required");
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "warpgate_auth_required",
|
||||
url: decision.url,
|
||||
securityKey: decision.securityKey,
|
||||
instructions: decision.instructions,
|
||||
}),
|
||||
);
|
||||
|
||||
this.context.warpgateAuthTimeout = setTimeout(() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.warpgateAuthPromptSent = false;
|
||||
sshLogger.warn("Warpgate authentication timeout", {
|
||||
operation: "warpgate_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Warpgate authentication timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, WARPGATE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private handleTotp(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
promptIndex: number,
|
||||
finish: (responses: string[]) => void,
|
||||
password: string | undefined,
|
||||
): void {
|
||||
if (this.context.totpPromptSent) {
|
||||
sshLogger.warn("TOTP prompt asked again - invalid code", {
|
||||
operation: "ssh_keyboard_interactive_totp_retry",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
authLogger.warn("TOTP verification failed for SSH session", {
|
||||
operation: "terminal_totp_failed",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.sendLog("auth", "warning", "Invalid TOTP code");
|
||||
this.context.ws.send(JSON.stringify({ type: "totp_retry" }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.totpPromptSent = true;
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
this.context.keyboardInteractiveFinish = (answers: string[]) => {
|
||||
finish(
|
||||
responsesWithAnswer(
|
||||
prompts,
|
||||
promptIndex,
|
||||
(answers[0] || "").trim(),
|
||||
password,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
this.armTimeout(TOTP_TIMEOUT_MS, "totp");
|
||||
this.sendLog("auth", "info", "TOTP verification required");
|
||||
authLogger.info("TOTP verification prompt sent to client", {
|
||||
operation: "terminal_totp_prompt",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_required",
|
||||
prompt: prompts[promptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleInput(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
promptIndex: number,
|
||||
isPush: boolean,
|
||||
finish: (responses: string[]) => void,
|
||||
password: string | undefined,
|
||||
): void {
|
||||
if (this.context.keyboardInteractiveResponded) return;
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
|
||||
this.context.keyboardInteractiveFinish = (answers: string[]) => {
|
||||
finish(
|
||||
responsesWithAnswer(
|
||||
prompts,
|
||||
promptIndex,
|
||||
(answers[0] || "").trim(),
|
||||
password,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
this.armTimeout(isPush ? PUSH_TIMEOUT_MS : TOTP_TIMEOUT_MS, "password");
|
||||
this.sendLog("auth", "info", "Password authentication required");
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "password_required",
|
||||
prompt: prompts[promptIndex].prompt,
|
||||
echo: prompts[promptIndex].echo,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private armTimeout(ms: number, kind: "totp" | "password"): void {
|
||||
if (this.context.totpTimeout) clearTimeout(this.context.totpTimeout);
|
||||
this.context.totpTimeout = setTimeout(() => {
|
||||
if (!this.context.keyboardInteractiveFinish) return;
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
if (kind === "totp") {
|
||||
this.context.totpPromptSent = false;
|
||||
} else {
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
}
|
||||
sshLogger.warn(
|
||||
`${kind === "totp" ? "TOTP" : "Password"} prompt timeout`,
|
||||
{
|
||||
operation: `${kind}_timeout`,
|
||||
hostId: this.context.hostId,
|
||||
},
|
||||
);
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
kind === "totp"
|
||||
? "TOTP verification timeout. Please reconnect."
|
||||
: "Password verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
sendLog(
|
||||
stage: string,
|
||||
level: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "connection_log",
|
||||
data: { stage, level, message, details },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
this.context.totpTimeout = null;
|
||||
}
|
||||
if (this.context.warpgateAuthTimeout) {
|
||||
clearTimeout(this.context.warpgateAuthTimeout);
|
||||
this.context.warpgateAuthTimeout = null;
|
||||
}
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.totpPromptSent = false;
|
||||
this.context.warpgateAuthPromptSent = false;
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* How the bytes get to a host: port knocking, a Cloudflare Access tunnel, a
|
||||
* jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and
|
||||
* returns the jump client so the caller can close it with the connection.
|
||||
*/
|
||||
|
||||
import type { Client } from "ssh2";
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../../utils/socks5-helper.js";
|
||||
import type { ProxyNode } from "../../../types/index.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
||||
import { performPortKnocking } from "../terminal-auth-helpers.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthLog,
|
||||
SshConnectHost,
|
||||
} from "./types.js";
|
||||
|
||||
export function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
|
||||
const chain = Array.isArray(host.socks5ProxyChain)
|
||||
? (host.socks5ProxyChain as ProxyNode[])
|
||||
: [];
|
||||
if (!host.useSocks5 || (!host.socks5Host && chain.length === 0)) return null;
|
||||
return {
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host ?? undefined,
|
||||
socks5Port: host.socks5Port ?? undefined,
|
||||
socks5Username: host.socks5Username ?? undefined,
|
||||
socks5Password: host.socks5Password ?? undefined,
|
||||
socks5ProxyChain: chain,
|
||||
};
|
||||
}
|
||||
|
||||
export class SshTransportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly stage: "cloudflare" | "jump-host" | "jump-forward" | "proxy",
|
||||
options?: { cause?: unknown },
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "SshTransportError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenTransportOptions {
|
||||
/** Knock before connecting when the host has a sequence. Default true. */
|
||||
portKnock?: boolean;
|
||||
/** Use terminalConfig.cfAccessClient* when present. Default true. */
|
||||
cloudflare?: boolean;
|
||||
/** Resolve DNS up front for a direct connection. Default true. */
|
||||
resolveDns?: boolean;
|
||||
log?: SshAuthLog;
|
||||
}
|
||||
|
||||
export interface OpenedTransport {
|
||||
jumpClient: Client | null;
|
||||
via: "direct" | "proxy" | "jump" | "cloudflare";
|
||||
}
|
||||
|
||||
function forwardThrough(
|
||||
jumpClient: Client,
|
||||
host: SshConnectHost,
|
||||
): Promise<NonNullable<MutableConnectConfig["sock"]>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
host.ip,
|
||||
host.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
reject(
|
||||
new SshTransportError(
|
||||
"Failed to forward through jump host: " + err.message,
|
||||
"jump-forward",
|
||||
{ cause: err },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(stream);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function openSshTransport(
|
||||
host: SshConnectHost,
|
||||
config: MutableConnectConfig,
|
||||
options: OpenTransportOptions = {},
|
||||
): Promise<OpenedTransport> {
|
||||
const log = options.log ?? (() => {});
|
||||
|
||||
if (
|
||||
options.portKnock !== false &&
|
||||
Array.isArray(host.portKnockSequence) &&
|
||||
host.portKnockSequence.length > 0
|
||||
) {
|
||||
try {
|
||||
await performPortKnocking(host.ip, host.portKnockSequence);
|
||||
} catch {
|
||||
logger.warn("Port knocking failed, attempting connection anyway", {
|
||||
operation: "port_knock",
|
||||
hostId: host.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let via: OpenedTransport["via"] = "direct";
|
||||
|
||||
const terminalConfig = (host.terminalConfig ?? {}) as Record<string, unknown>;
|
||||
if (
|
||||
options.cloudflare !== false &&
|
||||
terminalConfig.cfAccessClientId &&
|
||||
terminalConfig.cfAccessClientSecret
|
||||
) {
|
||||
try {
|
||||
const WebSocket = (await import("ws")).default;
|
||||
const { createWebSocketDuplex, waitForWebSocketOpen } =
|
||||
await import("../cloudflare-websocket.js");
|
||||
const cfHostname = (terminalConfig.cfTunnelHostname as string) || host.ip;
|
||||
const cfWs = new WebSocket(
|
||||
`wss://${cfHostname}/cdn-cgi/access/ssh-connect`,
|
||||
{
|
||||
headers: {
|
||||
"CF-Access-Client-Id": terminalConfig.cfAccessClientId as string,
|
||||
"CF-Access-Client-Secret":
|
||||
terminalConfig.cfAccessClientSecret as string,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitForWebSocketOpen(cfWs, 30000);
|
||||
config.sock = createWebSocketDuplex(
|
||||
cfWs,
|
||||
) as unknown as typeof config.sock;
|
||||
via = "cloudflare";
|
||||
log("info", "Connected via Cloudflare Tunnel");
|
||||
} catch (error) {
|
||||
throw new SshTransportError(
|
||||
"Cloudflare tunnel connection failed: " + getErrorMessage(error),
|
||||
"cloudflare",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const jumpUserId = host.userId || "";
|
||||
if (host.jumpHosts && host.jumpHosts.length > 0 && jumpUserId) {
|
||||
const jumpClient = await createJumpHostChain(host.jumpHosts, jumpUserId);
|
||||
if (!jumpClient) {
|
||||
throw new SshTransportError(
|
||||
"Failed to connect through jump hosts",
|
||||
"jump-host",
|
||||
);
|
||||
}
|
||||
try {
|
||||
config.sock = await forwardThrough(jumpClient, host);
|
||||
} catch (error) {
|
||||
jumpClient.end();
|
||||
throw error;
|
||||
}
|
||||
return { jumpClient, via: "jump" };
|
||||
}
|
||||
|
||||
const proxyConfig = getHostSocks5Config(host);
|
||||
if (proxyConfig && via === "direct") {
|
||||
try {
|
||||
const proxySocket = await createSocks5Connection(
|
||||
host.ip,
|
||||
host.port || 22,
|
||||
proxyConfig,
|
||||
);
|
||||
if (proxySocket) {
|
||||
config.sock = proxySocket;
|
||||
via = "proxy";
|
||||
}
|
||||
} catch (error) {
|
||||
throw new SshTransportError(
|
||||
"Proxy connection failed: " + getErrorMessage(error),
|
||||
"proxy",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (via === "direct" && options.resolveDns !== false) {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
}
|
||||
|
||||
return { jumpClient: null, via };
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
/**
|
||||
* What a connection is for. Picks keepalive and timeout defaults so every
|
||||
* transport keeps the numbers it had before the pipeline existed.
|
||||
*/
|
||||
export type SshConnectPurpose =
|
||||
| "terminal"
|
||||
| "file-manager"
|
||||
| "file-transfer"
|
||||
| "tmux"
|
||||
| "metrics"
|
||||
| "fleet"
|
||||
| "tunnel"
|
||||
| "docker"
|
||||
| "docker-console"
|
||||
| "proxmox"
|
||||
| "snippet"
|
||||
| "credential-deploy"
|
||||
| "jump-host"
|
||||
| "remote-desktop"
|
||||
| "plugin";
|
||||
|
||||
/** The resolved host fields the pipeline reads. */
|
||||
export interface SshConnectHost {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
userId?: string | null;
|
||||
authType?: string | null;
|
||||
password?: string | null;
|
||||
key?: string | Buffer | null;
|
||||
keyPassword?: string | null;
|
||||
keyType?: string | null;
|
||||
certPublicKey?: string | null;
|
||||
useWarpgate?: boolean | null;
|
||||
forceKeyboardInteractive?: boolean | null;
|
||||
vaultProfile?: { id?: number | null } | null;
|
||||
terminalConfig?: Record<string, unknown> | null;
|
||||
jumpHosts?: Array<{ hostId: number }> | null;
|
||||
useSocks5?: boolean | null;
|
||||
socks5Host?: string | null;
|
||||
socks5Port?: number | null;
|
||||
socks5Username?: string | null;
|
||||
socks5Password?: string | null;
|
||||
socks5ProxyChain?: unknown;
|
||||
portKnockSequence?: Array<{
|
||||
port: number;
|
||||
protocol?: string;
|
||||
delay?: number;
|
||||
}> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type MutableConnectConfig = ConnectConfig & Record<string, unknown>;
|
||||
|
||||
export type SshAuthLog = (
|
||||
level: "info" | "warning" | "error",
|
||||
message: string,
|
||||
) => void;
|
||||
|
||||
export interface SshAuthEnv {
|
||||
/** The client about to connect; certificate auth patches it. */
|
||||
client: Client;
|
||||
/** The acting user. Certificates and tokens are cached per user. */
|
||||
userId: string;
|
||||
/** Server-side host id, used for per-host caches. */
|
||||
hostId: number;
|
||||
purpose: SshConnectPurpose;
|
||||
/** True when a person can answer prompts or finish a browser sign-in. */
|
||||
interactive: boolean;
|
||||
log: SshAuthLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* What preparing auth produced.
|
||||
*
|
||||
* `interaction-required` means a browser step has to happen first; the
|
||||
* transport turns `interaction` into its own message, for example
|
||||
* `opkssh_auth_required` over the terminal socket.
|
||||
*/
|
||||
export type SshAuthOutcome =
|
||||
| { status: "ready" }
|
||||
| {
|
||||
status: "interaction-required";
|
||||
interaction: string;
|
||||
message: string;
|
||||
/** Extra boolean the HTTP transports set in their 401 body. */
|
||||
flag?: string;
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
code:
|
||||
| "missing-secret"
|
||||
| "invalid-key"
|
||||
| "passphrase-required"
|
||||
| "provider-missing"
|
||||
| "failed";
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
/** Try once more with these config changes. Used by Tailscale. */
|
||||
status: "retry";
|
||||
patch: Partial<MutableConnectConfig>;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface SshAuthFailureContext {
|
||||
error: Error;
|
||||
/** How many retries this connection already made. */
|
||||
retries: number;
|
||||
/** A tunnelled socket cannot be reused for a retry. */
|
||||
canRetry: boolean;
|
||||
/** The server refused keyboard-interactive or none. */
|
||||
methodNotAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface SshInteractionRequest {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
/** Host row as stored, not resolved. */
|
||||
host: { name?: string | null; ip: string; username: string };
|
||||
socket: WebSocket;
|
||||
requestOrigin: string;
|
||||
/** Anything the client sent with the start message. */
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SshAuthField {
|
||||
key: string;
|
||||
type:
|
||||
| "boolean"
|
||||
| "string"
|
||||
| "number"
|
||||
| "select"
|
||||
| "secret"
|
||||
| "textarea"
|
||||
| "custom";
|
||||
labelKey?: string;
|
||||
descriptionKey?: string;
|
||||
placeholderKey?: string;
|
||||
default?: unknown;
|
||||
options?: Array<{ value: string; labelKey: string }>;
|
||||
component?: string;
|
||||
}
|
||||
|
||||
export interface SshAuthProvider {
|
||||
/** Stored in ssh_data.auth_type. */
|
||||
type: string;
|
||||
/** "core" for built-ins and the legacy providers A8 keeps in core. */
|
||||
pluginId: string;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
/** Editor fields, rendered by core unless the frontend registered an editor. */
|
||||
fields?: SshAuthField[];
|
||||
/** Also offered as a stored credential type. */
|
||||
credentialType?: boolean;
|
||||
/** Needs a browser sign-in or a person at the keyboard. */
|
||||
needsUserInteraction?: boolean;
|
||||
/** Carries a secret, so a shared host needs one resolved for the recipient. */
|
||||
requiresSecret?: boolean;
|
||||
/**
|
||||
* Can connect with nobody watching, for polling and batch work. Defaults to
|
||||
* true; false for types that need a person or a fresh browser sign-in.
|
||||
*/
|
||||
supportsBackground?: boolean;
|
||||
/** Overrides applied to the base config before prepare runs. */
|
||||
connectOptions?: (
|
||||
host: SshConnectHost,
|
||||
purpose: SshConnectPurpose,
|
||||
) => Partial<MutableConnectConfig>;
|
||||
/** The interaction name this provider's outcomes use, e.g. "opkssh". */
|
||||
interaction?: string;
|
||||
/**
|
||||
* Starts the browser step behind an `interaction-required` outcome, for
|
||||
* transports that can show one (the terminal today).
|
||||
*/
|
||||
startInteraction?: (request: SshInteractionRequest) => Promise<void>;
|
||||
prepare: (
|
||||
config: MutableConnectConfig,
|
||||
host: SshConnectHost,
|
||||
env: SshAuthEnv,
|
||||
) => Promise<SshAuthOutcome>;
|
||||
/**
|
||||
* Called when the server rejected auth. Synchronous on purpose: transports
|
||||
* decide on a retry before the socket's close event arrives. Cache cleanup
|
||||
* inside runs in the background.
|
||||
*/
|
||||
onAuthFailed?: (
|
||||
host: SshConnectHost,
|
||||
env: SshAuthEnv,
|
||||
context: SshAuthFailureContext,
|
||||
) => SshAuthOutcome | undefined;
|
||||
}
|
||||
|
||||
export interface KeyboardInteractivePrompt {
|
||||
prompt: string;
|
||||
echo?: boolean;
|
||||
}
|
||||
|
||||
/** What to do with one keyboard-interactive round. */
|
||||
export type KeyboardInteractiveDecision =
|
||||
| { kind: "auto"; responses: string[] }
|
||||
| {
|
||||
kind: "warpgate";
|
||||
url: string | null;
|
||||
securityKey: string;
|
||||
instructions: string;
|
||||
}
|
||||
| { kind: "totp"; promptIndex: number }
|
||||
| { kind: "input"; promptIndex: number; isPush: boolean };
|
||||
|
||||
/**
|
||||
* Interceptors run before the built-in classifier. A provider that owns a
|
||||
* prompt style (Warpgate today) returns a decision; everyone else returns null.
|
||||
*/
|
||||
export interface KeyboardInteractiveInterceptor {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
detect: (
|
||||
round: {
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
},
|
||||
host: SshConnectHost,
|
||||
) => KeyboardInteractiveDecision | null;
|
||||
/** Answer password prompts silently instead of asking the user. */
|
||||
autoAnswerPasswords?: (host: SshConnectHost) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a transport asks a person something mid-connect.
|
||||
*
|
||||
* `ask` resolves with the typed answer, or null when the user gave up or the
|
||||
* transport has nobody to ask. The terminal answers over its WebSocket, the
|
||||
* file manager parks the connection and answers over HTTP.
|
||||
*/
|
||||
export interface SshPromptChannel {
|
||||
ask: (request: SshPromptRequest) => Promise<string | null>;
|
||||
/** Terminal socket used for the host key prompt, when there is one. */
|
||||
hostKeySocket?: WebSocket | null;
|
||||
}
|
||||
|
||||
export type SshPromptRequest =
|
||||
| { kind: "totp"; prompt: string; retry: boolean }
|
||||
| { kind: "input"; prompt: string; echo: boolean; isPush: boolean }
|
||||
| {
|
||||
kind: "warpgate";
|
||||
url: string | null;
|
||||
securityKey: string;
|
||||
instructions: string;
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { Client as SSHClient, ConnectConfig } from "ssh2";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
|
||||
/**
|
||||
* Attaches a user-managed CA-signed certificate to an SFTP connection, if the
|
||||
* host carries one.
|
||||
*
|
||||
* The terminal has always done this. The file manager never did: it imports
|
||||
* and calls `setupOPKSSHCertAuth`, but `setupCACertAuth` — the sibling helper
|
||||
* for user-managed `-cert.pub` files — had no call site here at all. A host
|
||||
* whose key is paired with a CA-signed certificate therefore authenticated in
|
||||
* a terminal and failed in the file manager, while OPKSSH certificates worked
|
||||
* in both. That asymmetry is the whole bug.
|
||||
*
|
||||
* `cert_public_key` is a column on `ssh_data` but is not part of the shared
|
||||
* `Host` type, so callers pass whichever record they hold and it is read off
|
||||
* structurally — the same way the terminal reads it.
|
||||
*
|
||||
* A certificate that cannot be applied is logged and skipped rather than
|
||||
* failing the connection: the private key alone may still be accepted, which
|
||||
* is exactly what happened before any of this was wired up.
|
||||
*/
|
||||
export async function applyCACertIfPresent(
|
||||
config: Record<string, unknown>,
|
||||
client: SSHClient,
|
||||
privateKey: Buffer | string,
|
||||
source: { certPublicKey?: string | null },
|
||||
username: string,
|
||||
passphrase?: string,
|
||||
): Promise<void> {
|
||||
const certPublicKey = source.certPublicKey;
|
||||
if (!certPublicKey || !certPublicKey.trim()) return;
|
||||
|
||||
try {
|
||||
const { setupCACertAuth } = await import("../opkssh-cert-auth.js");
|
||||
await setupCACertAuth(
|
||||
config as ConnectConfig,
|
||||
client,
|
||||
privateKey,
|
||||
certPublicKey,
|
||||
username,
|
||||
passphrase,
|
||||
);
|
||||
} catch (certError) {
|
||||
fileLogger.warn("CA certificate setup failed, continuing with key only", {
|
||||
operation: "sftp_ca_cert_auth_failed",
|
||||
error: getErrorMessage(certError),
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Auth types whose SSH certificate is issued on demand through a browser
|
||||
* sign-in and cached per user and host (opkssh_tokens). They share the
|
||||
* whole connect path; only the issuing flow differs.
|
||||
*/
|
||||
export const ISSUED_CERTIFICATE_AUTH_TYPES = ["opkssh", "stepca"] as const;
|
||||
|
||||
export function usesIssuedCertificate(authType: string | null | undefined) {
|
||||
return (ISSUED_CERTIFICATE_AUTH_TYPES as readonly string[]).includes(
|
||||
authType ?? "",
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||
import { buildConnectConfig } from "./connect/build-connect-config.js";
|
||||
import { createAutoKeyboardInteractiveHandler } from "./connect/keyboard-interactive.js";
|
||||
import type { SshConnectHost } from "./connect/types.js";
|
||||
import { resolveHostById } from "./host-resolver.js";
|
||||
|
||||
type JumpHostConfig = {
|
||||
@@ -114,15 +113,6 @@ export async function createJumpHostChain(
|
||||
const jumpClient = new SSHClient();
|
||||
clients.push(jumpClient);
|
||||
|
||||
const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier(
|
||||
jumpHostConfig.id,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
null,
|
||||
userId,
|
||||
true,
|
||||
);
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
@@ -167,100 +157,28 @@ export async function createJumpHostChain(
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
const connectConfig: Record<string, unknown> = {
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: readyTimeoutMs,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
connectConfig.password = jumpHostConfig.password;
|
||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||
try {
|
||||
connectConfig.privateKey = preparePrivateKeyForSSH2(
|
||||
jumpHostConfig.key,
|
||||
jumpHostConfig.keyPassword,
|
||||
);
|
||||
} catch (keyError) {
|
||||
clearTimeout(timeout);
|
||||
lastError = new Error(
|
||||
`Jump host ${i + 1}/${totalHops} key error: ${getErrorMessage(keyError, "Invalid private key format")}`,
|
||||
);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (jumpHostConfig.keyPassword) {
|
||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||
}
|
||||
} else if (jumpHostConfig.authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
connectConfig,
|
||||
jumpHostConfig.terminalConfig as
|
||||
Record<string, unknown> | undefined,
|
||||
// Each hop goes through the same pipeline as the target, so hops get
|
||||
// every auth type, not just password, key and agent.
|
||||
const built = await buildConnectConfig(
|
||||
jumpHostConfig as unknown as SshConnectHost,
|
||||
{ userId, purpose: "jump-host", client: jumpClient },
|
||||
);
|
||||
if (built.outcome.status !== "ready") {
|
||||
clearTimeout(timeout);
|
||||
lastError = new Error(
|
||||
`Jump host ${i + 1}/${totalHops}: ${built.outcome.message}`,
|
||||
);
|
||||
if ("error" in result) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const connectConfig = built.config;
|
||||
connectConfig.readyTimeout = readyTimeoutMs;
|
||||
|
||||
jumpClient.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
|
||||
return jumpHostConfig.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
createAutoKeyboardInteractiveHandler(
|
||||
jumpHostConfig as unknown as SshConnectHost,
|
||||
),
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
|
||||
@@ -1,224 +1,45 @@
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import type { Client } from "ssh2";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../utils/socks5-helper.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
import { createJumpHostChain } from "./jump-host-chain.js";
|
||||
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
||||
connectHost,
|
||||
getConnectionPoolKey,
|
||||
SshConnectError,
|
||||
} from "./connect/connect-host.js";
|
||||
import { buildConnectConfig } from "./connect/build-connect-config.js";
|
||||
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
|
||||
|
||||
/**
|
||||
* Non-interactive SSH connection helpers shared by fleet execution.
|
||||
*
|
||||
* Mirrors buildSshConfig/createSshFactory/getPoolKey in
|
||||
* plugins/host-metrics/src/backend/routes.ts,
|
||||
* minus the keyboard-interactive TOTP prompt handling (metrics/terminal-only)
|
||||
* and OPKSSH/Vault cert setup (not supported as fleet auth for v1 - those flows
|
||||
* need an interactive browser step that has no place in a batch fleet run).
|
||||
* Non-interactive SSH connections for background work (fleets, automations,
|
||||
* AI tools). A thin wrapper over the connect pipeline, kept so the pool keys
|
||||
* and call sites stay the same.
|
||||
*/
|
||||
|
||||
export function getFleetPoolKey(host: SSHHost): string {
|
||||
const socks5Key = host.useSocks5
|
||||
? `:socks5:${host.socks5Host}:${host.socks5Port}`
|
||||
: "";
|
||||
return `fleet:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
|
||||
return getConnectionPoolKey("fleet", host as unknown as SshConnectHost);
|
||||
}
|
||||
|
||||
export async function buildFleetSshConfig(
|
||||
host: SSHHost,
|
||||
): Promise<ConnectConfig> {
|
||||
const base: ConnectConfig = {
|
||||
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 30000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
|
||||
host.id,
|
||||
host.ip,
|
||||
host.port,
|
||||
null,
|
||||
host.userId || "",
|
||||
false,
|
||||
),
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
},
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
} as ConnectConfig;
|
||||
|
||||
const authType = host.authType;
|
||||
|
||||
if (authType === "password" || authType === "credential") {
|
||||
if (!host.password) {
|
||||
throw new Error(`No password available for host ${host.ip}`);
|
||||
}
|
||||
base.password = host.password;
|
||||
} else if (authType === "key") {
|
||||
if (!host.key) {
|
||||
throw new Error(`No SSH key available for host ${host.ip}`);
|
||||
}
|
||||
(base as Record<string, unknown>).privateKey = preparePrivateKeyForSSH2(
|
||||
host.key,
|
||||
host.keyPassword,
|
||||
);
|
||||
if (host.keyPassword) {
|
||||
(base as Record<string, unknown>).passphrase = host.keyPassword;
|
||||
}
|
||||
} else if (authType === "none" || authType === "tailscale") {
|
||||
// no credentials needed
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported authentication type '${authType}' for fleet execution on host ${host.ip}`,
|
||||
);
|
||||
client?: Client,
|
||||
): Promise<MutableConnectConfig> {
|
||||
const { Client: SshClient } = await import("ssh2");
|
||||
const built = await buildConnectConfig(host as unknown as SshConnectHost, {
|
||||
userId: host.userId || "",
|
||||
purpose: "fleet",
|
||||
client: client ?? new SshClient(),
|
||||
});
|
||||
if (built.outcome.status !== "ready") {
|
||||
throw new SshConnectError(built.outcome);
|
||||
}
|
||||
|
||||
return base;
|
||||
return built.config;
|
||||
}
|
||||
|
||||
export function createFleetSshFactory(host: SSHHost): () => Promise<Client> {
|
||||
return async () => {
|
||||
const config = await buildFleetSshConfig(host);
|
||||
const client = new Client();
|
||||
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
host.useSocks5 &&
|
||||
(host.socks5Host ||
|
||||
(host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
|
||||
? {
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
}
|
||||
: null;
|
||||
|
||||
const hasJumpHosts =
|
||||
host.jumpHosts && host.jumpHosts.length > 0 && host.userId;
|
||||
|
||||
let jumpClient: Client | null = null;
|
||||
if (hasJumpHosts) {
|
||||
jumpClient = await createJumpHostChain(host.jumpHosts!, host.userId!);
|
||||
if (!jumpClient) {
|
||||
throw new Error("Failed to establish jump host chain");
|
||||
}
|
||||
} else if (proxyConfig) {
|
||||
try {
|
||||
const proxySocket = await createSocks5Connection(
|
||||
host.ip,
|
||||
host.port,
|
||||
proxyConfig,
|
||||
);
|
||||
if (proxySocket) {
|
||||
config.sock = proxySocket;
|
||||
}
|
||||
} catch (proxyError) {
|
||||
throw new Error(
|
||||
"Proxy connection failed: " + getErrorMessage(proxyError),
|
||||
{ cause: proxyError },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<Client>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
client.end();
|
||||
jumpClient?.end();
|
||||
reject(new Error("SSH connection timeout"));
|
||||
}, 30000);
|
||||
|
||||
client.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(client);
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
jumpClient?.end();
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
jumpClient?.end();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
if (jumpClient) {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
host.ip,
|
||||
host.port,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
jumpClient!.end();
|
||||
reject(
|
||||
new Error(
|
||||
"Failed to forward through jump host: " + err.message,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
config.sock = stream;
|
||||
client.connect(config);
|
||||
},
|
||||
);
|
||||
} else if (config.sock) {
|
||||
client.connect(config);
|
||||
} else {
|
||||
resolveSshConnectConfigHost(config)
|
||||
.then(() => {
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
const connection = await connectHost(host as unknown as SshConnectHost, {
|
||||
userId: host.userId || "",
|
||||
purpose: "fleet",
|
||||
});
|
||||
return connection.client;
|
||||
};
|
||||
}
|
||||
+246
-719
File diff suppressed because it is too large.
Load diff
@@ -1,11 +0,0 @@
|
||||
import type { SSHHost } from "../../../types/index.js";
|
||||
|
||||
export function getTmuxAuthBehavior(authType: SSHHost["authType"]): {
|
||||
credentialless: boolean;
|
||||
tryKeyboard: boolean;
|
||||
} {
|
||||
return {
|
||||
credentialless: authType === "none" || authType === "tailscale",
|
||||
tryKeyboard: authType !== "tailscale",
|
||||
};
|
||||
}
|
||||
+12
-177
@@ -1,7 +1,9 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import type { Client } from "ssh2";
|
||||
import { connectHost, getConnectionPoolKey } from "../connect/connect-host.js";
|
||||
import type { SshConnectHost } from "../connect/types.js";
|
||||
import { createCorsMiddleware } from "../../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../../utils/compression-config.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
@@ -12,19 +14,9 @@ import {
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
||||
import { resolveHostById, checkHostAccess } from "../host-resolver.js";
|
||||
import type { HostAction } from "../../utils/permission-manager.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../../utils/socks5-helper.js";
|
||||
import { withConnection } from "../ssh-connection-pool.js";
|
||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
||||
import { execCommand, tmuxCommand } from "./helper.js";
|
||||
import {
|
||||
SEP,
|
||||
@@ -42,7 +34,6 @@ import {
|
||||
type PaneMetrics,
|
||||
} from "./monitor-helpers.js";
|
||||
import type { SSHHost, AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { getTmuxAuthBehavior } from "./auth-utils.js";
|
||||
import { listenOnServicePort } from "../../utils/service-listen.js";
|
||||
|
||||
const PANE_ID_RE = /^%\d+$/;
|
||||
@@ -59,179 +50,23 @@ interface TmuxSessionOverview extends TmuxSessionSummary {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// SSH connection (lean variant of the per-module pattern used by server-stats
|
||||
// and docker; jump hosts and SOCKS5 reuse the shared helpers)
|
||||
|
||||
async function buildSshConfig(host: SSHHost): Promise<ConnectConfig> {
|
||||
const authBehavior = getTmuxAuthBehavior(host.authType);
|
||||
const base: ConnectConfig = {
|
||||
host: (host.ip || "").replace(/^\[|\]$/g, ""),
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
tryKeyboard: authBehavior.tryKeyboard,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 60000,
|
||||
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
|
||||
host.id,
|
||||
host.ip,
|
||||
host.port,
|
||||
null,
|
||||
host.userId || "",
|
||||
false,
|
||||
),
|
||||
algorithms: SSH_ALGORITHMS,
|
||||
} as ConnectConfig;
|
||||
|
||||
if (host.authType === "password") {
|
||||
if (!host.password) {
|
||||
throw new Error(`No password available for host ${host.ip}`);
|
||||
}
|
||||
base.password = host.password;
|
||||
} else if (host.authType === "key") {
|
||||
if (!host.key) {
|
||||
throw new Error(`No valid SSH key available for host ${host.ip}`);
|
||||
}
|
||||
(base as Record<string, unknown>).privateKey = preparePrivateKeyForSSH2(
|
||||
host.key,
|
||||
host.keyPassword,
|
||||
);
|
||||
if (host.keyPassword) {
|
||||
(base as Record<string, unknown>).passphrase = host.keyPassword;
|
||||
}
|
||||
} else if (authBehavior.credentialless) {
|
||||
// no credentials needed
|
||||
} else if (host.authType === "vault") {
|
||||
// cert auth setup happens in connectToHost (needs client instance)
|
||||
} else if (host.authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
base as Record<string, unknown>,
|
||||
host.terminalConfig as unknown as Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} else {
|
||||
// opkssh and other interactive flows are not supported by this module
|
||||
throw new Error(
|
||||
`Authentication type '${host.authType}' is not supported by the tmux monitor. Open a terminal connection instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
// SSH connection
|
||||
|
||||
export function connectToHost(host: SSHHost): () => Promise<Client> {
|
||||
return async () => {
|
||||
const config = await buildSshConfig(host);
|
||||
const client = new Client();
|
||||
|
||||
if (host.authType === "vault") {
|
||||
const { setupVaultSshSignerAuth } =
|
||||
await import("../vault-ssh-connect.js");
|
||||
await setupVaultSshSignerAuth(config, client, host);
|
||||
}
|
||||
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
host.useSocks5 &&
|
||||
(host.socks5Host ||
|
||||
(host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
|
||||
? {
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
}
|
||||
: null;
|
||||
|
||||
let jumpClient: Client | null = null;
|
||||
if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) {
|
||||
jumpClient = await createJumpHostChain(host.jumpHosts, host.userId);
|
||||
if (!jumpClient) {
|
||||
throw new Error("Failed to establish jump host chain");
|
||||
}
|
||||
} else if (proxyConfig) {
|
||||
const proxySocket = await createSocks5Connection(
|
||||
host.ip,
|
||||
host.port,
|
||||
proxyConfig,
|
||||
);
|
||||
if (proxySocket) {
|
||||
config.sock = proxySocket;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<Client>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
client.end();
|
||||
jumpClient?.end();
|
||||
reject(new Error("SSH connection timeout"));
|
||||
}, 30000);
|
||||
|
||||
client.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(client);
|
||||
});
|
||||
client.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
jumpClient?.end();
|
||||
reject(err);
|
||||
});
|
||||
client.on(
|
||||
"keyboard-interactive",
|
||||
(_name, _instructions, _lang, prompts, finish) => {
|
||||
finish(
|
||||
prompts.map((p) =>
|
||||
/password/i.test(p.prompt) ? host.password || "" : "",
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (jumpClient) {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
host.ip,
|
||||
host.port,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
jumpClient!.end();
|
||||
reject(
|
||||
new Error(
|
||||
"Failed to forward through jump host: " + err.message,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
config.sock = stream;
|
||||
client.connect(config);
|
||||
},
|
||||
);
|
||||
} else if (config.sock) {
|
||||
client.connect(config);
|
||||
} else {
|
||||
resolveSshConnectConfigHost(config)
|
||||
.then(() => {
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
const connection = await connectHost(host as unknown as SshConnectHost, {
|
||||
userId: host.userId || "",
|
||||
purpose: "tmux",
|
||||
});
|
||||
return connection.client;
|
||||
};
|
||||
}
|
||||
|
||||
function getPoolKey(host: SSHHost): string {
|
||||
const socks5Key = host.useSocks5
|
||||
? `:socks5:${host.socks5Host}:${host.socks5Port}`
|
||||
: "";
|
||||
return `tmux-monitor:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
|
||||
return getConnectionPoolKey(
|
||||
"tmux-monitor",
|
||||
host as unknown as SshConnectHost,
|
||||
);
|
||||
}
|
||||
|
||||
async function withHostConnection<T>(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createSocks5Connection } from "../../utils/socks5-helper.js";
|
||||
import { tunnelLogger } from "../../utils/logger.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import {
|
||||
applyAuthOptions,
|
||||
applyTunnelAuth,
|
||||
bindForwardIn,
|
||||
connectClient,
|
||||
forwardOut,
|
||||
@@ -163,13 +163,28 @@ async function connectC2SSourceClient(
|
||||
algorithms: getManagedTunnelAlgorithms(),
|
||||
};
|
||||
|
||||
applyAuthOptions(connOptions, {
|
||||
password: tunnelConfig.sourcePassword,
|
||||
sshKey: tunnelConfig.sourceSSHKey,
|
||||
keyPassword: tunnelConfig.sourceKeyPassword,
|
||||
keyType: tunnelConfig.sourceKeyType,
|
||||
authMethod: tunnelConfig.sourceAuthMethod,
|
||||
});
|
||||
const client = new Client();
|
||||
const authOutcome = await applyTunnelAuth(
|
||||
connOptions,
|
||||
{
|
||||
password: tunnelConfig.sourcePassword,
|
||||
sshKey: tunnelConfig.sourceSSHKey,
|
||||
keyPassword: tunnelConfig.sourceKeyPassword,
|
||||
keyType: tunnelConfig.sourceKeyType,
|
||||
authMethod: tunnelConfig.sourceAuthMethod,
|
||||
},
|
||||
{
|
||||
client,
|
||||
userId: tunnelConfig.requestingUserId || tunnelConfig.sourceUserId,
|
||||
hostId: tunnelConfig.sourceHostId,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
ip: tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
},
|
||||
);
|
||||
if (authOutcome.status !== "ready") {
|
||||
throw new Error(authOutcome.message);
|
||||
}
|
||||
|
||||
if (
|
||||
tunnelConfig.useSocks5 &&
|
||||
@@ -194,7 +209,7 @@ async function connectC2SSourceClient(
|
||||
}
|
||||
}
|
||||
|
||||
return connectClient(connOptions, tunnelConfig.name, "source");
|
||||
return connectClient(connOptions, tunnelConfig.name, "source", client);
|
||||
}
|
||||
|
||||
async function handleC2SRemoteRelayOpen(
|
||||
|
||||
@@ -23,9 +23,8 @@ import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { createSocks5Connection } from "../../utils/socks5-helper.js";
|
||||
import { withConnection } from "../ssh-connection-pool.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||
import {
|
||||
applyAuthOptions,
|
||||
applyTunnelAuth,
|
||||
bindForwardIn,
|
||||
connectClient,
|
||||
forwardOut,
|
||||
@@ -659,8 +658,29 @@ export async function connectEndpointThroughSource(
|
||||
algorithms: getManagedTunnelAlgorithms(),
|
||||
};
|
||||
|
||||
applyAuthOptions(endpointOptions, endpointCredentials);
|
||||
return connectClient(endpointOptions, tunnelConfig.name, "endpoint");
|
||||
const endpointClient = new Client();
|
||||
const authOutcome = await applyTunnelAuth(
|
||||
endpointOptions,
|
||||
endpointCredentials,
|
||||
{
|
||||
client: endpointClient,
|
||||
userId: tunnelConfig.endpointUserId,
|
||||
hostId: null,
|
||||
username: tunnelConfig.endpointUsername,
|
||||
ip: tunnelConfig.endpointIP,
|
||||
port: tunnelConfig.endpointSSHPort,
|
||||
},
|
||||
);
|
||||
if (authOutcome.status !== "ready") {
|
||||
endpointSock.destroy();
|
||||
throw new Error(authOutcome.message);
|
||||
}
|
||||
return connectClient(
|
||||
endpointOptions,
|
||||
tunnelConfig.name,
|
||||
"endpoint",
|
||||
endpointClient,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string {
|
||||
@@ -1500,51 +1520,24 @@ export async function connectSSHTunnel(
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
resolvedSourceCredentials.authMethod === "key" &&
|
||||
resolvedSourceCredentials.sshKey
|
||||
) {
|
||||
try {
|
||||
connOptions.privateKey = preparePrivateKeyForSSH2(
|
||||
resolvedSourceCredentials.sshKey,
|
||||
resolvedSourceCredentials.keyPassword,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "Invalid SSH key format");
|
||||
tunnelLogger.error(
|
||||
`Invalid SSH key format for tunnel '${tunnelName}': ${message}`,
|
||||
undefined,
|
||||
{
|
||||
operation: "tunnel_invalid_ssh_key_format",
|
||||
tunnelName,
|
||||
sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
|
||||
keyType: resolvedSourceCredentials.keyType,
|
||||
},
|
||||
);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: message,
|
||||
});
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedSourceCredentials.keyPassword) {
|
||||
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
|
||||
}
|
||||
if (
|
||||
resolvedSourceCredentials.keyType &&
|
||||
resolvedSourceCredentials.keyType !== "auto"
|
||||
) {
|
||||
connOptions.privateKeyType = resolvedSourceCredentials.keyType;
|
||||
}
|
||||
} else if (resolvedSourceCredentials.authMethod === "key") {
|
||||
const sourceAuth = await applyTunnelAuth(
|
||||
connOptions,
|
||||
resolvedSourceCredentials,
|
||||
{
|
||||
client: conn,
|
||||
userId: effectiveUserId,
|
||||
hostId: tunnelConfig.sourceHostId,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
ip: tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
},
|
||||
);
|
||||
if (sourceAuth.status !== "ready") {
|
||||
tunnelLogger.error(
|
||||
`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`,
|
||||
`SSH auth could not be prepared for tunnel '${tunnelName}': ${sourceAuth.message}`,
|
||||
undefined,
|
||||
{
|
||||
operation: "tunnel_ssh_key_missing",
|
||||
operation: "tunnel_auth_prepare_failed",
|
||||
tunnelName,
|
||||
sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
|
||||
authMethod: resolvedSourceCredentials.authMethod,
|
||||
@@ -1553,12 +1546,10 @@ export async function connectSSHTunnel(
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: "SSH key authentication requested but no key provided",
|
||||
reason: sourceAuth.message,
|
||||
});
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
return;
|
||||
} else {
|
||||
connOptions.password = resolvedSourceCredentials.password;
|
||||
}
|
||||
|
||||
const finalStatus = connectionStatus.get(tunnelName);
|
||||
@@ -1694,23 +1685,6 @@ export async function killRemoteTunnelByMarker(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedSourceCredentials.authMethod === "key" &&
|
||||
resolvedSourceCredentials.sshKey
|
||||
) {
|
||||
try {
|
||||
preparePrivateKeyForSSH2(
|
||||
resolvedSourceCredentials.sshKey,
|
||||
resolvedSourceCredentials.keyPassword,
|
||||
);
|
||||
} catch (error) {
|
||||
callback(
|
||||
error instanceof Error ? error : new Error("Invalid SSH key format"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const poolKey = `tunnel:${tunnelConfig.sourceUserId}:${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}:${tunnelConfig.sourceUsername}`;
|
||||
|
||||
const factory = async (): Promise<Client> => {
|
||||
@@ -1758,25 +1732,21 @@ export async function killRemoteTunnelByMarker(
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
resolvedSourceCredentials.authMethod === "key" &&
|
||||
resolvedSourceCredentials.sshKey
|
||||
) {
|
||||
connOptions.privateKey = preparePrivateKeyForSSH2(
|
||||
resolvedSourceCredentials.sshKey,
|
||||
resolvedSourceCredentials.keyPassword,
|
||||
);
|
||||
if (resolvedSourceCredentials.keyPassword) {
|
||||
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
|
||||
}
|
||||
if (
|
||||
resolvedSourceCredentials.keyType &&
|
||||
resolvedSourceCredentials.keyType !== "auto"
|
||||
) {
|
||||
connOptions.privateKeyType = resolvedSourceCredentials.keyType;
|
||||
}
|
||||
} else {
|
||||
connOptions.password = resolvedSourceCredentials.password;
|
||||
const conn = new Client();
|
||||
const killAuth = await applyTunnelAuth(
|
||||
connOptions,
|
||||
resolvedSourceCredentials,
|
||||
{
|
||||
client: conn,
|
||||
userId: tunnelConfig.requestingUserId || tunnelConfig.sourceUserId,
|
||||
hostId: tunnelConfig.sourceHostId,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
ip: tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
},
|
||||
);
|
||||
if (killAuth.status !== "ready") {
|
||||
throw new Error(killAuth.message);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1827,7 +1797,6 @@ export async function killRemoteTunnelByMarker(
|
||||
}
|
||||
|
||||
return new Promise<Client>((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
conn.on("ready", () => resolve(conn));
|
||||
conn.on("error", (err) => reject(err));
|
||||
conn.connect(connOptions);
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Client, type ClientChannel } from "ssh2";
|
||||
import type { Duplex } from "stream";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { tunnelLogger } from "../../utils/logger.js";
|
||||
import { getSshAuthProvider } from "../connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../connect/core-providers.js";
|
||||
import type { MutableConnectConfig, SshAuthOutcome } from "../connect/types.js";
|
||||
|
||||
export function getManagedTunnelAlgorithms() {
|
||||
return {
|
||||
@@ -40,40 +43,90 @@ export function getManagedTunnelAlgorithms() {
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAuthOptions(
|
||||
export interface TunnelCredentials {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authMethod?: string;
|
||||
certPublicKey?: string | null;
|
||||
terminalConfig?: Record<string, unknown> | null;
|
||||
vaultProfile?: { id?: number | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a tunnel side's auth into its connect options through the same
|
||||
* providers every other transport uses, so tunnels accept every auth type.
|
||||
*/
|
||||
export async function applyTunnelAuth(
|
||||
connOptions: Record<string, unknown>,
|
||||
credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authMethod?: string;
|
||||
credentials: TunnelCredentials,
|
||||
env: {
|
||||
client: Client;
|
||||
userId?: string | null;
|
||||
hostId?: number | null;
|
||||
username: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
},
|
||||
): void {
|
||||
if (credentials.authMethod === "key" && credentials.sshKey) {
|
||||
const cleanKey = credentials.sshKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connOptions.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (credentials.keyPassword) {
|
||||
connOptions.passphrase = credentials.keyPassword;
|
||||
}
|
||||
if (credentials.keyType && credentials.keyType !== "auto") {
|
||||
connOptions.privateKeyType = credentials.keyType;
|
||||
}
|
||||
} else {
|
||||
connOptions.password = credentials.password;
|
||||
): Promise<SshAuthOutcome> {
|
||||
ensureCoreSshAuthProviders();
|
||||
const authType =
|
||||
credentials.authMethod || (credentials.sshKey ? "key" : "password");
|
||||
const provider = getSshAuthProvider(authType);
|
||||
if (!provider) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "provider-missing",
|
||||
message: `Unsupported authentication type '${authType}' for tunnels`,
|
||||
};
|
||||
}
|
||||
|
||||
const outcome = await provider.prepare(
|
||||
connOptions as MutableConnectConfig,
|
||||
{
|
||||
id: env.hostId ?? 0,
|
||||
ip: env.ip,
|
||||
port: env.port,
|
||||
username: env.username,
|
||||
userId: env.userId,
|
||||
authType,
|
||||
password: credentials.password,
|
||||
key: credentials.sshKey,
|
||||
keyPassword: credentials.keyPassword,
|
||||
keyType: credentials.keyType,
|
||||
certPublicKey: credentials.certPublicKey,
|
||||
terminalConfig: credentials.terminalConfig ?? null,
|
||||
vaultProfile: credentials.vaultProfile ?? null,
|
||||
},
|
||||
{
|
||||
client: env.client,
|
||||
userId: env.userId ?? "",
|
||||
hostId: env.hostId ?? 0,
|
||||
purpose: "tunnel",
|
||||
interactive: false,
|
||||
log: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
outcome.status === "ready" &&
|
||||
connOptions.privateKey &&
|
||||
credentials.keyType &&
|
||||
credentials.keyType !== "auto"
|
||||
) {
|
||||
connOptions.privateKeyType = credentials.keyType;
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
export function connectClient(
|
||||
connOptions: Record<string, unknown>,
|
||||
tunnelName: string,
|
||||
role: "source" | "endpoint",
|
||||
client: Client = new Client(),
|
||||
): Promise<Client> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Client();
|
||||
let settled = false;
|
||||
client.once("ready", () => {
|
||||
settled = true;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
|
||||
type VaultAuthHost = {
|
||||
id: number;
|
||||
username: string;
|
||||
userId?: string | null;
|
||||
vaultProfile?: { id?: number | null } | null;
|
||||
};
|
||||
|
||||
export async function setupVaultSshSignerAuth(
|
||||
config: ConnectConfig,
|
||||
client: Client,
|
||||
host: VaultAuthHost,
|
||||
): Promise<void> {
|
||||
if (!host.userId) {
|
||||
throw new Error("Vault SSH signer authentication requires a user session");
|
||||
}
|
||||
|
||||
const vaultProfileId = host.vaultProfile?.id;
|
||||
if (!vaultProfileId) {
|
||||
throw new Error("Host has no Vault signer profile configured");
|
||||
}
|
||||
|
||||
const { getVaultCert } = await import("./vault-signer-auth.js");
|
||||
const cert = await getVaultCert(host.userId, vaultProfileId);
|
||||
if (!cert) {
|
||||
throw new Error(
|
||||
"Vault SSH signer authentication required. Please open a Terminal connection first.",
|
||||
);
|
||||
}
|
||||
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(
|
||||
config,
|
||||
client,
|
||||
{ privateKey: cert.privateKey, sshCert: cert.sshCert },
|
||||
host.username,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* ctx.ssh and ctx.auth.
|
||||
*
|
||||
* ctx.ssh is a thin, capability-checked door onto core's connect pipeline.
|
||||
* ctx.auth registers login methods, second factors and SSH auth types into
|
||||
* core's registries, scoped to the plugin's manifest and its disposable bag.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginAuth,
|
||||
PluginSsh,
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import {
|
||||
getSshAuthProvider,
|
||||
registerKeyboardInteractiveInterceptor,
|
||||
registerSshAuthProvider,
|
||||
} from "../hosts/connect/auth-provider-registry.js";
|
||||
import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js";
|
||||
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
|
||||
import { registerLoginMethod, registerSecondFactor } from "../auth/registry.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
SshConnectPurpose,
|
||||
} from "../hosts/connect/types.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
details: string,
|
||||
outcome: { success: boolean; errorMessage?: string },
|
||||
) => Promise<void>;
|
||||
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
bag: DisposableBag;
|
||||
audit: AuditFn;
|
||||
}
|
||||
|
||||
const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
|
||||
"plugin",
|
||||
"docker",
|
||||
"docker-console",
|
||||
"metrics",
|
||||
"proxmox",
|
||||
"fleet",
|
||||
"remote-desktop",
|
||||
"tunnel",
|
||||
]);
|
||||
|
||||
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
|
||||
const purpose = (options?.purpose ?? "plugin") as SshConnectPurpose;
|
||||
return PLUGIN_PURPOSES.has(purpose) ? purpose : "plugin";
|
||||
}
|
||||
|
||||
/**
|
||||
* The user a connection runs as: the request's actor, or for background work
|
||||
* on a resolved host, that host's owner. A bare host id with no actor has
|
||||
* nobody to resolve it for.
|
||||
*/
|
||||
function actingUser(host: number | PluginSshHost): string {
|
||||
const actor = getActor();
|
||||
if (actor) return actor;
|
||||
if (typeof host === "object" && host.userId) return host.userId;
|
||||
throw new Error(
|
||||
"ctx.ssh needs an acting user: call it inside a request, ctx.asUser, or pass a resolved host",
|
||||
);
|
||||
}
|
||||
|
||||
function describeHost(host: number | PluginSshHost): string {
|
||||
return typeof host === "number" ? `host ${host}` : `host ${host.id}`;
|
||||
}
|
||||
|
||||
export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
const open = new Set<() => void>();
|
||||
const poolKeys = new Set<string>();
|
||||
|
||||
bag.add(async () => {
|
||||
for (const dispose of [...open]) dispose();
|
||||
open.clear();
|
||||
if (poolKeys.size > 0) {
|
||||
const { connectionPool } =
|
||||
await import("../hosts/ssh-connection-pool.js");
|
||||
for (const key of poolKeys) connectionPool.clearKeyConnections(key);
|
||||
poolKeys.clear();
|
||||
}
|
||||
}, "SSH connections");
|
||||
|
||||
const checkSsh = async (withCredentials: boolean) => {
|
||||
await assertCapability(pluginId, "ssh:connect", declared);
|
||||
if (withCredentials) {
|
||||
await assertCapability(pluginId, "credentials:use", declared);
|
||||
}
|
||||
};
|
||||
|
||||
/** Checks, connects and audits one new connection. */
|
||||
const connectOnce = async (
|
||||
host: number | PluginSshHost,
|
||||
options?: PluginSshConnectOptions,
|
||||
) => {
|
||||
try {
|
||||
await checkSsh(true);
|
||||
} catch (error) {
|
||||
await audit("ssh_connect", describeHost(host), {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const userId = actingUser(host);
|
||||
const { connectHost } = await import("../hosts/connect/connect-host.js");
|
||||
try {
|
||||
const connection = await connectHost(host as number | SshConnectHost, {
|
||||
userId,
|
||||
purpose: purposeOf(options),
|
||||
timeoutMs: options?.timeoutMs,
|
||||
prompt: options?.prompt,
|
||||
overrides: options?.overrides as Partial<MutableConnectConfig>,
|
||||
});
|
||||
await audit("ssh_connect", describeHost(host), { success: true });
|
||||
|
||||
const dispose = () => {
|
||||
open.delete(dispose);
|
||||
connection.dispose();
|
||||
};
|
||||
open.add(dispose);
|
||||
connection.client.once("close", () => open.delete(dispose));
|
||||
return { ...connection, dispose };
|
||||
} catch (error) {
|
||||
await audit("ssh_connect", describeHost(host), {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const poolKey = (pool: string, host: PluginSshHost) =>
|
||||
`${pool}:${host.userId}:${host.ip}:${host.port}:${host.username}${
|
||||
host.useSocks5 ? `:socks5:${host.socks5Host}:${host.socks5Port}` : ""
|
||||
}`;
|
||||
|
||||
return {
|
||||
connect: async (host, options) => {
|
||||
const connection = await connectOnce(host, options);
|
||||
return {
|
||||
client: connection.client as never,
|
||||
jumpClient: connection.jumpClient as never,
|
||||
dispose: connection.dispose,
|
||||
};
|
||||
},
|
||||
|
||||
withConnection: async (host, options, fn) => {
|
||||
await checkSsh(true);
|
||||
const { resolveConnectHost } =
|
||||
await import("../hosts/connect/connect-host.js");
|
||||
const resolved = await resolveConnectHost(
|
||||
host as number | SshConnectHost,
|
||||
actingUser(host),
|
||||
);
|
||||
const key = poolKey(options.pool, resolved as PluginSshHost);
|
||||
poolKeys.add(key);
|
||||
const { withConnection } =
|
||||
await import("../hosts/ssh-connection-pool.js");
|
||||
return withConnection(
|
||||
key,
|
||||
async () =>
|
||||
(await connectOnce(resolved as PluginSshHost, options)).client,
|
||||
(client) => fn(client as never),
|
||||
);
|
||||
},
|
||||
|
||||
jumpChain: async (jumpHosts, chainOptions) => {
|
||||
await checkSsh(true);
|
||||
const userId = actingUser(chainOptions?.forHost ?? 0);
|
||||
const { createJumpHostChain } =
|
||||
await import("../hosts/jump-host-chain.js");
|
||||
const client = await createJumpHostChain(jumpHosts, userId);
|
||||
const details = `jump chain of ${jumpHosts.length} hop(s)`;
|
||||
if (!client) {
|
||||
await audit("ssh_jump_chain", details, {
|
||||
success: false,
|
||||
errorMessage: "Jump host chain could not be established",
|
||||
});
|
||||
throw new Error("Jump host chain could not be established");
|
||||
}
|
||||
await audit("ssh_jump_chain", details, { success: true });
|
||||
const dispose = () => {
|
||||
open.delete(dispose);
|
||||
client.end();
|
||||
};
|
||||
open.add(dispose);
|
||||
client.once("close", () => open.delete(dispose));
|
||||
return { client: client as never, jumpClient: null, dispose };
|
||||
},
|
||||
|
||||
poolKey,
|
||||
|
||||
prepare: async (host, options) => {
|
||||
await checkSsh(true);
|
||||
const { buildConnectConfig } =
|
||||
await import("../hosts/connect/build-connect-config.js");
|
||||
const built = await buildConnectConfig(host as SshConnectHost, {
|
||||
userId: actingUser(host),
|
||||
purpose: purposeOf(options),
|
||||
client: options.client as never,
|
||||
serverHostId: options.serverHostId,
|
||||
log: options.log,
|
||||
});
|
||||
await audit("ssh_prepare", describeHost(host), {
|
||||
success: built.outcome.status === "ready",
|
||||
errorMessage:
|
||||
built.outcome.status === "ready" ? undefined : built.outcome.message,
|
||||
});
|
||||
return { config: built.config, outcome: built.outcome };
|
||||
},
|
||||
|
||||
openTransport: async (host, config) => {
|
||||
await checkSsh(false);
|
||||
const { openSshTransport } =
|
||||
await import("../hosts/connect/transport.js");
|
||||
const opened = await openSshTransport(
|
||||
host as SshConnectHost,
|
||||
config as MutableConnectConfig,
|
||||
);
|
||||
if (opened.jumpClient) {
|
||||
const jumpClient = opened.jumpClient;
|
||||
const dispose = () => {
|
||||
open.delete(dispose);
|
||||
jumpClient.end();
|
||||
};
|
||||
open.add(dispose);
|
||||
jumpClient.once("close", () => open.delete(dispose));
|
||||
}
|
||||
return opened;
|
||||
},
|
||||
|
||||
classifyKeyboardInteractive: (round, host) => {
|
||||
ensureCoreSshAuthProviders();
|
||||
return classifyKeyboardInteractive(round, host as SshConnectHost);
|
||||
},
|
||||
|
||||
autoResponses: (prompts, password) =>
|
||||
prompts.map((p) =>
|
||||
/password/i.test(p.prompt) && password ? password : "",
|
||||
),
|
||||
|
||||
requiresSecret: (authType) => {
|
||||
ensureCoreSshAuthProviders();
|
||||
return !!getSshAuthProvider(authType)?.requiresSecret;
|
||||
},
|
||||
|
||||
supportsBackground: (authType) => {
|
||||
ensureCoreSshAuthProviders();
|
||||
const provider = getSshAuthProvider(authType);
|
||||
return !!provider && provider.supportsBackground !== false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
const contributes = manifest.contributes?.auth ?? {};
|
||||
|
||||
// Registration is synchronous so a plugin can call it inline in activate,
|
||||
// like ctx.http.router. The declaration is checked here; the grant is
|
||||
// checked on every call into what was registered.
|
||||
const requireDeclared = (
|
||||
list: string[] | undefined,
|
||||
id: string,
|
||||
field: string,
|
||||
) => {
|
||||
if (!declared.includes("auth:provide")) {
|
||||
throw new PluginCapabilityError(pluginId, "auth:provide");
|
||||
}
|
||||
if (!list?.includes(id)) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot register "${id}": it is not listed in contributes.auth.${field}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const granted = () => assertCapability(pluginId, "auth:provide", declared);
|
||||
|
||||
const record = (action: string, id: string) =>
|
||||
void audit(action, `${pluginId} registered ${id}`, { success: true });
|
||||
|
||||
return {
|
||||
registerSshAuthProvider: (provider) => {
|
||||
requireDeclared(contributes.sshAuthTypes, provider.type, "sshAuthTypes");
|
||||
const wrapped: SshAuthProvider = {
|
||||
...(provider as unknown as SshAuthProvider),
|
||||
pluginId,
|
||||
prepare: async (config, host, env) => {
|
||||
await granted();
|
||||
return provider.prepare(
|
||||
config,
|
||||
host as PluginSshHost,
|
||||
env as never,
|
||||
) as never;
|
||||
},
|
||||
};
|
||||
ensureCoreSshAuthProviders();
|
||||
const disposers = [registerSshAuthProvider(wrapped)];
|
||||
if (provider.onKeyboardInteractive) {
|
||||
const detect = provider.onKeyboardInteractive;
|
||||
disposers.push(
|
||||
registerKeyboardInteractiveInterceptor({
|
||||
id: `${pluginId}:${provider.type}`,
|
||||
pluginId,
|
||||
detect: (round, host) =>
|
||||
host.authType === provider.type
|
||||
? (detect(round, host as PluginSshHost) as never)
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
bag.add(
|
||||
() => disposers.forEach((dispose) => dispose()),
|
||||
`SSH auth type "${provider.type}"`,
|
||||
);
|
||||
record("auth_register_ssh", provider.type);
|
||||
},
|
||||
|
||||
registerLoginMethod: (method) => {
|
||||
requireDeclared(contributes.loginMethods, method.id, "loginMethods");
|
||||
{
|
||||
const dispose = registerLoginMethod({
|
||||
...method,
|
||||
pluginId,
|
||||
start: method.start
|
||||
? async (...args) => {
|
||||
await granted();
|
||||
return method.start!(...args);
|
||||
}
|
||||
: undefined,
|
||||
callback: method.callback
|
||||
? async (...args) => {
|
||||
await granted();
|
||||
return method.callback!(...args);
|
||||
}
|
||||
: undefined,
|
||||
verify: method.verify
|
||||
? async (...args) => {
|
||||
await granted();
|
||||
return method.verify!(...args);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
bag.add(dispose, `login method "${method.id}"`);
|
||||
}
|
||||
record("auth_register_login", method.id);
|
||||
},
|
||||
|
||||
registerSecondFactor: (factor) => {
|
||||
requireDeclared(contributes.secondFactors, factor.id, "secondFactors");
|
||||
{
|
||||
const dispose = registerSecondFactor({
|
||||
...factor,
|
||||
pluginId,
|
||||
verify: async (userId, body) => {
|
||||
await granted();
|
||||
return factor.verify(userId, body);
|
||||
},
|
||||
});
|
||||
bag.add(dispose, `second factor "${factor.id}"`);
|
||||
}
|
||||
record("auth_register_factor", factor.id);
|
||||
},
|
||||
|
||||
recordEnrollment: async (userId, factorId) => {
|
||||
requireDeclared(contributes.secondFactors, factorId, "secondFactors");
|
||||
await granted();
|
||||
const { createCurrentUserAuthRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
await createCurrentUserAuthRepository().recordSecondFactor(
|
||||
userId,
|
||||
pluginId,
|
||||
factorId,
|
||||
);
|
||||
await audit("auth_factor_enrolled", `${factorId} for ${userId}`, {
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
|
||||
removeEnrollment: async (userId, factorId) => {
|
||||
requireDeclared(contributes.secondFactors, factorId, "secondFactors");
|
||||
await granted();
|
||||
const { createCurrentUserAuthRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
await createCurrentUserAuthRepository().removeSecondFactor(
|
||||
userId,
|
||||
pluginId,
|
||||
factorId,
|
||||
);
|
||||
await audit("auth_factor_removed", `${factorId} for ${userId}`, {
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import * as syncRegistry from "./sync-registry.js";
|
||||
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
||||
|
||||
export type { PluginModule };
|
||||
|
||||
@@ -160,6 +161,11 @@ export function createPluginContext(
|
||||
handle: PluginHandle,
|
||||
): PluginContext {
|
||||
const pluginId = manifest.id;
|
||||
const auditCall = (
|
||||
action: string,
|
||||
details: string,
|
||||
outcome: { success: boolean; errorMessage?: string },
|
||||
) => writeAudit(manifest, { action, details: () => details }, outcome);
|
||||
// Forced: a plugin cannot log as another plugin.
|
||||
const logContext = { operation: `plugin:${pluginId}` };
|
||||
const declared = manifest.capabilities;
|
||||
@@ -566,9 +572,14 @@ export function createPluginContext(
|
||||
},
|
||||
|
||||
disposables: {
|
||||
add: (dispose) => handle.bag.add(dispose, "plugin resource"),
|
||||
add: (dispose) => {
|
||||
handle.bag.add(dispose, "plugin resource");
|
||||
},
|
||||
},
|
||||
|
||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
|
||||
/**
|
||||
* Background work acts as a named user. Always audited, because "this ran
|
||||
* as someone" is exactly the thing an operator needs to be able to see.
|
||||
|
||||
@@ -27,14 +27,19 @@ export class DisposableBag {
|
||||
|
||||
constructor(private readonly pluginId: string) {}
|
||||
|
||||
add(dispose: Disposer, label = "disposable"): void {
|
||||
/** Returns a function that drops the entry without running it. */
|
||||
add(dispose: Disposer, label = "disposable"): () => void {
|
||||
if (this.disposed) {
|
||||
// Registering after teardown means the resource would never be cleaned
|
||||
// up. Dispose it immediately rather than silently leaking it.
|
||||
void this.run({ dispose, label });
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
this.entries.push({ dispose, label });
|
||||
const entry = { dispose, label };
|
||||
this.entries.push(entry);
|
||||
return () => {
|
||||
this.entries = this.entries.filter((candidate) => candidate !== entry);
|
||||
};
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { setPluginEnabledCheck, unregisterPluginHttp } from "./http.js";
|
||||
import { PluginLoader, type LoadedPlugin } from "./loader.js";
|
||||
import type { PluginPermissionContribution } from "./manifest.js";
|
||||
import { invalidatePluginPermissionCache } from "./permissions.js";
|
||||
import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js";
|
||||
|
||||
let loader: PluginLoader | null = null;
|
||||
|
||||
@@ -26,6 +27,16 @@ export function getPluginRuntime(): { loader: PluginLoader } {
|
||||
setPluginEnabledCheck(
|
||||
(pluginId) => loader?.get(pluginId)?.state === "active",
|
||||
);
|
||||
// Lets a host whose auth type belongs to a disabled plugin name it.
|
||||
setSshAuthTypeOwnerSource(() =>
|
||||
(loader?.list() ?? []).flatMap((plugin) =>
|
||||
(plugin.manifest.contributes?.auth?.sshAuthTypes ?? []).map((type) => ({
|
||||
type,
|
||||
pluginId: plugin.id,
|
||||
pluginName: plugin.manifest.name,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
return { loader };
|
||||
}
|
||||
|
||||
@@ -265,6 +265,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
await import("./utils/crypto-migration/channel-config-encryption.js");
|
||||
await runChannelConfigEncryptionMigration();
|
||||
|
||||
const { runExternalIdentityMigration } =
|
||||
await import("./utils/crypto-migration/external-identity-migration.js");
|
||||
await runExternalIdentityMigration();
|
||||
|
||||
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||
await provisionLocalDesktopUserIfNeeded();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,8 @@ const state = vi.hoisted(() => ({
|
||||
}[],
|
||||
// Which users have admin.plugins.manage, keyed by userId.
|
||||
managers: new Set<string>(["admin-1"]),
|
||||
// Plugins the loader reports as running.
|
||||
active: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
@@ -93,7 +95,18 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({
|
||||
loader: { list: () => [], get: () => undefined },
|
||||
loader: {
|
||||
list: () => [],
|
||||
get: (id: string) =>
|
||||
state.active.has(id)
|
||||
? {
|
||||
id,
|
||||
state: "active",
|
||||
dir: "/nonexistent/plugin",
|
||||
manifest: { id, locales: "locales" },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
activatePlugin: vi.fn(),
|
||||
deactivatePlugin: vi.fn(),
|
||||
@@ -166,6 +179,7 @@ describe("plugins route", () => {
|
||||
state.plugins = new Map();
|
||||
state.grants = [];
|
||||
state.managers = new Set(["admin-1"]);
|
||||
state.active = new Set();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -226,6 +240,94 @@ describe("plugins route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /plugins/public-manifest", () => {
|
||||
it("lists only running plugins with login or second-factor UI, and nothing sensitive", async () => {
|
||||
state.plugins.set(
|
||||
"login-plugin",
|
||||
makePlugin({
|
||||
id: "login-plugin",
|
||||
manifestJson: JSON.stringify({
|
||||
capabilities: ["auth:provide", "credentials:read"],
|
||||
contributes: {
|
||||
auth: { loginMethods: ["corp-sso"], sshAuthTypes: ["corp"] },
|
||||
settings: { admin: [{ key: "clientSecret", type: "secret" }] },
|
||||
permissions: [{ name: "manage" }],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set(
|
||||
"factor-plugin",
|
||||
makePlugin({
|
||||
id: "factor-plugin",
|
||||
manifestJson: JSON.stringify({
|
||||
contributes: { auth: { secondFactors: ["pin"] } },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set(
|
||||
"stopped-login",
|
||||
makePlugin({
|
||||
id: "stopped-login",
|
||||
manifestJson: JSON.stringify({
|
||||
contributes: { auth: { loginMethods: ["other"] } },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set(
|
||||
"disabled-login",
|
||||
makePlugin({
|
||||
id: "disabled-login",
|
||||
state: "disabled",
|
||||
manifestJson: JSON.stringify({
|
||||
contributes: { auth: { loginMethods: ["x"] } },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
state.active = new Set([
|
||||
"login-plugin",
|
||||
"factor-plugin",
|
||||
"disabled-login",
|
||||
"sample-plugin",
|
||||
]);
|
||||
|
||||
const res = await fetch(`${baseUrl}/plugins/public-manifest`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
|
||||
"login-plugin",
|
||||
"factor-plugin",
|
||||
]);
|
||||
expect(body[0].contributes).toEqual({
|
||||
auth: { loginMethods: ["corp-sso"], secondFactors: [] },
|
||||
});
|
||||
for (const plugin of body) {
|
||||
expect(Object.keys(plugin).sort()).toEqual(
|
||||
[
|
||||
"assetVersion",
|
||||
"contributes",
|
||||
"css",
|
||||
"dependencies",
|
||||
"enabled",
|
||||
"frontend",
|
||||
"id",
|
||||
"locales",
|
||||
"name",
|
||||
"optionalDependencies",
|
||||
"state",
|
||||
"version",
|
||||
].sort(),
|
||||
);
|
||||
}
|
||||
const raw = JSON.stringify(body);
|
||||
expect(raw).not.toContain("clientSecret");
|
||||
expect(raw).not.toContain("credentials:read");
|
||||
expect(raw).not.toContain("sshAuthTypes");
|
||||
});
|
||||
});
|
||||
|
||||
it("lists a plugin's declared and granted capabilities", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
state.grants.push({
|
||||
|
||||
@@ -9,6 +9,10 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({ findById, update: userUpdate }),
|
||||
createCurrentTrustedDeviceRepository: () => ({}),
|
||||
createCurrentUserSessionRepository: () => ({}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
removeSecondFactor: vi.fn().mockResolvedValue(true),
|
||||
recordSecondFactor: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
|
||||
@@ -17,6 +17,8 @@ const state = vi.hoisted(() => ({
|
||||
unlockedUsers: new Set<string>(),
|
||||
updates: [] as { id: string; changes: Record<string, unknown> }[],
|
||||
auditCalls: [] as Record<string, unknown>[],
|
||||
secondFactors: [] as { userId: string; pluginId: string; factorId: string }[],
|
||||
trustedDevicesCleared: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
|
||||
@@ -89,6 +91,38 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
switchUserRoleName: async () => {},
|
||||
assignRoleNameToUser: async () => {},
|
||||
}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listSecondFactors: async (userId: string) =>
|
||||
state.secondFactors.filter((row) => row.userId === userId),
|
||||
removeSecondFactor: async (
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
) => {
|
||||
state.secondFactors = state.secondFactors.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.userId === userId &&
|
||||
row.pluginId === pluginId &&
|
||||
row.factorId === factorId
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
clearSecondFactors: async (userId: string) => {
|
||||
const before = state.secondFactors.length;
|
||||
state.secondFactors = state.secondFactors.filter(
|
||||
(row) => row.userId !== userId,
|
||||
);
|
||||
return before - state.secondFactors.length;
|
||||
},
|
||||
}),
|
||||
createCurrentTrustedDeviceRepository: () => ({
|
||||
deleteByUserId: async (userId: string) => {
|
||||
state.trustedDevicesCleared.push(userId);
|
||||
},
|
||||
}),
|
||||
getCurrentSettingValue: () => null,
|
||||
}));
|
||||
|
||||
const { registerUserAdminRoutes } =
|
||||
@@ -180,6 +214,8 @@ async function invoke(
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "admin1";
|
||||
state.secondFactors = [];
|
||||
state.trustedDevicesCleared = [];
|
||||
state.users = new Map([
|
||||
[
|
||||
"admin1",
|
||||
@@ -356,3 +392,64 @@ describe("GET /admin/export/:userId", () => {
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /admin/:userId/second-factors", () => {
|
||||
it("clears every factor, including one whose plugin is gone, and audits", async () => {
|
||||
state.secondFactors = [
|
||||
{ userId: "target1", pluginId: "core", factorId: "totp" },
|
||||
{ userId: "target1", pluginId: "gone-plugin", factorId: "yubikey" },
|
||||
{ userId: "locked1", pluginId: "core", factorId: "totp" },
|
||||
];
|
||||
const res = await invoke("delete", "/admin/:userId/second-factors", {
|
||||
params: { userId: "target1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(state.secondFactors).toEqual([
|
||||
{ userId: "locked1", pluginId: "core", factorId: "totp" },
|
||||
]);
|
||||
expect(state.trustedDevicesCleared).toContain("target1");
|
||||
// TOTP's own reset cleared its secret.
|
||||
expect(
|
||||
state.updates.find((u) => u.id === "target1")?.changes,
|
||||
).toMatchObject({ totpEnabled: false, totpSecret: null });
|
||||
const audit = state.auditCalls.find(
|
||||
(c) => c.action === "admin_reset_second_factors",
|
||||
);
|
||||
expect(audit).toMatchObject({ resourceId: "target1", success: true });
|
||||
expect(JSON.parse(audit!.details as string).removed).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ pluginId: "gone-plugin", factorId: "yubikey" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists factors with their availability", async () => {
|
||||
state.secondFactors = [
|
||||
{ userId: "target1", pluginId: "gone-plugin", factorId: "yubikey" },
|
||||
];
|
||||
const res = await invoke("get", "/admin/:userId/second-factors", {
|
||||
params: { userId: "target1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((res.jsonBody as { factors: unknown[] }).factors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ factorId: "yubikey", available: false }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("403s when the caller is not an admin", async () => {
|
||||
state.currentUserId = "target1";
|
||||
const res = await invoke("delete", "/admin/:userId/second-factors", {
|
||||
params: { userId: "locked1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("404s for an unknown target", async () => {
|
||||
const res = await invoke("delete", "/admin/:userId/second-factors", {
|
||||
params: { userId: "ghost" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,24 @@ const mocks = vi.hoisted(() => ({
|
||||
verify: vi.fn(),
|
||||
user: vi.fn(),
|
||||
update: vi.fn(),
|
||||
manager: {
|
||||
authenticateWebAuthnUser: vi.fn(),
|
||||
authenticateOIDCUser: vi.fn(),
|
||||
isTrustedDevice: vi.fn(),
|
||||
generateJWTToken: vi.fn(),
|
||||
getSecureCookieOptions: vi.fn(),
|
||||
getClearCookieOptions: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => mocks.manager },
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(),
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
}));
|
||||
vi.mock("../../../hosts/automation-events.js", () => ({
|
||||
notifyAutomationInternalEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@simplewebauthn/server", () => ({
|
||||
generateAuthenticationOptions: vi
|
||||
@@ -17,6 +35,10 @@ vi.mock("@simplewebauthn/server", () => ({
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({ findById: mocks.user }),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listSecondFactors: async () => [],
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentWebauthnCredentialRepository: () => ({
|
||||
findByCredentialId: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
@@ -30,7 +52,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
getCurrentSettingValue: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({ authLogger: { warn: vi.fn() } }));
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
return { authLogger: log, sshLogger: log, logger: log, databaseLogger: log };
|
||||
});
|
||||
vi.mock("../../../utils/user-agent-parser.js", () => ({
|
||||
parseUserAgent: () => ({ type: "browser", deviceInfo: "test" }),
|
||||
generateDeviceFingerprint: () => "device",
|
||||
@@ -40,12 +71,7 @@ const { registerUserWebAuthnRoutes } =
|
||||
await import("../../../database/routes/user-webauthn-routes.js");
|
||||
type Handler = (req: Request, res: Response) => Promise<unknown>;
|
||||
const handlers = new Map<string, Handler>();
|
||||
const manager = {
|
||||
authenticateWebAuthnUser: vi.fn().mockResolvedValue(true),
|
||||
isTrustedDevice: vi.fn().mockResolvedValue(false),
|
||||
generateJWTToken: vi.fn().mockResolvedValue("token"),
|
||||
getSecureCookieOptions: vi.fn().mockReturnValue({ httpOnly: true }),
|
||||
};
|
||||
const manager = mocks.manager;
|
||||
registerUserWebAuthnRoutes(
|
||||
{
|
||||
post: (path: string, ...callbacks: Handler[]) =>
|
||||
@@ -62,6 +88,7 @@ registerUserWebAuthnRoutes(
|
||||
function response() {
|
||||
const res = { json: vi.fn(), status: vi.fn(), cookie: vi.fn() };
|
||||
res.status.mockReturnValue(res);
|
||||
res.cookie.mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
async function login(userVerification: string) {
|
||||
@@ -78,13 +105,21 @@ async function login(userVerification: string) {
|
||||
await handlers.get("/webauthn/authenticate/verify")!(
|
||||
{
|
||||
body: { challengeId, response: { id: "key" }, rememberMe: true },
|
||||
} as Request,
|
||||
get: () => undefined,
|
||||
headers: {},
|
||||
socket: {},
|
||||
} as unknown as Request,
|
||||
res as unknown as Response,
|
||||
);
|
||||
return res;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
manager.authenticateWebAuthnUser.mockResolvedValue(true);
|
||||
manager.authenticateOIDCUser.mockResolvedValue(true);
|
||||
manager.generateJWTToken.mockResolvedValue("token");
|
||||
manager.getSecureCookieOptions.mockReturnValue({ httpOnly: true });
|
||||
manager.getClearCookieOptions.mockReturnValue({});
|
||||
mocks.user.mockResolvedValue({
|
||||
id: "user",
|
||||
username: "alice",
|
||||
@@ -134,7 +169,11 @@ describe("WebAuthn second factor policy", () => {
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
});
|
||||
it("allows users without TOTP to log in with a possession-only key", async () => {
|
||||
mocks.user.mockResolvedValue({ id: "user", totpEnabled: false });
|
||||
mocks.user.mockResolvedValue({
|
||||
id: "user",
|
||||
username: "alice",
|
||||
totpEnabled: false,
|
||||
});
|
||||
expect((await login("discouraged")).cookie).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { vi } from "vitest";
|
||||
|
||||
/** Stands in for ssh2's Client: records connect() and lets a test drive events. */
|
||||
export class FakeSshClient extends EventEmitter {
|
||||
connectConfig: Record<string, unknown> | null = null;
|
||||
ended = false;
|
||||
forwardOut = vi.fn(
|
||||
(
|
||||
_srcIp: string,
|
||||
_srcPort: number,
|
||||
_dstIp: string,
|
||||
_dstPort: number,
|
||||
cb: (err: Error | undefined, stream: unknown) => void,
|
||||
) => cb(undefined, { fakeStream: true }),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
FakeSshClient.instances.push(this);
|
||||
}
|
||||
|
||||
connect(config: Record<string, unknown>) {
|
||||
this.connectConfig = config;
|
||||
const behaviour = FakeSshClient.nextBehaviour.shift() ?? "ready";
|
||||
queueMicrotask(() => {
|
||||
if (behaviour === "ready") this.emit("ready");
|
||||
else if (behaviour === "auth-fail")
|
||||
this.emit(
|
||||
"error",
|
||||
new Error("All configured authentication methods failed"),
|
||||
);
|
||||
else if (behaviour === "hang") return;
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
end() {
|
||||
this.ended = true;
|
||||
}
|
||||
|
||||
static nextBehaviour: Array<"ready" | "auth-fail" | "hang"> = [];
|
||||
static instances: FakeSshClient[] = [];
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ssh2 from "ssh2";
|
||||
import { FakeSshClient } from "./pipeline-test-helpers.js";
|
||||
|
||||
vi.mock("ssh2", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ssh2")>();
|
||||
const { FakeSshClient: Fake } = await import("./pipeline-test-helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...(actual as unknown as { default: object }).default,
|
||||
Client: Fake,
|
||||
},
|
||||
Client: Fake,
|
||||
};
|
||||
});
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verifier: () => {},
|
||||
createHostVerifier: vi.fn(),
|
||||
getOPKSSHToken: vi.fn(),
|
||||
invalidateOPKSSHToken: vi.fn(),
|
||||
setupOPKSSHCertAuth: vi.fn(),
|
||||
setupCACertAuth: vi.fn(),
|
||||
getVaultCert: vi.fn(),
|
||||
deleteVaultCert: vi.fn(),
|
||||
applyAgentAuth: vi.fn(),
|
||||
performPortKnocking: vi.fn(),
|
||||
createJumpHostChain: vi.fn(),
|
||||
createSocks5Connection: vi.fn(),
|
||||
resolveHostById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { logger: log, sshLogger: log, authLogger: log, fileLogger: log };
|
||||
});
|
||||
vi.mock("../../../hosts/host-key-verifier.js", () => ({
|
||||
SSHHostKeyVerifier: {
|
||||
preloadHostData: async () => null,
|
||||
createHostVerifier: mocks.createHostVerifier,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/opkssh-auth.js", () => ({
|
||||
getOPKSSHToken: mocks.getOPKSSHToken,
|
||||
invalidateOPKSSHToken: mocks.invalidateOPKSSHToken,
|
||||
}));
|
||||
vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({
|
||||
setupOPKSSHCertAuth: mocks.setupOPKSSHCertAuth,
|
||||
setupCACertAuth: mocks.setupCACertAuth,
|
||||
}));
|
||||
vi.mock("../../../hosts/vault-signer-auth.js", () => ({
|
||||
getVaultCert: mocks.getVaultCert,
|
||||
deleteVaultCert: mocks.deleteVaultCert,
|
||||
}));
|
||||
vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({
|
||||
applyAgentAuth: mocks.applyAgentAuth,
|
||||
performPortKnocking: mocks.performPortKnocking,
|
||||
}));
|
||||
vi.mock("../../../hosts/jump-host-chain.js", () => ({
|
||||
createJumpHostChain: mocks.createJumpHostChain,
|
||||
}));
|
||||
vi.mock("../../../utils/socks5-helper.js", () => ({
|
||||
createSocks5Connection: mocks.createSocks5Connection,
|
||||
}));
|
||||
vi.mock("../../../hosts/ssh-dns.js", () => ({
|
||||
resolveSshConnectConfigHost: async (config: unknown) => config,
|
||||
}));
|
||||
vi.mock("../../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: mocks.resolveHostById,
|
||||
}));
|
||||
vi.mock("../../../hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
_key: string,
|
||||
factory: () => Promise<unknown>,
|
||||
fn: (client: unknown) => Promise<unknown>,
|
||||
) => fn(await factory()),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildConnectConfig,
|
||||
getPurposeDefaults,
|
||||
} from "../../../hosts/connect/build-connect-config.js";
|
||||
import {
|
||||
connectHost,
|
||||
getConnectionPoolKey,
|
||||
SshConnectError,
|
||||
withHostConnection,
|
||||
} from "../../../hosts/connect/connect-host.js";
|
||||
import {
|
||||
getSshAuthProvider,
|
||||
listCredentialTypes,
|
||||
registerSshAuthProvider,
|
||||
setSshAuthTypeOwnerSource,
|
||||
} from "../../../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
|
||||
import { TAILSCALE_CHECK_TIMEOUT_MS } from "../../../auth/legacy-providers.js";
|
||||
import type { SshConnectHost } from "../../../hosts/connect/types.js";
|
||||
|
||||
const plainKey = ssh2.utils.generateKeyPairSync("ed25519").private;
|
||||
const encryptedKey = ssh2.utils.generateKeyPairSync("ed25519", {
|
||||
passphrase: "secret",
|
||||
cipher: "aes256-cbc",
|
||||
}).private;
|
||||
|
||||
function host(overrides: Partial<SshConnectHost> = {}): SshConnectHost {
|
||||
return {
|
||||
id: 7,
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "root",
|
||||
userId: "owner-1",
|
||||
authType: "password",
|
||||
password: "hunter2",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function build(
|
||||
target: SshConnectHost,
|
||||
purpose: Parameters<typeof buildConnectConfig>[1]["purpose"] = "terminal",
|
||||
) {
|
||||
return buildConnectConfig(target, {
|
||||
userId: "user-1",
|
||||
purpose,
|
||||
client: new FakeSshClient() as never,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
FakeSshClient.nextBehaviour = [];
|
||||
FakeSshClient.instances = [];
|
||||
mocks.createHostVerifier.mockResolvedValue(mocks.verifier);
|
||||
mocks.applyAgentAuth.mockImplementation(async (config) => {
|
||||
config.agent = "agent-object";
|
||||
return { socketPath: "/tmp/agent.sock" };
|
||||
});
|
||||
mocks.setupOPKSSHCertAuth.mockImplementation(async (config) => {
|
||||
config.authHandler = "cert-handler";
|
||||
});
|
||||
mocks.setupCACertAuth.mockImplementation(async (config) => {
|
||||
config.authHandler = "ca-handler";
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConnectConfig per auth type", () => {
|
||||
it("password puts the password in the config", async () => {
|
||||
const { config, outcome } = await build(host());
|
||||
expect(outcome).toEqual({ status: "ready" });
|
||||
expect(config).toMatchObject({
|
||||
host: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "root",
|
||||
password: "hunter2",
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 5,
|
||||
readyTimeout: 120000,
|
||||
});
|
||||
expect(config.hostVerifier).toBe(mocks.verifier);
|
||||
});
|
||||
|
||||
it("password leaves the password out when keyboard-interactive is forced", async () => {
|
||||
const { config, outcome } = await build(
|
||||
host({ forceKeyboardInteractive: true }),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(config.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("password without a password is an error", async () => {
|
||||
const { outcome } = await build(host({ password: null }));
|
||||
expect(outcome).toMatchObject({ status: "error", code: "missing-secret" });
|
||||
});
|
||||
|
||||
it("key sets privateKey, passphrase and keeps a password fallback", async () => {
|
||||
const { config, outcome } = await build(
|
||||
host({
|
||||
authType: "key",
|
||||
key: encryptedKey,
|
||||
keyPassword: "secret",
|
||||
password: "fallback",
|
||||
}),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
|
||||
expect(config.passphrase).toBe("secret");
|
||||
expect(config.password).toBe("fallback");
|
||||
expect(mocks.setupCACertAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("key with a CA certificate grafts the certificate", async () => {
|
||||
const { config } = await build(
|
||||
host({
|
||||
authType: "key",
|
||||
key: plainKey,
|
||||
password: null,
|
||||
certPublicKey: "ssh-ed25519-cert-v01@openssh.com AAAA",
|
||||
}),
|
||||
);
|
||||
expect(mocks.setupCACertAuth).toHaveBeenCalledOnce();
|
||||
expect(config.authHandler).toBe("ca-handler");
|
||||
});
|
||||
|
||||
it("key with a failing CA certificate falls back to key only", async () => {
|
||||
mocks.setupCACertAuth.mockRejectedValueOnce(new Error("bad cert"));
|
||||
const { outcome } = await build(
|
||||
host({ authType: "key", key: plainKey, certPublicKey: "junk" }),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
});
|
||||
|
||||
it("an encrypted key without its passphrase asks for one", async () => {
|
||||
const { outcome } = await build(
|
||||
host({ authType: "key", key: encryptedKey }),
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
status: "error",
|
||||
code: "passphrase-required",
|
||||
});
|
||||
});
|
||||
|
||||
it("a broken key is an invalid-key error", async () => {
|
||||
const { outcome } = await build(
|
||||
host({ authType: "key", key: "not a key" }),
|
||||
);
|
||||
expect(outcome).toMatchObject({ status: "error", code: "invalid-key" });
|
||||
});
|
||||
|
||||
it("credential behaves like whichever secret the resolver left", async () => {
|
||||
const keyed = await build(
|
||||
host({ authType: "credential", key: plainKey, password: null }),
|
||||
);
|
||||
expect(Buffer.isBuffer(keyed.config.privateKey)).toBe(true);
|
||||
|
||||
const passworded = await build(host({ authType: "credential" }));
|
||||
expect(passworded.config.password).toBe("hunter2");
|
||||
|
||||
const empty = await build(host({ authType: "credential", password: null }));
|
||||
expect(empty.outcome).toMatchObject({ code: "missing-secret" });
|
||||
});
|
||||
|
||||
it("agent hands the config to the agent helper", async () => {
|
||||
const { config, outcome } = await build(
|
||||
host({
|
||||
authType: "agent",
|
||||
password: null,
|
||||
terminalConfig: { agentSocketPath: "/tmp/agent.sock" },
|
||||
}),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(config.agent).toBe("agent-object");
|
||||
expect(mocks.applyAgentAuth).toHaveBeenCalledWith(config, {
|
||||
agentSocketPath: "/tmp/agent.sock",
|
||||
});
|
||||
});
|
||||
|
||||
it("agent errors are reported", async () => {
|
||||
mocks.applyAgentAuth.mockResolvedValueOnce({ error: "no socket" });
|
||||
const { outcome } = await build(host({ authType: "agent" }));
|
||||
expect(outcome).toEqual({
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message: "no socket",
|
||||
});
|
||||
});
|
||||
|
||||
it("none adds no credentials", async () => {
|
||||
const { config, outcome } = await build(
|
||||
host({ authType: "none", password: null }),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(config.password).toBeUndefined();
|
||||
expect(config.privateKey).toBeUndefined();
|
||||
expect(config.tryKeyboard).toBe(true);
|
||||
});
|
||||
|
||||
it("opkssh without a cached token asks for a sign-in", async () => {
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce(null);
|
||||
const { outcome } = await build(host({ authType: "opkssh" }));
|
||||
expect(outcome).toMatchObject({
|
||||
status: "interaction-required",
|
||||
interaction: "opkssh",
|
||||
});
|
||||
expect(mocks.getOPKSSHToken).toHaveBeenCalledWith("user-1", 7);
|
||||
});
|
||||
|
||||
it("opkssh and stepca use the cached certificate", async () => {
|
||||
for (const authType of ["opkssh", "stepca"]) {
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
const { config, outcome } = await build(host({ authType }));
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(config.authHandler).toBe("cert-handler");
|
||||
}
|
||||
expect(mocks.setupOPKSSHCertAuth).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("vault needs a profile, then a cached certificate", async () => {
|
||||
const noProfile = await build(host({ authType: "vault" }));
|
||||
expect(noProfile.outcome).toMatchObject({ status: "error" });
|
||||
|
||||
mocks.getVaultCert.mockResolvedValueOnce(null);
|
||||
const noCert = await build(
|
||||
host({ authType: "vault", vaultProfile: { id: 3 } }),
|
||||
);
|
||||
// The file manager turns the flag into requiresVaultAuth in its 401.
|
||||
expect(noCert.outcome).toMatchObject({
|
||||
status: "interaction-required",
|
||||
interaction: "vault",
|
||||
flag: "requiresVaultAuth",
|
||||
});
|
||||
|
||||
mocks.getVaultCert.mockResolvedValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
const ready = await build(
|
||||
host({ authType: "vault", vaultProfile: { id: 3 } }),
|
||||
);
|
||||
expect(ready.outcome.status).toBe("ready");
|
||||
expect(mocks.getVaultCert).toHaveBeenLastCalledWith("user-1", 3);
|
||||
});
|
||||
|
||||
it("tailscale turns off keyboard-interactive, with long timeouts in the terminal", async () => {
|
||||
const terminal = await build(host({ authType: "tailscale" }));
|
||||
expect(terminal.config).toMatchObject({
|
||||
tryKeyboard: false,
|
||||
readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS,
|
||||
timeout: TAILSCALE_CHECK_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const fleet = await build(host({ authType: "tailscale" }), "fleet");
|
||||
expect(fleet.config.tryKeyboard).toBe(false);
|
||||
expect(fleet.config.readyTimeout).toBe(30000);
|
||||
});
|
||||
|
||||
it("tailscale retries once with +password, then reports the failure", async () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
const provider = getSshAuthProvider("tailscale")!;
|
||||
const env = {} as never;
|
||||
const failure = new Error("All configured authentication methods failed");
|
||||
|
||||
const first = provider.onAuthFailed!(host({ authType: "tailscale" }), env, {
|
||||
error: failure,
|
||||
retries: 0,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
});
|
||||
expect(first).toMatchObject({
|
||||
status: "retry",
|
||||
patch: { username: "root+password", tryKeyboard: false },
|
||||
});
|
||||
|
||||
const second = provider.onAuthFailed!(
|
||||
host({ authType: "tailscale" }),
|
||||
env,
|
||||
{
|
||||
error: failure,
|
||||
retries: 1,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
},
|
||||
);
|
||||
expect(second).toMatchObject({ status: "error" });
|
||||
});
|
||||
|
||||
it("a type nobody provides names the plugin that would", async () => {
|
||||
setSshAuthTypeOwnerSource(() => [
|
||||
{ type: "made-up", pluginId: "made", pluginName: "Made Up" },
|
||||
]);
|
||||
const { outcome } = await build(host({ authType: "made-up" }));
|
||||
expect(outcome).toEqual({
|
||||
status: "error",
|
||||
code: "provider-missing",
|
||||
message: "This host uses made-up, which needs the Made Up plugin",
|
||||
});
|
||||
setSshAuthTypeOwnerSource(() => []);
|
||||
});
|
||||
|
||||
it("jump hops skip keyboard-interactive for auth type none", async () => {
|
||||
const { config } = await build(
|
||||
host({ authType: "none", password: null }),
|
||||
"jump-host",
|
||||
);
|
||||
expect(config.tryKeyboard).toBe(false);
|
||||
expect(mocks.createHostVerifier).toHaveBeenCalledWith(
|
||||
7,
|
||||
"10.0.0.7",
|
||||
22,
|
||||
null,
|
||||
"user-1",
|
||||
true,
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purpose defaults", () => {
|
||||
it("keeps each transport's old keepalive numbers", () => {
|
||||
expect(getPurposeDefaults("terminal")).toMatchObject({
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 5,
|
||||
});
|
||||
expect(getPurposeDefaults("file-manager")).toMatchObject({
|
||||
keepaliveIntervalMs: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
});
|
||||
expect(getPurposeDefaults("fleet")).toMatchObject({
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("host keepalive settings apply where the transport honoured them", async () => {
|
||||
const withSettings = host({
|
||||
terminalConfig: { keepaliveInterval: 10, keepaliveCountMax: 2 },
|
||||
});
|
||||
const terminal = await build(withSettings, "terminal");
|
||||
expect(terminal.config.keepaliveInterval).toBe(10000);
|
||||
const fleet = await build(withSettings, "fleet");
|
||||
expect(fleet.config.keepaliveInterval).toBe(30000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider registry", () => {
|
||||
it("registers and removes a plugin provider", async () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
const dispose = registerSshAuthProvider({
|
||||
type: "fixture",
|
||||
pluginId: "fixture-plugin",
|
||||
labelKey: "fixture.label",
|
||||
credentialType: true,
|
||||
prepare: async (config) => {
|
||||
config.password = "from-plugin";
|
||||
return { status: "ready" };
|
||||
},
|
||||
});
|
||||
expect(listCredentialTypes()).toContain("fixture");
|
||||
const { config } = await build(host({ authType: "fixture" }));
|
||||
expect(config.password).toBe("from-plugin");
|
||||
|
||||
dispose();
|
||||
expect(getSshAuthProvider("fixture")).toBeUndefined();
|
||||
expect(listCredentialTypes()).not.toContain("fixture");
|
||||
const { outcome } = await build(host({ authType: "fixture" }));
|
||||
expect(outcome).toMatchObject({ code: "provider-missing" });
|
||||
});
|
||||
|
||||
it("refuses to let a second plugin take over a type", () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
expect(() =>
|
||||
registerSshAuthProvider({
|
||||
type: "password",
|
||||
pluginId: "someone-else",
|
||||
labelKey: "x",
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
}),
|
||||
).toThrow(/already provided by core/);
|
||||
});
|
||||
|
||||
it("offers password and key as credential types", () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
expect(listCredentialTypes()).toEqual(
|
||||
expect.arrayContaining(["password", "key"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectHost", () => {
|
||||
it("resolves the host for the acting user and connects", async () => {
|
||||
mocks.resolveHostById.mockResolvedValueOnce(host());
|
||||
const connection = await connectHost(7, {
|
||||
userId: "user-1",
|
||||
purpose: "fleet",
|
||||
});
|
||||
expect(mocks.resolveHostById).toHaveBeenCalledWith(7, "user-1");
|
||||
const client = connection.client as unknown as FakeSshClient;
|
||||
expect(client.connectConfig).toMatchObject({ password: "hunter2" });
|
||||
connection.dispose();
|
||||
expect(client.ended).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a shared-override resolution exactly as the resolver returned it", async () => {
|
||||
mocks.resolveHostById.mockResolvedValueOnce(
|
||||
host({ authType: "key", key: plainKey, password: null, username: "me" }),
|
||||
);
|
||||
const connection = await connectHost(7, {
|
||||
userId: "recipient",
|
||||
purpose: "fleet",
|
||||
});
|
||||
const config = (connection.client as unknown as FakeSshClient)
|
||||
.connectConfig!;
|
||||
expect(config.username).toBe("me");
|
||||
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
|
||||
expect(config.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails for an unknown or forbidden host", async () => {
|
||||
mocks.resolveHostById.mockResolvedValueOnce(null);
|
||||
await expect(
|
||||
connectHost(9, { userId: "user-1", purpose: "fleet" }),
|
||||
).rejects.toThrow("Host not found or access denied");
|
||||
});
|
||||
|
||||
it("throws the auth outcome instead of connecting", async () => {
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce(null);
|
||||
const attempt = connectHost(host({ authType: "opkssh" }), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
});
|
||||
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
|
||||
await expect(
|
||||
connectHost(host({ authType: "opkssh" }), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
}).catch((error) => error.code),
|
||||
).resolves.toBe("opkssh-required");
|
||||
});
|
||||
|
||||
it("goes through the jump chain and forwards to the target", async () => {
|
||||
const jumpClient = new FakeSshClient();
|
||||
mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient);
|
||||
const connection = await connectHost(
|
||||
host({ jumpHosts: [{ hostId: 2 }, { hostId: 3 }] }),
|
||||
{ userId: "user-1", purpose: "fleet" },
|
||||
);
|
||||
expect(mocks.createJumpHostChain).toHaveBeenCalledWith(
|
||||
[{ hostId: 2 }, { hostId: 3 }],
|
||||
"owner-1",
|
||||
);
|
||||
expect(jumpClient.forwardOut).toHaveBeenCalledWith(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
"10.0.0.7",
|
||||
22,
|
||||
expect.any(Function),
|
||||
);
|
||||
const config = (connection.client as unknown as FakeSshClient)
|
||||
.connectConfig!;
|
||||
expect(config.sock).toEqual({ fakeStream: true });
|
||||
connection.dispose();
|
||||
expect(jumpClient.ended).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a SOCKS5 proxy when the host has one", async () => {
|
||||
mocks.createSocks5Connection.mockResolvedValueOnce({ proxied: true });
|
||||
const connection = await connectHost(
|
||||
host({ useSocks5: true, socks5Host: "proxy", socks5Port: 1080 }),
|
||||
{ userId: "user-1", purpose: "fleet" },
|
||||
);
|
||||
expect(mocks.createSocks5Connection).toHaveBeenCalledWith(
|
||||
"10.0.0.7",
|
||||
22,
|
||||
expect.objectContaining({ socks5Host: "proxy", socks5Port: 1080 }),
|
||||
);
|
||||
expect(
|
||||
(connection.client as unknown as FakeSshClient).connectConfig!.sock,
|
||||
).toEqual({ proxied: true });
|
||||
});
|
||||
|
||||
it("knocks before connecting when the host has a sequence", async () => {
|
||||
await connectHost(
|
||||
host({ portKnockSequence: [{ port: 7000 }, { port: 8000 }] }),
|
||||
{ userId: "user-1", purpose: "fleet" },
|
||||
);
|
||||
expect(mocks.performPortKnocking).toHaveBeenCalledWith("10.0.0.7", [
|
||||
{ port: 7000 },
|
||||
{ port: 8000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => {
|
||||
FakeSshClient.nextBehaviour = ["hang"];
|
||||
const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" });
|
||||
await vi.waitFor(() => {
|
||||
const last = FakeSshClient.instances.at(-1)!;
|
||||
expect(last.connectConfig).not.toBeNull();
|
||||
});
|
||||
const client = FakeSshClient.instances.at(-1)!;
|
||||
const finish = vi.fn();
|
||||
client.emit(
|
||||
"keyboard-interactive",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[
|
||||
{ prompt: "Password: ", echo: false },
|
||||
{ prompt: "Something else: ", echo: true },
|
||||
],
|
||||
finish,
|
||||
);
|
||||
expect(finish).toHaveBeenCalledWith(["hunter2", ""]);
|
||||
client.emit("ready");
|
||||
await expect(attempt).resolves.toMatchObject({ client });
|
||||
});
|
||||
|
||||
it("asks through the prompt channel when there is one", async () => {
|
||||
FakeSshClient.nextBehaviour = ["hang"];
|
||||
const ask = vi.fn().mockResolvedValue("123456");
|
||||
const attempt = connectHost(host(), {
|
||||
userId: "user-1",
|
||||
purpose: "file-manager",
|
||||
prompt: { ask },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(),
|
||||
);
|
||||
const client = FakeSshClient.instances.at(-1)!;
|
||||
const finish = vi.fn();
|
||||
client.emit(
|
||||
"keyboard-interactive",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[
|
||||
{ prompt: "Password: ", echo: false },
|
||||
{ prompt: "Verification code: ", echo: true },
|
||||
],
|
||||
finish,
|
||||
);
|
||||
await vi.waitFor(() => expect(finish).toHaveBeenCalled());
|
||||
expect(ask).toHaveBeenCalledWith({
|
||||
kind: "totp",
|
||||
prompt: "Verification code: ",
|
||||
retry: false,
|
||||
});
|
||||
expect(finish).toHaveBeenCalledWith(["hunter2", "123456"]);
|
||||
client.emit("ready");
|
||||
await attempt;
|
||||
});
|
||||
|
||||
it("times out when the server never answers", async () => {
|
||||
FakeSshClient.nextBehaviour = ["hang"];
|
||||
await expect(
|
||||
connectHost(host(), {
|
||||
userId: "user-1",
|
||||
purpose: "fleet",
|
||||
timeoutMs: 20,
|
||||
}),
|
||||
).rejects.toThrow("SSH connection timeout");
|
||||
});
|
||||
|
||||
it("lets the provider react to an auth failure", async () => {
|
||||
FakeSshClient.nextBehaviour = ["auth-fail"];
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
await expect(
|
||||
connectHost(host({ authType: "opkssh" }), {
|
||||
userId: "user-1",
|
||||
purpose: "fleet",
|
||||
}),
|
||||
).rejects.toThrow(/All configured authentication methods failed/);
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.invalidateOPKSSHToken).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
7,
|
||||
"SSH auth failed",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("pools under the old key shape", async () => {
|
||||
expect(getConnectionPoolKey("fleet", host())).toBe(
|
||||
"fleet:owner-1:10.0.0.7:22:root",
|
||||
);
|
||||
expect(
|
||||
getConnectionPoolKey(
|
||||
"stats",
|
||||
host({ useSocks5: true, socks5Host: "p", socks5Port: 1 }),
|
||||
),
|
||||
).toBe("stats:owner-1:10.0.0.7:22:root:socks5:p:1");
|
||||
|
||||
const result = await withHostConnection(
|
||||
"fleet:x",
|
||||
host(),
|
||||
{ userId: "user-1", purpose: "fleet" },
|
||||
async (client) => (client as unknown as FakeSshClient).connectConfig,
|
||||
);
|
||||
expect(result).toMatchObject({ password: "hunter2" });
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -1,17 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findCredentialByIdForUser: async () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { SSHAuthManager } from "../../hosts/auth-manager.js";
|
||||
import { SSHAuthManager } from "../../../hosts/connect/terminal-prompt.js";
|
||||
|
||||
function createManager() {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
@@ -1,92 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const setupCACertAuth = vi.fn();
|
||||
const warn = vi.fn();
|
||||
|
||||
vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({
|
||||
setupCACertAuth: (...args: unknown[]) => setupCACertAuth(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
fileLogger: { warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const { applyCACertIfPresent } =
|
||||
await import("../../../hosts/file-manager/ca-cert-auth.js");
|
||||
|
||||
/**
|
||||
* `setupCACertAuth` had no call site in the file manager at all, while its
|
||||
* sibling `setupOPKSSHCertAuth` had two. So a host whose key is paired with a
|
||||
* user-managed CA-signed certificate authenticated in the terminal and failed
|
||||
* over SFTP, and OPKSSH certificates — going through the other helper — worked
|
||||
* in both places.
|
||||
*/
|
||||
describe("applyCACertIfPresent", () => {
|
||||
const client = {} as never;
|
||||
const key = Buffer.from("private-key");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("attaches a certificate the host carries", async () => {
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
await applyCACertIfPresent(
|
||||
config,
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey: "ssh-rsa-cert-v01@openssh.com AAAA" },
|
||||
"root",
|
||||
"passphrase",
|
||||
);
|
||||
|
||||
expect(setupCACertAuth).toHaveBeenCalledWith(
|
||||
config,
|
||||
client,
|
||||
key,
|
||||
"ssh-rsa-cert-v01@openssh.com AAAA",
|
||||
"root",
|
||||
"passphrase",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when there is no certificate", async () => {
|
||||
// Most hosts. Touching the connection here would change key-only auth.
|
||||
for (const certPublicKey of [undefined, null, "", " "]) {
|
||||
await applyCACertIfPresent(
|
||||
{},
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey },
|
||||
"root",
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
expect(setupCACertAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the connection usable when the certificate is unusable", async () => {
|
||||
// The private key on its own may still be accepted — which is what
|
||||
// happened before this was wired up. Failing the connection here would
|
||||
// turn a working setup into a broken one.
|
||||
setupCACertAuth.mockRejectedValueOnce(new Error("bad cert format"));
|
||||
|
||||
await expect(
|
||||
applyCACertIfPresent(
|
||||
{},
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey: "garbage" },
|
||||
"root",
|
||||
undefined,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("CA certificate setup failed"),
|
||||
expect.objectContaining({ operation: "sftp_ca_cert_auth_failed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("file manager Vault authentication", () => {
|
||||
it("configures the cached Vault certificate before connecting", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../../hosts/file-manager/index.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const vaultBranch = source.indexOf(
|
||||
'resolvedCredentials.authType === "vault"',
|
||||
);
|
||||
const agentBranch = source.indexOf(
|
||||
'resolvedCredentials.authType === "agent"',
|
||||
vaultBranch,
|
||||
);
|
||||
const body = source.slice(vaultBranch, agentBranch);
|
||||
|
||||
expect(vaultBranch).toBeGreaterThan(-1);
|
||||
expect(agentBranch).toBeGreaterThan(vaultBranch);
|
||||
expect(body).toContain("setupVaultSshSignerAuth");
|
||||
expect(body).toContain("requiresVaultAuth: true");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTmuxAuthBehavior } from "../../../hosts/tmux/auth-utils.js";
|
||||
|
||||
describe("getTmuxAuthBehavior", () => {
|
||||
it("uses credentialless non-interactive authentication for Tailscale SSH", () => {
|
||||
expect(getTmuxAuthBehavior("tailscale")).toEqual({
|
||||
credentialless: true,
|
||||
tryKeyboard: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves keyboard-interactive fallback for none authentication", () => {
|
||||
expect(getTmuxAuthBehavior("none")).toEqual({
|
||||
credentialless: true,
|
||||
tryKeyboard: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat password authentication as credentialless", () => {
|
||||
expect(getTmuxAuthBehavior("password")).toEqual({
|
||||
credentialless: false,
|
||||
tryKeyboard: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
connects: [] as Array<{ target: unknown; options: Record<string, unknown> }>,
|
||||
factors: [] as Array<{ userId: string; pluginId: string; factorId: string }>,
|
||||
actor: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
|
||||
});
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../hosts/connect/connect-host.js", () => ({
|
||||
connectHost: async (target: unknown, options: Record<string, unknown>) => {
|
||||
h.connects.push({ target, options });
|
||||
const client = Object.assign(new EventEmitter(), { end: vi.fn() });
|
||||
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
recordSecondFactor: async (
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
) => {
|
||||
h.factors.push({ userId, pluginId, factorId });
|
||||
},
|
||||
removeSecondFactor: async () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createPluginAuth, createPluginSsh } =
|
||||
await import("../../plugins/ctx-ssh-auth.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { getSshAuthProvider } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const { getLoginMethod, getSecondFactor } =
|
||||
await import("../../auth/registry.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
function manifest(capabilities: string[], auth: Record<string, string[]> = {}) {
|
||||
return {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
contributes: { auth },
|
||||
} as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.granted = new Set();
|
||||
h.connects = [];
|
||||
h.factors = [];
|
||||
h.actor = "user-1";
|
||||
});
|
||||
|
||||
describe("ctx.ssh", () => {
|
||||
it("refuses without ssh:connect and credentials:use, and audits the refusal", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
h.granted = new Set(["ssh:connect"]);
|
||||
await expect(ssh.connect(7)).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"ssh_connect",
|
||||
"host 7",
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
expect(h.connects).toEqual([]);
|
||||
});
|
||||
|
||||
it("connects as the acting user through the pipeline and audits", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
await ssh.connect(7, { purpose: "docker" });
|
||||
expect(h.connects).toEqual([
|
||||
{
|
||||
target: 7,
|
||||
options: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
purpose: "docker",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(audit).toHaveBeenCalledWith("ssh_connect", "host 7", {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs background work on a resolved host as its owner", async () => {
|
||||
h.actor = undefined;
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await ssh.connect({
|
||||
id: 3,
|
||||
ip: "h",
|
||||
port: 22,
|
||||
username: "u",
|
||||
userId: "owner",
|
||||
});
|
||||
expect(h.connects[0].options).toMatchObject({ userId: "owner" });
|
||||
await expect(ssh.connect(3)).rejects.toThrow(/acting user/);
|
||||
});
|
||||
|
||||
it("closes open connections when the plugin is disposed", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const bag = new DisposableBag("fixture");
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const connection = await ssh.connect(7);
|
||||
await bag.disposeAll();
|
||||
expect(
|
||||
(connection.client as { end: ReturnType<typeof vi.fn> }).end,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.auth", () => {
|
||||
it("needs auth:provide and a contributes.auth entry to register anything", () => {
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest([], { sshAuthTypes: ["corp"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
auth.registerSshAuthProvider({
|
||||
type: "corp",
|
||||
labelKey: "corp",
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
}),
|
||||
).toThrow(PluginCapabilityError);
|
||||
|
||||
const declared = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { sshAuthTypes: ["corp"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
declared.registerSshAuthProvider({
|
||||
type: "other",
|
||||
labelKey: "other",
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
}),
|
||||
).toThrow(/contributes\.auth\.sshAuthTypes/);
|
||||
});
|
||||
|
||||
it("registers an SSH auth type, checks the grant on use and removes it on dispose", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { sshAuthTypes: ["corp"] }),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
auth.registerSshAuthProvider({
|
||||
type: "corp",
|
||||
labelKey: "corp",
|
||||
prepare: async (config) => {
|
||||
config.password = "from-corp";
|
||||
return { status: "ready" };
|
||||
},
|
||||
});
|
||||
const provider = getSshAuthProvider("corp")!;
|
||||
expect(provider.pluginId).toBe("fixture");
|
||||
|
||||
await expect(
|
||||
provider.prepare({} as never, {} as never, {} as never),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
const config: Record<string, unknown> = {};
|
||||
await provider.prepare(config as never, {} as never, {} as never);
|
||||
expect(config.password).toBe("from-corp");
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(getSshAuthProvider("corp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers login methods and second factors under the plugin, and records enrolment", async () => {
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
const bag = new DisposableBag("fixture");
|
||||
const audit = vi.fn(async () => {});
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], {
|
||||
loginMethods: ["corp-sso"],
|
||||
secondFactors: ["pin"],
|
||||
}),
|
||||
bag,
|
||||
audit,
|
||||
});
|
||||
auth.registerLoginMethod({
|
||||
id: "corp-sso",
|
||||
labelKey: "corp",
|
||||
kind: "redirect",
|
||||
start: async () => ({ redirectUrl: "https://corp" }),
|
||||
});
|
||||
auth.registerSecondFactor({
|
||||
id: "pin",
|
||||
labelKey: "pin",
|
||||
isEnrolled: async () => true,
|
||||
verify: async () => true,
|
||||
});
|
||||
expect(getLoginMethod("corp-sso")?.pluginId).toBe("fixture");
|
||||
expect(getSecondFactor("fixture", "pin")).toBeDefined();
|
||||
|
||||
await auth.recordEnrollment("user-1", "pin");
|
||||
expect(h.factors).toEqual([
|
||||
{ userId: "user-1", pluginId: "fixture", factorId: "pin" },
|
||||
]);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"auth_factor_enrolled",
|
||||
"pin for user-1",
|
||||
{ success: true },
|
||||
);
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(getLoginMethod("corp-sso")).toBeUndefined();
|
||||
expect(getSecondFactor("fixture", "pin")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -69,8 +69,8 @@ vi.mock("../../plugins/permissions.js", () => ({
|
||||
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: vi.fn() }));
|
||||
|
||||
const http = await import("../../plugins/http.js");
|
||||
const pluginApi = (await import("../../database/routes/plugin-api-routes.js"))
|
||||
.default;
|
||||
const { mountPluginApi } =
|
||||
await import("../../database/routes/plugin-api-routes.js");
|
||||
|
||||
function manifest(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -86,9 +86,9 @@ let server: Server | null = null;
|
||||
|
||||
async function startServer(): Promise<string> {
|
||||
const app = express();
|
||||
// Exactly how database.ts mounts it: no auth in front, because the plugin
|
||||
// router brings its own.
|
||||
app.use("/plugin-api", pluginApi);
|
||||
// The same mount database.ts uses. It used to put core auth in front of the
|
||||
// whole prefix, which made every public plugin route unreachable.
|
||||
mountPluginApi(app);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server = app.listen(0, "127.0.0.1", () => {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthState,
|
||||
type AuthState,
|
||||
} from "../../auth/auth-test-helpers.js";
|
||||
|
||||
const h = vi.hoisted(() => ({ state: null as unknown as AuthState }));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", async () => {
|
||||
const helpers = await import("../../auth/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/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runExternalIdentityMigration, parseLegacyIdentifier } =
|
||||
await import("../../../utils/crypto-migration/external-identity-migration.js");
|
||||
|
||||
function user(id: string, extra: Record<string, unknown>) {
|
||||
h.state.users.set(id, {
|
||||
id,
|
||||
username: id,
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.state = createAuthState();
|
||||
});
|
||||
|
||||
describe("parseLegacyIdentifier", () => {
|
||||
it("splits the prefixed forms and keeps bare subjects on their provider", () => {
|
||||
expect(parseLegacyIdentifier("ldap:4:bob", null)).toEqual({
|
||||
providerId: "4",
|
||||
subject: "bob",
|
||||
});
|
||||
expect(parseLegacyIdentifier("github:7:42", null)).toEqual({
|
||||
providerId: "7",
|
||||
subject: "42",
|
||||
});
|
||||
expect(parseLegacyIdentifier("ldap:4:cn=a:b", null)).toEqual({
|
||||
providerId: "4",
|
||||
subject: "cn=a:b",
|
||||
});
|
||||
expect(parseLegacyIdentifier("sub-123", 3)).toEqual({
|
||||
providerId: "3",
|
||||
subject: "sub-123",
|
||||
});
|
||||
expect(parseLegacyIdentifier("sub-123", null)).toEqual({
|
||||
providerId: "legacy-oidc",
|
||||
subject: "sub-123",
|
||||
});
|
||||
expect(parseLegacyIdentifier("github:null:42", null)).toEqual({
|
||||
providerId: "legacy-oidc",
|
||||
subject: "42",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runExternalIdentityMigration", () => {
|
||||
it("moves every identifier and TOTP enrolment without touching the old data", async () => {
|
||||
user("ldap-user", { oidcIdentifier: "ldap:4:bob", ssoProviderId: 4 });
|
||||
user("gh-user", { oidcIdentifier: "github:7:42", ssoProviderId: 7 });
|
||||
user("oidc-user", {
|
||||
oidcIdentifier: "sub-123",
|
||||
ssoProviderId: 3,
|
||||
totpEnabled: true,
|
||||
});
|
||||
user("env-user", { oidcIdentifier: "someone@example.com" });
|
||||
user("local", {
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
totpEnabled: true,
|
||||
});
|
||||
|
||||
const result = await runExternalIdentityMigration();
|
||||
|
||||
expect(result).toEqual({ identities: 4, factors: 2, skipped: 0 });
|
||||
expect(
|
||||
h.state.identities.map(({ userId, providerId, subject }) => ({
|
||||
userId,
|
||||
providerId,
|
||||
subject,
|
||||
})),
|
||||
).toEqual([
|
||||
{ userId: "ldap-user", providerId: "4", subject: "bob" },
|
||||
{ userId: "gh-user", providerId: "7", subject: "42" },
|
||||
{ userId: "oidc-user", providerId: "3", subject: "sub-123" },
|
||||
{
|
||||
userId: "env-user",
|
||||
providerId: "legacy-oidc",
|
||||
subject: "someone@example.com",
|
||||
},
|
||||
]);
|
||||
expect(h.state.factors).toEqual([
|
||||
{ userId: "oidc-user", pluginId: "core", factorId: "totp" },
|
||||
{ userId: "local", pluginId: "core", factorId: "totp" },
|
||||
]);
|
||||
// Lossless: the old column and flag stay.
|
||||
expect(h.state.users.get("ldap-user")!.oidcIdentifier).toBe("ldap:4:bob");
|
||||
expect(h.state.users.get("local")!.totpEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
user("ldap-user", { oidcIdentifier: "ldap:4:bob", totpEnabled: true });
|
||||
await runExternalIdentityMigration();
|
||||
const second = await runExternalIdentityMigration();
|
||||
expect(second).toEqual({ identities: 0, factors: 0, skipped: 2 });
|
||||
expect(h.state.identities).toHaveLength(1);
|
||||
expect(h.state.factors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Moves sign-in identities and 2FA enrolment into their own tables.
|
||||
*
|
||||
* users.oidc_identifier packed provider and subject into one string:
|
||||
* "ldap:<provider>:<id>", "github:<provider>:<id>", or a bare OIDC subject
|
||||
* whose provider was users.sso_provider_id. Each becomes a
|
||||
* user_external_identities row. users.totp_enabled becomes a
|
||||
* user_second_factors row for core's TOTP factor, so a login can tell a user
|
||||
* has a factor even after the plugin that runs it is gone.
|
||||
*
|
||||
* Lossless: the old column and flag are left as they are. Idempotent: rows
|
||||
* that already exist are skipped, so it runs on every boot.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
|
||||
/** Matches LEGACY_OIDC_PROVIDER_ID in auth/legacy/oidc-login.ts. */
|
||||
const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
|
||||
|
||||
export interface ExternalIdentityMigrationResult {
|
||||
identities: number;
|
||||
factors: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/** Splits a stored identifier into provider and subject. */
|
||||
export function parseLegacyIdentifier(
|
||||
identifier: string,
|
||||
ssoProviderId: number | null | undefined,
|
||||
): { providerId: string; subject: string } | null {
|
||||
if (!identifier) return null;
|
||||
|
||||
const prefixed = /^(ldap|github):([^:]+):(.+)$/.exec(identifier);
|
||||
if (prefixed) {
|
||||
const provider = prefixed[2];
|
||||
return {
|
||||
providerId:
|
||||
provider === "null" || provider === "undefined"
|
||||
? LEGACY_OIDC_PROVIDER_ID
|
||||
: provider,
|
||||
subject: prefixed[3],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
providerId:
|
||||
ssoProviderId !== null && ssoProviderId !== undefined
|
||||
? String(ssoProviderId)
|
||||
: LEGACY_OIDC_PROVIDER_ID,
|
||||
subject: identifier,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runExternalIdentityMigration(): Promise<ExternalIdentityMigrationResult> {
|
||||
const result: ExternalIdentityMigrationResult = {
|
||||
identities: 0,
|
||||
factors: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
const auth = createCurrentUserAuthRepository();
|
||||
|
||||
for (const user of users) {
|
||||
if (user.oidcIdentifier) {
|
||||
const parsed = parseLegacyIdentifier(
|
||||
user.oidcIdentifier,
|
||||
user.ssoProviderId,
|
||||
);
|
||||
if (parsed) {
|
||||
const existing = await auth.findIdentity(
|
||||
parsed.providerId,
|
||||
parsed.subject,
|
||||
);
|
||||
if (existing) {
|
||||
result.skipped++;
|
||||
} else {
|
||||
await auth.linkIdentity({
|
||||
userId: user.id,
|
||||
providerId: parsed.providerId,
|
||||
subject: parsed.subject,
|
||||
});
|
||||
result.identities++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (user.totpEnabled) {
|
||||
const enrolled = await auth.listSecondFactors(user.id);
|
||||
if (
|
||||
enrolled.some(
|
||||
(row) => row.pluginId === "core" && row.factorId === "totp",
|
||||
)
|
||||
) {
|
||||
result.skipped++;
|
||||
} else {
|
||||
await auth.recordSecondFactor(user.id, "core", "totp");
|
||||
result.factors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.identities > 0 || result.factors > 0) {
|
||||
databaseLogger.info("Moved sign-in identities and 2FA enrolment", {
|
||||
operation: "external_identity_migration",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("External identity migration failed", error, {
|
||||
operation: "external_identity_migration",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
HostResolutionCredentialRecord,
|
||||
HostResolutionHostRecord,
|
||||
} from "../database/repositories/host-resolution-repository.js";
|
||||
import { getSshAuthProvider } from "../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
|
||||
import {
|
||||
SharedHostSecretsManager,
|
||||
type SharedSecretData,
|
||||
@@ -38,13 +40,9 @@ export function requiresPersonalHostAuthentication(
|
||||
// Owner auth for RDP/VNC/Telnet is snapshotted for every recipient, so only
|
||||
// SSH, which sits behind shareSshAuth, can leave a recipient without auth.
|
||||
if (protocol !== "ssh") return false;
|
||||
return (
|
||||
!!host.credentialId ||
|
||||
host.authType === "password" ||
|
||||
host.authType === "key" ||
|
||||
host.authType === "credential" ||
|
||||
host.authType === "agent"
|
||||
);
|
||||
if (host.credentialId) return true;
|
||||
ensureCoreSshAuthProviders();
|
||||
return !!getSshAuthProvider(host.authType ?? "none")?.requiresSecret;
|
||||
}
|
||||
|
||||
/** Whether the owner's auth for this protocol is available to recipients. */
|
||||
|
||||
Reference in new issue
Block a user