feat: login policy hook for plugins and optional https redirect (#1584)

This commit is contained in:
LukeGus committed 2026-10-19 12:47:47 -05:00
1 parent 34a9c77114
commit ad46f42ab1
53 files changed
+15250 -59

No files matched your search

+15
View File
@@ -1,5 +1,20 @@
# Changelog
## 1.0.15
Plugin API 1.7.0.
### Added
- `ctx.auth.policy.provide` and the `auth:policy` capability, so a plugin can set an idle timeout, cap session length, turn off remember me and require a second factor
- `ctx.auth.listSecondFactors` lists the second factors from every running plugin
- `AuthEnrollmentSections` takes `factorsOnly`
- `getHttpsSettings` and `updateHttpsSettings` in `@termix-ssh/plugin-sdk/ui`
### Fixed
- Plugin frontend tests against a locally linked SDK failed with a second copy of React
## 1.0.14
Plugin API 1.6.0.
+10 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.14 | Plugin API 1.6.0
SDK 1.0.15 | Plugin API 1.7.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -226,6 +226,7 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
| `sync` | none | Sync your tables to a linked desktop |
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
| `users.getLocale` | `users:read` | A user's UI language |
| `system` | `system:tls` | The server's HTTPS certificate |
| `plugins.list()` | `plugins:read` | Which plugins are installed |
@@ -699,6 +700,13 @@ closely. Declare ids in `contributes.auth` and register them with `ctx.auth.regi
`registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols`
and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one.
Login rules need `auth:policy`. `ctx.auth.policy.provide({ evaluate, allowRememberDevice })` is asked at
every login with the user, the method, the device and every second factor (`ctx.auth.listSecondFactors()`
lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`, `idleTimeoutSeconds` and
`secondFactor` (`"always"` or `"new-device"`). The strictest answer from all plugins wins, and a throw
refuses the login. A user who must have a second factor but has none gets a session that can only set one
up, and `/users/me` reports `restriction: "second-factor-setup"`.
## Testing
```ts
@@ -895,6 +903,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
| `device:serial` | high | Open serial and USB ports on the server |
| `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.14",
"version": "1.0.15",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -151,6 +151,7 @@
"process:spawn",
"users:write",
"auth:provide",
"auth:policy",
"system:tls",
"device:serial",
"hosts:write",
+52
View File
@@ -1761,6 +1761,54 @@ export interface PluginSecondFactor {
reset?: (userId: string) => Promise<void>;
}
/** A login core is about to turn into a session. */
export interface PluginAuthPolicyInput {
userId: string;
/** Login method id, `"proxy"` for a trusted proxy login. */
methodId: string;
/** The method signs in through an outside identity provider (SSO, LDAP). */
external: boolean;
deviceType: "web" | "desktop" | "mobile";
/** The device passed a second factor before and was remembered. */
trustedDevice: boolean;
/** The method itself proved a second factor, like a verified passkey. */
mfaSatisfied: boolean;
/** Every second factor core has, and whether this user set it up. */
secondFactors: { id: string; pluginId: string; enrolled: boolean }[];
}
export interface PluginAuthPolicyDecision {
/** False turns remember me off and ignores remembered devices. */
allowRememberDevice?: boolean;
/** Caps how long the session lasts. */
maxSessionSeconds?: number;
/** Ends the session after this many seconds without a request. */
idleTimeoutSeconds?: number;
/**
* Requires a second factor even for users who have none: they get a
* session that can only set one up. `"new-device"` skips it on a
* remembered device.
*/
secondFactor?: "always" | "new-device";
}
/**
* Login rules from a plugin. Core asks every provider and the strictest
* answer wins. A provider that throws refuses the login.
*/
export interface PluginAuthPolicyProvider {
evaluate: (
input: PluginAuthPolicyInput,
) => PluginAuthPolicyDecision | Promise<PluginAuthPolicyDecision>;
/** Whether the login page should offer remember me. */
allowRememberDevice?: () => boolean;
}
export interface PluginAuthPolicyApi {
/** Needs auth:policy. Removed on deactivate. */
provide: (provider: PluginAuthPolicyProvider) => () => void;
}
export interface PluginUserTokenOptions {
/** Why the token exists, kept in the audit log and on the session. */
purpose: string;
@@ -1850,6 +1898,10 @@ export interface PluginAuth {
userId: string,
options: PluginUserTokenOptions,
) => Promise<PluginUserToken>;
/** Login rules: idle timeout, session length, remember me, second factors. */
policy: PluginAuthPolicyApi;
/** Second factors from every running plugin. Needs no capability. */
listSecondFactors: () => { id: string; pluginId: string }[];
}
/** What a plugin may learn about a user. Needs users:read. */
+1
View File
@@ -47,6 +47,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:impersonate", "high"),
entry("db:core-refs", "high"),
entry("auth:provide", "high"),
entry("auth:policy", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
entry("ui:layout", "high"),
+1 -1
View File
@@ -37,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.6.0";
export const PLUGIN_API_VERSION = "1.7.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
+32
View File
@@ -77,6 +77,7 @@ import type {
PluginHostStatusEntry,
PluginActivityProvider,
PluginHostLockProvider,
PluginAuthPolicyProvider,
PluginCredentialSummary,
PluginHostShareGrant,
PluginHostStatusProvider,
@@ -195,6 +196,8 @@ export interface FakeContextOptions {
baseUrl?: string;
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
linkedUsers?: Record<string, number>;
/** Second factors from other plugins that ctx.auth.listSecondFactors adds. */
secondFactors?: { id: string; pluginId: string }[];
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number;
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
@@ -274,6 +277,8 @@ export interface FakeAuthRegistrations {
loginFailures: Map<string, number>;
/** Every ctx.auth.issueUserToken call, in order. */
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
/** Providers passed to ctx.auth.policy.provide that are still registered. */
policies: PluginAuthPolicyProvider[];
}
export interface FakePluginContext {
@@ -624,6 +629,7 @@ export function createFakeContext(
revokedSessions: [],
loginFailures: new Map(),
userTokens: [],
policies: [],
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
@@ -1297,6 +1303,22 @@ export function createFakeContext(
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
};
},
policy: {
provide: (provider) => {
auth.policies.push(provider);
return () => {
const at = auth.policies.indexOf(provider);
if (at >= 0) auth.policies.splice(at, 1);
};
},
},
listSecondFactors: () => [
...auth.secondFactors.map((factor) => ({
id: factor.id,
pluginId,
})),
...(options.secondFactors ?? []),
],
},
users: {
@@ -1634,6 +1656,8 @@ export interface MockContextOptions {
/** See FakeContextOptions. */
loginAttemptLimit?: number;
/** See FakeContextOptions. */
secondFactors?: FakeContextOptions["secondFactors"];
/** See FakeContextOptions. */
locales?: Record<string, string>;
}
@@ -1753,6 +1777,7 @@ export function createMockCtx(
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
secondFactors: options.secondFactors,
locales: options.locales,
manifest: {
capabilities: options.capabilities ?? [],
@@ -2104,6 +2129,13 @@ export function createMockCtx(
require("users:impersonate");
return ctx.auth.issueUserToken(userId, tokenOptions);
},
policy: {
provide: (provider) => {
require("auth:policy");
return ctx.auth.policy.provide(provider);
},
},
listSecondFactors: () => ctx.auth.listSecondFactors(),
},
users: {
+23
View File
@@ -9,6 +9,7 @@
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
*/
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -121,6 +122,28 @@ export function pluginVitestConfig(
// Vite must be allowed to read it, and React must come from the plugin.
const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url)));
const server = { fs: { allow: [pluginDir, sdkDir] } };
// dedupe alone is not enough: react-dom or the testing library loaded from
// the linked SDK's own node_modules pull in a second React, and hooks then
// see no renderer.
if (!sdkDir.startsWith(pluginDir)) {
const pluginRequire = createRequire(path.join(pluginDir, "package.json"));
for (const name of [
"react",
"react-dom",
"@testing-library/react",
"react-i18next",
"i18next",
]) {
if (alias[name]) continue;
try {
alias[name] = path.dirname(
pluginRequire.resolve(`${name}/package.json`),
);
} catch {
// Not installed in the plugin: leave it to normal resolution.
}
}
}
const resolve = {
alias,
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
+4 -1
View File
@@ -22,7 +22,7 @@ AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<H
ApiKey (type): ApiKey
AuditLog (type): AuditLog
AuditLogFilters (type): AuditLogFilters
AuthEnrollmentSections (value): () => Element
AuthEnrollmentSections (value): ({ factorsOnly, }?: { factorsOnly?: boolean; }) => Element
AuthOverrideProtocol (type): string
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
@@ -161,6 +161,7 @@ HostRowFields (type): HostRowFields
HostSidebarFilterState (type): HostSidebarFilterState
HostSidebarPreferences (type): HostSidebarPreferences
HostTrayTrigger (type): HostTrayTrigger
HttpsSettings (type): HttpsSettings
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
@@ -416,6 +417,7 @@ getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPass
getHostPolicy (value): () => Promise<HostPolicy>
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
getHostTags (value): () => Promise<string[]>
getHttpsSettings (value): () => Promise<HttpsSettings>
getLiveHostStatus (value): (hostId: number) => StatusValue
getLogLevel (value): () => Promise<{ level: string; }>
getNotificationPrivateEndpoints (value): () => Promise<string[]>
@@ -573,6 +575,7 @@ updateCredential (value): (credentialId: number, credentialData: Record<string,
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }>
updateHttpsSettings (value): (forceHttps: boolean) => Promise<HttpsSettings>
updateLogLevel (value): (level: string) => Promise<void>
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>