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

+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;
}