feat: add auth extension points and one ssh connect pipeline

This commit is contained in:
LukeGus committed 2026-09-23 00:44:33 -05:00
1 parent d3c209af13
commit d774df8b41
159 files changed
+43243 -7451

No files matched your search

+9
View File
@@ -249,6 +249,15 @@ http {
proxy_read_timeout 300s;
}
location ~ ^/ssh-auth(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/snippets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
+9
View File
@@ -229,6 +229,15 @@ http {
proxy_read_timeout 300s;
}
location ~ ^/ssh-auth(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/snippets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
+24
View File
@@ -0,0 +1,24 @@
CREATE TABLE `user_external_identities` (
`id` int AUTO_INCREMENT NOT NULL,
`user_id` varchar(255) NOT NULL,
`provider_id` varchar(255) NOT NULL,
`subject` varchar(255) NOT NULL,
`email` text,
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `user_external_identities_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_user_external_identities_provider_subject` UNIQUE(`provider_id`,`subject`)
);
--> statement-breakpoint
CREATE TABLE `user_second_factors` (
`id` int AUTO_INCREMENT NOT NULL,
`user_id` varchar(255) NOT NULL,
`plugin_id` varchar(255) NOT NULL,
`factor_id` varchar(255) NOT NULL,
`enrolled_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `user_second_factors_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_user_second_factors_user_factor` UNIQUE(`user_id`,`plugin_id`,`factor_id`)
);
--> statement-breakpoint
ALTER TABLE `user_external_identities` ADD CONSTRAINT `user_external_identities_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `user_second_factors` ADD CONSTRAINT `user_second_factors_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_user_external_identities_user` ON `user_external_identities` (`user_id`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -211,6 +211,13 @@
"when": 1790118361160,
"tag": "0029_open_captain_america",
"breakpoints": true
},
{
"idx": 30,
"version": "5",
"when": 1790135163718,
"tag": "0030_sweet_warlock",
"breakpoints": true
}
]
}
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE "user_external_identities" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"provider_id" varchar(255) NOT NULL,
"subject" varchar(255) NOT NULL,
"email" text,
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_second_factors" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"plugin_id" varchar(255) NOT NULL,
"factor_id" varchar(255) NOT NULL,
"enrolled_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_external_identities" ADD CONSTRAINT "user_external_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_second_factors" ADD CONSTRAINT "user_second_factors_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "idx_user_external_identities_provider_subject" ON "user_external_identities" USING btree ("provider_id","subject");--> statement-breakpoint
CREATE INDEX "idx_user_external_identities_user" ON "user_external_identities" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_user_second_factors_user_factor" ON "user_second_factors" USING btree ("user_id","plugin_id","factor_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -218,6 +218,13 @@
"when": 1790118360674,
"tag": "0030_cynical_callisto",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1790135163099,
"tag": "0031_nifty_sentry",
"breakpoints": true
}
]
}
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE `user_external_identities` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`provider_id` text NOT NULL,
`subject` text NOT NULL,
`email` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_user_external_identities_provider_subject` ON `user_external_identities` (`provider_id`,`subject`);--> statement-breakpoint
CREATE INDEX `idx_user_external_identities_user` ON `user_external_identities` (`user_id`);--> statement-breakpoint
CREATE TABLE `user_second_factors` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`plugin_id` text NOT NULL,
`factor_id` text NOT NULL,
`enrolled_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_user_second_factors_user_factor` ON `user_second_factors` (`user_id`,`plugin_id`,`factor_id`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -190,6 +190,13 @@
"when": 1790118360159,
"tag": "0026_fair_sauron",
"breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1790135162496,
"tag": "0027_luxuriant_cargill",
"breakpoints": true
}
]
}
+10 -2
View File
@@ -1674,13 +1674,21 @@ ipcMain.handle(
const success = url.searchParams.get("success");
const error = url.searchParams.get("error");
const token = url.searchParams.get("token");
const secondFactor = url.searchParams.get("second_factor") === "1";
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
`<html><body><h2>${success === "true" ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
`<html><body><h2>${success === "true" || secondFactor ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
);
if (success === "true") {
if (secondFactor) {
finish({
success: false,
secondFactor: true,
tempToken: url.searchParams.get("temp_token"),
factors: url.searchParams.get("second_factors"),
});
} else if (success === "true") {
finish({ success: true, token });
} else {
finish({
+184 -6
View File
@@ -309,6 +309,11 @@ Anonymous shared-session and collab pages have no session. They load only
plugins whose manifest sets `contributes.guest: true`, from the public
`GET /plugins/public`, and `app.guest` is true there.
The login screen runs a smaller pass before anyone signs in: it activates only
enabled plugins that contribute `loginMethods` or `secondFactors`, listed by
the public `GET /plugins/public-manifest`. They stay active after login, and
the full pass does not load the same bundle twice. See Auth below.
#### Shared modules and the import map
A bundle keeps `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
@@ -360,14 +365,15 @@ boundary and Suspense.
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Typed now, wired by A8 |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`,
`useCurrentUser`, `useTheme`, `useToast`, `usePluginApi`, `useTabs`. The SDK
`useCurrentUser`, `useTheme`, `useToast`, `usePluginApi`, `useTabs`,
`useSshAuthTypes` (every SSH auth type the server knows, with its plugin). The SDK
has no runtime dependencies: the hooks delegate to a host bridge core installs.
Slots core owns: `terminal.toolbar`, `terminal.dock`, `terminal.overlay`
@@ -461,7 +467,153 @@ panel, card, host editor section, settings component or slot, and
Core keeps password login, sessions, API keys, trusted proxy login, Electron
auto-session and trusted devices, plus the base SSH auth types password, key,
stored credential, agent and none. Every other login method, second factor and
SSH auth method is a plugin through `ctx.auth`. **A8.**
SSH auth method is a plugin through `ctx.auth`.
In 2.9.0, OIDC (and GitHub, Google), LDAP, passkeys, TOTP, OPKSSH, Step-CA,
Vault, Tailscale and Warpgate still live in core. They register through the
same interfaces from `src/backend/auth/legacy-providers.ts` and
`src/ui/auth/legacy-auth-ui.tsx` with `pluginId: "core"`. Phase C moves each
one into its plugin by moving its block out of those two files; D1 deletes them.
Nothing else in core branches on those type names.
#### One SSH connect pipeline
Every SSH connection, core's and plugins', goes through
`src/backend/hosts/connect/`:
1. `resolveHostById` resolves the host for the acting user: RBAC, owner-key
decryption, shared-host overrides and shared agent, and `op://` external
secret references.
2. `buildConnectConfig` builds the ssh2 config: the defaults for the
connection's purpose (terminal, file-manager, tmux, metrics, fleet, docker,
...), the host key verifier, then the auth provider's `prepare`.
3. `openSshTransport` does port knocking, the Cloudflare Access tunnel, the
jump host chain (every hop through steps 1 and 2) or SOCKS5.
4. `connectHost` connects with a keyboard-interactive handler: a prompt channel
when a person can answer (terminal, file manager, docker), otherwise the
stored password for password prompts and nothing else.
Keyboard-interactive rounds are classified once (`classifyKeyboardInteractive`):
an interceptor first (Warpgate), then push MFA, TOTP, and plain input. The
transport decides how to ask.
A host whose `authType` has no provider fails with
`This host uses <type>, which needs the <plugin> plugin` (the plugin is found
from `contributes.auth.sshAuthTypes`, including disabled plugins), and the host
editor shows the same notice.
#### SSH auth providers
```ts
ctx.auth.registerSshAuthProvider({
type: "corp-ca", // stored in ssh_data.auth_type
labelKey: "authType",
requiresSecret: false, // shared hosts: does a recipient need a secret
needsUserInteraction: true, // a browser sign-in or a person at the keyboard
supportsBackground: false, // can metrics and fleets poll it unattended
credentialType: false, // also offered as a stored credential type
interaction: "corp-ca", // what its outcomes call the browser step
fields: [/* settings fields */],
connectOptions: (host, purpose) => ({/* config overrides */}),
prepare: async (config, host, env) => {
// fill config (env.client is the ssh2 Client, for certificate patching)
return { status: "ready" };
// or { status: "interaction-required", interaction: "corp-ca", message }
// or { status: "error", code: "missing-secret", message }
},
onKeyboardInteractive: (round, host) => null, // claim a prompt style
onAuthFailed: (host, env, context) => undefined, // clear caches, retry once
startInteraction: async (request) => {}, // start the browser step
});
```
The type must be listed in `contributes.auth.sshAuthTypes`, and the plugin
needs `auth:provide`. The grant is checked on every `prepare`. `fields` are the
plugin's own host settings (declare them in `contributes.settings.host` too):
the host editor renders them when the plugin registered no editor, and
`prepare` reads them with `ctx.settings.getHost(host.id, key)`. A frontend can
instead draw its own editor with `app.registerSshAuthEditor`.
A transport that can show a browser step sends `<interaction>_auth_required`
and later calls the provider's `startInteraction` for `<interaction>_start_auth`.
#### Login methods and second factors
```ts
ctx.auth.registerLoginMethod({
id: "corp-sso",
labelKey: "signIn",
kind: "redirect",
describe: async () => [{ id: "main", label: "Corp", enabled: true }],
start: async (request, instanceId) => ({ redirectUrl }),
callback: async (request) => ({
kind: "external",
provider: "corp",
subject: "123",
email,
name,
groups,
isAdmin,
allowedUsers,
returnTo,
rememberMe,
}),
});
ctx.auth.registerSecondFactor({
id: "pin",
labelKey: "pin",
isEnrolled: async (userId) => true,
challenge: async (userId) => ({/* for the UI */}),
verify: async (userId, body) => body.pin === "1234",
reset: async (userId) => {},
});
await ctx.auth.recordEnrollment(userId, "pin"); // and removeEnrollment
```
A method never issues a session. It returns an identity: an existing user id,
or an external identity. Core then:
1. finds the user through `user_external_identities` (provider, subject), or
provisions one with the core rules: first user is admin, the allowed-users
list is checked on every sign-in, provisioning needs SSO auto-provisioning,
admin approval is a hook that is off today, the admin group and role map
are kept in step;
2. unlocks the data key (the password for password logins);
3. runs second factors: every row in `user_second_factors` plus any factor
that reports itself enrolled. **Fail closed:** a row whose plugin is
disabled or gone refuses the login ("contact an admin"). A trusted device
or a method that already proved a factor (a verified passkey) skips the step;
4. issues the JWT, cookie, trusted device and the `login` audit line.
Routes, all under `/users/auth`: `GET methods` (public), `GET :methodId/start`,
`GET|POST :methodId/callback`, `POST :methodId/verify`,
`POST second-factor/:factorId/challenge`, `POST second-factor/:factorId/verify`.
A redirect method's provider must send the browser back to
`/users/auth/<methodId>/callback`. The older routes (`/users/login`,
`/users/totp/verify-login`, `/users/oidc/*`, `/users/ldap/login`,
`/users/webauthn/authenticate/verify`) are thin wrappers over the same pipeline.
An admin clears a user's factors, including orphaned ones, with
`DELETE /users/admin/:userId/second-factors` (audited as
`admin_reset_second_factors`).
Password login is always a method. The admin "allow password login" setting
still turns it off, but only while another method (an enabled SSO instance,
or trusted proxy login) can sign someone in; otherwise it stays on with a
warning so nobody is locked out.
#### Login UI before sign-in
The login screen loads the frontends of enabled plugins that contribute login
methods or second factors from the public `GET /plugins/public-manifest`
(ids, versions, asset info and `contributes.auth` only), before anyone signs
in. `app.registerLoginMethod` draws a button or form for a method the server
lists (props: `instances`, `rememberMe`, `submit`, `startRedirect`,
`complete`); a redirect method with no UI gets one button per instance.
`app.registerSecondFactorUI` draws the challenge (props: `verify`,
`challenge`, `cancel`) and, with `enrollment`, a section in Settings >
Security. At activation before sign-in `app.api` calls fail with 401, so do
nothing there but register.
### 13. Where plugins live
@@ -594,6 +746,12 @@ not do.
"defaultRoles": ["user"], // system roles only, applied once
},
],
"auth": {
// each needs "auth:provide" in capabilities
"sshAuthTypes": ["corp-ca"], // host auth types this plugin handles
"loginMethods": ["corp-sso"],
"secondFactors": ["pin"],
},
"settings": {
"admin": [
{
@@ -690,6 +848,10 @@ refuses to register a view the manifest does not declare. `contributes.guest`
opts a plugin into anonymous guest pages. Action contributions and slots
accept the kinds `button` and `component`.
`contributes.auth` lists the ids a plugin registers through `ctx.auth`. A
register call for an id not listed here throws. Core also reads
`sshAuthTypes` from disabled plugins, to say which plugin a host needs.
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
`requiresSecret` are carried forward from v1 unchanged because the service and
secret registries read them at activation. Phase B reshapes them.
@@ -718,7 +880,7 @@ deciding, not the mechanism.
| `ssh:connect` | high | Run commands on hosts it is connected to |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Create and change user accounts |
| `auth:provide` | high | Add a login method or second factor |
| `auth:provide` | high | Add a login method, second factor or SSH auth type |
| `system:tls` | high | Request and replace the server certificate |
| `hosts:write` | medium | Create and change hosts |
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
@@ -772,12 +934,28 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.*` | `notify:send` | A6 |
| `ctx.auth.*` | `auth:provide` | A8 |
| `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.fetch` | `network:outbound` | B |
`ctx.ssh` was pulled forward from B so plugin transports go through core's
connect pipeline instead of importing ssh2 helpers from core:
- `connect(hostOrId, { purpose, pool? })` returns `{ client, dispose }`.
- `withConnection(host, { pool, purpose }, fn)` borrows a pooled connection.
- `jumpChain(jumpHosts, { forHost? })` returns a client at the end of a jump
chain, for forwarding to something that is not SSH.
- `prepare(host, { client, purpose })`, `openTransport`,
`classifyKeyboardInteractive` and `autoResponses` are the lower level for a
transport with its own prompt flow (docker's console, host metrics).
- `requiresSecret(authType)` and `supportsBackground(authType)` ask the
provider.
Each new connection is audited and runs as the current actor; pooled reuse is
not. Connections and pool entries are closed on deactivate.
### The actor
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
@@ -264,6 +264,24 @@
}
},
"guest": { "type": "boolean" },
"auth": {
"type": "object",
"additionalProperties": false,
"properties": {
"sshAuthTypes": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
},
"loginMethods": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
},
"secondFactors": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
}
}
},
"panels": {
"type": "array",
"items": {
+391 -3
View File
@@ -6,11 +6,11 @@
* description of the contract rather than an implementation a plugin could
* reach around.
*
* Members marked A3-A8 are not built yet. A plugin written against them today
* will type-check and fail at runtime, which is the intended signal.
* ctx.ssh is the part of step B's host surface that A8 needed early, so plugin
* transports could leave core's internals behind.
*/
import type { PluginManifest } from "./manifest.js";
import type { PluginManifest, PluginSettingsField } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
/**
@@ -36,6 +36,23 @@ export class PluginCapabilityError extends Error {
}
}
/**
* Thrown by a login method or second factor to refuse a sign-in with a
* message the user can see. `code` is a stable id the login screen can map to
* its own text; `status` is the HTTP status for form methods.
*/
export class LoginMethodError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status = 401, code?: string) {
super(message);
this.name = "LoginMethodError";
this.status = status;
this.code = code;
}
}
export interface PluginLogger {
debug: (message: string) => void;
info: (message: string) => void;
@@ -349,6 +366,373 @@ export interface PluginSettings {
readCore: (key: string) => Promise<string | null>;
}
/**
* A host as the SSH pipeline reads it: resolved by core, with secrets filled
* in for the acting user. Plugins get one from core and hand it back; they
* should not build one from scratch.
*/
export interface PluginSshHost {
id: number;
ip: string;
port: number;
username: string;
userId?: string | null;
authType?: string | null;
[key: string]: unknown;
}
/** Picks keepalive and timeout defaults. "plugin" is the generic one. */
export type PluginSshPurpose =
| "plugin"
| "docker"
| "docker-console"
| "metrics"
| "proxmox"
| "fleet"
| "remote-desktop"
| "tunnel";
export type PluginSshAuthOutcome =
| { status: "ready" }
| {
status: "interaction-required";
/** Transports send "<interaction>_auth_required", e.g. "opkssh". */
interaction: string;
message: string;
/** Extra boolean HTTP transports put 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. */
status: "retry";
patch: Record<string, unknown>;
message: string;
};
export interface PluginKeyboardInteractivePrompt {
prompt: string;
echo?: boolean;
}
export type PluginKeyboardInteractiveDecision =
| { kind: "auto"; responses: string[] }
| {
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
}
| { kind: "totp"; promptIndex: number }
| { kind: "input"; promptIndex: number; isPush: boolean };
export type PluginSshPromptRequest =
| { kind: "totp"; prompt: string; retry: boolean }
| { kind: "input"; prompt: string; echo: boolean; isPush: boolean }
| {
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
};
/** Answers keyboard-interactive prompts. Resolve null to give up. */
export interface PluginSshPromptChannel {
ask: (request: PluginSshPromptRequest) => Promise<string | null>;
}
export interface PluginSshConnectOptions {
purpose?: PluginSshPurpose;
/** Overall time to reach "ready". Defaults to 30s. */
timeoutMs?: number;
/** Without one, the stored password answers password prompts. */
prompt?: PluginSshPromptChannel;
/** Config fields to force, e.g. a longer readyTimeout. */
overrides?: Record<string, unknown>;
}
export interface PluginSshConnection<Client = unknown> {
/** An ssh2 Client, already "ready". */
client: Client;
jumpClient: Client | null;
/** Ends the connection and its jump chain. Also runs on deactivate. */
dispose: () => void;
}
/**
* SSH through core's one connect pipeline: host resolution, auth providers,
* jump hosts, proxies, host key checks, keepalive and port knocking.
*
* Every call needs ssh:connect and credentials:use, runs as the acting user
* (or the host's owner for background work on a resolved host) and writes an
* audit line per new connection. Connections still open on deactivate are
* closed.
*/
export interface PluginSsh {
connect: <Client = unknown>(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
) => Promise<PluginSshConnection<Client>>;
/** Borrows a pooled connection. The pool key is `<pool>:<owner>:<address>`. */
withConnection: <T, Client = unknown>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
) => Promise<T>;
/**
* A ready client at the end of a jump host chain, each hop resolved and
* authenticated through the pipeline. For forwarding to something that is
* not an SSH server (an RDP port behind a bastion).
*/
jumpChain: <Client = unknown>(
jumpHosts: Array<{ hostId: number }>,
options?: {
/** Background work: resolve the hops as this host's owner. */
forHost?: PluginSshHost;
},
) => Promise<PluginSshConnection<Client>>;
/** The pool key withConnection would use, for clearing it. */
poolKey: (pool: string, host: PluginSshHost) => string;
/**
* Lower level: fills an ssh2 config for a client the plugin drives itself,
* for transports with their own prompt flow. Never connects.
*/
prepare: (
host: PluginSshHost,
options: {
purpose?: PluginSshPurpose;
client: unknown;
/** Server-side host id when it differs from host.id. */
serverHostId?: number;
log?: (level: "info" | "warning" | "error", message: string) => void;
},
) => Promise<{
config: Record<string, unknown>;
outcome: PluginSshAuthOutcome;
}>;
/** Lower level: port knocking, jump hosts or proxy; sets config.sock. */
openTransport: (
host: PluginSshHost,
config: Record<string, unknown>,
) => Promise<{ jumpClient: unknown | null; via: string }>;
/** What a keyboard-interactive round is. Pure; no capability needed. */
classifyKeyboardInteractive: (
round: {
name: string;
instructions: string;
prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
) => PluginKeyboardInteractiveDecision;
/** Stored password for password prompts, empty for the rest. */
autoResponses: (
prompts: PluginKeyboardInteractivePrompt[],
password: string | null | undefined,
) => string[];
/** Whether an auth type carries a secret (drives "auth_required" flows). */
requiresSecret: (authType: string) => boolean;
/** Whether an auth type can connect unattended, for polling. */
supportsBackground: (authType: string) => boolean;
}
export interface PluginSshAuthEnv {
/** The ssh2 Client about to connect; certificate auth patches it. */
client: unknown;
userId: string;
hostId: number;
purpose: string;
interactive: boolean;
log: (level: "info" | "warning" | "error", message: string) => void;
}
/**
* An SSH auth type. Its `type` is what ssh_data.auth_type stores, and it must
* be listed in the manifest's contributes.auth.sshAuthTypes.
*/
export interface PluginSshAuthProvider {
type: string;
labelKey: string;
descriptionKey?: string;
/** Host and credential editor fields, in the settings field schema. */
fields?: PluginSettingsField[];
/** 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 per recipient. */
requiresSecret?: boolean;
/** Can connect unattended. Defaults to true. */
supportsBackground?: boolean;
/** Interaction name its outcomes use, for startInteraction routing. */
interaction?: string;
connectOptions?: (
host: PluginSshHost,
purpose: string,
) => Record<string, unknown>;
/** Fills the ssh2 config for this host. Never connects. */
prepare: (
config: Record<string, unknown>,
host: PluginSshHost,
env: PluginSshAuthEnv,
) => Promise<PluginSshAuthOutcome>;
/** Claims keyboard-interactive rounds for hosts of this type. */
onKeyboardInteractive?: (
round: {
name: string;
instructions: string;
prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
) => PluginKeyboardInteractiveDecision | null;
/** Synchronous: decide on a retry before the socket closes. */
onAuthFailed?: (
host: PluginSshHost,
env: PluginSshAuthEnv,
context: {
error: Error;
retries: number;
canRetry: boolean;
methodNotAvailable: boolean;
},
) => PluginSshAuthOutcome | undefined;
/** Starts the browser step behind an interaction-required outcome. */
startInteraction?: (request: {
userId: string;
hostId: number;
host: { name?: string | null; ip: string; username: string };
socket: unknown;
requestOrigin: string;
payload: Record<string, unknown>;
}) => Promise<void>;
}
/**
* Who a login method says the user is. Core finds or provisions the user,
* runs second factors and issues the session; a method never does.
*/
export type PluginVerifiedIdentity =
| {
kind: "user";
userId: string;
/** The method already proved a second factor (a verified passkey). */
mfaSatisfied?: boolean;
/** Password logins unlock the user's data key with it. */
password?: string;
/** Redirect methods: where the browser goes back to. */
returnTo?: string;
rememberMe?: boolean;
}
| {
kind: "external";
/** Stable provider id, e.g. an SSO provider row id. */
provider: string;
/** The provider's id for the user. At most 255 characters. */
subject: string;
email?: string | null;
name?: string | null;
groups?: string[];
/** Provider says the user is an admin (admin group, LDAP group). */
isAdmin?: boolean;
/** Allowed-users list to check before provisioning or signing in. */
allowedUsers?: string | null;
mfaSatisfied?: boolean;
/** Redirect methods: where the browser goes back to. */
returnTo?: string;
rememberMe?: boolean;
};
/** Request shape a login method sees. Structural, like PluginRequestLike. */
export interface PluginLoginRequest {
body: Record<string, unknown>;
query: Record<string, unknown>;
headers: Record<string, unknown>;
ip?: string;
[key: string]: unknown;
}
export interface PluginLoginInstance {
/** Passed back to start/verify as instanceId, e.g. an SSO provider id. */
id: string;
label: string;
enabled: boolean;
}
export interface PluginLoginMethod {
id: string;
labelKey: string;
icon?: string;
kind: "redirect" | "form";
/** Enabled instances, shown on the login screen. No secrets. */
describe?: () => Promise<PluginLoginInstance[]>;
/** Redirect methods: where to send the browser. */
start?: (
request: PluginLoginRequest,
instanceId: string | null,
) => Promise<{ redirectUrl: string }>;
/** Redirect methods: the provider's callback. */
callback?: (request: PluginLoginRequest) => Promise<PluginVerifiedIdentity>;
/** Form methods: check what the user typed. */
verify?: (
request: PluginLoginRequest,
instanceId: string | null,
) => Promise<PluginVerifiedIdentity>;
}
export interface PluginSecondFactor {
id: string;
labelKey: string;
isEnrolled: (userId: string) => Promise<boolean>;
/** Anything the client needs before the user answers. */
challenge?: (userId: string) => Promise<unknown>;
/**
* True when the answer is right. Return an object to refuse with a specific
* message, for example when the factor had to be reset.
*/
verify: (
userId: string,
body: Record<string, unknown>,
) => Promise<boolean | { ok: false; error: string; code?: string }>;
/** Removes the user's enrolment. Called by the admin reset. */
reset?: (userId: string) => Promise<void>;
}
/**
* Login methods, second factors and SSH auth types. Every register call needs
* auth:provide and a matching contributes.auth entry, and is undone on
* deactivate. Core owns sessions: a method returns an identity and core does
* the rest.
*/
export interface PluginAuth {
registerSshAuthProvider: (provider: PluginSshAuthProvider) => void;
registerLoginMethod: (method: PluginLoginMethod) => void;
registerSecondFactor: (factor: PluginSecondFactor) => void;
/**
* Marks a user as enrolled in one of this plugin's factors. Core keeps the
* row even when the plugin is gone, so it can refuse the login instead of
* skipping the factor.
*/
recordEnrollment: (userId: string, factorId: string) => Promise<void>;
removeEnrollment: (userId: string, factorId: string) => Promise<void>;
}
export interface PluginContext {
readonly pluginId: string;
readonly manifest: PluginManifest;
@@ -365,6 +749,10 @@ export interface PluginContext {
readonly rbac: PluginRbac;
readonly settings: PluginSettings;
readonly disposables: PluginDisposables;
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
readonly ssh: PluginSsh;
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
readonly auth: PluginAuth;
/**
* Runs `fn` with `userId` as the acting user, for background work that has
+67 -3
View File
@@ -365,18 +365,58 @@ export interface SshAuthEditorContribution {
component?: ComponentType<SshAuthEditorProps>;
}
/** A7 declares these; A8 wires them into the login screen. */
/** What the login screen hands a login method's UI. */
export interface LoginMethodUIProps {
methodId: string;
/** Enabled instances from the server, e.g. one per SSO provider. */
instances: Array<{ id: string; label: string }>;
rememberMe: boolean;
disabled: boolean;
/** What the user typed in the username field, when there is one. */
username?: string;
/**
* Form methods: posts the body to the method's verify endpoint, then the
* login screen finishes the login or shows the second-factor step.
*/
submit: (body: Record<string, unknown>, instanceId?: string) => Promise<void>;
/** Redirect methods: sends the browser (or the system browser) away. */
startRedirect: (instanceId?: string) => Promise<void>;
/** Hands a login response from a request the UI made itself. */
complete: (response: Record<string, unknown>) => Promise<void>;
}
/**
* A button or form on the login screen. `id` matches the login method the
* backend registered with ctx.auth.registerLoginMethod; the screen shows it
* only while the server reports that method as enabled.
*/
export interface LoginMethodContribution {
id: string;
titleKey: string;
icon?: IconComponent;
component: ComponentType<Record<string, unknown>>;
component: ComponentType<LoginMethodUIProps>;
}
/** What the second-factor step hands a factor's UI. */
export interface SecondFactorUIProps {
factorId: string;
rememberMe: boolean;
disabled: boolean;
/** Sends the user's answer; the login screen finishes or shows the error. */
verify: (body: Record<string, unknown>) => Promise<void>;
/** Whatever the backend factor's challenge() returns. */
challenge: () => Promise<unknown>;
cancel: () => void;
}
export interface SecondFactorContribution {
/** Matches the id the backend registered with ctx.auth.registerSecondFactor. */
id: string;
titleKey: string;
component: ComponentType<Record<string, unknown>>;
/** The challenge shown after the first login step. */
component: ComponentType<SecondFactorUIProps>;
/** Enrolment, shown in Settings > Security. */
enrollment?: ComponentType<Record<string, unknown>>;
}
/** The subset of axios a plugin uses, rooted at /plugin-api/<id>/. */
@@ -516,6 +556,18 @@ export interface SettingsState {
save: (values: Record<string, unknown>) => Promise<void>;
}
/** An SSH auth type the server can connect with. */
export interface SshAuthTypeInfo {
type: string;
/** Core translation key, or the plugin editor's title. */
labelKey: string;
pluginId: string;
/** Also offered as a stored credential type. */
credentialType: boolean;
/** Can connect unattended, for polling. */
supportsBackground: boolean;
}
/**
* What core implements behind the hooks. Internal: a plugin never calls this.
*/
@@ -539,6 +591,7 @@ export interface PluginHostBridge {
getApi: (pluginId: string) => PluginApiClient;
useTabs: () => TabsApi;
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
useSshAuthTypes: () => { types: SshAuthTypeInfo[]; loaded: boolean };
}
let host: PluginHostBridge | null = null;
@@ -630,6 +683,17 @@ export function useTabs(): TabsApi {
return requireHost().useTabs();
}
/**
* The SSH auth types this server can connect with, core's and every enabled
* plugin's, for pickers such as a default auth type.
*/
export function useSshAuthTypes(): {
types: SshAuthTypeInfo[];
loaded: boolean;
} {
return requireHost().useSshAuthTypes();
}
/**
* Runs an action another plugin registered, from anywhere in a plugin's code.
* Resolves undefined when nobody registered it, so an optional dependency
+66
View File
@@ -233,6 +233,20 @@ export interface PluginSettingsContribution {
host?: PluginHostSettingsContribution;
}
/**
* What a plugin adds to sign-in and SSH auth. Declared so core can tell who
* owns an auth type or factor even while the plugin is disabled, and so the
* login screen knows which bundles it needs before anyone has signed in.
*/
export interface PluginAuthContribution {
/** Values of ssh_data.auth_type this plugin provides. */
sshAuthTypes?: string[];
/** Login method ids. */
loginMethods?: string[];
/** Second factor ids. */
secondFactors?: string[];
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
panels?: PluginViewContribution[];
@@ -252,6 +266,7 @@ export interface PluginContributions {
* proxmox and web-endpoint ship it today.
*/
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
auth?: PluginAuthContribution;
}
export interface PluginManifest {
@@ -328,6 +343,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"permissions",
"settings",
"hostCapability",
"auth",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -684,6 +700,44 @@ function validateContributes(
validateActionSlots(contributes.actionSlots, errors);
validateSettings(contributes.settings, errors);
validateHostCapability(contributes.hostCapability, errors);
validateAuthContribution(contributes.auth, errors);
}
const AUTH_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
function validateAuthContribution(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!isPlainObject(value)) {
errors.push('Field "contributes.auth" must be an object');
return;
}
rejectUnknown(
value,
["sshAuthTypes", "loginMethods", "secondFactors"],
"contributes.auth",
errors,
);
for (const key of ["sshAuthTypes", "loginMethods", "secondFactors"]) {
const list = value[key];
if (list === undefined) continue;
if (!Array.isArray(list)) {
errors.push(`Field "contributes.auth.${key}" must be an array`);
continue;
}
const seen = new Set<string>();
list.forEach((entry, index) => {
if (typeof entry !== "string" || !AUTH_ID_PATTERN.test(entry)) {
errors.push(
`Field "contributes.auth.${key}[${index}]" must be a lowercase id (letters, digits and dashes)`,
);
return;
}
if (seen.has(entry)) {
errors.push(`Duplicate "${entry}" in contributes.auth.${key}`);
}
seen.add(entry);
});
}
}
function validateSettings(settings: unknown, errors: string[]): void {
@@ -1205,6 +1259,18 @@ export function parseManifest(raw: unknown): ParsedManifest {
}
}
const auth = manifest.contributes?.auth;
const contributesAuth =
(auth?.sshAuthTypes?.length ?? 0) +
(auth?.loginMethods?.length ?? 0) +
(auth?.secondFactors?.length ?? 0) >
0;
if (contributesAuth && !manifest.capabilities.includes("auth:provide")) {
errors.push(
'contributes.auth needs the "auth:provide" capability in the capabilities array',
);
}
for (const secret of manifest.requiresSecret ?? []) {
if (secret.plugin === manifest.id) {
errors.push(
+145 -2
View File
@@ -18,6 +18,12 @@ import type { TermixApp } from "./frontend.js";
import type { PluginManifest } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
import type { PluginMiddleware, PluginRouterOptions } from "./backend.js";
import type {
PluginLoginMethod,
PluginSecondFactor,
PluginSshAuthProvider,
PluginSshHost,
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
export interface FakeContextOptions {
@@ -29,6 +35,16 @@ export interface FakeContextOptions {
settings?: Record<string, unknown>;
/** Seed for the core settings ctx.settings.readCore can reach. */
coreSettings?: Record<string, string>;
/** What ctx.ssh.connect and withConnection hand back as the client. */
sshClient?: unknown;
}
export interface FakeAuthRegistrations {
sshAuthProviders: PluginSshAuthProvider[];
loginMethods: PluginLoginMethod[];
secondFactors: PluginSecondFactor[];
/** "<userId>:<factorId>" for every recorded enrolment. */
enrollments: Set<string>;
}
export interface FakePluginContext {
@@ -51,6 +67,10 @@ export interface FakePluginContext {
settings: Map<string, unknown>;
/** Core settings readable through ctx.settings.readCore. */
coreSettings: Map<string, string>;
/** Every ctx.ssh.connect and withConnection call, in order. */
sshConnections: Array<{ host: number | PluginSshHost; pool?: string }>;
/** Everything registered through ctx.auth. */
auth: FakeAuthRegistrations;
}
/** How a settings row is keyed in the doubles. Mirrors the real unique index. */
@@ -93,6 +113,14 @@ export function createFakeContext(
Object.entries(options.coreSettings ?? {}),
);
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
const sshConnections: FakePluginContext["sshConnections"] = [];
const auth: FakeAuthRegistrations = {
sshAuthProviders: [],
loginMethods: [],
secondFactors: [],
enrollments: new Set(),
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
for (const [key, value] of Object.entries(options.settings ?? {})) {
@@ -267,6 +295,64 @@ export function createFakeContext(
disposables,
ssh: {
connect: async (host) => {
sshConnections.push({ host });
return {
client: sshClient as never,
jumpClient: null,
dispose: () => {},
};
},
withConnection: async (host, connectOptions, fn) => {
sshConnections.push({ host, pool: connectOptions.pool });
return fn(sshClient as never);
},
jumpChain: async () => ({
client: sshClient as never,
jumpClient: null,
dispose: () => {},
}),
poolKey: (pool, host) =>
`${pool}:${host.userId}:${host.ip}:${host.port}:${host.username}`,
prepare: async () => ({ config: {}, outcome: { status: "ready" } }),
openTransport: async () => ({ jumpClient: null, via: "direct" }),
classifyKeyboardInteractive: ({ prompts }, host) => ({
kind: "auto",
responses: prompts.map((p) =>
/password/i.test(p.prompt) && typeof host.password === "string"
? host.password
: "",
),
}),
autoResponses: (prompts, password) =>
prompts.map((p) =>
/password/i.test(p.prompt) && password ? password : "",
),
requiresSecret: (authType) =>
["password", "key", "credential", "agent"].includes(authType),
supportsBackground: (authType) =>
!["none", "opkssh", "stepca"].includes(authType),
},
auth: {
registerSshAuthProvider: (provider) => {
auth.sshAuthProviders.push(provider);
},
registerLoginMethod: (method) => {
auth.loginMethods.push(method);
},
registerSecondFactor: (factor) => {
auth.secondFactors.push(factor);
},
recordEnrollment: async (userId, factorId) => {
auth.enrollments.add(`${userId}:${factorId}`);
},
removeEnrollment: async (userId, factorId) => {
auth.enrollments.delete(`${userId}:${factorId}`);
},
},
asUser: async (userId, fn) => {
const previous = actor;
actor = userId;
@@ -291,6 +377,8 @@ export function createFakeContext(
httpRouters,
settings,
coreSettings,
sshConnections,
auth,
};
}
@@ -307,6 +395,8 @@ export interface MockContextOptions {
coreSettings?: Record<string, string>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
/** What ctx.ssh.connect and withConnection hand back as the client. */
sshClient?: unknown;
}
export interface MockPluginContext extends FakePluginContext {
@@ -320,8 +410,9 @@ export interface MockPluginContext extends FakePluginContext {
*
* The gates on today's ctx surface: kv:own on every ctx.kv call, db:own on
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
* topic outside the plugin's own namespace, and settings:read-core on
* ctx.settings.readCore. Reading a plugin's own settings is deliberately
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, and
* auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
export function createMockCtx(
@@ -335,6 +426,7 @@ export function createMockCtx(
actor: options.actor,
settings: options.settings,
coreSettings: options.coreSettings,
sshClient: options.sshClient,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -437,6 +529,57 @@ export function createMockCtx(
return ctx.settings.readCore(key);
},
},
ssh: {
...ctx.ssh,
connect: async (host, connectOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.connect(host, connectOptions);
},
withConnection: async (host, connectOptions, fn) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.withConnection(host, connectOptions, fn);
},
prepare: async (host, prepareOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.prepare(host, prepareOptions);
},
openTransport: async (host, config) => {
require("ssh:connect");
return ctx.ssh.openTransport(host, config);
},
jumpChain: async (jumpHosts, chainOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.jumpChain(jumpHosts, chainOptions);
},
},
auth: {
registerSshAuthProvider: (provider) => {
require("auth:provide");
ctx.auth.registerSshAuthProvider(provider);
},
registerLoginMethod: (method) => {
require("auth:provide");
ctx.auth.registerLoginMethod(method);
},
registerSecondFactor: (factor) => {
require("auth:provide");
ctx.auth.registerSecondFactor(factor);
},
recordEnrollment: async (userId, factorId) => {
require("auth:provide");
return ctx.auth.recordEnrollment(userId, factorId);
},
removeEnrollment: async (userId, factorId) => {
require("auth:provide");
return ctx.auth.removeEnrollment(userId, factorId);
},
},
};
return { ...base, ctx: gatedCtx, checked };
+10
View File
@@ -101,6 +101,16 @@ const offline = () =>
onReady: () => noop,
}),
invokeAction: async () => undefined,
useSshAuthTypes: () => ({
types: ["password", "key", "credential", "agent", "none"].map((type) => ({
type,
labelKey: type,
pluginId: "core",
credentialType: type === "password" || type === "key",
supportsBackground: type !== "none",
})),
loaded: true,
}),
};
afterEach(() => {
+3
View File
@@ -1,8 +1,11 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { startAiService, stopAiService } from "./routes.js";
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
startAiService(ctx.http.router());
// Offered by reference, not copied: this hands over a resolver, so the key
+36
View File
@@ -0,0 +1,36 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+4 -8
View File
@@ -7,11 +7,7 @@ import {
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
createFleetSshFactory,
getFleetPoolKey,
} from "../../../../../src/backend/hosts/ssh-client-factory.js";
import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js";
import { withSshConnection } from "../ssh.js";
import { getTool } from "./catalog.js";
/** Approved commands get a bounded window rather than hanging the request. */
@@ -277,9 +273,9 @@ async function runCommandOnHost(
command: string,
): Promise<{ output?: string; error?: string }> {
try {
const result = await withConnection(
getFleetPoolKey(host as any),
createFleetSshFactory(host as any),
const result = await withSshConnection(
host as any as never,
{ pool: "fleet", purpose: "fleet" },
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
);
@@ -34,14 +34,10 @@ vi.mock(
execCommand: (...args: unknown[]) => execCommand(...args),
}),
);
vi.mock("../../../../../src/backend/hosts/ssh-client-factory.js", () => ({
createFleetSshFactory: () => () => ({}),
getFleetPoolKey: () => "pool",
}));
vi.mock("../../../../../src/backend/hosts/ssh-connection-pool.js", () => ({
withConnection: async (
_key: string,
_factory: unknown,
vi.mock("../../../src/backend/ssh.js", () => ({
withSshConnection: async (
_host: unknown,
_options: unknown,
run: (client: unknown) => Promise<unknown>,
) => run({}),
}));
@@ -7,11 +7,7 @@ import {
execElevated,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import {
createFleetSshFactory,
getFleetPoolKey,
} from "../../../../../src/backend/hosts/ssh-client-factory.js";
import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js";
import { withSshConnection } from "../ssh.js";
import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js";
import {
createCurrentNotificationChannelRepository,
@@ -383,9 +379,9 @@ async function execOnHost(
if (timeout <= 0) return { error: "Run deadline exceeded" };
try {
const result = await withConnection(
getFleetPoolKey(sshHost),
createFleetSshFactory(sshHost),
const result = await withSshConnection(
sshHost as never,
{ pool: "fleet", purpose: "fleet" },
async (client) => {
if (elevated) {
return execElevated(client, command, sshHost.sudoPassword, {
@@ -3,11 +3,7 @@ import { createCurrentAutomationRepository } from "../../../../src/backend/datab
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
createFleetSshFactory,
getFleetPoolKey,
} from "../../../../src/backend/hosts/ssh-client-factory.js";
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
import { withSshConnection } from "./ssh.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { onDockerEvent } from "./triggers.js";
@@ -154,9 +150,9 @@ async function pollHost(hostId: number, userId: string): Promise<void> {
return;
}
const result = await withConnection(
getFleetPoolKey(host as never),
createFleetSshFactory(host as never),
const result = await withSshConnection(
host as never as never,
{ pool: "fleet", purpose: "fleet" },
(client) =>
execCommand(
client,
+3
View File
@@ -1,4 +1,5 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { startAutomationsService, stopAutomationsService } from "./routes.js";
import {
startAutomationScheduler,
@@ -6,6 +7,8 @@ import {
} from "./scheduler.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
startAutomationsService(
// The webhook route authenticates on its own per-automation token rather
// than a session, which is the point of an inbound webhook.
+36
View File
@@ -0,0 +1,36 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+11 -270
View File
@@ -3,20 +3,18 @@
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { StringDecoder } from "string_decoder";
import { Client as SSHClient } from "ssh2";
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
import type { Client as SSHClient } from "ssh2";
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
import { connectSsh } from "./ssh.js";
import { WebSocketServer, WebSocket } from "ws";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js";
import { systemLogger } from "../../../../src/backend/utils/logger.js";
import type { SSHHost } from "../../../../src/types/index.js";
import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
type ContainerRuntime,
} from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
import {
hostAddressMismatch,
HOST_ADDRESS_MISMATCH_MESSAGE,
@@ -151,197 +149,6 @@ async function detectShell(
return "sh";
}
async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>,
userId: string,
): Promise<SSHClient | null> {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
}
let currentClient: SSHClient | null = null;
const repository = createCurrentHostResolutionRepository();
for (let i = 0; i < jumpHosts.length; i++) {
const jumpHostId = jumpHosts[i].hostId;
const resolvedJumpHost = await repository.findHostById(jumpHostId, userId);
if (!resolvedJumpHost || resolvedJumpHost.userId !== userId) {
throw new Error(`Jump host ${jumpHostId} not found`);
}
const jumpHost = resolvedJumpHost as unknown as SSHHost;
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
try {
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
} catch (e) {
sshLogger.error("Failed to parse jump hosts", e, {
hostId: jumpHost.id,
});
jumpHost.jumpHosts = [];
}
}
let resolvedCredentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
} = {
password: jumpHost.password,
sshKey: jumpHost.key,
keyPassword: jumpHost.keyPassword,
authType: jumpHost.authType,
};
if (jumpHost.credentialId) {
const credential = await repository.findCredentialByIdForUser(
jumpHost.credentialId as number,
userId,
);
if (credential) {
resolvedCredentials = {
password: credential.password as string | undefined,
sshKey: (credential.key || credential.privateKey) as
string | undefined,
keyPassword: credential.keyPassword as string | undefined,
authType: credential.authType as string | undefined,
};
}
}
const client = new SSHClient();
const config: Record<string, unknown> = {
host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip,
port: jumpHost.port || 22,
username: jumpHost.username,
tryKeyboard: resolvedCredentials.authType !== "none",
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 120,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
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 (
resolvedCredentials.authType === "password" &&
resolvedCredentials.password
) {
config.password = resolvedCredentials.password;
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey
) {
const cleanKey = resolvedCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedCredentials.keyPassword) {
config.passphrase = resolvedCredentials.keyPassword;
}
} else if (resolvedCredentials.authType === "agent") {
const result = await applyAgentAuth(
config,
jumpHost.terminalConfig as unknown as
Record<string, unknown> | undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
}
client.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) && resolvedCredentials.password) {
return resolvedCredentials.password as string;
}
return "";
});
finish(responses);
},
);
if (currentClient) {
await new Promise<void>((resolve, reject) => {
currentClient!.forwardOut(
"127.0.0.1",
0,
jumpHost.ip,
jumpHost.port || 22,
(err, stream) => {
if (err) return reject(err);
config.sock = stream;
resolve();
},
);
});
}
if (!config.sock) {
await resolveSshConnectConfigHost(config);
}
await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve());
client.on("error", reject);
client.connect(config);
});
currentClient = client;
}
return currentClient;
}
async function onConsoleConnection(
ws: WebSocket,
req: import("http").IncomingMessage,
@@ -514,81 +321,15 @@ async function onConsoleConnection(
return;
}
const client = new SSHClient();
const config: Record<string, unknown> = {
host: resolvedHost.ip?.replace(/^\[|\]$/g, "") || resolvedHost.ip,
port: resolvedHost.port || 22,
username: resolvedHost.username,
tryKeyboard: true,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 120,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
};
if (resolvedHost.authType === "password" && resolvedHost.password) {
config.password = resolvedHost.password;
} else if (resolvedHost.authType === "key" && resolvedHost.key) {
const cleanKey = resolvedHost.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedHost.keyPassword) {
config.passphrase = resolvedHost.keyPassword;
}
} else if (resolvedHost.authType === "agent") {
const result = await applyAgentAuth(
config,
resolvedHost.terminalConfig as unknown as
Record<string, unknown> | undefined,
);
if ("error" in result) {
ws.send(
JSON.stringify({
type: "error",
message: result.error,
}),
);
return;
}
}
if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) {
const jumpClient = await createJumpHostChain(
resolvedHost.jumpHosts,
// The console socket is public and authenticates itself, so the
// acting user is carried on the host rather than the request.
const { client } = await connectSsh(
{
...(resolvedHost as unknown as PluginSshHost),
userId,
);
if (jumpClient) {
const stream = await new Promise<import("ssh2").ClientChannel>(
(resolve, reject) => {
jumpClient.forwardOut(
"127.0.0.1",
0,
resolvedHost.ip,
resolvedHost.port || 22,
(err, stream) => {
if (err) return reject(err);
resolve(stream);
},
);
},
);
config.sock = stream;
}
}
if (!config.sock) {
await resolveSshConnectConfigHost(config);
}
await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve());
client.on("error", reject);
client.connect(config);
});
} as PluginSshHost,
{ purpose: "docker-console", timeoutMs: 65000 },
);
const { runtime: containerRuntime } = getContainerRuntimeConfig(
resolvedHost.dockerConfig,
+3
View File
@@ -1,5 +1,6 @@
import express, { type Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { registerDockerContainerRoutes } from "./container-routes.js";
@@ -23,6 +24,8 @@ let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
null;
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
// An express app rather than the router directly, so the existing
// register*Routes helpers keep the Application they expect. Core mounts it
// at /plugin-api/docker and runs compression, CORS, cookies, auth and body
+177 -561
View File
@@ -2,10 +2,11 @@
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
import express from "express";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
import { pluginSsh } from "./ssh.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import {
logAudit,
@@ -13,33 +14,23 @@ import {
getRequestMeta,
} from "../../../../src/backend/utils/audit-logger.js";
import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js";
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import type {
AuthenticatedRequest,
ProxyNode,
SSHHost,
} from "../../../../src/types/index.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../../../../src/backend/utils/socks5-helper.js";
import type {
LogEntry,
ConnectionStage,
} from "../../../../src/types/connection-log.js";
import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js";
import { preparePrivateKeyForSSH2 } from "../../../../src/backend/utils/ssh-key-utils.js";
import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
getRuntimeLabel,
} from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
import {
type SSHSession,
sshSessions,
@@ -242,200 +233,69 @@ export function registerDockerSshRoutes(app: express.Express): void {
delete pendingTOTPSessions[sessionId];
}
const resolvedCredentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
} = {
password: host.password,
sshKey: host.key,
keyPassword: host.keyPassword,
authType: host.authType,
};
const connectTarget = {
...(host as unknown as PluginSshHost),
id: hostId,
port: host.port || 22,
userId,
// The Docker panel sends its own proxy settings with the request.
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain,
} as PluginSshHost;
if (userProvidedPassword) {
resolvedCredentials.password = userProvidedPassword;
resolvedCredentials.authType = "password";
connectTarget.password = userProvidedPassword;
connectTarget.authType = "password";
}
if (userProvidedSshKey) {
resolvedCredentials.sshKey = userProvidedSshKey;
resolvedCredentials.authType = "key";
connectTarget.key = userProvidedSshKey;
connectTarget.authType = "key";
}
if (userProvidedKeyPassword) {
resolvedCredentials.keyPassword = userProvidedKeyPassword;
connectTarget.keyPassword = userProvidedKeyPassword;
}
const resolvedPassword = connectTarget.password as string | undefined;
const authType = (connectTarget.authType as string) || "none";
const client = new SSHClient();
const config: Record<string, unknown> = {
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
port: host.port || 22,
username: host.username,
tryKeyboard: true,
keepaliveInterval:
typeof host.terminalConfig?.keepaliveInterval === "number"
? host.terminalConfig.keepaliveInterval * 1000
: 60000,
keepaliveCountMax:
typeof host.terminalConfig?.keepaliveCountMax === "number"
? host.terminalConfig.keepaliveCountMax
: 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
hostId,
host.ip,
host.port || 22,
null,
userId,
false,
),
};
if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale" ||
resolvedCredentials.authType === "warpgate"
) {
// Tailscale SSH, "none", and Warpgate auth: no static credentials
} else if (resolvedCredentials.authType === "password") {
if (resolvedCredentials.password) {
config.password = resolvedCredentials.password;
}
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
try {
const { getOPKSSHToken } =
await import("../../../../src/backend/hosts/opkssh-auth.js");
const token = await getOPKSSHToken(userId, hostId);
if (!token) {
connectionLogs.push(
createConnectionLog(
"error",
"docker_auth",
"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.",
),
);
return res.status(401).json({
error:
"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.",
requiresOPKSSHAuth: true,
connectionLogs,
});
}
const { setupOPKSSHCertAuth } =
await import("../../../../src/backend/hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(
config as import("ssh2").ConnectConfig,
client,
token,
host.username,
);
const ssh = pluginSsh();
const prepared = await ssh.prepare(connectTarget, {
purpose: "docker",
client,
log: (level, message) =>
connectionLogs.push(
createConnectionLog(
"info",
"docker_auth",
"Using OPKSSH certificate authentication",
),
);
} catch (opksshError) {
sshLogger.error("OPKSSH authentication error for Docker", {
operation: "docker_connect",
sessionId,
hostId,
error: getErrorMessage(opksshError),
});
connectionLogs.push(
createConnectionLog(
"error",
"docker_auth",
`OPKSSH authentication failed: ${getErrorMessage(opksshError)}`,
),
);
return res.status(500).json({
error: "OPKSSH authentication failed",
createConnectionLog(level, "docker_auth", message),
),
});
const config = prepared.config;
if (prepared.outcome.status !== "ready") {
const outcome = prepared.outcome;
connectionLogs.push(
createConnectionLog("error", "docker_auth", outcome.message),
);
if (outcome.status === "interaction-required") {
return res.status(401).json({
error: outcome.message,
requiresAuthInteraction: outcome.interaction,
...(outcome.flag ? { [outcome.flag]: true } : {}),
connectionLogs,
});
}
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey
) {
try {
config.privateKey = preparePrivateKeyForSSH2(
resolvedCredentials.sshKey,
resolvedCredentials.keyPassword,
);
if (resolvedCredentials.keyPassword) {
config.passphrase = resolvedCredentials.keyPassword;
}
} catch (error) {
const message = getErrorMessage(error, "Invalid private key format");
sshLogger.error("SSH key processing error", error, {
operation: "docker_connect",
sessionId,
hostId,
});
connectionLogs.push(
createConnectionLog(
"error",
"docker_auth",
`SSH key processing error: ${message}`,
),
);
return res.status(400).json({
error: `SSH key format error: ${message}`,
connectionLogs,
});
}
} else if (resolvedCredentials.authType === "key") {
sshLogger.error(
"SSH key authentication requested but no key provided",
{
operation: "docker_connect",
sessionId,
hostId,
},
);
connectionLogs.push(
createConnectionLog(
"error",
"docker_auth",
"SSH key authentication requested but no key provided",
),
);
return res.status(400).json({
error: "SSH key authentication requested but no key provided",
connectionLogs,
});
} else if (resolvedCredentials.authType === "agent") {
const result = await applyAgentAuth(
config,
host.terminalConfig as unknown as Record<string, unknown> | undefined,
);
if ("error" in result) {
connectionLogs.push(
createConnectionLog("error", "docker_auth", result.error),
);
return res.status(400).json({ error: result.error, connectionLogs });
}
connectionLogs.push(
createConnectionLog(
"info",
"docker_auth",
"Using SSH agent authentication",
),
);
return res.status(400).json({ error: outcome.message, connectionLogs });
}
// Auth types with no stored secret answer a password prompt with
// "auth_required" so the panel can ask for credentials.
const credentialless = !ssh.requiresSecret(authType);
let responseSent = false;
connectionLogs.push(
createConnectionLog("info", "dns", `Resolving DNS for ${host.ip}`),
);
connectionLogs.push(
createConnectionLog(
"info",
@@ -443,36 +303,10 @@ export function registerDockerSshRoutes(app: express.Express): void {
`Connecting to ${host.ip}:${host.port || 22}`,
),
);
connectionLogs.push(
createConnectionLog("info", "handshake", "Initiating SSH handshake"),
);
if (resolvedCredentials.authType === "password") {
connectionLogs.push(
createConnectionLog("info", "auth", "Authenticating with password"),
);
} else if (resolvedCredentials.authType === "key") {
connectionLogs.push(
createConnectionLog("info", "auth", "Authenticating with SSH key"),
);
} else if (resolvedCredentials.authType === "agent") {
connectionLogs.push(
createConnectionLog("info", "auth", "Authenticating with SSH agent"),
);
} else if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale"
) {
connectionLogs.push(
createConnectionLog(
"info",
"auth",
"Attempting keyboard-interactive authentication",
),
);
}
client.on("ready", () => {
if (responseSent) return;
responseSent = true;
@@ -630,8 +464,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
}
if (
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
credentialless &&
(err.message.includes("authentication") ||
err.message.includes(
"All configured authentication methods failed",
@@ -662,388 +495,171 @@ export function registerDockerSshRoutes(app: express.Express): void {
}
});
const parkForAnswer = (
finish: (responses: string[]) => void,
prompts: Array<{ prompt: string; echo: boolean }>,
promptIndex: number,
isWarpgate = false,
) => {
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip: host.ip,
port: host.port || 22,
username: host.username,
userId,
prompts,
totpPromptIndex: promptIndex,
resolvedPassword,
totpAttempts: 0,
containerRuntime,
...(isWarpgate ? { isWarpgate: true } : {}),
};
};
client.on(
"keyboard-interactive",
(
name: string,
instructions: string,
instructionsLang: string,
_instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const promptTexts = prompts.map((p) => p.prompt);
const decision = ssh.classifyKeyboardInteractive(
{ name, instructions, prompts },
connectTarget,
);
const autoFinish = () =>
finish(ssh.autoResponses(prompts, resolvedPassword));
const warpgatePattern = /warpgate\s+authentication/i;
const isWarpgate =
warpgatePattern.test(name) ||
warpgatePattern.test(instructions) ||
promptTexts.some((p) => warpgatePattern.test(p));
if (isWarpgate) {
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) {
if (responseSent) return;
responseSent = true;
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip: host.ip,
port: host.port || 22,
username: host.username,
userId,
prompts,
totpPromptIndex: -1,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
isWarpgate: true,
containerRuntime,
};
connectionLogs.push(
createConnectionLog(
"info",
"docker_auth",
"Warpgate authentication required",
),
);
res.json({
requires_warpgate: true,
sessionId,
url: urlMatch[0],
securityKey: keyMatch ? keyMatch[1] : "N/A",
connectionLogs,
});
return;
}
if (decision.kind === "auto") {
finish(decision.responses);
return;
}
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPromptIndex !== -1) {
if (responseSent) {
const responses = prompts.map((p) => {
if (
/password/i.test(p.prompt) &&
resolvedCredentials.password
) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
if (decision.kind === "warpgate") {
if (responseSent) return;
responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (
/password/i.test(p.prompt) &&
resolvedCredentials.password
) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip: host.ip,
port: host.port || 22,
username: host.username,
userId,
prompts,
totpPromptIndex,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
containerRuntime,
};
parkForAnswer(finish, prompts, -1, true);
connectionLogs.push(
createConnectionLog(
"info",
"docker_auth",
"TOTP verification required",
"Warpgate authentication required",
),
);
res.json({
requires_warpgate: true,
sessionId,
url: decision.url,
securityKey: decision.securityKey,
connectionLogs,
});
return;
}
const promptText = prompts[decision.promptIndex].prompt;
const isPasswordPrompt = /password/i.test(promptText);
if (
decision.kind === "input" &&
!decision.isPush &&
!isPasswordPrompt
) {
autoFinish();
return;
}
if (decision.kind === "input" && isPasswordPrompt && credentialless) {
if (responseSent) return;
responseSent = true;
client.end();
res.json({ status: "auth_required", reason: "no_keyboard" });
return;
}
if (responseSent || pendingTOTPSessions[sessionId]) {
autoFinish();
return;
}
responseSent = true;
parkForAnswer(finish, prompts, decision.promptIndex);
if (decision.kind === "input" && isPasswordPrompt) {
res.json({
requires_totp: true,
sessionId,
prompt: prompts[totpPromptIndex].prompt,
connectionLogs,
prompt: promptText,
isPassword: true,
});
} else {
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
if (resolvedCredentials.authType === "warpgate") {
finish(prompts.map(() => ""));
return;
}
if (
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
passwordPromptIndex !== -1
) {
if (responseSent) return;
responseSent = true;
client.end();
res.json({
status: "auth_required",
reason: "no_keyboard",
});
return;
}
const hasStoredPassword =
resolvedCredentials.password &&
resolvedCredentials.authType !== "none";
if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (responseSent) {
const responses = prompts.map((p) => {
if (
/password/i.test(p.prompt) &&
resolvedCredentials.password
) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (
/password/i.test(p.prompt) &&
resolvedCredentials.password
) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip: host.ip,
port: host.port || 22,
username: host.username,
userId,
prompts,
totpPromptIndex: passwordPromptIndex,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
containerRuntime,
};
res.json({
requires_totp: true,
sessionId,
prompt: prompts[passwordPromptIndex].prompt,
isPassword: true,
});
return;
}
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
},
);
const proxyConfig: SOCKS5Config | null =
useSocks5 &&
(socks5Host ||
(socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0))
? {
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain: socks5ProxyChain as ProxyNode[],
}
: null;
const hasJumpHosts = host.jumpHosts && host.jumpHosts.length > 0;
if (hasJumpHosts) {
try {
if (proxyConfig) {
connectionLogs.push(
createConnectionLog(
"info",
"proxy",
"Connecting via proxy + jump hosts",
),
);
}
connectionLogs.push(
createConnectionLog(
"info",
"jump",
`Connecting via ${host.jumpHosts!.length} jump host(s)`,
"docker_auth",
"TOTP verification required",
),
);
const jumpClient = await createJumpHostChain(
host.jumpHosts as Array<{ hostId: number }>,
userId,
);
if (!jumpClient) {
connectionLogs.push(
createConnectionLog(
"error",
"jump",
"Failed to establish jump host chain",
),
);
return res.status(500).json({
error: "Failed to establish jump host chain",
connectionLogs,
});
}
jumpClient.forwardOut(
"127.0.0.1",
0,
host.ip,
host.port || 22,
(err, stream) => {
if (err) {
sshLogger.error("Failed to forward through jump host", err, {
operation: "docker_jump_forward",
sessionId,
hostId,
});
connectionLogs.push(
createConnectionLog(
"error",
"jump",
`Failed to forward through jump host: ${err.message}`,
),
);
jumpClient.end();
if (!responseSent) {
responseSent = true;
return res.status(500).json({
error:
"Failed to forward through jump host: " + err.message,
connectionLogs,
});
}
return;
}
config.sock = stream;
client.connect(config);
},
);
} catch (jumpError) {
sshLogger.error("Jump host connection failed", jumpError, {
operation: "docker_jump_connect",
res.json({
requires_totp: true,
sessionId,
hostId,
prompt: promptText,
connectionLogs,
});
connectionLogs.push(
createConnectionLog(
"error",
"jump",
`Jump host connection failed: ${getErrorMessage(jumpError)}`,
),
);
if (!responseSent) {
responseSent = true;
return res.status(500).json({
error:
"Jump host connection failed: " + getErrorMessage(jumpError),
connectionLogs,
});
}
return;
}
} else if (proxyConfig) {
},
);
if (host.jumpHosts && host.jumpHosts.length > 0) {
connectionLogs.push(
createConnectionLog(
"info",
"jump",
`Connecting via ${host.jumpHosts.length} jump host(s)`,
),
);
} else if (useSocks5) {
connectionLogs.push(
createConnectionLog("info", "proxy", "Connecting via proxy"),
);
try {
const proxySocket = await createSocks5Connection(
host.ip,
host.port || 22,
proxyConfig,
);
if (proxySocket) {
config.sock = proxySocket;
}
client.connect(config);
} catch (proxyError) {
sshLogger.error("Proxy connection failed", proxyError, {
operation: "docker_proxy_connect",
sessionId,
hostId,
});
connectionLogs.push(
createConnectionLog(
"error",
"proxy",
`Proxy connection failed: ${getErrorMessage(proxyError)}`,
),
);
if (!responseSent) {
responseSent = true;
return res.status(500).json({
error: "Proxy connection failed: " + getErrorMessage(proxyError),
connectionLogs,
});
}
return;
}
} else {
await resolveSshConnectConfigHost(config);
client.connect(config);
}
try {
const transport = await ssh.openTransport(connectTarget, config);
if (transport.jumpClient) {
const jumpClient = transport.jumpClient as SSHClient;
client.on("close", () => jumpClient.end());
}
} catch (transportError) {
sshLogger.error("Docker SSH transport failed", transportError, {
operation: "docker_transport",
sessionId,
hostId,
});
connectionLogs.push(
createConnectionLog(
"error",
host.jumpHosts && host.jumpHosts.length > 0 ? "jump" : "proxy",
getErrorMessage(transportError),
),
);
if (!responseSent) {
responseSent = true;
return res.status(500).json({
error: getErrorMessage(transportError),
connectionLogs,
});
}
return;
}
client.connect(config);
} catch (error) {
sshLogger.error("Docker SSH connection error", error, {
operation: "docker_connect",
+36
View File
@@ -0,0 +1,36 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+3
View File
@@ -1,7 +1,10 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { startFleetsService, stopFleetsService } from "./routes.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
startFleetsService(ctx.http.router());
ctx.log.info("Fleets routes mounted at /plugin-api/fleets");
}
+16 -20
View File
@@ -26,11 +26,7 @@ import {
createCurrentUserRepository,
} from "../../../../src/backend/database/repositories/factory.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import {
getFleetPoolKey,
createFleetSshFactory,
} from "../../../../src/backend/hosts/ssh-client-factory.js";
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
import { withSshConnection } from "./ssh.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js";
import {
@@ -915,9 +911,9 @@ router.post(
inputValues && typeof inputValues === "object" ? inputValues : {},
);
const { stdout, stderr, code } = await withConnection(
getFleetPoolKey(fullHost),
createFleetSshFactory(fullHost),
const { stdout, stderr, code } = await withSshConnection(
fullHost as never,
{ pool: "fleet", purpose: "fleet" },
(client) => execCommand(client, resolvedCommand, 60000),
);
@@ -1017,9 +1013,9 @@ router.post(
return { success: false, error: "Host not found" };
}
await withConnection(
getFleetPoolKey(fullHost),
createFleetSshFactory(fullHost),
await withSshConnection(
fullHost as never,
{ pool: "fleet", purpose: "fleet" },
async (client) => {
const sftp = await getSftp(client);
await sftpWriteFile(sftp, remotePath, file.buffer);
@@ -1112,9 +1108,9 @@ router.post(
return { success: false, error: "Host not found" };
}
const data = await withConnection(
getFleetPoolKey(fullHost),
createFleetSshFactory(fullHost),
const data = await withSshConnection(
fullHost as never,
{ pool: "fleet", purpose: "fleet" },
async (client) => {
const sftp = await getSftp(client);
return sftpReadFile(sftp, remotePath);
@@ -1305,9 +1301,9 @@ router.post(
return { success: false, error: "Host not found" };
}
const record = await withConnection(
getFleetPoolKey(fullHost),
createFleetSshFactory(fullHost),
const record = await withSshConnection(
fullHost as never,
{ pool: "fleet", purpose: "fleet" },
async (client) => {
const platform = await detectPlatform(client);
const { stdout } = await execCommand(
@@ -1418,9 +1414,9 @@ router.post(
return { success: false, error: "Host not found" };
}
return withConnection(
getFleetPoolKey(fullHost),
createFleetSshFactory(fullHost),
return withSshConnection(
fullHost as never,
{ pool: "fleet", purpose: "fleet" },
async (client) => {
const platform = await detectPlatform(client);
// "remove" has no PackageAction counterpart in buildPackageActionCommand
+36
View File
@@ -0,0 +1,36 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+4 -9
View File
@@ -60,15 +60,10 @@ vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
}));
vi.mock("../../../../src/backend/hosts/ssh-client-factory.js", () => ({
getFleetPoolKey: () => "pool-key",
createFleetSshFactory: () => async () => ({}),
}));
vi.mock("../../../../src/backend/hosts/ssh-connection-pool.js", () => ({
withConnection: async (
_key: string,
_factory: unknown,
vi.mock("../../src/backend/ssh.js", () => ({
withSshConnection: async (
_host: unknown,
_options: unknown,
fn: (client: unknown) => unknown,
) => fn({}),
}));
+2 -4
View File
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
import { pluginSsh } from "./ssh.js";
export type StatsCapableHost = {
connectionType?: string;
@@ -14,9 +14,7 @@ export type TcpPingStatsConfig = {
export function supportsMetrics(host: StatsCapableHost): boolean {
const connectionType = host.connectionType || "ssh";
if (connectionType !== "ssh") return false;
if (host.authType === "none" || usesIssuedCertificate(host.authType))
return false;
return true;
return pluginSsh().supportsBackground(host.authType || "none");
}
export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
@@ -1,4 +1,5 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import {
onHostDeleted,
onHostLogin,
@@ -8,6 +9,8 @@ import {
} from "./routes.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
await startHostMetricsService(ctx.http.router());
// Core used to POST these to this plugin's own port with an internal auth
+99 -635
View File
@@ -1,13 +1,7 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
import express, { type Router } from "express";
import net from "net";
import { Client, type ConnectConfig } from "ssh2";
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
import {
pickResolvedPassword,
pickResolvedUsername,
} from "../../../../src/backend/hosts/credential-username.js";
import {
getCurrentSettingValue,
createCurrentHostMetricsHistoryRepository,
@@ -47,16 +41,7 @@ import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../../../../src/backend/utils/socks5-helper.js";
import { preparePrivateKeyForSSH2 } from "../../../../src/backend/utils/ssh-key-utils.js";
import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js";
import {
connectionPool,
withConnection,
} from "../../../../src/backend/hosts/ssh-connection-pool.js";
import { connectionPool } from "../../../../src/backend/hosts/ssh-connection-pool.js";
import { registerHostMetricsSettingsRoutes } from "./settings-routes.js";
import { registerHostMetricsViewerRoutes } from "./viewer-routes.js";
import { registerHostMetricsPreferencesRoutes } from "./preferences-routes.js";
@@ -75,10 +60,10 @@ import {
} from "./automation-bridge.js";
import { notifyAutomationInternalEvent } from "../../../../src/backend/hosts/automation-events.js";
import { registerManagerRoutes } from "./managers/index.js";
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
import { AccessDeniedError } from "./managers/route-helpers.js";
import type { ManagerHost } from "./managers/types.js";
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
import { pluginSsh, withSshConnection as withPooledSsh } from "./ssh.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import {
isTcpPingEnabled,
@@ -617,10 +602,15 @@ class PollingManager {
let isOnline: boolean;
if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) {
const jumpClient = await createJumpHostChain(
refreshedHost.jumpHosts,
userId,
);
let jumpClient: Client | null = null;
try {
({ client: jumpClient } = await pluginSsh().jumpChain<Client>(
refreshedHost.jumpHosts,
{ forHost: refreshedHost as unknown as PluginSshHost },
));
} catch {
jumpClient = null;
}
isOnline = jumpClient
? await tcpPingThroughJumpHost(
jumpClient,
@@ -1139,176 +1129,38 @@ async function fetchHostById(
}
}
/**
* Resolves through core's host resolver, so shared-host overrides and
* external secret references apply here exactly as in the terminal.
*/
async function resolveHostCredentials(
host: Record<string, unknown>,
userId: string,
): Promise<SSHHostWithCredentials | undefined> {
try {
const baseHost: Record<string, unknown> = {
id: host.id,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder || "",
const resolved = (await resolveHostById(
host.id as number,
userId,
)) as unknown as Record<string, unknown> | null;
if (!resolved) return undefined;
const terminalConfig = resolved.terminalConfig as
Record<string, unknown> | undefined;
return {
...resolved,
tags:
typeof host.tags === "string"
? host.tags
? host.tags.split(",").filter(Boolean)
typeof resolved.tags === "string"
? resolved.tags
? resolved.tags.split(",").filter(Boolean)
: []
: [],
pin: !!host.pin,
authType: host.authType,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath || "/",
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections as string)
: [],
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
statsConfig: host.statsConfig || undefined,
sudoPassword: (() => {
const top = host.sudoPassword as string | null | undefined;
if (top) return top;
try {
const tc =
typeof host.terminalConfig === "string"
? JSON.parse(host.terminalConfig as string)
: host.terminalConfig;
return (tc?.sudoPassword as string) || undefined;
} catch {
return undefined;
}
})(),
createdAt: host.createdAt,
updatedAt: host.updatedAt,
userId: host.userId,
useSocks5: !!host.useSocks5,
socks5Host: host.socks5Host || undefined,
socks5Port: host.socks5Port || undefined,
socks5Username: host.socks5Username || undefined,
socks5Password: host.socks5Password || undefined,
socks5ProxyChain: host.socks5ProxyChain
? JSON.parse(host.socks5ProxyChain as string)
: undefined,
: (resolved.tags ?? []),
sudoPassword:
(resolved.sudoPassword as string | undefined) ||
(terminalConfig?.sudoPassword as string | undefined) ||
undefined,
connectionType:
(host.connectionType as SSHHostWithCredentials["connectionType"]) ||
(resolved.connectionType as SSHHostWithCredentials["connectionType"]) ||
"ssh",
rdpPort: (host.rdpPort as number | undefined) ?? undefined,
vncPort: (host.vncPort as number | undefined) ?? undefined,
telnetPort: (host.telnetPort as number | undefined) ?? undefined,
};
if (host.credentialId) {
try {
const ownerId = host.userId;
const isSharedHost = userId !== ownerId;
if (isSharedHost) {
const { SharedHostSecretsManager } =
await import("../../../../src/backend/utils/shared-host-secrets-manager.js");
const sharedCred =
await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id as number,
userId,
"ssh",
);
if (sharedCred) {
baseHost.credentialId = host.credentialId;
baseHost.authType = sharedCred.authType;
baseHost.username = pickResolvedUsername(
host.username,
sharedCred.username,
host.overrideCredentialUsername,
);
if (sharedCred.password) {
baseHost.password = sharedCred.password;
}
if (sharedCred.key) {
baseHost.key = sharedCred.key;
}
if (sharedCred.keyPassword) {
baseHost.keyPassword = sharedCred.keyPassword;
}
if (sharedCred.keyType) {
baseHost.keyType = sharedCred.keyType;
}
}
} else {
const credential =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
host.credentialId as number,
userId,
);
if (credential) {
baseHost.credentialId = credential.id;
baseHost.authType =
credential.authType ||
(credential.password
? "password"
: credential.key ||
(credential as Record<string, unknown>).privateKey
? "key"
: "none");
baseHost.username = pickResolvedUsername(
host.username,
credential.username,
host.overrideCredentialUsername,
);
baseHost.password = pickResolvedPassword(
host.password,
credential.password,
);
if (
credential.key ||
(credential as Record<string, unknown>).privateKey
) {
baseHost.key =
credential.key ||
((credential as Record<string, unknown>).privateKey as string);
}
if (credential.keyPassword) {
baseHost.keyPassword = credential.keyPassword;
}
if (credential.keyType) {
baseHost.keyType = credential.keyType;
}
} else {
addLegacyCredentials(baseHost, host);
if (baseHost.authType === "credential") {
baseHost.authType = baseHost.password
? "password"
: baseHost.key
? "key"
: "none";
}
}
}
} catch (error) {
statsLogger.warn(
`Failed to resolve credential ${host.credentialId} for host ${host.id}: ${getErrorMessage(error)}`,
);
addLegacyCredentials(baseHost, host);
if (baseHost.authType === "credential") {
baseHost.authType = baseHost.password
? "password"
: baseHost.key
? "key"
: "none";
}
}
} else {
addLegacyCredentials(baseHost, host);
}
return baseHost as unknown as SSHHostWithCredentials;
} as unknown as SSHHostWithCredentials;
} catch (error) {
statsLogger.error(
`Failed to resolve host credentials for host ${host.id}: ${getErrorMessage(error)}`,
@@ -1317,340 +1169,21 @@ async function resolveHostCredentials(
}
}
function addLegacyCredentials(
baseHost: Record<string, unknown>,
host: Record<string, unknown>,
): void {
baseHost.password = host.password || null;
baseHost.key = host.key || null;
baseHost.keyPassword = host.keyPassword || null;
baseHost.keyType = host.keyType;
}
async function buildSshConfig(
host: SSHHostWithCredentials,
): Promise<ConnectConfig> {
const base: ConnectConfig = {
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
port: host.port,
username: host.username,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
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",
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"],
},
} 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 SSH key available for host ${host.ip}`);
}
try {
(base as Record<string, unknown>).privateKey = preparePrivateKeyForSSH2(
host.key,
host.keyPassword,
);
if (host.keyPassword) {
(base as Record<string, unknown>).passphrase = host.keyPassword;
}
} catch (keyError) {
statsLogger.error(
`SSH key format error for host ${host.ip}: ${getErrorMessage(keyError)}`,
);
throw new Error(`Invalid SSH key format for host ${host.ip}`, {
cause: keyError,
});
}
} else if (
host.authType === "none" ||
host.authType === "tailscale" ||
host.authType === "warpgate"
) {
// no credentials needed
} else if (usesIssuedCertificate(host.authType)) {
// cert auth setup happens in createSshFactory (needs client instance)
} else if (host.authType === "vault") {
// cert auth setup happens in createSshFactory (needs client instance)
} else if (host.authType === "credential") {
if (host.password) {
base.password = host.password;
} else if (host.key) {
const cleanKey = host.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
(base as Record<string, unknown>).privateKey = Buffer.from(
cleanKey,
"utf8",
);
if (host.keyPassword) {
(base as Record<string, unknown>).passphrase = host.keyPassword;
}
} else {
throw new Error(`Credential for host ${host.ip} could not be resolved`);
}
} else {
throw new Error(
`Unsupported authentication type '${host.authType}' for host ${host.ip}`,
);
}
return base;
}
function getPoolKey(host: SSHHostWithCredentials): string {
const socks5Key = host.useSocks5
? `:socks5:${host.socks5Host}:${host.socks5Port}`
: "";
return `stats:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
}
function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
return async () => {
const config = await buildSshConfig(host);
const client = new Client();
// Set up OPKSSH cert auth if needed (requires client instance)
if (usesIssuedCertificate(host.authType) && host.userId) {
const { getOPKSSHToken } =
await import("../../../../src/backend/hosts/opkssh-auth.js");
const token = await getOPKSSHToken(host.userId, host.id);
if (!token) {
throw new Error(
"OPKSSH authentication required. Please open a Terminal connection first.",
);
}
const { setupOPKSSHCertAuth } =
await import("../../../../src/backend/hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(config, client, token, host.username);
} else if (host.authType === "vault") {
const { setupVaultSshSignerAuth } =
await import("../../../../src/backend/hosts/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;
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);
});
client.on(
"keyboard-interactive",
(
_name: string,
_instructions: string,
_instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPromptIndex !== -1) {
const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId: host.id,
userId: host.userId!,
prompts: prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo ?? false,
})),
totpPromptIndex,
resolvedPassword: host.password,
totpAttempts: 0,
};
return;
} else if (host.password) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt)) {
return host.password || "";
}
return "";
});
finish(responses);
} else {
finish(prompts.map(() => ""));
}
},
);
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);
});
return;
}
});
};
return pluginSsh().poolKey("stats", host as unknown as PluginSshHost);
}
async function withSshConnection<T>(
host: SSHHostWithCredentials,
fn: (client: Client) => Promise<T>,
): Promise<T> {
const key = getPoolKey(host);
const factory = createSshFactory(host);
return withConnection(key, factory, fn);
// Background polling has nobody to answer a TOTP prompt, so the pooled
// connection answers password prompts only and fails fast otherwise.
return withPooledSsh(
host as unknown as PluginSshHost,
{ pool: "stats", purpose: "metrics", overrides: { readyTimeout: 60000 } },
fn,
);
}
const proxmoxPollingManager = new ProxmoxPollingManager<SSHHostWithCredentials>(
@@ -2347,34 +1880,30 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
return res.json({ success: true, connectionLogs });
}
const config = await buildSshConfig(host);
const client = new Client();
if (usesIssuedCertificate(host.authType) && host.userId) {
const { getOPKSSHToken } =
await import("../../../../src/backend/hosts/opkssh-auth.js");
const token = await getOPKSSHToken(host.userId, host.id);
if (!token) {
connectionLogs.push(
createConnectionLog(
"error",
"auth",
"OPKSSH authentication required. Please open a Terminal connection first.",
),
);
const ssh = pluginSsh();
const prepared = await ssh.prepare(host as unknown as PluginSshHost, {
purpose: "metrics",
client,
log: (level, message) =>
connectionLogs.push(createConnectionLog(level, "auth", message)),
});
const config = prepared.config as ConnectConfig;
config.readyTimeout = 60000;
if (prepared.outcome.status !== "ready") {
const outcome = prepared.outcome;
connectionLogs.push(
createConnectionLog("error", "auth", outcome.message),
);
if (outcome.status === "interaction-required") {
return res.status(401).json({
error: "OPKSSH authentication required",
requiresOPKSSHAuth: true,
error: outcome.message,
requiresAuthInteraction: outcome.interaction,
...(outcome.flag ? { [outcome.flag]: true } : {}),
connectionLogs,
});
}
const { setupOPKSSHCertAuth } =
await import("../../../../src/backend/hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(config, client, token, host.username);
} else if (host.authType === "vault") {
const { setupVaultSshSignerAuth } =
await import("../../../../src/backend/hosts/vault-ssh-connect.js");
await setupVaultSshSignerAuth(config, client, host);
return res.status(400).json({ error: outcome.message, connectionLogs });
}
const connectionPromise = new Promise<{
@@ -2396,12 +1925,13 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
client.on(
"keyboard-interactive",
(name, instructions, instructionsLang, prompts, finish) => {
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
(name, instructions, _instructionsLang, prompts, finish) => {
const decision = ssh.classifyKeyboardInteractive(
{ name, instructions, prompts },
host as unknown as PluginSshHost,
);
const totpPromptIndex =
decision.kind === "totp" ? decision.promptIndex : -1;
if (totpPromptIndex !== -1) {
const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -2443,13 +1973,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
}
return;
} else {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && host.password) {
return host.password;
}
return "";
});
finish(responses);
finish(ssh.autoResponses(prompts, host.password));
}
},
);
@@ -2577,10 +2101,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
}
});
const hasJumpHosts =
host.jumpHosts && host.jumpHosts.length > 0 && host.userId;
if (hasJumpHosts) {
if (host.jumpHosts && host.jumpHosts.length > 0) {
connectionLogs.push(
createConnectionLog(
"info",
@@ -2588,92 +2109,33 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
"Connecting via jump host chain",
),
);
createJumpHostChain(host.jumpHosts!, host.userId!)
.then((jumpClient) => {
jumpClient.forwardOut(
"127.0.0.1",
0,
host.ip,
host.port,
(err, stream) => {
if (err || !stream) {
if (!isResolved) {
isResolved = true;
clearTimeout(timeout);
reject(err || new Error("Jump host forward failed"));
}
return;
}
config.sock = stream;
delete config.host;
delete config.port;
client.connect(config);
},
);
})
.catch((error) => {
if (!isResolved) {
isResolved = true;
clearTimeout(timeout);
connectionLogs.push(
createConnectionLog(
"error",
"proxy",
`Jump host connection failed: ${getErrorMessage(error)}`,
),
);
reject(error);
}
});
} else if (
host.useSocks5 &&
(host.socks5Host ||
(host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
) {
connectionLogs.push(
createConnectionLog("info", "proxy", "Connecting via SOCKS5 proxy"),
);
createSocks5Connection(host.ip, host.port, {
useSocks5: host.useSocks5,
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
})
.then((socks5Socket) => {
if (socks5Socket) {
config.sock = socks5Socket;
}
client.connect(config);
})
.catch((error) => {
if (!isResolved) {
isResolved = true;
clearTimeout(timeout);
connectionLogs.push(
createConnectionLog(
"error",
"proxy",
`SOCKS5 proxy connection failed: ${getErrorMessage(error)}`,
),
);
reject(error);
}
});
} else {
resolveSshConnectConfigHost(config)
.then(() => {
client.connect(config);
})
.catch((error) => {
if (!isResolved) {
isResolved = true;
clearTimeout(timeout);
reject(error);
}
});
}
ssh
.openTransport(
host as unknown as PluginSshHost,
config as Record<string, unknown>,
)
.then((transport) => {
if (transport.jumpClient) {
const jumpClient = transport.jumpClient as Client;
client.on("close", () => jumpClient.end());
}
client.connect(config);
})
.catch((error) => {
if (!isResolved) {
isResolved = true;
clearTimeout(timeout);
connectionLogs.push(
createConnectionLog(
"error",
"proxy",
`Connection setup failed: ${getErrorMessage(error)}`,
),
);
reject(error);
}
});
});
const result = await connectionPromise;
@@ -3093,5 +2555,7 @@ export function shutdown(): void {
}
pollingManager.destroy();
proxmoxPollingManager.destroy();
connectionPool.destroy();
// The pool is core's and shared with tmux and the fleet tools; destroying it
// here broke them until a restart. ctx.ssh drops this plugin's pooled
// connections on deactivate instead.
}
+36
View File
@@ -0,0 +1,36 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { describe, it, expect, vi } from "vitest";
import { afterAll, beforeAll, describe, it, expect, vi } from "vitest";
import {
supportsMetrics,
isTcpPingEnabled,
@@ -7,8 +7,17 @@ import {
tcpPingThroughJumpHost,
} from "../../src/backend/helpers.js";
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
import { createFakeContext } from "@termix/plugin-sdk/testing";
import { setPluginSsh } from "../../src/backend/ssh.js";
describe("supportsMetrics", () => {
beforeAll(() => {
setPluginSsh(createFakeContext().ctx.ssh);
});
afterAll(() => {
setPluginSsh(null);
});
it("supports plain ssh hosts", () => {
expect(
supportsMetrics({ connectionType: "ssh", authType: "password" }),
+3
View File
@@ -1,7 +1,10 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { startProxmoxService, stopProxmoxService } from "./routes.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
startProxmoxService(ctx.http.router());
ctx.log.info("Proxmox routes mounted at /plugin-api/proxmox");
}
+8 -102
View File
@@ -1,6 +1,6 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import express, { type Router } from "express";
import { Client as SSHClient } from "ssh2";
import type { Client as SSHClient } from "ssh2";
import { logger } from "../../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js";
@@ -9,9 +9,9 @@ import {
type AuthenticatedRequest,
type SSHHost,
} from "../../../../src/types/index.js";
import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
import { connectSsh } from "./ssh.js";
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
import {
parseProxmoxJumpHosts,
@@ -309,109 +309,15 @@ async function discoverProxmoxGuestsForHost(
const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig);
const config = parseProxmoxConfig(proxmoxCfgRaw);
const resolvedCredentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
} = {
password: host.password,
sshKey: host.key,
keyPassword: host.keyPassword,
authType: host.authType,
};
const hostCredentialId = host.credentialId ?? null;
const sshConfig: Record<string, unknown> = {
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
port: host.port || 22,
username: host.username,
tryKeyboard: false,
readyTimeout: 30000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
parsedHostId,
host.ip,
host.port || 22,
null,
userId,
false,
),
};
const { client } = await connectSsh(host as unknown as PluginSshHost, {
purpose: "proxmox",
timeoutMs: 35000,
overrides: { tryKeyboard: false, readyTimeout: 30000 },
});
const authType = resolvedCredentials.authType;
if (authType === "key" && resolvedCredentials.sshKey) {
sshConfig.privateKey = resolvedCredentials.sshKey;
if (resolvedCredentials.keyPassword)
sshConfig.passphrase = resolvedCredentials.keyPassword;
} else if (authType === "agent") {
const { applyAgentAuth } =
await import("../../../../src/backend/hosts/terminal-auth-helpers.js");
const result = await applyAgentAuth(
sshConfig,
host.terminalConfig as unknown as Record<string, unknown> | undefined,
);
if ("error" in result) {
const error = new Error(result.error);
(error as Error & { status?: number }).status = 400;
throw error;
}
} else if (resolvedCredentials.password) {
sshConfig.password = resolvedCredentials.password;
}
const client = new SSHClient();
try {
await new Promise<void>((resolve, reject) => {
client.on("ready", resolve);
client.on("error", reject);
// Reuse the shared jump-host chain (same path terminal/metrics use)
// so Proxmox hosts that are only reachable via a jump host work too
// (otherwise the direct connect fails with EHOSTUNREACH). jumpHosts is
// stored as a JSON string on the decrypted record, so parse it first.
let parsedJumpHosts: Array<{ hostId: number }> = [];
try {
const rawJumpHosts = (host as { jumpHosts?: unknown }).jumpHosts;
const parsed =
typeof rawJumpHosts === "string"
? JSON.parse(rawJumpHosts)
: rawJumpHosts;
if (Array.isArray(parsed)) parsedJumpHosts = parsed;
} catch {
parsedJumpHosts = [];
}
if (parsedJumpHosts.length > 0) {
createJumpHostChain(parsedJumpHosts, userId)
.then((jumpClient) => {
if (!jumpClient) {
reject(new Error("Jump host chain could not be established"));
return;
}
jumpClient.forwardOut(
"127.0.0.1",
0,
sshConfig.host as string,
sshConfig.port as number,
(err, stream) => {
if (err || !stream) {
reject(err || new Error("Jump host forward failed"));
return;
}
sshConfig.sock = stream;
delete sshConfig.host;
delete sshConfig.port;
client.connect(sshConfig as import("ssh2").ConnectConfig);
},
);
})
.catch(reject);
} else {
client.connect(sshConfig as import("ssh2").ConnectConfig);
}
});
proxmoxLogger.info("Proxmox discovery SSH connection established", {
operation: "proxmox_discover",
hostId: parsedHostId,
+36
View File
@@ -0,0 +1,36 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useSshAuthTypes, useTranslation } from "@termix/plugin-sdk/frontend";
import { Server } from "lucide-react";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
@@ -20,6 +20,7 @@ export function HostProxmoxTab({
setField: SetHostField;
}) {
const { t } = useTranslation();
const sshAuthTypes = useSshAuthTypes();
const [credentials, setCredentials] = useState<
{ id: number; name: string; username: string | null }[]
>([]);
@@ -107,14 +108,11 @@ export function HostProxmoxTab({
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="password">{t("hosts.authTypePassword")}</option>
<option value="key">{t("hosts.authTypeKey")}</option>
<option value="credential">
{t("hosts.authTypeCredential")}
</option>
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
<option value="stepca">{t("hosts.authTypeStepca")}</option>
<option value="none">{t("hosts.authTypeNone")}</option>
{sshAuthTypes.types.map((option) => (
<option key={option.type} value={option.type}>
{t(option.labelKey)}
</option>
))}
</Select2>
</SettingRow>
<SettingRow
@@ -1,4 +1,5 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import {
getGuacSessionInfo,
handleGuacamoleUpgrade,
@@ -10,6 +11,8 @@ import {
import { startRemoteDesktopService } from "./routes.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
await startGuacamoleService();
startRemoteDesktopService(ctx.http.router());
+5 -13
View File
@@ -19,7 +19,8 @@ import {
createCurrentSettingsRepository,
} from "../../../../src/backend/database/repositories/factory.js";
import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js";
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
import type { Client } from "ssh2";
import { pluginSsh } from "./ssh.js";
import { getGuacSessionByConnectId } from "./guacamole-server.js";
import {
logAudit,
@@ -545,18 +546,9 @@ router.post(
try {
// The chain dials the first hop through that hop's own SOCKS5
// settings; the target host's proxy config does not apply to it.
const jumpClient = await createJumpHostChain(jumpHosts, userId);
if (!jumpClient) {
guacLogger.error(
"Failed to establish jump host chain for guacamole",
undefined,
{ operation: "guac_ssh_tunnel_error", hostId },
);
return res.status(500).json({
error: "Failed to establish SSH tunnel to remote host",
});
}
const { client: jumpClient } = await pluginSsh().jumpChain<Client>(
jumpHosts as Array<{ hostId: number }>,
);
const targetHostname = hostname;
const targetPort = port;
+36
View File
@@ -0,0 +1,36 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
@@ -1,7 +1,10 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
startWebEndpointService(ctx.http.router());
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
}
+36
View File
@@ -0,0 +1,36 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
-1
View File
@@ -139,7 +139,6 @@
"plugins/docker/src/backend/session-manager.ts",
"plugins/fleets/src/backend/routes.ts",
"plugins/host-metrics/src/backend/automation-bridge.ts",
"plugins/host-metrics/src/backend/helpers.ts",
"plugins/host-metrics/src/backend/history-routes.ts",
"plugins/host-metrics/src/backend/managers/cron.ts",
"plugins/host-metrics/src/backend/managers/firewall.ts",
+116
View File
@@ -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);
}
+79
View File
@@ -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 };
}
+393
View File
@@ -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;
}
+227
View File
@@ -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);
}
}
+518
View File
@@ -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,
);
}
}
+128
View File
@@ -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,
});
},
};
+564
View File
@@ -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());
}
+329
View File
@@ -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;
}
+69
View File
@@ -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();
}
+86
View File
@@ -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 }));
}
+162
View File
@@ -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);
}
+37
View File
@@ -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;
}
+4 -2
View File
@@ -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"),
+23
View File
@@ -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,
+54
View File
@@ -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 })
+54
View File
@@ -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 })
+54
View File
@@ -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;
}
}
+314
View File
@@ -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({
+20 -5
View File
@@ -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;
+13 -368
View File
@@ -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;
+66
View File
@@ -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:
+38 -128
View File
@@ -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");
+32 -187
View File
@@ -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
-452
View File
@@ -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());
}
+208
View File
@@ -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;
}
}
+196
View File
@@ -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 };
}
+255
View File
@@ -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 ?? "",
);
}
+20 -102
View File
@@ -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) {
+26 -205
View File
@@ -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;
};
}
File diff suppressed because it is too large. Load diff
-11
View File
@@ -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",
};
}
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more