feat: add auth extension points and one ssh connect pipeline

This commit is contained in:
LukeGus committed 2026-09-23 00:44:33 -05:00
1 parent d3c209af13
commit d774df8b41
159 files changed
+43243 -7451

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
/**
* Password login, core's one built-in method. It only proves who the user is;
* the pipeline does second factors and the session.
*/
import bcrypt from "bcryptjs";
import { authLogger } from "../utils/logger.js";
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js";
import { createCurrentUserRepository } from "../database/repositories/factory.js";
import { getPasswordLoginStatus } from "./core-auth.js";
import { registerLoginMethod, type LoginMethod } from "./registry.js";
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
function nonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export async function verifyPasswordLogin(request: {
body: Record<string, unknown>;
ip?: string;
socket?: { remoteAddress?: string };
}): Promise<VerifiedIdentity & { rateLimitUsername: string }> {
if (isTrustedProxyAuthEnabled()) {
throw new LoginMethodError(
"Password login is disabled while trusted proxy authentication is enabled",
403,
);
}
const { username, password } = request.body;
const clientIp = request.ip || request.socket?.remoteAddress || "unknown";
if (!nonEmpty(username) || !nonEmpty(password)) {
throw new LoginMethodError("Invalid username or password", 400);
}
const lockStatus = loginRateLimiter.isLocked(clientIp, username);
if (lockStatus.locked) {
authLogger.warn("Login attempt blocked due to rate limiting", {
operation: "user_login_blocked",
username,
ip: clientIp,
remainingTime: lockStatus.remainingTime,
});
const error = new LoginMethodError(
"Too many login attempts. Please try again later.",
429,
);
Object.assign(error, { remainingTime: lockStatus.remainingTime });
throw error;
}
if (!(await getPasswordLoginStatus()).allowed) {
throw new LoginMethodError(
"Password authentication is currently disabled",
403,
);
}
const user = await createCurrentUserRepository().findByUsername(username);
if (!user) {
loginRateLimiter.recordFailedAttempt(clientIp, username);
authLogger.warn("Login failed: user not found", {
operation: "user_login",
username,
ip: clientIp,
});
throw new LoginMethodError("Invalid username or password", 401);
}
if (user.isOidc && (!user.passwordHash || user.passwordHash.trim() === "")) {
authLogger.warn("OIDC-only user attempted traditional login", {
operation: "user_login",
username,
userId: user.id,
});
throw new LoginMethodError("This user uses external authentication", 403);
}
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
loginRateLimiter.recordFailedAttempt(clientIp, username);
authLogger.warn("Login failed: incorrect password", {
operation: "user_login",
username,
userId: user.id,
ip: clientIp,
});
throw new LoginMethodError("Invalid username or password", 401);
}
return {
kind: "user",
userId: user.id,
password,
rememberMe: !!request.body.rememberMe,
rateLimitUsername: username,
};
}
export const passwordLoginMethod: LoginMethod = {
id: "password",
pluginId: "core",
labelKey: "auth.password",
kind: "form",
describe: async () => {
const status = await getPasswordLoginStatus();
return [{ id: "password", label: "Password", enabled: status.allowed }];
},
verify: (request) => verifyPasswordLogin(request as never),
};
export function registerPasswordLoginMethod(): () => void {
return registerLoginMethod(passwordLoginMethod);
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Core's own login method (password) and the lockout guard.
*/
import { getCurrentSettingValue } from "../database/repositories/factory.js";
import { authLogger } from "../utils/logger.js";
import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js";
import { registerPasswordLoginMethod } from "./builtin-login-methods.js";
import { registerLegacyLoginProviders } from "./legacy-providers.js";
import { listLoginMethods } from "./registry.js";
let registered = false;
/** Registers core's login methods and factors once. */
export function ensureCoreLoginProviders(): void {
if (registered) return;
registered = true;
registerPasswordLoginMethod();
registerLegacyLoginProviders();
}
/** Test helper. */
export function resetCoreLoginProvidersForTests(): void {
registered = false;
}
/** The admin setting (or ALLOW_PASSWORD_LOGIN), before the lockout guard. */
export function isPasswordLoginSettingOn(): boolean {
const envVal = process.env.ALLOW_PASSWORD_LOGIN;
if (envVal !== undefined) return envVal.trim().toLowerCase() === "true";
try {
const value = getCurrentSettingValue("allow_password_login");
return value ? value === "true" : true;
} catch {
return true;
}
}
/**
* Whether anyone could still sign in without a password: trusted proxy auth,
* or a login method reporting an enabled instance (an SSO provider).
*/
export async function hasOtherEnabledLoginMethod(): Promise<boolean> {
if (isTrustedProxyAuthEnabled()) return true;
ensureCoreLoginProviders();
for (const method of listLoginMethods()) {
if (method.id === "password" || !method.describe) continue;
try {
const instances = await method.describe();
if (instances.some((instance) => instance.enabled)) return true;
} catch {
// A method that cannot describe itself does not count.
}
}
return false;
}
export interface PasswordLoginStatus {
allowed: boolean;
/** Turned off by the admin but kept on so nobody is locked out. */
forced: boolean;
}
/**
* Password login is off only when the admin turned it off AND another way to
* sign in exists. Otherwise it stays on, with a warning, so that disabling it
* (or losing the only SSO provider) can never lock everyone out.
*/
export async function getPasswordLoginStatus(): Promise<PasswordLoginStatus> {
if (isPasswordLoginSettingOn()) return { allowed: true, forced: false };
if (await hasOtherEnabledLoginMethod()) {
return { allowed: false, forced: false };
}
authLogger.warn(
"Password login is disabled but no other login method is enabled; keeping it on",
{ operation: "password_login_lockout_guard" },
);
return { allowed: true, forced: true };
}
+393
View File
@@ -0,0 +1,393 @@
/**
* Auth methods that still live in core for 2.9.0, registered through the same
* interfaces a plugin uses. Phase C moves each one into its own plugin by
* relocating its block from here; D1 deletes this file once it is empty.
*
* Nothing outside this file should branch on these type names.
*/
import { getErrorMessage } from "../utils/error-message.js";
import { sshLogger } from "../utils/logger.js";
import {
registerKeyboardInteractiveInterceptor,
registerSshAuthProvider,
} from "../hosts/connect/auth-provider-registry.js";
import type {
SshAuthProvider,
SshConnectHost,
} from "../hosts/connect/types.js";
import {
registerLoginMethod,
registerSecondFactor,
type LoginMethod,
} from "./registry.js";
import { totpSecondFactor } from "./legacy/totp-factor.js";
const OPKSSH_REQUIRED_MESSAGE =
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.";
const VAULT_REQUIRED_MESSAGE =
"Vault SSH signer authentication required. Please open a Terminal connection first.";
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
/** How long a Tailscale check-mode login may sit waiting for approval. */
export const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000;
function issuedCertificateProvider(
type: "opkssh" | "stepca",
labelKey: string,
): SshAuthProvider {
return {
type,
pluginId: "core",
labelKey,
needsUserInteraction: true,
supportsBackground: false,
interaction: "opkssh",
prepare: async (config, host, env) => {
const { getOPKSSHToken } = await import("../hosts/opkssh-auth.js");
const token = await getOPKSSHToken(env.userId, env.hostId);
if (!token) {
env.log("info", "No valid certificate found, requesting sign-in");
return {
status: "interaction-required",
interaction: "opkssh",
message: OPKSSH_REQUIRED_MESSAGE,
flag: "requiresOPKSSHAuth",
};
}
try {
const { setupOPKSSHCertAuth } =
await import("../hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(config, env.client, token, host.username);
} catch (error) {
return {
status: "error",
code: "failed",
message: "OPKSSH authentication failed: " + getErrorMessage(error),
};
}
env.log("info", "Using cached SSH certificate");
return { status: "ready" };
},
onAuthFailed: (_host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
sshLogger.warn("OPKSSH authentication failed - invalidating token", {
operation: "opkssh_auth_failed",
hostId: env.hostId,
userId: env.userId,
});
void import("../hosts/opkssh-auth.js")
.then(({ invalidateOPKSSHToken }) =>
invalidateOPKSSHToken(env.userId, env.hostId, "SSH auth failed"),
)
.catch(() => {});
return {
status: "interaction-required",
interaction: "opkssh",
message:
"OPKSSH authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
if (type === "stepca") {
const { startStepCaAuth } = await import("../hosts/step-ca-auth.js");
await startStepCaAuth(
request.userId,
request.hostId,
request.host.username,
request.socket,
request.requestOrigin,
);
return;
}
const { startOPKSSHAuth } = await import("../hosts/opkssh-auth.js");
await startOPKSSHAuth(
request.userId,
request.hostId,
request.host.name || request.host.ip,
request.socket,
request.requestOrigin,
);
},
};
}
function vaultProfileId(host: SshConnectHost): number | undefined {
return host.vaultProfile?.id ?? undefined;
}
const vaultProvider: SshAuthProvider = {
type: "vault",
pluginId: "core",
labelKey: "hosts.filterAuthVault",
needsUserInteraction: true,
interaction: "vault",
prepare: async (config, host, env) => {
const profileId = vaultProfileId(host);
if (!profileId) {
return {
status: "error",
code: "failed",
message: "Host has no Vault signer profile configured",
};
}
const { getVaultCert } = await import("../hosts/vault-signer-auth.js");
const cert = await getVaultCert(env.userId, profileId);
if (!cert) {
env.log(
"info",
"No valid Vault certificate found, requesting authentication",
);
return {
status: "interaction-required",
interaction: "vault",
message: VAULT_REQUIRED_MESSAGE,
flag: "requiresVaultAuth",
};
}
try {
const { setupOPKSSHCertAuth } =
await import("../hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(
config,
env.client,
{ privateKey: cert.privateKey, sshCert: cert.sshCert },
host.username,
);
} catch (error) {
return {
status: "error",
code: "failed",
message:
"Vault SSH signer authentication failed: " + getErrorMessage(error),
};
}
env.log("info", "Using cached Vault-signed certificate");
return { status: "ready" };
},
onAuthFailed: (host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
sshLogger.warn("Vault certificate authentication failed", {
operation: "vault_auth_failed",
hostId: env.hostId,
userId: env.userId,
});
const profileId = vaultProfileId(host);
if (profileId) {
void import("../hosts/vault-signer-auth.js")
.then(({ deleteVaultCert }) => deleteVaultCert(env.userId, profileId))
.catch(() => {});
}
return {
status: "interaction-required",
interaction: "vault",
message:
"Vault authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
const { loadVaultProfileForHost, startVaultAuth } =
await import("../hosts/vault-oidc-auth.js");
const profile = await loadVaultProfileForHost(
request.hostId,
request.userId,
);
if (!profile) {
throw new Error("No Vault signer profile configured for this host");
}
await startVaultAuth(
request.userId,
request.hostId,
profile,
request.socket,
request.requestOrigin,
);
},
};
const tailscaleProvider: SshAuthProvider = {
type: "tailscale",
pluginId: "core",
labelKey: "hosts.filterAuthTailscale",
connectOptions: (_host, purpose) =>
purpose === "terminal"
? {
tryKeyboard: false,
readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS,
// The socket sits idle while a check-mode login is pending.
timeout: TAILSCALE_CHECK_TIMEOUT_MS,
}
: { tryKeyboard: false },
prepare: async (_config, _host, env) => {
env.log("info", "Using Tailscale SSH");
return { status: "ready" };
},
onAuthFailed: (host, _env, context) => {
const failed =
context.methodNotAvailable ||
AUTH_FAILED_PATTERN.test(context.error.message);
if (!failed) return;
// Tailscale documents the "+password" suffix for clients that mishandle a
// successful "none" reply. The password value is ignored.
if (context.retries === 0 && context.canRetry) {
return {
status: "retry",
message: "Retrying Tailscale SSH in forced password mode",
patch: {
username: `${host.username}+password`,
password: "termix",
tryKeyboard: false,
},
};
}
return {
status: "error",
code: "failed",
message: `Tailscale SSH authentication failed for user "${host.username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${host.username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`,
};
},
};
const WARPGATE_PATTERN = /warpgate\s+authentication/i;
let sshRegistered = false;
export function registerLegacySshAuthProviders(): void {
if (sshRegistered) return;
sshRegistered = true;
registerSshAuthProvider(
issuedCertificateProvider("opkssh", "hosts.filterAuthOpkssh"),
);
registerSshAuthProvider(
issuedCertificateProvider("stepca", "hosts.filterAuthStepca"),
);
registerSshAuthProvider(vaultProvider);
registerSshAuthProvider(tailscaleProvider);
// Warpgate is a flag on a host, not an auth type: it shows up as a
// keyboard-interactive round with a sign-in URL.
registerKeyboardInteractiveInterceptor({
id: "warpgate",
pluginId: "core",
detect: ({ name, instructions, prompts }) => {
const texts = prompts.map((p) => p.prompt);
const isWarpgate =
WARPGATE_PATTERN.test(name) ||
WARPGATE_PATTERN.test(instructions) ||
texts.some((p) => WARPGATE_PATTERN.test(p));
if (!isWarpgate) return null;
const fullText = `${name}\n${instructions}\n${texts.join("\n")}`;
const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i);
if (!urlMatch) return null;
const keyMatch = fullText.match(
/security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i,
);
return {
kind: "warpgate",
url: urlMatch[0],
securityKey: keyMatch ? keyMatch[1] : "N/A",
instructions,
};
},
// Warpgate asks for the password before its browser round; answer it
// silently so the only thing the user sees is the Warpgate dialog.
autoAnswerPasswords: (host) => host.useWarpgate === true,
});
}
// Login methods and second factors -------------------------------------------
async function listPublicProviders(types: string[]) {
const { listPublicSsoProviders } =
await import("../database/routes/sso-provider-routes.js");
return (await listPublicSsoProviders()).filter((provider) =>
types.includes(provider.type),
);
}
const oidcLoginMethod: LoginMethod = {
id: "oidc",
pluginId: "core",
labelKey: "auth.loginWithSso",
icon: "key-round",
kind: "redirect",
describe: async () =>
(await listPublicProviders(["oidc", "github", "google"])).map(
(provider) => ({
id: String(provider.id),
label: provider.name,
enabled: true,
}),
),
start: async (request, instanceId) => {
const { startOidcLogin } = await import("./legacy/oidc-login.js");
// Instance "0" is the provider synthesized from environment variables.
const result = await startOidcLogin(request as never, {
providerId: instanceId && instanceId !== "0" ? instanceId : undefined,
});
return { redirectUrl: result.auth_url };
},
callback: async (request) => {
const { handleOidcCallback } = await import("./legacy/oidc-login.js");
return handleOidcCallback(request as never);
},
};
const ldapLoginMethod: LoginMethod = {
id: "ldap",
pluginId: "core",
labelKey: "auth.loginWithLdap",
icon: "server",
kind: "form",
describe: async () =>
(await listPublicProviders(["ldap"])).map((provider) => ({
id: String(provider.id),
label: provider.name,
enabled: true,
})),
verify: async (request, instanceId) => {
const { verifyLdapLogin } = await import("./legacy/ldap-login.js");
const body = { ...request.body };
if (instanceId && !body.providerId) body.providerId = Number(instanceId);
return verifyLdapLogin({ body, ip: request.ip });
},
};
// Passkeys prove the user as well as possession, so a passkey login can
// stand in for the second factor. No describe: a passkey only helps a user
// who already registered one, so it never counts for the lockout guard.
const passkeyLoginMethod: LoginMethod = {
id: "passkey",
pluginId: "core",
labelKey: "auth.loginWithPasskey",
icon: "fingerprint",
kind: "form",
verify: async (request) => {
const { verifyPasskeyLogin } =
await import("../database/routes/user-webauthn-routes.js");
return verifyPasskeyLogin(request as never);
},
};
let loginRegistered = false;
export function registerLegacyLoginProviders(): void {
if (loginRegistered) return;
loginRegistered = true;
registerLoginMethod(oidcLoginMethod);
registerLoginMethod(ldapLoginMethod);
registerLoginMethod(passkeyLoginMethod);
registerSecondFactor(totpSecondFactor);
}
/** Test helper. */
export function resetLegacyProvidersForTests(): void {
sshRegistered = false;
loginRegistered = false;
}
+227
View File
@@ -0,0 +1,227 @@
/**
* LDAP sign-in as a form login method. Moved out of the LDAP route so the
* pipeline issues the session; Phase C moves it into the LDAP plugin.
*/
import ldap from "ldapjs";
import type { LDAPProviderConfig } from "../../../types/index.js";
import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import { createCurrentSsoProviderRepository } from "../../database/repositories/factory.js";
import { loadProviderConfig } from "../../database/routes/user-oidc-utils.js";
import { LoginMethodError, type VerifiedIdentity } from "../types.js";
export function ldapEscapeFilter(value: string): string {
return value.replace(
/[\\*()\x00]/g,
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
);
}
function createLDAPClient(
host: string,
port: number,
useTLS: boolean,
): ldap.Client {
const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`;
return ldap.createClient({
url,
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
});
}
function ldapBind(
client: ldap.Client,
dn: string,
password: string,
): Promise<void> {
return new Promise((resolve, reject) => {
client.bind(dn, password, (err) => {
if (err) reject(err);
else resolve();
});
});
}
function ldapSearch(
client: ldap.Client,
base: string,
filter: string,
attributes: string[],
): Promise<ldap.SearchEntry[]> {
return new Promise((resolve, reject) => {
const entries: ldap.SearchEntry[] = [];
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
if (err) return reject(err);
res.on("searchEntry", (entry) => entries.push(entry));
res.on("error", reject);
res.on("end", () => resolve(entries));
});
});
}
function ldapUnbind(client: ldap.Client): void {
try {
client.unbind();
} catch {
// best effort
}
}
function firstValue(entry: ldap.SearchEntry, key: string): string {
const attr = entry.attributes.find((a) => a.type === key);
if (!attr) return "";
return Array.isArray(attr.values) ? attr.values[0] : String(attr.values);
}
export async function verifyLdapLogin(request: {
body: Record<string, unknown>;
ip?: string;
}): Promise<VerifiedIdentity> {
const { providerId, username, password, rememberMe } = request.body as {
providerId?: number;
username?: string;
password?: string;
rememberMe?: boolean;
};
if (!providerId || !username || !password) {
throw new LoginMethodError(
"providerId, username, and password are required",
400,
);
}
// Same limits as password login: an LDAP bind is a password guess.
const clientIp = request.ip || "unknown";
const rateLimitKey = `ldap:${providerId}:${username}`;
const lockStatus = loginRateLimiter.isLocked(clientIp, rateLimitKey);
if (lockStatus.locked) {
const error = new LoginMethodError(
"Too many login attempts. Please try again later.",
429,
);
Object.assign(error, { remainingTime: lockStatus.remainingTime });
throw error;
}
const provider =
await createCurrentSsoProviderRepository().findById(providerId);
if (!provider || provider.type !== "ldap" || !provider.enabled) {
throw new LoginMethodError("LDAP provider not found", 404);
}
const providerResult = await loadProviderConfig(providerId);
if (!providerResult) {
throw new LoginMethodError("LDAP provider not found", 404);
}
const config = providerResult.config as unknown as LDAPProviderConfig;
if (
!config.host ||
!config.bindDN ||
!config.userSearchBase ||
!config.userSearchFilter
) {
throw new LoginMethodError("LDAP provider is misconfigured", 500);
}
const serviceClient = createLDAPClient(
config.host,
config.port || 389,
config.useTLS || false,
);
try {
await ldapBind(serviceClient, config.bindDN, config.bindPassword);
const filter = config.userSearchFilter.replace(
/\{\{username\}\}/g,
ldapEscapeFilter(username),
);
const entries = await ldapSearch(
serviceClient,
config.userSearchBase,
filter,
[
config.usernameAttribute || "uid",
config.displayNameAttribute || "cn",
"mail",
"email",
],
);
if (entries.length === 0) {
loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey);
authLogger.warn("LDAP user not found", {
operation: "ldap_login",
username,
});
throw new LoginMethodError("Invalid username or password", 401);
}
const userEntry = entries[0];
const userDN = userEntry.dn.toString();
const ldapIdentifier =
firstValue(userEntry, config.usernameAttribute || "uid") || username;
const displayName =
firstValue(userEntry, config.displayNameAttribute || "cn") || username;
const email =
firstValue(userEntry, "mail") || firstValue(userEntry, "email") || "";
const userClient = createLDAPClient(
config.host,
config.port || 389,
config.useTLS || false,
);
try {
await ldapBind(userClient, userDN, password);
} catch {
loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey);
authLogger.warn("LDAP bind failed - wrong password", {
operation: "ldap_login",
ldapIdentifier,
});
throw new LoginMethodError("Invalid username or password", 401);
} finally {
ldapUnbind(userClient);
}
let isAdmin: boolean | undefined;
if (config.adminGroup && config.groupSearchBase) {
isAdmin = false;
try {
const groupEntries = await ldapSearch(
serviceClient,
config.groupSearchBase,
`(member=${ldapEscapeFilter(userDN)})`,
["cn", "dn"],
);
isAdmin = groupEntries.some(
(group) =>
firstValue(group, "cn") === config.adminGroup ||
group.dn.toString() === config.adminGroup,
);
} catch (groupErr) {
authLogger.warn("LDAP group check failed", {
operation: "ldap_group_check",
error: groupErr,
});
}
}
return {
kind: "external",
provider: String(providerId),
subject: ldapIdentifier,
email: email || null,
name: displayName,
isAdmin,
allowedUsers: config.allowedUsers ?? null,
legacyIdentifier: `ldap:${providerId}:${ldapIdentifier}`,
ssoProviderId: providerId,
rememberMe: !!rememberMe,
rateLimitUsername: rateLimitKey,
};
} finally {
ldapUnbind(serviceClient);
}
}
+518
View File
@@ -0,0 +1,518 @@
/**
* OIDC, GitHub and Google sign-in, moved out of the users routes so they can
* register as a login method. Phase C moves this into the OIDC plugin.
*
* start builds the provider's authorize URL; callback turns the provider's
* answer into a verified identity. Neither touches sessions.
*/
import crypto from "crypto";
import type { Request } from "express";
import { nanoid } from "nanoid";
import { authLogger } from "../../utils/logger.js";
import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js";
import { getDesktopOidcCallbackUrl } from "../../utils/oidc-desktop-callback.js";
import { createCurrentSettingsRepository } from "../../database/repositories/factory.js";
import {
OIDCTokenFormatError,
buildFetchOptions,
extractOidcGroupsFromSources,
loadProviderConfig,
parseOidcRoleMap,
resolveOidcMappedRoles,
verifyOIDCToken,
} from "../../database/routes/user-oidc-utils.js";
import { LoginMethodError, type VerifiedIdentity } from "../types.js";
/** An error raised after the return address is known redirects there. */
export class RedirectLoginError extends LoginMethodError {
constructor(
message: string,
readonly returnTo: string,
status = 400,
code?: string,
) {
super(message, status, code);
this.name = "RedirectLoginError";
}
}
/** Provider id used for OIDC identities that came from env configuration. */
export const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
function nonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
// RFC 7636 PKCE: 43-128 char unreserved-character string.
function generatePkceCodeVerifier(): string {
return crypto.randomBytes(64).toString("base64url");
}
function generatePkceCodeChallenge(verifier: string): string {
return crypto.createHash("sha256").update(verifier).digest("base64url");
}
export async function deleteOIDCStateSettings(state: string): Promise<void> {
const settingsRepository = createCurrentSettingsRepository();
await settingsRepository.delete(`oidc_state_${state}`);
await settingsRepository.delete(`oidc_backend_callback_${state}`);
await settingsRepository.delete(`oidc_frontend_origin_${state}`);
await settingsRepository.delete(`oidc_remember_me_${state}`);
await settingsRepository.delete(`oidc_provider_${state}`);
await settingsRepository.delete(`oidc_pkce_verifier_${state}`);
}
export async function startOidcLogin(
req: Request,
overrides: { providerId?: string | null } = {},
): Promise<{ auth_url: string; state: string; nonce: string }> {
const { rememberMe, desktopCallbackPort, appCallbackUrl } = req.query;
const providerIdStr = overrides.providerId ?? req.query.providerId;
const origin = getRequestOriginWithForceHTTPS(req);
const basePath = (process.env.BASE_PATH || "").replace(/\/+$/, "");
const backendCallbackUri = `${origin}${basePath}/users/oidc/callback`;
const resolvedProviderId = providerIdStr
? parseInt(providerIdStr as string, 10)
: null;
const providerResult = await loadProviderConfig(
resolvedProviderId || undefined,
);
if (!providerResult) {
throw new LoginMethodError("OIDC not configured", 404);
}
const { config, providerDbId } = providerResult;
const state = nanoid();
const nonce = nanoid();
const referer = req.get("Referer");
let frontendOrigin: string;
if (desktopCallbackPort) {
frontendOrigin = getDesktopOidcCallbackUrl(desktopCallbackPort);
if (!frontendOrigin) {
throw new LoginMethodError("Invalid desktop callback port", 400);
}
} else if (typeof appCallbackUrl === "string" && appCallbackUrl) {
let callbackUrl: URL;
try {
callbackUrl = new URL(appCallbackUrl);
} catch {
throw new LoginMethodError("Invalid app callback URL", 400);
}
if (callbackUrl.protocol !== "termix-mobile:") {
throw new LoginMethodError("Unsupported app callback URL", 400);
}
frontendOrigin = callbackUrl.toString();
} else if (referer) {
const refererUrl = new URL(referer);
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
} else {
frontendOrigin = origin;
}
const codeVerifier = generatePkceCodeVerifier();
const codeChallenge = generatePkceCodeChallenge(codeVerifier);
const settingsRepository = createCurrentSettingsRepository();
await settingsRepository.set(`oidc_state_${state}`, nonce);
await settingsRepository.set(
`oidc_backend_callback_${state}`,
backendCallbackUri,
);
await settingsRepository.set(`oidc_frontend_origin_${state}`, frontendOrigin);
await settingsRepository.set(
`oidc_remember_me_${state}`,
rememberMe === "true" ? "true" : "false",
);
await settingsRepository.set(`oidc_pkce_verifier_${state}`, codeVerifier);
if (providerDbId != null) {
await settingsRepository.set(
`oidc_provider_${state}`,
String(providerDbId),
);
}
const authUrl = new URL(config.authorization_url);
authUrl.searchParams.set("client_id", config.client_id);
authUrl.searchParams.set("redirect_uri", backendCallbackUri);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", config.scopes);
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("nonce", nonce);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
return { auth_url: authUrl.toString(), state, nonce };
}
type ProviderConfig = NonNullable<
Awaited<ReturnType<typeof loadProviderConfig>>
>["config"];
async function githubIdentity(
code: string,
state: string,
config: ProviderConfig,
providerDbId: number | null,
backendCallbackUri: string,
frontendOrigin: string,
rememberMe: boolean,
): Promise<VerifiedIdentity> {
const fetchOptions = buildFetchOptions(config.ca_cert);
const tokenResponse = await fetch(config.token_url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.client_id,
client_secret: config.client_secret,
code,
redirect_uri: backendCallbackUri,
}),
...fetchOptions,
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
authLogger.error("GitHub token exchange failed", {
operation: "github_token_exchange_failed",
status: tokenResponse.status,
errorResponse: errorText,
});
throw new LoginMethodError("Failed to exchange authorization code", 400);
}
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
await deleteOIDCStateSettings(state);
const userInfoResponse = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
Accept: "application/json",
"User-Agent": "Termix",
},
...fetchOptions,
});
if (!userInfoResponse.ok) {
throw new LoginMethodError("Failed to get GitHub user information", 400);
}
const userInfo = (await userInfoResponse.json()) as Record<string, unknown>;
const emailResponse = await fetch("https://api.github.com/user/emails", {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
Accept: "application/json",
"User-Agent": "Termix",
},
});
if (emailResponse.ok) {
const emails = (await emailResponse.json()) as Array<{
email: string;
primary: boolean;
verified: boolean;
}>;
const primary = emails.find((e) => e.primary && e.verified);
if (primary) userInfo.email = primary.email;
}
const subject = String(userInfo.id ?? userInfo.login);
const legacyIdentifier = `github:${providerDbId}:${subject}`;
return {
kind: "external",
provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID),
subject,
email: (userInfo.email as string | undefined) ?? null,
name: ((userInfo.name || userInfo.login) as string) || legacyIdentifier,
allowedUsers: config.allowed_users ?? null,
legacyIdentifier,
ssoProviderId: providerDbId,
returnTo: frontendOrigin,
rememberMe,
};
}
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
if (!path || !obj) return null;
return path
.split(".")
.reduce<unknown>(
(current, key) => (current as Record<string, unknown>)?.[key],
obj,
);
}
/**
* The provider's callback. Throws RedirectLoginError once the return address
* is known, and a plain LoginMethodError (answered as JSON) before that.
*/
export async function handleOidcCallback(
req: Request,
): Promise<VerifiedIdentity> {
const { code, state } = req.query;
if (!nonEmpty(code) || !nonEmpty(state)) {
throw new LoginMethodError("Code and state are required", 400);
}
const settingsRepository = createCurrentSettingsRepository();
const backendCallbackUri = await settingsRepository.get(
`oidc_backend_callback_${state}`,
);
const frontendOrigin = await settingsRepository.get(
`oidc_frontend_origin_${state}`,
);
const rememberMe =
(await settingsRepository.get(`oidc_remember_me_${state}`)) === "true";
const codeVerifier = await settingsRepository.get(
`oidc_pkce_verifier_${state}`,
);
if (!backendCallbackUri || !frontendOrigin) {
throw new LoginMethodError(
"Invalid state parameter - redirect URIs not found",
400,
);
}
try {
const storedNonce = await settingsRepository.get(`oidc_state_${state}`);
if (!storedNonce) {
throw new LoginMethodError("Invalid state parameter", 400);
}
const storedProviderId = await settingsRepository.get(
`oidc_provider_${state}`,
);
const callbackProviderId = storedProviderId
? parseInt(storedProviderId, 10)
: null;
const providerResult = await loadProviderConfig(
callbackProviderId || undefined,
);
if (!providerResult) {
throw new LoginMethodError("OIDC not configured", 500);
}
const { config, providerType, providerDbId } = providerResult;
await settingsRepository.delete(`oidc_provider_${state}`);
// GitHub does not issue OIDC id_tokens; it has its own token exchange.
if (providerType === "github") {
return await githubIdentity(
code,
state,
config,
providerDbId ?? null,
backendCallbackUri,
frontendOrigin,
rememberMe,
);
}
const caCert = config.ca_cert;
const fetchOptions = buildFetchOptions(caCert);
const tokenResponse = await fetch(config.token_url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.client_id,
client_secret: config.client_secret,
code,
redirect_uri: backendCallbackUri,
...(codeVerifier ? { code_verifier: codeVerifier } : {}),
}),
...fetchOptions,
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
authLogger.error("OIDC token exchange failed", {
operation: "oidc_token_exchange_failed",
status: tokenResponse.status,
statusText: tokenResponse.statusText,
backendCallbackUri,
frontendOrigin,
errorResponse: errorText,
});
throw new LoginMethodError("Failed to exchange authorization code", 400);
}
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
await deleteOIDCStateSettings(state);
let userInfo: Record<string, unknown> | null = null;
const claimSources: Record<string, unknown>[] = [];
const userInfoUrls: string[] = [];
const normalizedIssuerUrl = config.issuer_url.endsWith("/")
? config.issuer_url.slice(0, -1)
: config.issuer_url;
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "");
try {
const discoveryResponse = await fetch(
`${normalizedIssuerUrl}/.well-known/openid-configuration`,
fetchOptions,
);
if (discoveryResponse.ok) {
const discovery = (await discoveryResponse.json()) as Record<
string,
unknown
>;
if (discovery.userinfo_endpoint) {
userInfoUrls.push(discovery.userinfo_endpoint as string);
}
}
} catch (discoveryError) {
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
}
if (config.userinfo_url) userInfoUrls.unshift(config.userinfo_url);
userInfoUrls.push(
`${baseUrl}/userinfo/`,
`${baseUrl}/userinfo`,
`${normalizedIssuerUrl}/userinfo/`,
`${normalizedIssuerUrl}/userinfo`,
`${baseUrl}/oauth2/userinfo/`,
`${baseUrl}/oauth2/userinfo`,
`${normalizedIssuerUrl}/oauth2/userinfo/`,
`${normalizedIssuerUrl}/oauth2/userinfo`,
);
if (tokenData.id_token) {
try {
userInfo = await verifyOIDCToken(
tokenData.id_token as string,
config.issuer_url,
config.client_id,
caCert,
);
if (userInfo.nonce !== storedNonce) {
authLogger.warn("OIDC ID token nonce mismatch", {
operation: "oidc_nonce_mismatch",
providerId: callbackProviderId,
});
throw new LoginMethodError("Invalid OIDC token nonce", 401);
}
claimSources.push(userInfo);
} catch (error) {
// A token that is not a JWS carries no claims we could trust; fall
// through to userinfo. Signature and claim failures still reject.
if (!(error instanceof OIDCTokenFormatError)) throw error;
userInfo = null;
authLogger.warn(
"OIDC ID token cannot be verified, falling back to userinfo endpoint",
{
operation: "oidc_id_token_unverifiable",
providerId: callbackProviderId,
reason: error.message,
},
);
}
}
if (tokenData.access_token) {
for (const userInfoUrl of userInfoUrls) {
try {
const response = await fetch(userInfoUrl, {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
...fetchOptions,
});
if (response.ok) {
const fetched = (await response.json()) as Record<string, unknown>;
claimSources.push(fetched);
userInfo = { ...userInfo, ...fetched };
break;
}
authLogger.error(
`Userinfo endpoint ${userInfoUrl} failed with status: ${response.status}`,
);
} catch (error) {
authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error);
}
}
}
if (!userInfo) {
authLogger.error("Failed to get user information from all sources");
throw new LoginMethodError("Failed to get user information", 400);
}
const identifier = (getNestedValue(userInfo, config.identifier_path) ||
userInfo[config.identifier_path] ||
userInfo.sub ||
userInfo.email ||
userInfo.preferred_username) as string;
const name = (getNestedValue(userInfo, config.name_path) ||
userInfo[config.name_path] ||
userInfo.name ||
userInfo.given_name ||
identifier) as string;
if (!identifier) {
throw new LoginMethodError(
`User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(", ")}`,
400,
);
}
let isAdmin: boolean | undefined;
if (config.admin_group) {
const groups = extractOidcGroupsFromSources(
claimSources,
config.group_claim,
);
isAdmin = groups.includes(config.admin_group);
}
let roleSync: { desired: string[]; managed: string[] } | undefined;
try {
const roleMap = parseOidcRoleMap(
config.role_map ?? process.env.OIDC_ROLE_MAP,
);
if (roleMap.size > 0) {
const groups = extractOidcGroupsFromSources(
claimSources,
config.group_claim,
);
const { desired, managed } = resolveOidcMappedRoles(groups, roleMap);
roleSync = { desired: [...desired], managed: [...managed] };
}
} catch (roleMapError) {
authLogger.error("Failed to read the OIDC role map", roleMapError, {
operation: "oidc_role_map_sync_failed",
});
}
return {
kind: "external",
provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID),
subject: identifier,
email: (userInfo.email as string | undefined) ?? null,
name,
isAdmin,
allowedUsers: config.allowed_users ?? null,
legacyIdentifier: identifier,
ssoProviderId: providerDbId ?? null,
oidcSub: typeof userInfo.sub === "string" ? userInfo.sub : null,
oidcSid: typeof userInfo.sid === "string" ? userInfo.sid : null,
roleSync,
returnTo: frontendOrigin,
rememberMe,
};
} catch (error) {
if (error instanceof LoginMethodError) throw error;
authLogger.error("OIDC callback failed", error);
throw new RedirectLoginError(
"OIDC authentication failed",
frontendOrigin,
500,
);
}
}
+128
View File
@@ -0,0 +1,128 @@
/**
* TOTP as a second factor. Enrolment stays in the TOTP routes; this is the
* login half. Phase C moves both into the TOTP plugin.
*/
import speakeasy from "speakeasy";
import { AuthManager } from "../../utils/auth-manager.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
import {
createCurrentUserAuthRepository,
createCurrentUserRepository,
} from "../../database/repositories/factory.js";
import type { SecondFactor } from "../registry.js";
export const TOTP_FACTOR_ID = "totp";
/**
* Checks a backup code and burns it. Codes are stored encrypted under the
* user's data key when it was available at enrolment, so both forms are read.
*/
async function consumeBackupCode(
userId: string,
storedCodes: string | null,
code: string,
userDataKey: Buffer,
): Promise<boolean> {
if (!storedCodes) return false;
const raw = LazyFieldEncryption.safeGetFieldValue(
storedCodes,
userDataKey,
userId,
"totpBackupCodes",
);
let codes: unknown = [];
try {
codes = raw ? JSON.parse(raw) : [];
} catch {
codes = [];
}
if (!Array.isArray(codes)) return false;
const index = codes.indexOf(code);
if (index === -1) return false;
codes.splice(index, 1);
await createCurrentUserRepository().update(userId, {
totpBackupCodes: FieldCrypto.encryptField(
JSON.stringify(codes),
userDataKey,
userId,
"totpBackupCodes",
),
});
return true;
}
export const totpSecondFactor: SecondFactor = {
id: TOTP_FACTOR_ID,
pluginId: "core",
labelKey: "auth.totpFactor",
isEnrolled: async (userId) => {
const user = await createCurrentUserRepository().findById(userId);
return !!user?.totpEnabled;
},
verify: async (userId, body) => {
const code = String(body.totp_code ?? body.code ?? "").trim();
if (!code) return false;
const user = await createCurrentUserRepository().findById(userId);
if (!user?.totpEnabled || !user.totpSecret) {
return { ok: false, error: "TOTP not enabled for this user" };
}
const userDataKey = AuthManager.getInstance().getUserDataKey(userId);
if (!userDataKey) {
return {
ok: false,
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
};
}
const secret = LazyFieldEncryption.safeGetFieldValue(
user.totpSecret,
userDataKey,
userId,
"totp_secret",
);
if (!secret) {
// The secret was encrypted under a key a password reset replaced.
await createCurrentUserRepository().update(userId, {
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
});
await createCurrentUserAuthRepository().removeSecondFactor(
userId,
"core",
TOTP_FACTOR_ID,
);
return {
ok: false,
error:
"TOTP has been disabled due to password reset. Please set up TOTP again.",
};
}
const verified = speakeasy.totp.verify({
secret,
encoding: "base32",
token: code,
window: 2,
});
if (verified) return true;
return consumeBackupCode(userId, user.totpBackupCodes, code, userDataKey);
},
reset: async (userId) => {
await createCurrentUserRepository().update(userId, {
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
});
},
};
+564
View File
@@ -0,0 +1,564 @@
/**
* Every login runs through here:
*
* method -> verified identity -> find or provision the user -> unlock the
* data key -> second factors (fail closed) -> session
*
* A method never mints a session. It says who the user is and core does the
* rest, so password, passkey, OIDC, LDAP and any plugin method get the same
* second factors, the same cookie and the same audit line.
*/
import crypto from "crypto";
import type { Request, Response } from "express";
import { AuthManager } from "../utils/auth-manager.js";
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
import { authLogger } from "../utils/logger.js";
import {
generateDeviceFingerprint,
getDeviceId,
parseUserAgent,
} from "../utils/user-agent-parser.js";
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
import {
createCurrentUserAuthRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import type { UserRecord } from "../database/repositories/user-repository.js";
import { ensureCoreLoginProviders } from "./core-auth.js";
import { findOrProvisionExternalUser } from "./provisioning.js";
import {
getSecondFactor,
listSecondFactors,
type SecondFactor,
} from "./registry.js";
import {
issueSession,
sendSession,
syncSharedCredentialsForUserRoles,
} from "./session-issuer.js";
import {
LoginMethodError,
type PendingLogin,
type VerifiedIdentity,
} from "./types.js";
const PENDING_TTL_MS = 10 * 60 * 1000;
export const PENDING_LOGIN_COOKIE = "termix_pending_login";
const pendingLogins = new Map<string, PendingLogin>();
function prunePending(now = Date.now()): void {
for (const [key, pending] of pendingLogins) {
if (now - pending.createdAt > PENDING_TTL_MS) pendingLogins.delete(key);
}
}
function pendingKey(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
/** Test helper. */
export function resetPendingLoginsForTests(): void {
pendingLogins.clear();
}
export interface SecondFactorState {
/** Factors the user must pass, all with an active provider. */
required: SecondFactor[];
/** Enrolled in a factor whose plugin is disabled or missing. */
blocked: { pluginId: string; factorId: string }[];
}
/**
* Which second factors apply to a user. A factor recorded in
* user_second_factors whose plugin is not running blocks the login: skipping
* it would turn "disable the plugin" into "remove everyone's 2FA".
*/
export async function evaluateSecondFactors(
userId: string,
): Promise<SecondFactorState> {
ensureCoreLoginProviders();
const rows =
await createCurrentUserAuthRepository().listSecondFactors(userId);
const required = new Map<string, SecondFactor>();
const blocked: SecondFactorState["blocked"] = [];
for (const row of rows) {
const factor = getSecondFactor(row.pluginId, row.factorId);
if (!factor) {
blocked.push({ pluginId: row.pluginId, factorId: row.factorId });
continue;
}
required.set(`${factor.pluginId}:${factor.id}`, factor);
}
// A factor can also report enrolment itself, for data from before
// user_second_factors existed.
for (const factor of listSecondFactors()) {
const key = `${factor.pluginId}:${factor.id}`;
if (required.has(key)) continue;
try {
if (await factor.isEnrolled(userId)) required.set(key, factor);
} catch (error) {
authLogger.warn("Second factor enrolment check failed", {
operation: "second_factor_enrolment_check",
factorId: factor.id,
error,
});
}
}
return { required: [...required.values()], blocked };
}
async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
const deviceInfo = parseUserAgent(req);
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
if (!fingerprint) return false;
const trusted = await AuthManager.getInstance().isTrustedDevice(
userId,
fingerprint,
);
if (trusted) {
authLogger.info("Second factor bypassed for trusted device", {
operation: "totp_bypass",
userId,
deviceFingerprint: fingerprint,
});
}
return trusted;
}
type DeviceType = ReturnType<typeof parseUserAgent>["type"];
async function resolveUser(
identity: VerifiedIdentity,
deviceType: DeviceType,
): Promise<UserRecord> {
if (identity.kind === "external") {
return findOrProvisionExternalUser(identity, deviceType);
}
const user = await createCurrentUserRepository().findById(identity.userId);
if (!user)
throw new LoginMethodError("User not found", 404, "user_not_found");
return user;
}
/**
* Unlocks the user's data key. A password login migrates and opens the key
* with the password; everything else uses the server-held wrapping.
*/
async function unlockUser(
user: UserRecord,
identity: VerifiedIdentity,
deviceType: DeviceType,
): Promise<void> {
const authManager = AuthManager.getInstance();
if (identity.kind === "external" || user.isOidc) {
try {
await authManager.authenticateOIDCUser(user.id, deviceType);
} catch (error) {
authLogger.error("Failed to set up external user encryption", error, {
operation: "external_user_encryption_setup_failed",
userId: user.id,
});
}
return;
}
const ok = identity.password
? await authManager.authenticateUser(user.id, identity.password, deviceType)
: await authManager.authenticateWebAuthnUser(user.id, deviceType);
if (!ok) {
throw new LoginMethodError(
identity.password
? "Incorrect password"
: (identity.unlockError ?? "This account could not be unlocked"),
401,
"unlock_failed",
);
}
}
export interface LoginContext {
methodId: string;
rememberMe: boolean;
/** Username the rate limiter counted, cleared on success. */
rateLimitUsername?: string;
}
export type LoginResult =
| {
kind: "session";
token: string;
maxAge: number;
body: Record<string, unknown>;
}
| {
kind: "second-factor";
tempToken: string;
factors: Array<{ id: string; pluginId: string; labelKey: string }>;
rememberMe: boolean;
};
/**
* Takes a verified identity to either a session or a second-factor step.
* Throws LoginMethodError for anything the user should be told.
*/
export async function runLogin(
req: Request,
identity: VerifiedIdentity,
context: LoginContext,
): Promise<LoginResult> {
const deviceType = parseUserAgent(req).type;
const user = await resolveUser(identity, deviceType);
await unlockUser(user, identity, deviceType);
await syncSharedCredentialsForUserRoles(
user.id,
`${context.methodId}_role_shared_credentials`,
);
const factors = await evaluateSecondFactors(user.id);
if (factors.blocked.length > 0) {
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
operation: "second_factor_unavailable",
userId: user.id,
factors: factors.blocked,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: user.id,
username: user.username,
action: "login_blocked_second_factor",
resourceType: "session",
details: JSON.stringify({ unavailable: factors.blocked }),
ipAddress,
userAgent,
success: false,
errorMessage: "Enrolled second factor unavailable",
});
throw new LoginMethodError(
"Your account uses a second factor that is not available right now. Contact an admin to reset it.",
403,
"second_factor_unavailable",
);
}
const ssoClaims = {
ssoProviderId: identity.ssoProviderId ?? null,
oidcSub: identity.oidcSub ?? null,
oidcSid: identity.oidcSid ?? null,
};
if (
factors.required.length > 0 &&
!identity.mfaSatisfied &&
!(await isTrustedDevice(req, user.id))
) {
const tempToken = await AuthManager.getInstance().generateJWTToken(
user.id,
{ pendingTOTP: true, expiresIn: "10m" },
);
prunePending();
pendingLogins.set(pendingKey(tempToken), {
userId: user.id,
methodId: context.methodId,
rememberMe: context.rememberMe,
...ssoClaims,
createdAt: Date.now(),
});
return {
kind: "second-factor",
tempToken,
rememberMe: context.rememberMe,
factors: factors.required.map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
labelKey: factor.labelKey,
})),
};
}
const session = await issueSession(req, user, {
methodId: context.methodId,
rememberMe: context.rememberMe,
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
longLivedForApps: identity.kind === "external",
...ssoClaims,
});
return { kind: "session", ...session };
}
/** JSON form of a login result, the shape the login screen expects. */
export function secondFactorBody(
result: Extract<LoginResult, { kind: "second-factor" }>,
): Record<string, unknown> {
return {
success: true,
requires_totp: true,
requires_second_factor: true,
temp_token: result.tempToken,
rememberMe: result.rememberMe,
second_factors: result.factors,
};
}
/** Runs a form login and writes the response. */
export async function respondWithLogin(
req: Request,
res: Response,
identity: VerifiedIdentity,
context: LoginContext,
): Promise<Response> {
const result = await runLogin(req, identity, context);
if (result.kind === "second-factor") {
return res.json(secondFactorBody(result));
}
return sendSession(req, res, result);
}
export function sendLoginError(res: Response, error: unknown): Response {
if (error instanceof LoginMethodError) {
const remainingTime = (error as { remainingTime?: number }).remainingTime;
return res.status(error.status).json({
error: error.message,
...(error.code ? { code: error.code } : {}),
...(remainingTime !== undefined ? { remainingTime } : {}),
});
}
authLogger.error("Login failed", error);
return res.status(500).json({ error: "Login failed" });
}
export interface PendingLoginLookup {
userId: string;
token: string;
pending: PendingLogin | null;
}
/** Checks a pending token from the body or the redirect cookie. */
export async function readPendingLogin(
req: Request,
): Promise<PendingLoginLookup | null> {
const token =
(typeof req.body?.temp_token === "string" && req.body.temp_token) ||
(req.cookies?.[PENDING_LOGIN_COOKIE] as string | undefined);
if (!token) return null;
const decoded = await AuthManager.getInstance().verifyJWTToken(token);
if (!decoded || !decoded.pendingTOTP) return null;
prunePending();
const pending = pendingLogins.get(pendingKey(token)) ?? null;
return { userId: decoded.userId, token, pending };
}
export function consumePendingLogin(token: string): void {
pendingLogins.delete(pendingKey(token));
}
/**
* Finishes a login that stopped for a second factor. The pending token comes
* from the body, or from the cookie a redirect method set.
*/
export async function verifySecondFactorAndRespond(
req: Request,
res: Response,
factorId: string,
): Promise<Response> {
const lookup = await readPendingLogin(req);
if (!lookup) {
return res.status(401).json({ error: "Invalid temporary token" });
}
const user = await createCurrentUserRepository().findById(lookup.userId);
if (!user) return res.status(404).json({ error: "User not found" });
const lockStatus = loginRateLimiter.isTOTPLocked(user.id);
if (lockStatus.locked) {
authLogger.warn("Second factor blocked due to rate limiting", {
operation: "totp_verify_blocked",
userId: user.id,
remainingTime: lockStatus.remainingTime,
});
return res.status(429).json({
error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`,
remainingTime: lockStatus.remainingTime,
code: "TOTP_RATE_LIMITED",
});
}
loginRateLimiter.recordFailedTOTPAttempt(user.id);
const factors = await evaluateSecondFactors(user.id);
if (factors.blocked.length > 0) {
return res.status(403).json({
error:
"Your account uses a second factor that is not available right now. Contact an admin to reset it.",
code: "second_factor_unavailable",
});
}
const factor = factors.required.find(
(candidate) => candidate.id === factorId,
);
if (!factor) {
return res
.status(400)
.json({ error: "That second factor is not enabled for this user" });
}
const result = await factor.verify(user.id, req.body ?? {});
if (result !== true) {
if (result && typeof result === "object") {
return res.status(result.code === "SESSION_EXPIRED" ? 401 : 400).json({
error: result.error,
...(result.code ? { code: result.code } : {}),
});
}
authLogger.warn("Second factor verification failed", {
operation: "totp_verify_failed",
userId: user.id,
factorId,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: user.id,
username: user.username,
action: "login_second_factor_failed",
resourceType: "session",
details: JSON.stringify({ factor: factorId }),
ipAddress,
userAgent,
success: false,
});
return res.status(401).json({
error: "Invalid TOTP code",
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(user.id),
});
}
loginRateLimiter.resetTOTPAttempts(user.id);
const rememberMe =
typeof req.body?.rememberMe === "boolean"
? req.body.rememberMe
: (lookup.pending?.rememberMe ?? false);
if (rememberMe) {
const deviceInfo = parseUserAgent(req);
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
if (fingerprint) {
await AuthManager.getInstance().addTrustedDevice(
user.id,
fingerprint,
deviceInfo.type,
deviceInfo.deviceInfo,
);
authLogger.info("Device automatically trusted via Remember Me", {
operation: "totp_auto_trust",
userId: user.id,
deviceType: deviceInfo.type,
});
}
}
const pending = lookup.pending;
const session = await issueSession(req, user, {
methodId: pending?.methodId ?? "password",
rememberMe,
ssoProviderId: pending?.ssoProviderId ?? null,
oidcSub: pending?.oidcSub ?? null,
oidcSid: pending?.oidcSid ?? null,
});
consumePendingLogin(lookup.token);
res.clearCookie(
PENDING_LOGIN_COOKIE,
AuthManager.getInstance().getClearCookieOptions(req),
);
return sendSession(req, res, session);
}
/**
* Finishes a redirect login (OIDC and friends): back to where the browser
* came from, with a cookie, or with the token in the URL for the desktop and
* mobile app callbacks that cannot read one.
*/
export async function respondWithRedirectLogin(
req: Request,
res: Response,
identity: VerifiedIdentity,
context: LoginContext,
isTokenCallback: (url: string) => boolean,
): Promise<void> {
const returnTo = identity.returnTo;
if (!returnTo) {
res.status(500).json({ error: "Login method did not say where to return" });
return;
}
const authManager = AuthManager.getInstance();
let result: LoginResult;
try {
result = await runLogin(req, identity, context);
} catch (error) {
redirectWithLoginError(res, returnTo, error);
return;
}
const url = new URL(returnTo);
const tokenCallback = isTokenCallback(returnTo);
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
if (result.kind === "second-factor") {
url.searchParams.set("second_factor", "1");
url.searchParams.set(
"second_factors",
result.factors.map((factor) => factor.id).join(","),
);
if (tokenCallback) {
url.searchParams.set("temp_token", result.tempToken);
res.redirect(url.toString());
return;
}
res
.cookie(
PENDING_LOGIN_COOKIE,
result.tempToken,
authManager.getSecureCookieOptions(req, PENDING_TTL_MS),
)
.redirect(url.toString());
return;
}
url.searchParams.set("success", "true");
if (tokenCallback) {
url.searchParams.set("token", result.token);
res.redirect(url.toString());
return;
}
res
.cookie(
"jwt",
result.token,
authManager.getSecureCookieOptions(req, result.maxAge),
)
.redirect(url.toString());
}
export function redirectWithLoginError(
res: Response,
returnTo: string,
error: unknown,
): void {
if (!(error instanceof LoginMethodError)) {
authLogger.error("Redirect login failed", error);
}
const url = new URL(returnTo);
url.searchParams.set(
"error",
error instanceof LoginMethodError
? (error.code ?? error.message)
: "OIDC authentication failed",
);
res.redirect(url.toString());
}
+329
View File
@@ -0,0 +1,329 @@
/**
* Finds or creates the user behind an external sign-in (OIDC, LDAP, a
* plugin's provider). The rules used to be copied into every SSO route; they
* live here once so every login method gets the same ones:
*
* - the allowed-users list is checked on every sign-in, new user or not
* - the first user ever becomes admin and skips the list
* - anyone else is only created when SSO auto-provisioning is on
* - an admin group, when the provider reports one, keeps admin in step
* - a mapped role set, when the provider reports one, keeps roles in step
*/
import { nanoid } from "nanoid";
import { authLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
import { PermissionManager } from "../utils/permission-manager.js";
import {
createCurrentRoleRepository,
createCurrentSettingsRepository,
createCurrentUserAuthRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import type { UserRecord } from "../database/repositories/user-repository.js";
import { isOIDCUserAllowed } from "../database/routes/user-oidc-utils.js";
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
type ExternalIdentity = Extract<VerifiedIdentity, { kind: "external" }>;
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** SSO auto-provisioning: the admin setting, or OIDC_ALLOW_REGISTRATION. */
export async function isExternalProvisioningAllowed(): Promise<boolean> {
try {
if (
await createCurrentSettingsRepository().getBoolean(
"oidc_auto_provision",
false,
)
) {
return true;
}
} catch {
// fall through to the environment
}
return (
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === "true"
);
}
/**
* Whether a newly provisioned user must wait for an admin. There is no
* approval queue yet, so nobody does; a plugin or a later release can change
* that here without touching any login method.
*/
export async function isApprovalRequired(
_identity: ExternalIdentity,
): Promise<boolean> {
return false;
}
function isAllowed(identity: ExternalIdentity): boolean {
if (!identity.allowedUsers) return true;
const email = identity.email ?? undefined;
return (
isOIDCUserAllowed(identity.allowedUsers, identity.subject, email) ||
(!!identity.legacyIdentifier &&
isOIDCUserAllowed(
identity.allowedUsers,
identity.legacyIdentifier,
email,
))
);
}
async function syncAdmin(
user: UserRecord,
isAdmin: boolean,
): Promise<UserRecord> {
if (!!user.isAdmin === isAdmin) return user;
const updated =
(await createCurrentUserRepository().update(user.id, { isAdmin })) ?? user;
try {
await createCurrentRoleRepository().switchUserRoleName({
userId: user.id,
addRoleName: isAdmin ? "admin" : "user",
removeRoleName: isAdmin ? "user" : "admin",
grantedBy: user.id,
});
} catch {
// Roles follow on the next sign-in; the admin flag itself is saved.
}
authLogger.info("Admin status synced from the identity provider", {
operation: "external_admin_sync",
userId: user.id,
isAdmin,
});
return updated;
}
/**
* Adds and removes the roles a provider's group map manages. Roles assigned
* by hand, and roles outside the map, are never touched.
*/
export async function applyRoleSync(
userId: string,
roleSync: { desired: string[]; managed: string[] },
): Promise<void> {
const desired = new Set(roleSync.desired);
const managed = new Set(roleSync.managed);
const roleRepository = createCurrentRoleRepository();
const currentRoles = await roleRepository.listUserRoles(userId);
const currentNames = new Set(currentRoles.map((role) => role.roleName));
const toAdd = [...desired].filter((name) => !currentNames.has(name));
const toRemove = currentRoles.filter(
(role) => managed.has(role.roleName) && !desired.has(role.roleName),
);
for (const roleName of toAdd) {
const assigned = await roleRepository.assignRoleNameToUser({
userId,
roleName,
grantedBy: userId,
});
if (!assigned) {
authLogger.warn("Role map references a role that does not exist", {
operation: "external_role_map_missing_role",
userId,
roleName,
});
}
}
for (const role of toRemove) {
await roleRepository.removeRoleFromUser(userId, role.roleId);
}
if (toAdd.length > 0 || toRemove.length > 0) {
authLogger.info("Roles synced from provider group membership", {
operation: "external_role_map_sync",
userId,
added: toAdd,
removed: toRemove.map((role) => role.roleName),
});
PermissionManager.getInstance().invalidateUserPermissionCache(userId);
}
}
async function findLinkedUser(
identity: ExternalIdentity,
): Promise<UserRecord | null> {
const identities = createCurrentUserAuthRepository();
const users = createCurrentUserRepository();
const linked = await identities.findIdentity(
identity.provider,
identity.subject,
);
if (linked) {
const user = await users.findById(linked.userId);
if (user) return user;
}
// Accounts from before user_external_identities matched on the raw
// identifier column; link them the first time they sign in.
if (identity.legacyIdentifier) {
const legacy = await users.findByOidcIdentifier(identity.legacyIdentifier);
if (legacy) {
await identities.linkIdentity({
userId: legacy.id,
providerId: identity.provider,
subject: identity.subject,
email: identity.email,
});
return legacy;
}
}
return null;
}
async function createUser(
identity: ExternalIdentity,
deviceType: string,
): Promise<UserRecord> {
const users = createCurrentUserRepository();
const id = nanoid();
const created = await users.createFirstSsoUser({
id,
username: identity.name || identity.subject,
passwordHash: "",
isAdmin: !!identity.isAdmin,
isOidc: true,
oidcIdentifier:
identity.legacyIdentifier ?? `${identity.provider}:${identity.subject}`,
ssoProviderId: identity.ssoProviderId ?? null,
});
try {
const assigned = await createCurrentRoleRepository().assignRoleNameToUser({
userId: id,
roleName: created.user.isAdmin ? "admin" : "user",
grantedBy: id,
});
if (!assigned) {
authLogger.warn("Default role not found for a provisioned user", {
operation: "external_default_role",
userId: id,
});
}
} catch (roleError) {
authLogger.error("Failed to assign a default role", roleError, {
operation: "external_default_role",
userId: id,
});
}
try {
const sessionDurationMs =
deviceType === "desktop" || deviceType === "mobile"
? THIRTY_DAYS_MS
: ONE_DAY_MS;
await AuthManager.getInstance().registerOIDCUser(id, sessionDurationMs);
} catch (encryptionError) {
await users.delete(id);
authLogger.error(
"Failed to set up a provisioned user's encryption, user removed",
encryptionError,
{ operation: "external_user_create_failed", userId: id },
);
throw new LoginMethodError(
"Failed to setup user security - user creation cancelled",
500,
"setup_failed",
);
}
await createCurrentUserAuthRepository().linkIdentity({
userId: id,
providerId: identity.provider,
subject: identity.subject,
email: identity.email,
});
try {
await DatabaseSaveTrigger.forceSave("external_user_create");
} catch (saveError) {
authLogger.error("Failed to persist a provisioned user", saveError, {
operation: "external_user_create_save_failed",
userId: id,
});
}
return created.user;
}
export async function findOrProvisionExternalUser(
identity: ExternalIdentity,
deviceType: string,
): Promise<UserRecord> {
let user = await findLinkedUser(identity);
if (!user) {
const isFirstUser = (await createCurrentUserRepository().countAll()) === 0;
if (!isFirstUser) {
if (!isAllowed(identity)) {
authLogger.warn("External user not in the allowed list", {
operation: "external_user_not_allowed",
provider: identity.provider,
});
throw new LoginMethodError("User not allowed", 403, "user_not_allowed");
}
if (!(await isExternalProvisioningAllowed())) {
authLogger.warn("External sign-in refused, provisioning is off", {
operation: "external_registration_disabled",
provider: identity.provider,
});
throw new LoginMethodError(
"Registration is disabled",
403,
"registration_disabled",
);
}
if (await isApprovalRequired(identity)) {
throw new LoginMethodError(
"Your account is waiting for an admin to approve it",
403,
"approval_required",
);
}
}
user = await createUser(identity, deviceType);
} else {
if (!isAllowed(identity)) {
authLogger.warn("External user not in the allowed list", {
operation: "external_user_not_allowed_existing",
provider: identity.provider,
userId: user.id,
});
throw new LoginMethodError("User not allowed", 403, "user_not_allowed");
}
// A user who also has a password picked their own username; leave it.
const isDualAuth = !!user.passwordHash && user.passwordHash.trim() !== "";
if (!isDualAuth && identity.name && user.username !== identity.name) {
user =
(await createCurrentUserRepository().update(user.id, {
username: identity.name,
})) ?? user;
}
if (identity.isAdmin !== undefined) {
user = await syncAdmin(user, identity.isAdmin);
}
}
if (identity.roleSync) {
try {
await applyRoleSync(user.id, identity.roleSync);
} catch (roleSyncError) {
authLogger.error("Failed to sync roles from provider", roleSyncError, {
operation: "external_role_map_sync_failed",
userId: user.id,
});
}
}
return user;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Login methods and second factors, from core and from plugins.
*
* Password login is registered by core and always present. Everything else
* (OIDC, LDAP, passkeys, TOTP, ...) registers here, from a plugin through
* ctx.auth or, in 2.9.0, from legacy-providers.ts.
*/
import type {
PluginLoginMethod,
PluginSecondFactor,
} from "@termix/plugin-sdk/backend";
export type LoginMethod = PluginLoginMethod & { pluginId: string };
export type SecondFactor = PluginSecondFactor & { pluginId: string };
const loginMethods = new Map<string, LoginMethod>();
const secondFactors = new Map<string, SecondFactor>();
function register<T extends { id: string; pluginId: string }>(
map: Map<string, T>,
kind: string,
entry: T,
): () => void {
const existing = map.get(entry.id);
if (existing && existing.pluginId !== entry.pluginId) {
throw new Error(
`${kind} "${entry.id}" is already provided by ${existing.pluginId}`,
);
}
map.set(entry.id, entry);
return () => {
if (map.get(entry.id) === entry) map.delete(entry.id);
};
}
export function registerLoginMethod(method: LoginMethod): () => void {
return register(loginMethods, "Login method", method);
}
export function getLoginMethod(id: string): LoginMethod | undefined {
return loginMethods.get(id);
}
export function listLoginMethods(): LoginMethod[] {
return [...loginMethods.values()];
}
export function registerSecondFactor(factor: SecondFactor): () => void {
return register(secondFactors, "Second factor", factor);
}
export function getSecondFactor(
pluginId: string,
factorId: string,
): SecondFactor | undefined {
const factor = secondFactors.get(factorId);
return factor && factor.pluginId === pluginId ? factor : undefined;
}
export function listSecondFactors(): SecondFactor[] {
return [...secondFactors.values()];
}
/** Test helper. */
export function resetAuthRegistryForTests(): void {
loginMethods.clear();
secondFactors.clear();
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Admin view and reset of a user's second factors. Covers factors whose
* plugin is disabled or gone, which is exactly when a user gets locked out
* and an admin has to step in.
*/
import {
createCurrentTrustedDeviceRepository,
createCurrentUserAuthRepository,
} from "../database/repositories/factory.js";
import { authLogger } from "../utils/logger.js";
import { ensureCoreLoginProviders } from "./core-auth.js";
import { getSecondFactor, listSecondFactors } from "./registry.js";
export interface UserSecondFactorSummary {
pluginId: string;
factorId: string;
labelKey: string | null;
/** False when the plugin behind it is not running. */
available: boolean;
}
export async function listUserSecondFactors(
userId: string,
): Promise<UserSecondFactorSummary[]> {
ensureCoreLoginProviders();
const rows =
await createCurrentUserAuthRepository().listSecondFactors(userId);
const summaries: UserSecondFactorSummary[] = rows.map((row) => {
const factor = getSecondFactor(row.pluginId, row.factorId);
return {
pluginId: row.pluginId,
factorId: row.factorId,
labelKey: factor?.labelKey ?? null,
available: !!factor,
};
});
for (const factor of listSecondFactors()) {
if (
summaries.some(
(summary) =>
summary.pluginId === factor.pluginId &&
summary.factorId === factor.id,
)
) {
continue;
}
if (await factor.isEnrolled(userId).catch(() => false)) {
summaries.push({
pluginId: factor.pluginId,
factorId: factor.id,
labelKey: factor.labelKey,
available: true,
});
}
}
return summaries;
}
/**
* Removes every factor: runs each running factor's own reset (so TOTP clears
* its secret), drops the enrolment rows including orphaned ones, and forgets
* trusted devices so an old "remember me" cannot skip the new factor.
*/
export async function resetUserSecondFactors(
userId: string,
): Promise<Array<{ pluginId: string; factorId: string }>> {
const factors = await listUserSecondFactors(userId);
for (const summary of factors) {
const factor = getSecondFactor(summary.pluginId, summary.factorId);
if (!factor?.reset) continue;
try {
await factor.reset(userId);
} catch (error) {
authLogger.error("Second factor reset failed", error, {
operation: "admin_reset_second_factor",
userId,
factorId: summary.factorId,
});
}
}
await createCurrentUserAuthRepository().clearSecondFactors(userId);
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
return factors.map(({ pluginId, factorId }) => ({ pluginId, factorId }));
}
+162
View File
@@ -0,0 +1,162 @@
/**
* The one place a login becomes a session: JWT, cookie, response body,
* audit line and the automation event. Every login method ends here, so
* they all behave the same way.
*/
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 { notifyAutomationInternalEvent } from "../hosts/automation-events.js";
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import type { UserRecord } from "../database/repositories/user-repository.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. */
export 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;
ssoProviderId?: number | null;
oidcSub?: string | null;
oidcSid?: string | null;
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
longLivedForApps?: boolean;
}
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,
oidcSub: options.oidcSub ?? null,
oidcSid: options.oidcSid ?? null,
ssoProviderId: options.ssoProviderId ?? 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,
});
notifyAutomationInternalEvent("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,
});
const maxAge =
options.longLivedForApps &&
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
? THIRTY_DAYS_MS
: await sessionCookieMaxAge(options.rememberMe);
return {
token,
maxAge,
body: {
success: true,
is_admin: !!user.isAdmin,
username: user.username,
userId: user.id,
is_oidc: !!user.isOidc,
totp_enabled: !!user.totpEnabled,
...(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);
}
+37
View File
@@ -0,0 +1,37 @@
import type { PluginVerifiedIdentity } from "@termix/plugin-sdk/backend";
export { LoginMethodError } from "@termix/plugin-sdk/backend";
/** Extra fields core's own methods may set. Plugins never see these. */
interface CoreIdentityExtras {
/**
* The identifier string the user row carried before user_external_identities
* existed ("ldap:<provider>:<id>", "github:<provider>:<id>" or a bare OIDC
* subject). Found users get their identity row linked lazily; new users keep
* the column filled so older code that reads it still works.
*/
legacyIdentifier?: string;
/** SSO provider row the session came from, for back-channel logout. */
ssoProviderId?: number | null;
oidcSub?: string | null;
oidcSid?: string | null;
/** Provider group to role mapping, applied on every login. */
roleSync?: { desired: string[]; managed: string[] };
/** Shown when the user's data key cannot be unlocked by this method. */
unlockError?: string;
/** Username the login rate limiter counted, cleared on success. */
rateLimitUsername?: string;
}
export type VerifiedIdentity = PluginVerifiedIdentity & CoreIdentityExtras;
/** What a login needs to remember while it waits for a second factor. */
export interface PendingLogin {
userId: string;
methodId: string;
rememberMe: boolean;
ssoProviderId?: number | null;
oidcSub?: string | null;
oidcSid?: string | null;
createdAt: number;
}