mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: login policy hook for plugins and optional https redirect (#1584)
This commit is contained in:
1 parent
34a9c77114
commit
ad46f42ab1
53 files changed
+15250
-59
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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; }>
|
||||
|
||||
Reference in new issue
Block a user