mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
* fix(sso): send the PKCE verifier for GitHub login (#1534) * fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) * test(remote-desktop): preserve sessions beyond one hour (#1536) * fix(hosts): expose controls for overflowing editor tabs (#1537) * fix(hosts): expose controls for overflowing editor tabs * test(hosts): mock resize observation in admin panel tests * fix(status): skip TCP probes while host sessions are active (#1538) * fix(database): batch session activity persistence (#1539) * docs(api): describe cookie and API key authentication (#1540) * fix(snippets): repair missing note column on SQLite upgrades (#1541) * fix(docker): retain stored SSH credential association (#1543) * fix(file-manager): render transfer toasts without plugin hooks (#1546) * feat(hosts): keep compact row actions inline (#1547) * fix(auth): allow retrying unavailable second-factor interfaces (#1549) * fix(auth): reject unresolved legacy identity provisioning (#1550) * fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) * fix(tmux): pass full session info to the terminal's session picker (#1552) The tmux.sessions service reduced detected sessions to bare names, but the picker reads session.name, so every entry rendered blank and selecting one sent an empty name, which created a new session instead of attaching. * fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553) The column holds the text "true"/"false", and the resolver passed it through as-is. The string "false" is truthy, so the password provider treated every saved host as forced keyboard-interactive and left the password out. Jump hops are built straight from the resolved host, so a password hop whose server doesn't offer keyboard-interactive failed with "All configured authentication methods failed". * fix(database): batch database saves for bulk host writes (#1554) * fix(database): yield to the event loop between database saves Each SQLite save serializes and encrypts the whole database into new buffers, which V8 only releases once the event loop turns. Boot migrations and bulk host import write plugin settings per host and per key in an awaited loop, so hundreds of full copies piled up and the container ran out of memory. * fix(database): save once per bulk host write instead of once per row On SQLite every repository write force-saves the whole database. Boot plugin data migrations, host defaults materialization and the bulk host routes write one row per host per setting, so they serialized and encrypted the full database hundreds of times in a row. DatabaseSaveTrigger.batched wraps a function so force saves made while it runs collapse into a single save when it finishes. Nested scopes fold into the outer one, and work that outlives its scope saves normally. * feat(hosts): add instance-wide predefined tag suggestions (#1548) * feat(hosts): add shared predefined tag suggestions * style(hosts): format tag catalog routes * test(database): advance fake timers past the save yield (#1555) * fix(i18n): complete Simplified Chinese core and plugin translations (#1542) * fix(i18n): complete Chinese onboarding and navigation labels * fix(i18n): translate remaining Chinese core and plugin interfaces * fix(i18n): translate host editor tab overflow controls * fix(i18n): use consistent Chinese fleet terminology * fix(i18n): localize hardcoded controls and plugin views * fix(i18n): translate built-in homepage widget catalog * test(homepage): follow translated timezone placeholder * fix(i18n): translate plugin-provided homepage widgets * fix(i18n): localize feature settings section titles * fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) * fix: plugins losing the saved ssh login when copying a host (#1391) * fix: fleets, proxmox and automations not getting the host sudo password * fix: host imports running a defaults pass and metrics restart per host (#1384) * fix: high cpu from status probes and full database saves on every sample (#1300) * fix: user data export freezing the server (#1393) * fix: op:// secret references rejected as ssh keys on credentials (#1394) * fix: slow file deletes from the trash lookups on every delete (#1390) * fix: add openapi docs and error handling to host tag routes * test: compare download stream buffers directly so it stops timing out * chore: drop em dash from host row comment * chore: update release notes for 2.9.1 * fix: restore space to add host tags and redesign predefined tags editor * fix: host key silently accepted when the host is missing from the database (#1397) * fix: clearer host login failed status label and fix its translations (#1396) * chore: add host key and status label fixes to release notes * fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles * chore: increment ver * fix(audit): store plugin entries with no acting user as a null user_id (#1556) Plugin audit entries written outside a request used the literal "system" as user_id. That column references users.id, so the insert was refused (Postgres logs it as an FK violation) and the entry was silently dropped. Write null instead and keep "system" / plugin:<id> in username. * chore: add sso/ldap dialog and audit log fixes to release notes * fix: tunnels and host settings missing from shared hosts on desktop * fix: remote desktop logins missing from shared hosts on desktop * fix: simplify host status to online/offline and keep it live without a refresh * chore: sync Crowdin translations for 2.9.1 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
905 lines
30 KiB
TypeScript
905 lines
30 KiB
TypeScript
/**
|
|
* 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,
|
|
PluginVerifiedIdentity,
|
|
PluginSsh,
|
|
PluginSshConnectOptions,
|
|
PluginSshHost,
|
|
} from "@termix/plugin-sdk/backend";
|
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
|
import { PluginSshInteractionError } from "@termix/plugin-sdk/backend";
|
|
import {
|
|
assertCapability,
|
|
capabilityRefused,
|
|
hasCachedCapability,
|
|
hasCapability,
|
|
warmPluginGrants,
|
|
} from "./permissions.js";
|
|
import { getActor, getActorSessionId } from "./actor.js";
|
|
import type { DisposableBag } from "./disposables.js";
|
|
import { redactHostSecrets } from "./ctx-hosts.js";
|
|
import {
|
|
getSshAuthProvider,
|
|
listSshAuthProviders,
|
|
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 {
|
|
getLoginMethod,
|
|
registerLoginMethod,
|
|
registerSecondFactor,
|
|
} from "../auth/registry.js";
|
|
import type { VerifiedIdentity } from "../auth/types.js";
|
|
import type {
|
|
MutableConnectConfig,
|
|
SshAuthProvider,
|
|
SshConnectHost,
|
|
SshConnectProfile,
|
|
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_PROFILES = new Set<SshConnectProfile>([
|
|
"terminal",
|
|
"session",
|
|
"stream",
|
|
"forward",
|
|
"background",
|
|
]);
|
|
|
|
function purposeOf(options?: { purpose?: string }): SshConnectPurpose {
|
|
return options?.purpose || "plugin";
|
|
}
|
|
|
|
// "jump" belongs to core's own jump chain; a plugin gets background.
|
|
function profileOf(options?: { profile?: string }): SshConnectProfile {
|
|
const profile = options?.profile as SshConnectProfile | undefined;
|
|
return profile && PLUGIN_PROFILES.has(profile) ? profile : "background";
|
|
}
|
|
|
|
/**
|
|
* The user a connection runs as: the actor, or for a host core resolved
|
|
* earlier, the user it was resolved for. A host object's own userId is plugin
|
|
* data and is never believed, or a plugin could borrow another user's jump
|
|
* hosts and stored credentials by naming them.
|
|
*/
|
|
function actingUser(resolvedFor?: string): string {
|
|
const actor = getActor() ?? resolvedFor;
|
|
if (actor) return actor;
|
|
throw new Error(
|
|
"ctx.ssh needs an acting user: call it inside a request or ctx.asUser",
|
|
);
|
|
}
|
|
|
|
function describeHost(host: number | PluginSshHost): string {
|
|
return typeof host === "number" ? `host ${host}` : `host ${host.id}`;
|
|
}
|
|
|
|
const OVERRIDE_FIELDS = [
|
|
"password",
|
|
"key",
|
|
"privateKey",
|
|
"keyPassword",
|
|
"passphrase",
|
|
] as const;
|
|
|
|
/**
|
|
* A secret the plugin set on its copy (a password the user just typed) wins
|
|
* over the stored one; everything else comes from what core resolved.
|
|
*/
|
|
function withOverrides(
|
|
resolved: SshConnectHost,
|
|
given: PluginSshHost,
|
|
): SshConnectHost {
|
|
if (given === (resolved as unknown)) return resolved;
|
|
const source = given as unknown as Record<string, unknown>;
|
|
const set = OVERRIDE_FIELDS.filter(
|
|
(field) => typeof source[field] === "string" && source[field] !== "",
|
|
);
|
|
if (set.length === 0) return resolved;
|
|
const merged = { ...resolved } as unknown as Record<string, unknown>;
|
|
for (const field of set) merged[field] = source[field];
|
|
if (typeof source.authType === "string") merged.authType = source.authType;
|
|
return merged as unknown as SshConnectHost;
|
|
}
|
|
|
|
export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
|
const pluginId = manifest.id;
|
|
const declared = manifest.capabilities;
|
|
const open = new Set<() => void>();
|
|
// Pool key -> host id, so dropPooled can find a host's keys after its
|
|
// address changed.
|
|
const poolKeys = new Map<string, number>();
|
|
|
|
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.keys()) {
|
|
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);
|
|
}
|
|
};
|
|
|
|
// Hosts core resolved, keyed by the redacted copy the plugin was given, so
|
|
// handing that copy back to connect still connects with the real secrets.
|
|
const resolvedHosts = new WeakMap<
|
|
object,
|
|
{ host: SshConnectHost; userId: string }
|
|
>();
|
|
|
|
// Plugins often copy a host before connecting ({ ...host, port }). Spread
|
|
// copies enumerable symbol keys, so the copy keeps this tag. Its value is an
|
|
// opaque token, not the host, so the secrets stay out of the plugin's reach.
|
|
const tag = Symbol("resolvedHost");
|
|
const tokens = new WeakMap<
|
|
object,
|
|
{ host: SshConnectHost; userId: string }
|
|
>();
|
|
|
|
const remember = (key: object, host: SshConnectHost, userId: string) => {
|
|
const entry = { host, userId };
|
|
resolvedHosts.set(key, entry);
|
|
const token = Object.freeze({});
|
|
tokens.set(token, entry);
|
|
if (Object.isExtensible(key)) {
|
|
Object.defineProperty(key, tag, {
|
|
value: token,
|
|
enumerable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
};
|
|
|
|
const lookup = (host: object) => {
|
|
const known = resolvedHosts.get(host);
|
|
if (known) return known;
|
|
const token = (host as Record<symbol, unknown>)[tag];
|
|
return token && typeof token === "object"
|
|
? tokens.get(token as object)
|
|
: undefined;
|
|
};
|
|
|
|
const handOut = (host: SshConnectHost, userId: string): PluginSshHost => {
|
|
const redacted = redactHostSecrets(
|
|
host as unknown as Record<string, unknown>,
|
|
) as unknown as PluginSshHost;
|
|
remember(redacted, host, userId);
|
|
return redacted;
|
|
};
|
|
|
|
/** The acting user for a call about this host. */
|
|
const userFor = (host: number | PluginSshHost | undefined): string =>
|
|
actingUser(
|
|
host && typeof host === "object" ? lookup(host)?.userId : undefined,
|
|
);
|
|
|
|
/**
|
|
* A host for core's pipeline. An id stays an id (core resolves it with an
|
|
* access check). An object core handed out maps back to what core resolved;
|
|
* any other object is the plugin's own, so it runs as the actor.
|
|
*/
|
|
const toConnectHost = (
|
|
host: number | PluginSshHost,
|
|
userId: string,
|
|
): number | SshConnectHost => {
|
|
if (typeof host === "number") return host;
|
|
const known = lookup(host);
|
|
if (known) return withOverrides(known.host, host);
|
|
return { ...(host as unknown as SshConnectHost), userId };
|
|
};
|
|
|
|
/** A working login to a saved host core resolved marks it online. */
|
|
const reportLogin = (host: number | PluginSshHost) => {
|
|
const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
|
|
if (!Number.isInteger(hostId) || hostId <= 0) return;
|
|
void import("../hosts/status/host-status-service.js")
|
|
.then(({ hostStatusService }) =>
|
|
hostStatusService.reportLogin(hostId, { ok: true }),
|
|
)
|
|
.catch(() => {});
|
|
};
|
|
|
|
/** 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;
|
|
}
|
|
|
|
let userId: string;
|
|
try {
|
|
userId = userFor(host);
|
|
} catch (error) {
|
|
await audit("ssh_connect", describeHost(host), {
|
|
success: false,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
const { connectHost } = await import("../hosts/connect/connect-host.js");
|
|
try {
|
|
const connection = await connectHost(toConnectHost(host, userId), {
|
|
userId,
|
|
purpose: purposeOf(options),
|
|
profile: profileOf(options),
|
|
timeoutMs: options?.timeoutMs,
|
|
prompt: options?.prompt,
|
|
overrides: options?.overrides as Partial<MutableConnectConfig>,
|
|
sock: options?.sock as MutableConnectConfig["sock"],
|
|
});
|
|
await audit("ssh_connect", describeHost(host), { success: true });
|
|
reportLogin(host);
|
|
|
|
const dispose = () => {
|
|
open.delete(dispose);
|
|
connection.dispose();
|
|
};
|
|
open.add(dispose);
|
|
connection.client.once("close", () => open.delete(dispose));
|
|
return {
|
|
...connection,
|
|
host: handOut(connection.host as SshConnectHost, userId),
|
|
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,
|
|
host: connection.host,
|
|
dispose: connection.dispose,
|
|
};
|
|
},
|
|
|
|
withConnection: async (host, options, fn) => {
|
|
await checkSsh(true);
|
|
const { resolveConnectHost } =
|
|
await import("../hosts/connect/connect-host.js");
|
|
const userId = userFor(host);
|
|
const resolved = await resolveConnectHost(
|
|
toConnectHost(host, userId),
|
|
userId,
|
|
);
|
|
remember(resolved, resolved, userId);
|
|
const key = poolKey(options.pool, resolved as PluginSshHost);
|
|
poolKeys.set(key, resolved.id);
|
|
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 = userFor(chainOptions?.forHost);
|
|
const { createJumpHostChain } =
|
|
await import("../hosts/jump-host-chain.js");
|
|
const client = await createJumpHostChain(
|
|
jumpHosts,
|
|
userId,
|
|
chainOptions?.prompt as Parameters<typeof createJumpHostChain>[2],
|
|
);
|
|
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));
|
|
// A jump chain ends at a forwarding client, not a single resolved SSH
|
|
// host: report the caller's own host if it gave one, else the last hop.
|
|
const host: PluginSshHost = chainOptions?.forHost
|
|
? (redactHostSecrets(
|
|
chainOptions.forHost as unknown as Record<string, unknown>,
|
|
) as unknown as PluginSshHost)
|
|
: {
|
|
id: jumpHosts[jumpHosts.length - 1]?.hostId ?? 0,
|
|
ip: "",
|
|
port: 22,
|
|
username: "",
|
|
};
|
|
return { client: client as never, jumpClient: null, host, dispose };
|
|
},
|
|
|
|
poolKey,
|
|
|
|
dropPooled: (pool, hostId) => {
|
|
const keys = [...poolKeys.entries()]
|
|
.filter(([key, id]) => id === hostId && key.startsWith(`${pool}:`))
|
|
.map(([key]) => key);
|
|
if (keys.length === 0) return;
|
|
for (const key of keys) poolKeys.delete(key);
|
|
void import("../hosts/ssh-connection-pool.js").then(
|
|
({ connectionPool }) => {
|
|
for (const key of keys) connectionPool.clearKeyConnections(key);
|
|
},
|
|
);
|
|
},
|
|
|
|
resolveHost: async (hostId, options) => {
|
|
await checkSsh(true);
|
|
const userId = actingUser();
|
|
const { resolveHostById, resolveHostBySyncId } =
|
|
await import("../hosts/host-resolver.js");
|
|
const resolved = options?.syncId
|
|
? await resolveHostBySyncId(options.syncId, userId)
|
|
: await resolveHostById(hostId, userId);
|
|
await audit("ssh_resolve_host", describeHost(hostId), {
|
|
success: !!resolved,
|
|
errorMessage: resolved ? undefined : "Host not found",
|
|
});
|
|
if (!resolved) return null;
|
|
// The secrets only go to a plugin that declared it reads them.
|
|
if (await hasCapability(pluginId, "credentials:read", declared)) {
|
|
await audit("credentials_read", describeHost(hostId), {
|
|
success: true,
|
|
});
|
|
const full = resolved as unknown as SshConnectHost;
|
|
remember(full, full, userId);
|
|
return full as unknown as PluginSshHost;
|
|
}
|
|
return handOut(resolved as unknown as SshConnectHost, userId);
|
|
},
|
|
|
|
// The config it returns carries the password and private key, so it
|
|
// needs credentials:read on top of the connect pair.
|
|
prepare: async (host, options) => {
|
|
await checkSsh(true);
|
|
await assertCapability(pluginId, "credentials:read", declared);
|
|
const userId = userFor(host);
|
|
const target = toConnectHost(host, userId) as SshConnectHost;
|
|
const { buildConnectConfig } =
|
|
await import("../hosts/connect/build-connect-config.js");
|
|
const built = await buildConnectConfig(target, {
|
|
userId,
|
|
purpose: purposeOf(options),
|
|
profile: profileOf(options),
|
|
client: options.client as never,
|
|
serverHostId: options.serverHostId,
|
|
log: options.log,
|
|
interactive: options.interactive,
|
|
hostKeySocket: (options.hostKeySocket ?? null) as never,
|
|
});
|
|
await audit("ssh_prepare", describeHost(host), {
|
|
success: built.outcome.status === "ready",
|
|
errorMessage:
|
|
built.outcome.status === "ready" ? undefined : built.outcome.message,
|
|
});
|
|
const provider = built.provider;
|
|
return {
|
|
config: built.config,
|
|
outcome: built.outcome,
|
|
authType: provider?.type ?? null,
|
|
onBanner: provider?.onBanner
|
|
? (banner: string) =>
|
|
provider.onBanner!(banner, target, built.env) as never
|
|
: undefined,
|
|
onAuthFailed: provider?.onAuthFailed
|
|
? (context) => provider.onAuthFailed!(target, built.env, context)
|
|
: undefined,
|
|
};
|
|
},
|
|
|
|
openTransport: async (host, config, transportOptions) => {
|
|
await checkSsh(true);
|
|
const target = toConnectHost(host, userFor(host)) as SshConnectHost;
|
|
const { openSshTransport } =
|
|
await import("../hosts/connect/transport.js");
|
|
let opened: Awaited<ReturnType<typeof openSshTransport>>;
|
|
try {
|
|
opened = await openSshTransport(
|
|
target,
|
|
config as MutableConnectConfig,
|
|
transportOptions,
|
|
);
|
|
} catch (error) {
|
|
await audit("ssh_open_transport", describeHost(host), {
|
|
success: false,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
await audit("ssh_open_transport", describeHost(host), { success: true });
|
|
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;
|
|
},
|
|
|
|
startInteraction: async (interaction, request) => {
|
|
await checkSsh(true);
|
|
const userId = actingUser();
|
|
ensureCoreSshAuthProviders();
|
|
const { PermissionManager } =
|
|
await import("../utils/permission-manager.js");
|
|
const access = await PermissionManager.getInstance().canAccessHost(
|
|
userId,
|
|
request.hostId,
|
|
"connect",
|
|
);
|
|
if (!access.hasAccess) {
|
|
await audit("ssh_start_interaction", describeHost(request.hostId), {
|
|
success: false,
|
|
errorMessage: "Host not found",
|
|
});
|
|
throw new PluginSshInteractionError("Host not found");
|
|
}
|
|
const { createCurrentHostResolutionRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const host = await createCurrentHostResolutionRepository().findHostById(
|
|
request.hostId,
|
|
userId,
|
|
);
|
|
if (!host) throw new PluginSshInteractionError("Host not found");
|
|
|
|
const own = getSshAuthProvider(host.authType as string);
|
|
const provider =
|
|
own?.interaction === interaction && own.startInteraction
|
|
? own
|
|
: listSshAuthProviders().find(
|
|
(candidate) =>
|
|
candidate.interaction === interaction &&
|
|
candidate.startInteraction,
|
|
);
|
|
if (!provider?.startInteraction) {
|
|
throw new PluginSshInteractionError(
|
|
`No enabled provider handles ${interaction} sign-in`,
|
|
);
|
|
}
|
|
await audit("ssh_start_interaction", describeHost(request.hostId), {
|
|
success: true,
|
|
});
|
|
await provider.startInteraction({
|
|
userId,
|
|
hostId: request.hostId,
|
|
host: {
|
|
name: host.name as string | null,
|
|
ip: host.ip as string,
|
|
username: host.username as string,
|
|
},
|
|
socket: request.socket as never,
|
|
requestOrigin: request.requestOrigin,
|
|
payload: request.payload ?? {},
|
|
});
|
|
},
|
|
|
|
cancelInteraction: async (interaction, request) => {
|
|
await checkSsh(false);
|
|
const userId = actingUser();
|
|
ensureCoreSshAuthProviders();
|
|
for (const provider of listSshAuthProviders()) {
|
|
if (provider.interaction !== interaction) continue;
|
|
if (!provider.cancelInteraction) continue;
|
|
await provider.cancelInteraction({ userId, ...request });
|
|
}
|
|
},
|
|
|
|
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;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Turns what a plugin method returned into core's identity. Only the fields
|
|
* the SDK documents are copied, so a plugin cannot set core-only extras, and
|
|
* its rate limit key is kept apart from core's and other plugins'.
|
|
*/
|
|
export function toCoreIdentity(
|
|
pluginId: string,
|
|
identity: PluginVerifiedIdentity,
|
|
): VerifiedIdentity {
|
|
if (identity.kind === "user") {
|
|
return {
|
|
kind: "user",
|
|
userId: identity.userId,
|
|
mfaSatisfied: identity.mfaSatisfied,
|
|
password: identity.password,
|
|
returnTo: identity.returnTo,
|
|
rememberMe: identity.rememberMe,
|
|
};
|
|
}
|
|
return {
|
|
kind: "external",
|
|
provider: identity.provider,
|
|
subject: identity.subject,
|
|
email: identity.email,
|
|
name: identity.name,
|
|
groups: identity.groups,
|
|
isAdmin: identity.isAdmin,
|
|
allowedUsers: identity.allowedUsers,
|
|
mfaSatisfied: identity.mfaSatisfied,
|
|
returnTo: identity.returnTo,
|
|
rememberMe: identity.rememberMe,
|
|
legacyIdentifier: identity.legacy?.identifier,
|
|
ssoProviderId:
|
|
identity.logoutClaims?.providerId ??
|
|
identity.legacy?.providerRowId ??
|
|
null,
|
|
oidcSub: identity.logoutClaims?.sub ?? null,
|
|
oidcSid: identity.logoutClaims?.sid ?? null,
|
|
roleSync: identity.roles
|
|
? {
|
|
desired: [...identity.roles.desired],
|
|
managed: [...identity.roles.managed],
|
|
}
|
|
: undefined,
|
|
rateLimitUsername: identity.rateLimitKey
|
|
? rateLimitKeyFor(pluginId, identity.rateLimitKey)
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function rateLimitKeyFor(pluginId: string, key: string): string {
|
|
return `plugin:${pluginId}:${key}`;
|
|
}
|
|
|
|
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 capabilityRefused(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);
|
|
// ssh2 calls some hooks synchronously, so those answer from the grant
|
|
// cache and do nothing until the grant is confirmed.
|
|
const grantedNow = () =>
|
|
hasCachedCapability(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;
|
|
},
|
|
};
|
|
if (provider.onBanner) {
|
|
const onBanner = provider.onBanner;
|
|
wrapped.onBanner = (banner, host, env) =>
|
|
grantedNow()
|
|
? (onBanner(banner, host as PluginSshHost, env as never) as never)
|
|
: (undefined as never);
|
|
}
|
|
if (provider.onAuthFailed) {
|
|
const onAuthFailed = provider.onAuthFailed;
|
|
wrapped.onAuthFailed = (host, env, context) =>
|
|
grantedNow()
|
|
? (onAuthFailed(
|
|
host as PluginSshHost,
|
|
env as never,
|
|
context as never,
|
|
) as never)
|
|
: undefined;
|
|
}
|
|
warmPluginGrants(pluginId);
|
|
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 && grantedNow()
|
|
? (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);
|
|
},
|
|
|
|
registerKeyboardInteractiveHandler: (handler) => {
|
|
requireDeclared(
|
|
contributes.keyboardInteractive,
|
|
handler.id,
|
|
"keyboardInteractive",
|
|
);
|
|
// Synchronous hooks inside ssh2's callback, so they answer from the
|
|
// grant cache. Deactivate removes the handler.
|
|
warmPluginGrants(pluginId);
|
|
const settingsFor = (host: SshConnectHost): Record<string, unknown> =>
|
|
host.pluginSettings?.[pluginId] ?? {};
|
|
ensureCoreSshAuthProviders();
|
|
const dispose = registerKeyboardInteractiveInterceptor({
|
|
id: handler.id,
|
|
pluginId,
|
|
detect: (round, host) => {
|
|
if (!grantedNow()) return null;
|
|
const detected = handler.detect(
|
|
round,
|
|
host as PluginSshHost,
|
|
settingsFor(host),
|
|
);
|
|
if (!detected) return null;
|
|
if (detected.kind === "browser") {
|
|
return { ...detected, id: handler.id, label: handler.label };
|
|
}
|
|
return detected;
|
|
},
|
|
autoAnswerPasswords: handler.autoAnswerPasswords
|
|
? (host) =>
|
|
grantedNow() &&
|
|
handler.autoAnswerPasswords!(
|
|
host as PluginSshHost,
|
|
settingsFor(host),
|
|
) === true
|
|
: undefined,
|
|
});
|
|
bag.add(dispose, `keyboard-interactive handler "${handler.id}"`);
|
|
record("auth_register_keyboard_interactive", handler.id);
|
|
},
|
|
|
|
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 toCoreIdentity(
|
|
pluginId,
|
|
await method.callback!(...args),
|
|
);
|
|
}
|
|
: undefined,
|
|
verify: method.verify
|
|
? async (...args) => {
|
|
await granted();
|
|
return toCoreIdentity(pluginId, await 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 { assertSecondFactorEnrollmentAllowed } =
|
|
await import("../auth/core-auth.js");
|
|
assertSecondFactorEnrollmentAllowed();
|
|
const {
|
|
createCurrentSessionRepository,
|
|
createCurrentTrustedDeviceRepository,
|
|
createCurrentUserAuthRepository,
|
|
} = await import("../database/repositories/factory.js");
|
|
await createCurrentUserAuthRepository().recordSecondFactor(
|
|
userId,
|
|
pluginId,
|
|
factorId,
|
|
);
|
|
// A new factor signs the user out everywhere else and forgets trusted
|
|
// devices, so nothing that skipped it before keeps skipping it.
|
|
await createCurrentSessionRepository().revokeAllForUser(
|
|
userId,
|
|
getActor() === userId ? getActorSessionId() : undefined,
|
|
);
|
|
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
|
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,
|
|
});
|
|
},
|
|
|
|
completeRedirectLogin: async (methodId, req, res) => {
|
|
requireDeclared(contributes.loginMethods, methodId, "loginMethods");
|
|
await granted();
|
|
const method = getLoginMethod(methodId);
|
|
if (!method || method.pluginId !== pluginId) {
|
|
throw new Error(
|
|
`Plugin ${pluginId} has no registered login method "${methodId}"`,
|
|
);
|
|
}
|
|
const { handleRedirectCallback } =
|
|
await import("../database/routes/auth-routes.js");
|
|
await handleRedirectCallback(method, req as never, res as never);
|
|
},
|
|
|
|
revokeSessions: async (match) => {
|
|
await granted();
|
|
if (!match.sub && !match.sid) return 0;
|
|
const { AuthManager } = await import("../utils/auth-manager.js");
|
|
const revoked =
|
|
await AuthManager.getInstance().revokeSessionsByExternalSession({
|
|
ssoProviderId: match.providerId ?? null,
|
|
sub: match.sub ?? null,
|
|
sid: match.sid ?? null,
|
|
});
|
|
await audit(
|
|
"auth_sessions_revoked",
|
|
`${revoked} session(s) for provider ${match.providerId ?? "none"}`,
|
|
{ success: true },
|
|
);
|
|
return revoked;
|
|
},
|
|
|
|
loginRateLimit: {
|
|
isLocked: async (ip, key) => {
|
|
await granted();
|
|
const { loginRateLimiter } =
|
|
await import("../utils/login-rate-limiter.js");
|
|
return loginRateLimiter.isLocked(ip, rateLimitKeyFor(pluginId, key));
|
|
},
|
|
recordFailure: async (ip, key) => {
|
|
await granted();
|
|
const { loginRateLimiter } =
|
|
await import("../utils/login-rate-limiter.js");
|
|
loginRateLimiter.recordFailedAttempt(
|
|
ip,
|
|
rateLimitKeyFor(pluginId, key),
|
|
);
|
|
},
|
|
},
|
|
|
|
countLinkedUsers: async (provider) => {
|
|
await granted();
|
|
const { createCurrentUserAuthRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
return createCurrentUserAuthRepository().countUsersForProvider(provider);
|
|
},
|
|
};
|
|
}
|