mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user