Files
Termix/src/backend/auth/session-issuer.ts
T
a89ccc6b07 v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

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

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
2026-10-20 16:03:29 -05:00

177 lines
5.4 KiB
TypeScript

/**
* The one place a login becomes a session: JWT, cookie, response body,
* audit line and the user_login internal event. Every login method ends here, so
* they all behave the same way.
*/
import { isExternalAccount } from "./external-account.js";
import type { Request, Response } from "express";
import { AuthManager } from "../utils/auth-manager.js";
import { authLogger } from "../utils/logger.js";
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
import { parseUserAgent } from "../utils/user-agent-parser.js";
import { emitInternalEvent } from "../hosts/internal-events.js";
import {
createCurrentSettingsRepository,
createCurrentUserAuthRepository,
} from "../database/repositories/factory.js";
import type { UserRecord } from "../database/repositories/user-repository.js";
import type { ExternalSessionRef } from "./types.js";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
export function isNativeAppRequest(req: Request): boolean {
return (
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
req.get("X-Electron-App") === "true"
);
}
/** Cookie lifetime: 30 days when remembered, else session_timeout_hours. */
async function sessionCookieMaxAge(rememberMe: boolean): Promise<number> {
if (rememberMe) return THIRTY_DAYS_MS;
const value = await createCurrentSettingsRepository().get(
"session_timeout_hours",
);
const hours = value ? parseInt(value, 10) || 24 : 24;
return hours * 60 * 60 * 1000;
}
export async function syncSharedCredentialsForUserRoles(
userId: string,
operation: string,
): Promise<void> {
try {
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
const { SharedCredentialSecretsManager } =
await import("../utils/shared-credential-secrets-manager.js");
await SharedCredentialSecretsManager.getInstance().snapshotForUserRoles(
userId,
);
} catch (error) {
authLogger.warn("Failed to sync role shared host secrets", {
operation,
userId,
error,
});
}
}
export interface IssueSessionOptions {
methodId: string;
rememberMe: boolean;
/** Username the rate limiter counted attempts under, to clear them. */
rateLimitUsername?: string;
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 {
token: string;
maxAge: number;
body: Record<string, unknown>;
}
/**
* Mints the JWT and writes the audit line. The caller decides how the token
* reaches the client: a JSON body with a cookie, or a redirect.
*/
export async function issueSession(
req: Request,
user: UserRecord,
options: IssueSessionOptions,
): Promise<IssuedSession> {
const authManager = AuthManager.getInstance();
const deviceInfo = parseUserAgent(req);
const token = await authManager.generateJWTToken(user.id, {
rememberMe: options.rememberMe,
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) {
const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
loginRateLimiter.resetAttempts(clientIp, options.rateLimitUsername);
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: user.id,
username: user.username,
action: "login",
resourceType: "session",
details: JSON.stringify({ method: options.methodId }),
ipAddress,
userAgent,
success: true,
});
emitInternalEvent("user_login", user.id, undefined, {
username: user.username,
ipAddress,
});
authLogger.success("User login successful", {
operation: "user_login_complete",
userId: user.id,
username: user.username,
method: options.methodId,
});
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,
maxAge,
body: {
success: true,
is_admin: !!user.isAdmin,
username: user.username,
userId: user.id,
is_external: isExternalAccount(user),
// Any second factor; the name is what 2.8 clients read.
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
user.id,
),
...(options.restriction ? { restriction: options.restriction } : {}),
...(isNativeAppRequest(req) ? { token } : {}),
},
};
}
/** Sets the cookie and sends the body. */
export function sendSession(
req: Request,
res: Response,
session: IssuedSession,
): Response {
const authManager = AuthManager.getInstance();
return res
.cookie(
"jwt",
session.token,
authManager.getSecureCookieOptions(req, session.maxAge),
)
.json(session.body);
}