mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: add auth extension points and one ssh connect pipeline
This commit is contained in:
1 parent
d3c209af13
commit
d774df8b41
159 files changed
+43243
-7451
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user