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

+3
View File
@@ -16,6 +16,9 @@
- Plugins that need a newer Termix are hidden in Browse, with a toggle to show them
- Hosts can be managed from a config file through a plugin. Managed hosts are read only for everyone, but pins and order still work
- A plugin can block adding hosts by hand, so every host comes from one place
- Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor
- A user who must have a second factor but has none can sign in only to set one up
- Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working
### Changed
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `sessions` ADD `restriction` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `idle_timeout` int;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -526,6 +526,13 @@
"when": 1792374489419,
"tag": "0074_mature_puff_adder",
"breakpoints": true
},
{
"idx": 75,
"version": "5",
"when": 1792430772041,
"tag": "0075_session_policy",
"breakpoints": true
}
]
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "sessions" ADD COLUMN "restriction" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idle_timeout" integer;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -526,6 +526,13 @@
"when": 1792374488837,
"tag": "0074_bouncy_sersi",
"breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1792430770990,
"tag": "0075_session_policy",
"breakpoints": true
}
]
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `sessions` ADD `restriction` text(32);--> statement-breakpoint
ALTER TABLE `sessions` ADD `idle_timeout` integer;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -491,6 +491,13 @@
"when": 1792374488196,
"tag": "0069_normal_ozymandias",
"breakpoints": true
},
{
"idx": 70,
"version": "6",
"when": 1792430769899,
"tag": "0070_session_policy",
"breakpoints": true
}
]
}
+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; }>
+3
View File
@@ -170,6 +170,7 @@ const ALLOWED = [
"HostFeatureFields",
"HostLock",
"HostPolicy",
"HttpsSettings",
"HostFeatureFieldsProps",
"HostFolder",
"HostGroupKey",
@@ -438,6 +439,7 @@ const ALLOWED = [
"getHostSidebarPreferences",
"getHostTags",
"getLiveHostStatus",
"getHttpsSettings",
"getLogLevel",
"getNotificationPrivateEndpoints",
"getPanel",
@@ -605,6 +607,7 @@ const ALLOWED = [
"updateSSHHost",
"updateSecondFactorAfterExternalLogin",
"updateSessionTimeout",
"updateHttpsSettings",
"updateStatusCheckSettings",
"updateSyncSettings",
"uploadTlsCertificate",
+74 -17
View File
@@ -26,6 +26,12 @@ import {
isSecondFactorAfterExternalLoginEnabled,
} from "./core-auth.js";
import { findOrProvisionExternalUser } from "./provisioning.js";
import {
SECOND_FACTOR_SETUP,
evaluateAuthPolicy,
hasAuthPolicies,
rememberDeviceAllowed,
} from "./policy.js";
import {
getLoginMethod,
getSecondFactor,
@@ -122,12 +128,15 @@ export async function evaluateSecondFactors(
* governs external methods (SSO, LDAP) alone.
*/
function shouldRunSecondFactors(methodId: string): boolean {
ensureCoreLoginProviders();
const method = getLoginMethod(methodId);
if (!method?.external) return true;
if (!isExternalMethod(methodId)) return true;
return isSecondFactorAfterExternalLoginEnabled();
}
function isExternalMethod(methodId: string): boolean {
ensureCoreLoginProviders();
return !!getLoginMethod(methodId)?.external;
}
export const TRUST_DEVICE_COOKIE = "termix_trust_device";
const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -294,9 +303,40 @@ export async function runLogin(
`${context.methodId}_role_shared_credentials`,
);
const factors = shouldRunSecondFactors(context.methodId)
? await evaluateSecondFactors(user.id)
: { required: [], blocked: [] };
// A login policy may want a factor where core would skip it, so it always
// sees the full picture.
const runFactors = shouldRunSecondFactors(context.methodId);
const factors =
runFactors || hasAuthPolicies()
? await evaluateSecondFactors(user.id)
: { required: [], blocked: [] };
const trusted =
factors.required.length > 0 || hasAuthPolicies()
? await isTrustedDevice(req, user.id)
: false;
const policy = await evaluateAuthPolicy({
userId: user.id,
methodId: context.methodId,
external: isExternalMethod(context.methodId),
deviceType,
trustedDevice: trusted,
mfaSatisfied: !!identity.mfaSatisfied,
secondFactors: listSecondFactors().map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
enrolled: factors.required.some(
(required) =>
required.id === factor.id && required.pluginId === factor.pluginId,
),
})),
});
if (!runFactors && !policy.secondFactor) {
factors.required = [];
factors.blocked = [];
}
const rememberMe = context.rememberMe && policy.allowRememberDevice;
const skipByDevice =
trusted && policy.allowRememberDevice && policy.secondFactor !== "always";
if (factors.blocked.length > 0) {
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
operation: "second_factor_unavailable",
@@ -324,11 +364,7 @@ export async function runLogin(
const ssoClaims = { externalSession: identity.externalSession ?? null };
if (
factors.required.length > 0 &&
!identity.mfaSatisfied &&
!(await isTrustedDevice(req, user.id))
) {
if (factors.required.length > 0 && !identity.mfaSatisfied && !skipByDevice) {
const tempToken = await AuthManager.getInstance().generateJWTToken(
user.id,
{ pendingTOTP: true, expiresIn: "10m" },
@@ -337,14 +373,17 @@ export async function runLogin(
pendingLogins.set(pendingKey(tempToken), {
userId: user.id,
methodId: context.methodId,
rememberMe: context.rememberMe,
rememberMe,
allowRememberDevice: policy.allowRememberDevice,
maxSessionSeconds: policy.maxSessionSeconds,
idleTimeoutSeconds: policy.idleTimeoutSeconds,
...ssoClaims,
createdAt: Date.now(),
});
return {
kind: "second-factor",
tempToken,
rememberMe: context.rememberMe,
rememberMe,
factors: factors.required.map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
@@ -353,11 +392,22 @@ export async function runLogin(
};
}
// Required but nothing set up yet: a session that can only set one up.
const needsSetup =
!!policy.secondFactor &&
factors.required.length === 0 &&
!identity.mfaSatisfied &&
!skipByDevice;
const session = await issueSession(req, user, {
methodId: context.methodId,
rememberMe: context.rememberMe,
rememberMe,
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
longLivedForApps: identity.kind === "external",
longLivedForApps:
identity.kind === "external" && policy.allowRememberDevice,
maxAgeSeconds: policy.maxSessionSeconds,
idleTimeoutSeconds: policy.idleTimeoutSeconds,
restriction: needsSetup ? SECOND_FACTOR_SETUP : null,
...ssoClaims,
});
return { kind: "session", ...session };
@@ -510,10 +560,15 @@ export async function verifySecondFactorAndRespond(
}
loginRateLimiter.resetTOTPAttempts(user.id);
// A pending entry lost to a restart falls back to the current policy.
const allowRemember = lookup.pending
? lookup.pending.allowRememberDevice !== false
: rememberDeviceAllowed();
const rememberMe =
typeof req.body?.rememberMe === "boolean"
allowRemember &&
(typeof req.body?.rememberMe === "boolean"
? req.body.rememberMe
: (lookup.pending?.rememberMe ?? false);
: (lookup.pending?.rememberMe ?? false));
let trustToken: string | null = null;
if (rememberMe) {
@@ -545,6 +600,8 @@ export async function verifySecondFactorAndRespond(
methodId: pending?.methodId ?? "password",
rememberMe,
externalSession: pending?.externalSession ?? null,
maxAgeSeconds: pending?.maxSessionSeconds ?? null,
idleTimeoutSeconds: pending?.idleTimeoutSeconds ?? null,
});
if (trustToken && isNativeAppRequest(req)) {
+125
View File
@@ -0,0 +1,125 @@
/**
* Login policy from plugins through ctx.auth.policy.provide: idle timeout,
* session length, remember me and a required second factor. Core asks every
* provider and the strictest answer wins. A provider that throws at login
* refuses the login, since skipping a security rule is worse than a failed
* sign in. The idle timeout is stored on the session at login. Desktop
* loopback, sync link and plugin minted sessions are device credentials, not
* logins, so the policy never reaches them.
*/
import type {
PluginAuthPolicyDecision,
PluginAuthPolicyInput,
PluginAuthPolicyProvider,
} from "@termix-ssh/plugin-sdk/backend";
import { authLogger } from "../utils/logger.js";
import { LoginMethodError } from "./types.js";
/** sessions.restriction of a session that may only set up a second factor. */
export const SECOND_FACTOR_SETUP = "second-factor-setup";
interface Entry {
pluginId: string;
provider: PluginAuthPolicyProvider;
}
const providers = new Map<symbol, Entry>();
export function provideAuthPolicy(
pluginId: string,
provider: PluginAuthPolicyProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearAuthPolicies(): void {
providers.clear();
}
export function hasAuthPolicies(): boolean {
return providers.size > 0;
}
export interface AuthPolicy {
allowRememberDevice: boolean;
maxSessionSeconds: number | null;
idleTimeoutSeconds: number | null;
secondFactor: "always" | "new-device" | null;
}
const NO_POLICY: AuthPolicy = {
allowRememberDevice: true,
maxSessionSeconds: null,
idleTimeoutSeconds: null,
secondFactor: null,
};
function shortest(current: number | null, next: unknown): number | null {
if (typeof next !== "number" || !(next > 0)) return current;
const value = Math.floor(next);
return current === null ? value : Math.min(current, value);
}
function merge(policy: AuthPolicy, next: PluginAuthPolicyDecision): AuthPolicy {
let secondFactor = policy.secondFactor;
if (next.secondFactor === "always") secondFactor = "always";
else if (next.secondFactor === "new-device" && secondFactor !== "always") {
secondFactor = "new-device";
}
return {
allowRememberDevice:
policy.allowRememberDevice && next.allowRememberDevice !== false,
maxSessionSeconds: shortest(
policy.maxSessionSeconds,
next.maxSessionSeconds,
),
idleTimeoutSeconds: shortest(
policy.idleTimeoutSeconds,
next.idleTimeoutSeconds,
),
secondFactor,
};
}
export async function evaluateAuthPolicy(
input: PluginAuthPolicyInput,
): Promise<AuthPolicy> {
let policy = NO_POLICY;
for (const entry of providers.values()) {
let decision: PluginAuthPolicyDecision;
try {
decision = (await entry.provider.evaluate(input)) ?? {};
} catch (error) {
authLogger.warn("Login policy failed, refusing the login", {
operation: "auth_policy",
pluginId: entry.pluginId,
error: error instanceof Error ? error.message : String(error),
});
throw new LoginMethodError(
"Sign in is not available right now. Contact an admin.",
503,
"auth_policy_unavailable",
);
}
policy = merge(policy, decision);
}
return policy;
}
/** Whether the login page should offer remember me. */
export function rememberDeviceAllowed(): boolean {
for (const entry of providers.values()) {
try {
if (entry.provider.allowRememberDevice?.() === false) return false;
} catch {
return false;
}
}
return true;
}
+13 -1
View File
@@ -68,6 +68,11 @@ export interface IssueSessionOptions {
externalSession?: ExternalSessionRef | null;
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
longLivedForApps?: boolean;
/** Login policy cap on the session length. */
maxAgeSeconds?: number | null;
/** See SECOND_FACTOR_SETUP. */
restriction?: string | null;
idleTimeoutSeconds?: number | null;
}
export interface IssuedSession {
@@ -93,6 +98,9 @@ export async function issueSession(
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
externalSession: options.externalSession ?? null,
maxAgeSeconds: options.maxAgeSeconds ?? null,
restriction: options.restriction ?? null,
idleTimeoutSeconds: options.idleTimeoutSeconds ?? null,
});
if (options.rateLimitUsername) {
@@ -123,11 +131,14 @@ export async function issueSession(
method: options.methodId,
});
const maxAge =
let maxAge =
options.longLivedForApps &&
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
? THIRTY_DAYS_MS
: await sessionCookieMaxAge(options.rememberMe);
if (options.maxAgeSeconds) {
maxAge = Math.min(maxAge, options.maxAgeSeconds * 1000);
}
return {
token,
@@ -142,6 +153,7 @@ export async function issueSession(
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
user.id,
),
...(options.restriction ? { restriction: options.restriction } : {}),
...(isNativeAppRequest(req) ? { token } : {}),
},
};
+4
View File
@@ -35,5 +35,9 @@ export interface PendingLogin {
methodId: string;
rememberMe: boolean;
externalSession?: ExternalSessionRef | null;
/** Login policy that applied when the login paused. */
allowRememberDevice?: boolean;
maxSessionSeconds?: number | null;
idleTimeoutSeconds?: number | null;
createdAt: number;
}
+2
View File
@@ -73,6 +73,7 @@ import {
} from "../plugins/user-data.js";
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
import { httpsPolicyMiddleware } from "../tls/https-policy.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
@@ -105,6 +106,7 @@ const __dirname = path.dirname(__filename);
const app = express();
app.set("trust proxy", "loopback");
app.use(httpsPolicyMiddleware);
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
+2
View File
@@ -1021,6 +1021,8 @@ const migrateSchema = () => {
}
enforceUniqueUsernames();
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
addColumnIfNotExists("sessions", "restriction", "TEXT");
addColumnIfNotExists("sessions", "idle_timeout", "INTEGER");
addColumnIfNotExists("ssh_data", "name", "TEXT");
addColumnIfNotExists("ssh_data", "folder", "TEXT");
+4
View File
@@ -59,6 +59,10 @@ export const sessions = mysqlTable(
lastActiveAt: text("last_active_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
// Set while the session may only set up a second factor.
restriction: text("restriction"),
// Seconds without a request before the session ends, from the login policy.
idleTimeout: int("idle_timeout"),
},
// Listing a user's devices, and the startup sweep of expired rows.
(table) => [
+4
View File
@@ -59,6 +59,10 @@ export const sessions = pgTable(
lastActiveAt: text("last_active_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
// Set while the session may only set up a second factor.
restriction: text("restriction"),
// Seconds without a request before the session ends, from the login policy.
idleTimeout: integer("idle_timeout"),
},
// Listing a user's devices, and the startup sweep of expired rows.
(table) => [
+4
View File
@@ -47,6 +47,10 @@ export const sessions = sqliteTable(
lastActiveAt: text("last_active_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
// Set while the session may only set up a second factor.
restriction: text("restriction", { length: 32 }),
// Seconds without a request before the session ends, from the login policy.
idleTimeout: integer("idle_timeout"),
},
// Listing a user's devices, and the startup sweep of expired rows.
(table) => [
@@ -69,6 +69,14 @@ export class SessionRepository {
return true;
}
async clearRestrictionForUser(userId: string): Promise<void> {
await this.context.drizzle
.update(sessions)
.set({ restriction: null })
.where(eq(sessions.userId, userId));
await this.afterWrite();
}
async updateToken(
id: string,
jwtToken: string,
+6 -2
View File
@@ -25,6 +25,7 @@ import {
} from "../../auth/registry.js";
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
import { rememberDeviceAllowed } from "../../auth/policy.js";
export interface PublicLoginMethod {
id: string;
@@ -243,7 +244,7 @@ export function registerAuthRoutes(router: Router): void {
* /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.
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only, plus rememberDevice, false when the login policy turned remember me off.
* tags:
* - Auth
* responses:
@@ -252,7 +253,10 @@ export function registerAuthRoutes(router: Router): void {
*/
router.get("/auth/methods", async (_req, res) => {
try {
res.json({ methods: await listPublicLoginMethods() });
res.json({
methods: await listPublicLoginMethods(),
rememberDevice: rememberDeviceAllowed(),
});
} catch (error) {
authLogger.error("Failed to list login methods", error);
res.status(500).json({ error: "Failed to list login methods" });
@@ -15,6 +15,8 @@ import {
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
import { isForceHttpsEnabled, setForceHttps } from "../../tls/https-policy.js";
import { getTlsConfig } from "../../tls/tls-service.js";
async function getAdminActor(
userId: string | undefined,
@@ -187,6 +189,90 @@ export function registerUserSettingsRoutes(
}
});
/**
* @openapi
* /users/https-settings:
* get:
* summary: Get the HTTPS redirect setting (admin only)
* description: Whether plain HTTP is redirected to HTTPS with HSTS, and whether HTTPS is on at all. The redirect only applies while HTTPS is on.
* tags:
* - Users
* responses:
* 200:
* description: forceHttps and tlsEnabled.
* 403:
* description: Not authorized.
*/
router.get("/https-settings", authenticateJWT, async (req, res) => {
try {
if (!(await getAdminActor((req as AuthenticatedRequest).userId))) {
return res.status(403).json({ error: "Not authorized" });
}
res.json({
forceHttps: await isForceHttpsEnabled(),
tlsEnabled: getTlsConfig().enabled,
});
} catch (err) {
authLogger.error("Failed to get HTTPS settings", err);
res.status(500).json({ error: "Failed to get HTTPS settings" });
}
});
/**
* @openapi
* /users/https-settings:
* patch:
* summary: Update the HTTPS redirect setting (admin only)
* description: Turns the redirect from HTTP to HTTPS and the HSTS header on or off. Off by default so reverse proxies keep working.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* forceHttps:
* type: boolean
* responses:
* 200:
* description: Setting updated.
* 400:
* description: Invalid value.
* 403:
* description: Not authorized.
*/
router.patch("/https-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { forceHttps } = req.body ?? {};
if (typeof forceHttps !== "boolean") {
return res.status(400).json({ error: "forceHttps must be a boolean" });
}
await setForceHttps(forceHttps);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_force_https",
resourceType: "setting",
details: JSON.stringify({ forceHttps }),
ipAddress,
userAgent,
success: true,
});
res.json({ forceHttps, tlsEnabled: getTlsConfig().enabled });
} catch (err) {
authLogger.error("Failed to set HTTPS settings", err);
res.status(500).json({ error: "Failed to set HTTPS settings" });
}
});
/**
* GET/PATCH a comma-list of private hosts an outbound feature may reach.
* One route pair per private endpoint allowlist, each with its own setting key.
+25 -1
View File
@@ -61,6 +61,7 @@ import {
syncSharedCredentialsForUserRoles,
} from "../../auth/session-issuer.js";
import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js";
import { evaluateAuthPolicy } from "../../auth/policy.js";
const authManager = AuthManager.getInstance();
@@ -420,9 +421,21 @@ router.post("/proxy-login", async (req, res) => {
userRecord.id,
"trusted_proxy_login_role_shared_credentials",
);
// The proxy owns second factors, so only the session length applies.
const policy = await evaluateAuthPolicy({
userId: userRecord.id,
methodId: "proxy",
external: true,
deviceType: deviceInfo.type,
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [],
});
const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
maxAgeSeconds: policy.maxSessionSeconds,
idleTimeoutSeconds: policy.idleTimeoutSeconds,
});
const payload = await authManager.verifyJWTToken(token);
const { ipAddress, userAgent } = getRequestMeta(req);
@@ -443,7 +456,16 @@ router.post("/proxy-login", async (req, res) => {
});
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req))
.cookie(
"jwt",
token,
authManager.getSecureCookieOptions(
req,
policy.maxSessionSeconds
? Math.min(24 * 60 * 60 * 1000, policy.maxSessionSeconds * 1000)
: undefined,
),
)
.json({
enabled: true,
success: true,
@@ -614,6 +636,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
user.id,
),
linked: await describeDesktopLink(user.id),
// "second-factor-setup" while the login policy wants a factor set up.
restriction: (req as AuthenticatedRequest).sessionRestriction ?? null,
});
} catch (err) {
authLogger.error("Failed to get username", err);
+27
View File
@@ -38,9 +38,11 @@ import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interacti
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import {
getLoginMethod,
listSecondFactors,
registerLoginMethod,
registerSecondFactor,
} from "../auth/registry.js";
import { provideAuthPolicy } from "../auth/policy.js";
import type { VerifiedIdentity } from "../auth/types.js";
import type {
MutableConnectConfig,
@@ -856,6 +858,8 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
getActor() === userId ? getActorSessionId() : undefined,
);
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
// The session that set it up may have been a setup only one.
await createCurrentSessionRepository().clearRestrictionForUser(userId);
await audit("auth_factor_enrolled", `${factorId} for ${userId}`, {
success: true,
});
@@ -934,5 +938,28 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
},
issueUserToken: (userId, options) =>
issueUserToken(manifest, userId, options),
policy: {
provide: (provider) => {
if (!declared.includes("auth:policy")) {
throw capabilityRefused(pluginId, "auth:policy");
}
const remove = provideAuthPolicy(pluginId, provider);
void audit("auth_policy_provide", `${pluginId} sets login rules`, {
success: true,
});
const drop = bag.add(remove, "login policy");
return () => {
drop();
remove();
};
},
},
listSecondFactors: () =>
listSecondFactors().map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
})),
};
}
@@ -97,6 +97,8 @@ const { getPasswordLoginStatus } = await import("../../auth/core-auth.js");
const { registerLoginMethod, registerSecondFactor } =
await import("../../auth/registry.js");
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
const { provideAuthPolicy, clearAuthPolicies, SECOND_FACTOR_SETUP } =
await import("../../auth/policy.js");
const PASSWORD = "correct-horse";
// Stands in for the totp plugin's factor: core only sees the registration.
@@ -137,6 +139,7 @@ beforeEach(() => {
delete process.env.ALLOW_PASSWORD_LOGIN;
disposeTotp?.();
registerTotpFixture();
clearAuthPolicies();
});
async function passwordLogin(body: Record<string, unknown>, headers = {}) {
@@ -891,3 +894,219 @@ describe("lockout guard", () => {
});
});
});
describe("login policy", () => {
function enrolTotp() {
h.state.factors.push({ userId: "u1", pluginId: "totp", factorId: "totp" });
}
function trustedLogin(token: string) {
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
cookies: { [TRUST_DEVICE_COOKIE]: token },
});
return runLogin(
req as never,
{ kind: "user", userId: "u1" },
{ methodId: "password", rememberMe: true },
);
}
it("hands the provider the user's factors and the device", async () => {
addUser();
enrolTotp();
const evaluate = vi.fn(() => ({}));
provideAuthPolicy("policy", { evaluate });
await passwordLogin({ username: "alice", password: PASSWORD });
expect(evaluate).toHaveBeenCalledWith({
userId: "u1",
methodId: "password",
external: false,
deviceType: "web",
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [{ id: "totp", pluginId: "totp", enrolled: true }],
});
});
it("turns remember me off and caps the session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 600 }),
});
const res = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 600 }),
);
expect((res.cookies[0].options as { maxAge: number }).maxAge).toBe(
600 * 1000,
);
});
it("stores the idle timeout on the session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ idleTimeoutSeconds: 900 }),
});
provideAuthPolicy("other", {
evaluate: () => ({ idleTimeoutSeconds: 300 }),
});
await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ idleTimeoutSeconds: 300 }),
);
});
it("ignores a remembered device when remember me is off", async () => {
addUser();
enrolTotp();
const token = "c".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false }),
});
const result = await trustedLogin(token);
expect(result.kind).toBe("second-factor");
});
it("asks for the factor on a remembered device with always, not with new-device", async () => {
addUser();
enrolTotp();
const token = "d".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
const remove = provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
expect((await trustedLogin(token)).kind).toBe("second-factor");
remove();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "new-device" }),
});
expect((await trustedLogin(token)).kind).toBe("session");
});
it("gives a user without a factor a setup only session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ restriction: SECOND_FACTOR_SETUP }),
);
expect(res.body).toMatchObject({ restriction: SECOND_FACTOR_SETUP });
});
it("lets a verified passkey satisfy a required factor", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
);
expect(result.kind).toBe("session");
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ restriction: null }),
);
});
it("runs factors for SSO logins when the policy requires one", async () => {
const dispose = registerLoginMethod({
id: "oidc",
pluginId: "sso",
labelKey: "loginWithSso",
kind: "redirect",
external: true,
});
try {
addUser({ passwordHash: "" });
enrolTotp();
h.state.identities.push({
id: 1,
userId: "u1",
providerId: "3",
subject: "sub-1",
email: null,
});
provideAuthPolicy("policy", {
evaluate: (input) => (input.external ? { secondFactor: "always" } : {}),
});
const result = await runLogin(
fakeRequest() as never,
{
kind: "external",
provider: "3",
subject: "sub-1",
email: null,
name: "Alice",
} as never,
{ methodId: "oidc", rememberMe: false },
);
expect(result.kind).toBe("second-factor");
} finally {
dispose();
}
});
it("keeps the policy for the factor step: no trusted device, same cap", async () => {
addUser();
enrolTotp();
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 1200 }),
});
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: TOTP_CODE,
rememberMe: true,
},
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(200);
expect(res.cookies.map((c) => c.name)).toEqual(["jwt"]);
expect(h.state.trustedAdded).toEqual([]);
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
"u1",
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 1200 }),
);
});
it("refuses the login when a provider throws", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => {
throw new Error("boom");
},
});
await expect(
runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1" },
{ methodId: "password", rememberMe: false },
),
).rejects.toMatchObject({ status: 503, code: "auth_policy_unavailable" });
expect(h.state.audits).toEqual([]);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
authLogger: { warn: vi.fn() },
}));
const {
clearAuthPolicies,
evaluateAuthPolicy,
hasAuthPolicies,
provideAuthPolicy,
rememberDeviceAllowed,
} = await import("../../auth/policy.js");
const input = {
userId: "u1",
methodId: "password",
external: false,
deviceType: "web" as const,
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [],
};
beforeEach(() => clearAuthPolicies());
describe("auth policy", () => {
it("changes nothing without a provider", async () => {
expect(hasAuthPolicies()).toBe(false);
expect(await evaluateAuthPolicy(input)).toEqual({
allowRememberDevice: true,
maxSessionSeconds: null,
idleTimeoutSeconds: null,
secondFactor: null,
});
expect(rememberDeviceAllowed()).toBe(true);
});
it("takes the strictest answer from every provider", async () => {
provideAuthPolicy("a", {
evaluate: () => ({
maxSessionSeconds: 3600,
idleTimeoutSeconds: 900,
secondFactor: "new-device",
}),
});
provideAuthPolicy("b", {
evaluate: async () => ({
allowRememberDevice: false,
maxSessionSeconds: 7200,
idleTimeoutSeconds: 300,
secondFactor: "always",
}),
allowRememberDevice: () => false,
});
expect(await evaluateAuthPolicy(input)).toEqual({
allowRememberDevice: false,
maxSessionSeconds: 3600,
idleTimeoutSeconds: 300,
secondFactor: "always",
});
expect(rememberDeviceAllowed()).toBe(false);
});
it("ignores zero and negative limits", async () => {
provideAuthPolicy("a", {
evaluate: () => ({ maxSessionSeconds: 0, idleTimeoutSeconds: -5 }),
});
expect(await evaluateAuthPolicy(input)).toMatchObject({
maxSessionSeconds: null,
idleTimeoutSeconds: null,
});
});
it("refuses the login when a provider throws, until it is removed", async () => {
const remove = provideAuthPolicy("a", {
evaluate: () => {
throw new Error("boom");
},
});
await expect(evaluateAuthPolicy(input)).rejects.toMatchObject({
status: 503,
code: "auth_policy_unavailable",
});
remove();
await expect(evaluateAuthPolicy(input)).resolves.toMatchObject({
secondFactor: null,
});
});
it("hides remember me when the hint throws", () => {
provideAuthPolicy("a", {
evaluate: () => ({}),
allowRememberDevice: () => {
throw new Error("boom");
},
});
expect(rememberDeviceAllowed()).toBe(false);
});
});
@@ -26,6 +26,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null,
getBoolean: async (key: string, fallback: boolean) =>
key in state.settings ? state.settings[key] === "true" : fallback,
set: async (key: string, value: string) => {
state.settings[key] = value;
},
@@ -39,6 +41,12 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
vi.mock("../../../tls/tls-service.js", () => ({
getTlsConfig: () => ({ enabled: true, port: 8443 }),
}));
const { resetHttpsPolicyForTests } =
await import("../../../tls/https-policy.js");
const { registerUserSettingsRoutes } =
await import("../../../database/routes/user-settings-routes.js");
@@ -59,6 +67,7 @@ function app() {
beforeEach(() => {
state.settings = {};
state.admin = true;
resetHttpsPolicyForTests();
});
describe("/users/host-defaults", () => {
@@ -77,3 +86,35 @@ describe("/users/host-defaults", () => {
expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true);
});
});
describe("/users/https-settings", () => {
it("is off by default and saves a change", async () => {
const read = await request(app()).get("/users/https-settings");
expect(read.body).toEqual({ forceHttps: false, tlsEnabled: true });
const write = await request(app())
.patch("/users/https-settings")
.send({ forceHttps: true });
expect(write.status).toBe(200);
expect(state.settings.force_https).toBe("true");
const after = await request(app()).get("/users/https-settings");
expect(after.body.forceHttps).toBe(true);
});
it("is admin only and checks the value", async () => {
const bad = await request(app())
.patch("/users/https-settings")
.send({ forceHttps: "yes" });
expect(bad.status).toBe(400);
state.admin = false;
expect((await request(app()).get("/users/https-settings")).status).toBe(
403,
);
const write = await request(app())
.patch("/users/https-settings")
.send({ forceHttps: true });
expect(write.status).toBe(403);
expect(state.settings.force_https).toBeUndefined();
});
});
@@ -12,6 +12,7 @@ const h = vi.hoisted(() => ({
sessionId: undefined as string | undefined,
revoked: [] as Array<{ userId: string; except?: string }>,
trustedCleared: [] as string[],
unrestricted: [] as string[],
policyError: null as Error | null,
logins: [] as Array<{ hostId: number; outcome: unknown }>,
connectError: null as Error | null,
@@ -126,6 +127,9 @@ vi.mock("../../database/repositories/factory.js", () => ({
h.revoked.push({ userId, except });
return 0;
},
clearRestrictionForUser: async (userId: string) => {
h.unrestricted.push(userId);
},
}),
createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async (userId: string) => {
@@ -145,6 +149,8 @@ const { getLoginMethod, getSecondFactor } =
await import("../../auth/registry.js");
const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend");
const { hasAuthPolicies, clearAuthPolicies } =
await import("../../auth/policy.js");
function manifest(capabilities: string[], auth: Record<string, string[]> = {}) {
return {
@@ -655,6 +661,7 @@ describe("ctx.auth", () => {
h.factors = [];
h.revoked = [];
h.trustedCleared = [];
h.unrestricted = [];
h.policyError = null;
h.actor = "user-1";
h.sessionId = "session-a";
@@ -666,10 +673,65 @@ describe("ctx.auth", () => {
await auth.recordEnrollment("user-1", "pin");
expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]);
expect(h.trustedCleared).toEqual(["user-1"]);
expect(h.unrestricted).toEqual(["user-1"]);
h.actor = undefined;
h.sessionId = undefined;
});
it("needs auth:policy for a login policy and drops it on deactivate", async () => {
clearAuthPolicies();
const plain = createPluginAuth({
manifest: manifest(["auth:provide"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
expect(() => plain.policy.provide({ evaluate: () => ({}) })).toThrow(
PluginCapabilityError,
);
const bag = new DisposableBag("fixture");
const audit = vi.fn(async () => {});
const auth = createPluginAuth({
manifest: manifest(["auth:policy"]),
bag,
audit,
});
const remove = auth.policy.provide({ evaluate: () => ({}) });
expect(hasAuthPolicies()).toBe(true);
expect(audit).toHaveBeenCalledWith(
"auth_policy_provide",
"fixture sets login rules",
{ success: true },
);
remove();
expect(hasAuthPolicies()).toBe(false);
auth.policy.provide({ evaluate: () => ({}) });
await bag.disposeAll();
expect(hasAuthPolicies()).toBe(false);
});
it("lists second factors from every plugin", async () => {
h.granted = new Set(["auth:provide"]);
const bag = new DisposableBag("fixture");
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
bag,
audit: vi.fn(async () => {}),
});
auth.registerSecondFactor({
id: "pin",
labelKey: "pin",
isEnrolled: async () => false,
verify: async () => true,
});
expect(auth.listSecondFactors()).toContainEqual({
id: "pin",
pluginId: "fixture",
});
await bag.disposeAll();
});
it("refuses enrolment when core policy forbids second factors", async () => {
h.granted = new Set(["auth:provide"]);
h.factors = [];
@@ -71,6 +71,7 @@ const UNGATED: Record<string, string> = {
"rbac.hasFor": "a yes/no about a role permission, grants nothing",
"rbac.require": "route middleware for the plugin's own permissions",
"capabilities.has": "a yes/no about the plugin's own grants",
"auth.listSecondFactors": "ids of the second factors, no user data",
"disposables.add": "the plugin's own cleanup",
"schedule.every": "the plugin's own timers",
"schedule.after": "the plugin's own timers",
+108
View File
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
settings: new Map<string, string>(),
tlsEnabled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
getBoolean: async (key: string, fallback: boolean) =>
h.settings.has(key) ? h.settings.get(key) === "true" : fallback,
set: async (key: string, value: string) => {
h.settings.set(key, value);
},
}),
}));
vi.mock("../../tls/tls-service.js", () => ({
getTlsConfig: () => ({ enabled: h.tlsEnabled, port: 8443 }),
}));
const { httpsPolicyMiddleware, setForceHttps, resetHttpsPolicyForTests } =
await import("../../tls/https-policy.js");
function run(
path: string,
{ secure = false, headers = {} as Record<string, string> } = {},
) {
const all: Record<string, string> = { host: "termix.lan:8080", ...headers };
const req = {
secure,
path,
originalUrl: path,
url: path,
get: (name: string) => all[name.toLowerCase()],
};
const res = {
headers: {} as Record<string, string>,
redirected: null as null | { status: number; url: string },
setHeader(name: string, value: string) {
res.headers[name] = value;
},
redirect(status: number, url: string) {
res.redirected = { status, url };
},
};
const next = vi.fn();
return httpsPolicyMiddleware(req as never, res as never, next).then(() => ({
res,
next,
}));
}
beforeEach(() => {
h.settings.clear();
h.tlsEnabled = true;
resetHttpsPolicyForTests();
});
describe("https policy", () => {
it("is off by default", async () => {
const { res, next } = await run("/users/me");
expect(next).toHaveBeenCalled();
expect(res.redirected).toBeNull();
expect(res.headers).toEqual({});
});
it("does nothing while HTTPS is off, even when turned on", async () => {
await setForceHttps(true);
h.tlsEnabled = false;
const { res, next } = await run("/users/me");
expect(next).toHaveBeenCalled();
expect(res.redirected).toBeNull();
});
it("redirects plain HTTP to the HTTPS port", async () => {
await setForceHttps(true);
const { res, next } = await run("/users/me?x=1");
expect(next).not.toHaveBeenCalled();
expect(res.redirected).toEqual({
status: 308,
url: "https://termix.lan:8443/users/me?x=1",
});
});
it("trusts X-Forwarded-Proto and sends HSTS over HTTPS", async () => {
await setForceHttps(true);
const forwarded = await run("/users/me", {
headers: { "x-forwarded-proto": "https, http" },
});
expect(forwarded.next).toHaveBeenCalled();
expect(forwarded.res.headers["Strict-Transport-Security"]).toBe(
"max-age=31536000",
);
const direct = await run("/users/me", { secure: true });
expect(direct.next).toHaveBeenCalled();
expect(direct.res.headers["Strict-Transport-Security"]).toBeDefined();
});
it("leaves certificate checks and health probes on HTTP", async () => {
await setForceHttps(true);
for (const path of ["/.well-known/acme-challenge/abc", "/health"]) {
const { res, next } = await run(path);
expect(next).toHaveBeenCalled();
expect(res.redirected).toBeNull();
}
});
});
@@ -8,6 +8,8 @@ const encryptionKey = crypto.randomBytes(32);
const mocks = vi.hoisted(() => ({
hasUserDEK: vi.fn(() => false),
adoptRecoveredDEK: vi.fn(async () => {}),
revoke: vi.fn(async () => true),
created: [] as Array<Record<string, unknown>>,
}));
vi.mock("../../database/db/index.js", () => ({
@@ -19,10 +21,27 @@ vi.mock("../../database/db/index.js", () => ({
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentSessionRepository: () => ({
findById: async (id: string) =>
id === "expired"
? { id, expiresAt: new Date(Date.now() - 1000).toISOString() }
: { id },
findById: async (id: string) => {
const minutesAgo = (n: number) =>
new Date(Date.now() - n * 60_000).toISOString();
if (id === "expired") {
return { id, expiresAt: new Date(Date.now() - 1000).toISOString() };
}
if (id === "idle") {
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(11) };
}
if (id === "active") {
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(5) };
}
if (id === "setup") return { id, restriction: "second-factor-setup" };
return { id };
},
create: async (row: Record<string, unknown>) => {
mocks.created.push(row);
return row;
},
revoke: mocks.revoke,
touch: async () => true,
}),
createCurrentUserRepository: () => ({}),
createCurrentApiKeyRepository: () => ({}),
@@ -54,7 +73,9 @@ vi.mock("../../utils/system-crypto.js", () => ({
},
}));
const { AuthManager } = await import("../../utils/auth-manager.js");
const { AuthManager, allowsSetupSession } =
await import("../../utils/auth-manager.js");
const { registerSecondFactor } = await import("../../auth/registry.js");
const authManager = AuthManager.getInstance();
function makeLegacyDataKeyWrap(
@@ -74,6 +95,8 @@ function makeLegacyDataKeyWrap(
}
beforeEach(() => {
mocks.created.length = 0;
mocks.revoke.mockClear();
mocks.hasUserDEK.mockReset().mockReturnValue(false);
mocks.adoptRecoveredDEK.mockReset().mockResolvedValue(undefined);
});
@@ -209,3 +232,97 @@ describe("AuthManager token handling", () => {
).toBeNull();
});
});
describe("login policy on sessions", () => {
const sign = (sessionId: string) =>
jwt.sign({ userId: "user-1", sessionId }, jwtSecret, { expiresIn: "1h" });
it("caps the lifetime and stores the idle timeout and restriction", async () => {
const token = await authManager.generateJWTToken("user-1", {
rememberMe: true,
deviceType: "web",
deviceInfo: "Firefox",
maxAgeSeconds: 600,
idleTimeoutSeconds: 300,
restriction: "second-factor-setup",
});
const payload = jwt.decode(token) as { iat: number; exp: number };
expect(payload.exp - payload.iat).toBe(600);
expect(mocks.created[0]).toMatchObject({
idleTimeout: 300,
restriction: "second-factor-setup",
});
const lifetime =
new Date(mocks.created[0].expiresAt as string).getTime() -
new Date(mocks.created[0].createdAt as string).getTime();
expect(lifetime).toBe(600_000);
});
it("ends a session idle past its timeout and keeps an active one", async () => {
expect(await authManager.verifyJWTToken(sign("idle"))).toBeNull();
expect(mocks.revoke).toHaveBeenCalledWith("idle");
expect(await authManager.verifyJWTToken(sign("active"))).not.toBeNull();
});
it("only lets a setup session reach setup routes", async () => {
const middleware = authManager.createAuthMiddleware();
const call = async (url: string) => {
const res = {
statusCode: 200,
body: undefined as unknown,
status(code: number) {
res.statusCode = code;
return res;
},
json(body: unknown) {
res.body = body;
return res;
},
clearCookie() {
return res;
},
};
const next = vi.fn();
await middleware(
{
cookies: { jwt: sign("setup") },
headers: {},
originalUrl: url,
url,
} as never,
res as never,
next,
);
return { res, next };
};
const blocked = await call("/host/db/host");
expect(blocked.res.statusCode).toBe(403);
expect(blocked.res.body).toMatchObject({
code: "SECOND_FACTOR_SETUP_REQUIRED",
});
expect(blocked.next).not.toHaveBeenCalled();
const me = await call("/users/me");
expect(me.next).toHaveBeenCalled();
});
it("allows second factor plugins' routes and nothing else", () => {
const dispose = registerSecondFactor({
id: "key",
pluginId: "keys",
labelKey: "key",
isEnrolled: async () => false,
verify: async () => true,
});
try {
expect(allowsSetupSession("/plugin-api/keys/register")).toBe(true);
expect(allowsSetupSession("/plugin-api/other/thing")).toBe(false);
expect(allowsSetupSession("/users/logout")).toBe(true);
expect(allowsSetupSession("/users/me/token")).toBe(false);
expect(allowsSetupSession("/credentials")).toBe(false);
} finally {
dispose();
}
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Admin option force_https: when HTTPS is on, plain HTTP requests are sent to
* the HTTPS port and HTTPS responses carry HSTS. Off by default, so a reverse
* proxy that terminates TLS itself keeps working. A request counts as HTTPS
* when it came in over TLS or a proxy says so in X-Forwarded-Proto.
*/
import type { NextFunction, Request, Response } from "express";
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { getTlsConfig } from "./tls-service.js";
export const FORCE_HTTPS_KEY = "force_https";
const HSTS = "max-age=31536000";
// Certificate checks and health probes must still work over plain HTTP.
const EXEMPT = [/^\/\.well-known\/acme-challenge\//, /^\/health$/];
let forceHttps: boolean | null = null;
export async function isForceHttpsEnabled(): Promise<boolean> {
if (forceHttps === null) {
forceHttps = await createCurrentSettingsRepository()
.getBoolean(FORCE_HTTPS_KEY, false)
.catch(() => false);
}
return forceHttps;
}
export async function setForceHttps(enabled: boolean): Promise<void> {
await createCurrentSettingsRepository().set(
FORCE_HTTPS_KEY,
enabled ? "true" : "false",
);
forceHttps = enabled;
}
/** Test seam. */
export function resetHttpsPolicyForTests(): void {
forceHttps = null;
}
export function isSecureRequest(req: Request): boolean {
if (req.secure) return true;
const forwarded = req.get("x-forwarded-proto");
return forwarded?.split(",")[0].trim().toLowerCase() === "https";
}
function httpsUrl(req: Request, port: number): string {
const host = (req.get("host") || "localhost").replace(/:\d+$/, "");
const suffix = port === 443 ? "" : `:${port}`;
return `https://${host}${suffix}${req.originalUrl || req.url}`;
}
export async function httpsPolicyMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const tls = getTlsConfig();
if (!tls.enabled || !(await isForceHttpsEnabled())) return next();
if (isSecureRequest(req)) {
res.setHeader("Strict-Transport-Security", HSTS);
return next();
}
if (EXEMPT.some((pattern) => pattern.test(req.path))) return next();
res.redirect(308, httpsUrl(req, tls.port));
} catch (error) {
next(error);
}
}
+72
View File
@@ -16,6 +16,7 @@ import { nanoid } from "nanoid";
import { inArray, isNotNull } from "drizzle-orm";
import type { ExternalSessionRef } from "../auth/types.js";
import { listSecondFactors } from "../auth/registry.js";
import { SECOND_FACTOR_SETUP } from "../auth/policy.js";
import type { DeviceType } from "./user-agent-parser.js";
import { getDb } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js";
@@ -50,6 +51,8 @@ interface JWTPayload {
* from the token, so a refreshed token stays marked.
*/
pluginSession?: boolean;
/** sessions.restriction, read from the row on verify. */
restriction?: string | null;
dataKeyWrap?: WrappedDataKey;
iat?: number;
exp?: number;
@@ -69,6 +72,7 @@ interface AuthenticatedRequest extends Request {
pendingTOTP?: boolean;
dataKey?: Buffer;
actingAdminUserId?: string;
sessionRestriction?: string | null;
}
interface RequestWithHeaders extends Request {
@@ -129,6 +133,28 @@ export function allowsPluginSession(path: string): boolean {
return !listSecondFactors().some((factor) => factor.pluginId === plugin);
}
// What a session that must set up a second factor first may call: who am I,
// sign out, the login and factor routes, plugin bundles, and the plugins that
// provide a second factor.
const SETUP_SESSION_ALLOWLIST = [
/^\/users\/(me|logout)$/,
/^\/users\/auth\//,
/^\/user-preferences\/look(\/|$)/,
/^\/plugins(\/|$)/,
/^\/plugin-assets\//,
];
/** Whether a session restricted to second factor setup may call this path. */
export function allowsSetupSession(path: string): boolean {
if (SETUP_SESSION_ALLOWLIST.some((pattern) => pattern.test(path))) {
return true;
}
const plugin = PLUGIN_API_PATH.exec(path)?.[1];
return (
!!plugin && listSecondFactors().some((factor) => factor.pluginId === plugin)
);
}
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
/** Set by the plugin runtime: whether a running plugin opted in. */
@@ -336,6 +362,11 @@ class AuthManager {
deviceType?: DeviceType;
deviceInfo?: string;
externalSession?: ExternalSessionRef | null;
/** Caps the lifetime, from the login policy. */
maxAgeSeconds?: number | null;
/** Stored on the session, see SECOND_FACTOR_SETUP. */
restriction?: string | null;
idleTimeoutSeconds?: number | null;
} = {},
): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret();
@@ -355,6 +386,12 @@ class AuthManager {
} else if (!expiresIn) {
expiresIn = defaultExpiry;
}
if (
options.maxAgeSeconds &&
options.maxAgeSeconds * 1000 < this.parseExpiresIn(expiresIn)
) {
expiresIn = `${Math.floor(options.maxAgeSeconds)}s`;
}
const payload: JWTPayload = { userId };
if (options.pendingTOTP) {
@@ -385,6 +422,8 @@ class AuthManager {
createdAt,
expiresAt,
lastActiveAt: createdAt,
restriction: options.restriction ?? null,
idleTimeout: options.idleTimeoutSeconds ?? null,
});
} catch (error) {
databaseLogger.error("Failed to create session", error, {
@@ -500,6 +539,19 @@ class AuthManager {
}
payload.pluginSession =
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
payload.restriction = sessionRecord.restriction ?? null;
if (this.idleExpired(sessionRecord)) {
databaseLogger.info("Session ended after idle timeout", {
operation: "session_idle_timeout",
sessionId: payload.sessionId,
userId: payload.userId,
});
await createCurrentSessionRepository()
.revoke(payload.sessionId)
.catch(() => {});
return null;
}
await this.migrateDataKeyFromPayload(payload);
} catch (dbError) {
@@ -531,6 +583,15 @@ class AuthManager {
}
}
private idleExpired(session: {
idleTimeout: number | null;
lastActiveAt: string;
}): boolean {
if (!session.idleTimeout) return false;
const idleMs = Date.now() - new Date(session.lastActiveAt).getTime();
return idleMs > session.idleTimeout * 1000;
}
async refreshSessionToken(
userId: string,
sessionId: string,
@@ -921,6 +982,7 @@ class AuthManager {
authReq.userId = payload.userId;
authReq.sessionId = payload.sessionId;
authReq.pendingTOTP = payload.pendingTOTP;
authReq.sessionRestriction = payload.restriction ?? null;
if (authReq.headers[ADMIN_TARGET_USER_HEADER]) {
const handled = await this.applyAdminImpersonation(
@@ -1022,6 +1084,16 @@ class AuthManager {
payload: JWTPayload,
): boolean {
const path = (req.originalUrl || req.url || "").split("?")[0];
if (
payload.restriction === SECOND_FACTOR_SETUP &&
!allowsSetupSession(path)
) {
res.status(403).json({
error: "Set up a second factor before using Termix",
code: "SECOND_FACTOR_SETUP_REQUIRED",
});
return true;
}
if (!payload.pluginSession || allowsPluginSession(path)) return false;
res.status(403).json({
error: "A session a plugin issued cannot use this route",
+2
View File
@@ -328,6 +328,8 @@ export interface AuthenticatedRequest extends Request {
sessionId?: string;
apiKeyId?: string;
actingAdminUserId?: string;
/** Set while the session may only set up a second factor. */
sessionRestriction?: string | null;
user?: {
id: string;
username: string;
+10
View File
@@ -46,6 +46,16 @@ export async function getLoginMethods(): Promise<PublicLoginMethod[]> {
}
}
/** False when the login policy turned remember me off. Public. */
export async function getRememberDeviceAllowed(): Promise<boolean> {
try {
const response = await authApi.get("/users/auth/methods");
return response.data?.rememberDevice !== false;
} catch {
return true;
}
}
export async function submitLoginMethod(
methodId: string,
body: Record<string, unknown>,
+27
View File
@@ -43,6 +43,33 @@ export async function updateSessionTimeout(
}
}
export interface HttpsSettings {
forceHttps: boolean;
tlsEnabled: boolean;
}
export async function getHttpsSettings(): Promise<HttpsSettings> {
try {
const response = await authApi.get("/users/https-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch HTTPS settings");
}
}
export async function updateHttpsSettings(
forceHttps: boolean,
): Promise<HttpsSettings> {
try {
const response = await authApi.patch("/users/https-settings", {
forceHttps,
});
return response.data;
} catch (error) {
handleApiError(error, "update HTTPS settings");
}
}
// ============================================================================
// BRANDING SETTINGS
// ============================================================================
+41 -27
View File
@@ -42,6 +42,7 @@ import {
import {
challengeSecondFactor,
getLoginMethods,
getRememberDeviceAllowed,
startLoginRedirect,
submitLoginMethod,
verifySecondFactor,
@@ -232,6 +233,7 @@ export function Auth({ onLogin }: AuthProps) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [rememberAllowed, setRememberAllowed] = useState(true);
const [rememberMe, setRememberMe] = useState(() => {
try {
return localStorage.getItem("rememberMe") === "true";
@@ -395,6 +397,12 @@ export function Auth({ onLogin }: AuthProps) {
void startPreLoginPlugins()
.catch(() => {})
.finally(() => setAuthPluginsLoading(false));
getRememberDeviceAllowed()
.then((allowed) => {
setRememberAllowed(allowed);
if (!allowed) setRememberMe(false);
})
.catch(() => {});
getLoginMethods()
.then((methods) => setAuthMethods(methods))
.catch(() => setAuthMethods([]))
@@ -1525,19 +1533,21 @@ export function Auth({ onLogin }: AuthProps) {
{view === "external" && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="rememberSSO"
checked={rememberMe}
onCheckedChange={(v) => setRememberMe(v === true)}
/>
<label
htmlFor="rememberSSO"
className="text-xs text-muted-foreground cursor-pointer"
>
{t("auth.rememberMe")}
</label>
</div>
{rememberAllowed && (
<div className="flex items-center gap-2">
<Checkbox
id="rememberSSO"
checked={rememberMe}
onCheckedChange={(v) => setRememberMe(v === true)}
/>
<label
htmlFor="rememberSSO"
className="text-xs text-muted-foreground cursor-pointer"
>
{t("auth.rememberMe")}
</label>
</div>
)}
<div className="flex flex-col gap-3">
{externalMethods.map((method) =>
@@ -1573,20 +1583,24 @@ export function Auth({ onLogin }: AuthProps) {
/>
</Field>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
id="rememberMe"
checked={rememberMe}
onCheckedChange={(v) => setRememberMe(v === true)}
disabled={loading}
/>
<label
htmlFor="rememberMe"
className="text-xs text-muted-foreground cursor-pointer"
>
{t("auth.rememberMe")}
</label>
</div>
{rememberAllowed ? (
<div className="flex items-center gap-2">
<Checkbox
id="rememberMe"
checked={rememberMe}
onCheckedChange={(v) => setRememberMe(v === true)}
disabled={loading}
/>
<label
htmlFor="rememberMe"
className="text-xs text-muted-foreground cursor-pointer"
>
{t("auth.rememberMe")}
</label>
</div>
) : (
<span />
)}
{passwordResetAllowed && (
<button
type="button"
+4
View File
@@ -1147,6 +1147,10 @@
"title": "Handle sign in",
"consequence": "It can add a login method, a second factor or an SSH sign-in type."
},
"auth:policy": {
"title": "Set sign in rules",
"consequence": "It can sign people out when idle, limit session length, turn off remember me and require a second factor."
},
"system:tls": {
"title": "Manage certificates",
"consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities."
+3
View File
@@ -911,6 +911,9 @@ export {
updateLogLevel,
getSessionTimeout,
updateSessionTimeout,
getHttpsSettings,
updateHttpsSettings,
type HttpsSettings,
} from "@/api/settings-api";
// ============================================================================
+3
View File
@@ -660,6 +660,8 @@ export {
getHostPolicy,
type HostPolicy,
getHostSidebarPreferences,
getHttpsSettings,
type HttpsSettings,
getLogLevel,
getPasswordLoginAllowed,
getPasswordResetAllowed,
@@ -704,6 +706,7 @@ export {
updateExternalAutoProvision,
updateFolderMetadata,
updateHostAccess,
updateHttpsSettings,
updateLogLevel,
updatePasswordLoginAllowed,
updatePasswordResetAllowed,
+9 -2
View File
@@ -5,9 +5,16 @@ import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry";
* Enrolment sections login methods and second factors register, such as
* adding a passkey or setting up an authenticator app.
*/
export function AuthEnrollmentSections() {
export function AuthEnrollmentSections({
factorsOnly = false,
}: {
/** Only second factors, for a login policy that requires one. */
factorsOnly?: boolean;
} = {}) {
const { t } = useTranslation();
const sections = [...useLoginMethods(), ...useSecondFactors()].filter(
const methods = useLoginMethods();
const factors = useSecondFactors();
const sections = [...(factorsOnly ? [] : methods), ...factors].filter(
(entry) => entry.enrollment,
);
if (sections.length === 0) return null;
@@ -40,6 +40,7 @@ const mainAxios = vi.hoisted(() => ({
const authMethodsApi = vi.hoisted(() => ({
getLoginMethods: vi.fn(),
getRememberDeviceAllowed: vi.fn(async () => true),
submitLoginMethod: vi.fn(),
startLoginRedirect: vi.fn(),
verifySecondFactor: vi.fn(),
@@ -150,6 +151,18 @@ async function openExternalTab() {
}
describe("login methods on the login screen", () => {
it("offers remember me unless the login policy turned it off", async () => {
await openExternalTab();
expect(screen.getByText("auth.rememberMe")).toBeTruthy();
cleanup();
authMethodsApi.getRememberDeviceAllowed.mockResolvedValueOnce(false);
await openExternalTab();
await waitFor(() =>
expect(screen.queryByText("auth.rememberMe")).toBeNull(),
);
});
it("lists every enabled method", async () => {
await openExternalTab();
// A redirect method whose plugin UI is not loaded gets a plain button.