feat: move sso and ldap logins into plugins (C2)

This commit is contained in:
LukeGus committed 2026-09-24 18:23:20 -05:00
1 parent 5c18435105
commit 925da4a661
223 files changed
+25778 -6820

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
/**
* The allowed-users list an external login method passes with its identity:
* one pattern per line or comma, "*" for anyone, "@example.com" for a
* domain, "*" as a wildcard inside a pattern, or an exact name. Empty allows
* everyone.
*/
export function isExternalUserAllowed(
allowedUsers: string,
identifier: string,
email?: string,
): boolean {
if (!allowedUsers || !allowedUsers.trim()) return true;
const patterns = allowedUsers
.split(/[\n,]/)
.map((p) => p.trim())
.filter(Boolean);
if (patterns.length === 0) return true;
const values = [
identifier,
...(email && email !== identifier ? [email] : []),
];
for (const pattern of patterns) {
if (pattern === "*") return true;
if (pattern.includes("*")) {
const escaped = pattern
.toLowerCase()
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*");
const regex = new RegExp(`^${escaped}$`);
if (values.some((v) => v && regex.test(v.toLowerCase()))) return true;
continue;
}
for (const value of values) {
if (!value) continue;
if (pattern.toLowerCase().startsWith("@")) {
if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true;
} else {
if (value.toLowerCase() === pattern.toLowerCase()) return true;
}
}
}
return false;
}
-2
View File
@@ -6,7 +6,6 @@ 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";
import { LoginMethodError } from "./types.js";
@@ -17,7 +16,6 @@ export function ensureCoreLoginProviders(): void {
if (registered) return;
registered = true;
registerPasswordLoginMethod();
registerLegacyLoginProviders();
}
/** Test helper. */
-71
View File
@@ -16,7 +16,6 @@ import type {
SshAuthProvider,
SshConnectHost,
} from "../hosts/connect/types.js";
import { registerLoginMethod, type LoginMethod } from "./registry.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.";
@@ -263,77 +262,7 @@ export function registerLegacySshAuthProviders(): void {
});
}
// 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",
external: true,
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",
external: true,
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 });
},
};
let loginRegistered = false;
export function registerLegacyLoginProviders(): void {
if (loginRegistered) return;
loginRegistered = true;
registerLoginMethod(oidcLoginMethod);
registerLoginMethod(ldapLoginMethod);
}
/** Test helper. */
export function resetLegacyProvidersForTests(): void {
sshRegistered = false;
loginRegistered = false;
}
-227
View File
@@ -1,227 +0,0 @@
/**
* 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
@@ -1,518 +0,0 @@
/**
* 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,
);
}
}
+3 -3
View File
@@ -22,7 +22,7 @@ import {
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 { isExternalUserAllowed } from "./allowed-users.js";
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
type ExternalIdentity = Extract<VerifiedIdentity, { kind: "external" }>;
@@ -64,9 +64,9 @@ function isAllowed(identity: ExternalIdentity): boolean {
if (!identity.allowedUsers) return true;
const email = identity.email ?? undefined;
return (
isOIDCUserAllowed(identity.allowedUsers, identity.subject, email) ||
isExternalUserAllowed(identity.allowedUsers, identity.subject, email) ||
(!!identity.legacyIdentifier &&
isOIDCUserAllowed(
isExternalUserAllowed(
identity.allowedUsers,
identity.legacyIdentifier,
email,
-45
View File
@@ -675,17 +675,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sso_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
// Expired open tabs are dropped when the tab list is read, against the
@@ -1879,40 +1868,6 @@ const migrateSchema = () => {
});
}
// Migrate legacy single oidc_config settings blob into sso_providers table
try {
const migrationDone = getRawSettingValue("sso_migration_v1");
if (!migrationDone) {
const providerCount = (
sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as {
c: number;
}
).c;
if (providerCount === 0) {
const legacyConfig = getRawSettingValue("oidc_config");
if (legacyConfig) {
sqlite
.prepare(
"INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
)
.run("OIDC", legacyConfig);
databaseLogger.info(
"Migrated legacy oidc_config into sso_providers table",
{
operation: "sso_migration_v1",
},
);
}
}
setRawSettingValue("sso_migration_v1", "true");
}
} catch (e) {
databaseLogger.warn("Failed to run SSO migration v1", {
operation: "sso_migration_v1",
error: e,
});
}
// --- alerts begin ---
// alert_rules, alert_rule_channels and alert_firings were only ever created
// here, never declared in schema.ts, so they never existed on Postgres or
-15
View File
@@ -49,21 +49,6 @@ export const settings = mysqlTable("settings", {
value: text("value").notNull(),
});
export const ssoProviders = mysqlTable("sso_providers", {
id: int("id").autoincrement().primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
type: text("type").notNull(),
enabled: boolean("enabled").notNull().default(true),
displayOrder: int("display_order").notNull().default(0),
config: text("config").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const sessions = mysqlTable(
"sessions",
{
-15
View File
@@ -50,21 +50,6 @@ export const settings = pgTable("settings", {
value: text("value").notNull(),
});
export const ssoProviders = pgTable("sso_providers", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
type: text("type").notNull(),
enabled: boolean("enabled").notNull().default(true),
displayOrder: integer("display_order").notNull().default(0),
config: text("config").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const sessions = pgTable(
"sessions",
{
-15
View File
@@ -40,21 +40,6 @@ export const settings = sqliteTable("settings", {
value: text("value").notNull(),
});
export const ssoProviders = sqliteTable("sso_providers", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
displayOrder: integer("display_order").notNull().default(0),
config: text("config").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const sessions = sqliteTable(
"sessions",
{
@@ -37,7 +37,6 @@ import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-re
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { SsoProviderRepository } from "./sso-provider-repository.js";
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
import { TermixIdentityRepository } from "./termix-identity-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
@@ -360,13 +359,6 @@ export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageR
);
}
export function createCurrentSsoProviderRepository(): SsoProviderRepository {
return new SsoProviderRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("sso_provider_repository_write"),
);
}
export function createCurrentTermixIdentityCaRepository(): TermixIdentityCaRepository {
return new TermixIdentityCaRepository(
createCurrentRepositoryContext(),
@@ -1,126 +0,0 @@
import { asc, eq } from "drizzle-orm";
import { ssoProviders, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type SsoProviderRecord = typeof ssoProviders.$inferSelect;
export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert;
export type SsoProviderUpdate = Partial<
Pick<
NewSsoProviderRecord,
"name" | "type" | "enabled" | "displayOrder" | "config" | "updatedAt"
>
>;
export type PublicSsoProviderRecord = Pick<
SsoProviderRecord,
"id" | "name" | "type" | "displayOrder"
>;
export class SsoProviderRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listEnabledPublic(): Promise<PublicSsoProviderRecord[]> {
return this.context.drizzle
.select({
id: ssoProviders.id,
name: ssoProviders.name,
type: ssoProviders.type,
displayOrder: ssoProviders.displayOrder,
})
.from(ssoProviders)
.where(eq(ssoProviders.enabled, true))
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
}
async listEnabled(): Promise<SsoProviderRecord[]> {
return this.context.drizzle
.select()
.from(ssoProviders)
.where(eq(ssoProviders.enabled, true))
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
}
async listAll(): Promise<SsoProviderRecord[]> {
return this.context.drizzle
.select()
.from(ssoProviders)
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
}
async findById(id: number): Promise<SsoProviderRecord | null> {
const rows = await this.context.drizzle
.select()
.from(ssoProviders)
.where(eq(ssoProviders.id, id))
.limit(1);
return rows[0] ?? null;
}
async findFirstEnabledOidcLike(): Promise<SsoProviderRecord | null> {
const rows = await this.context.drizzle
.select()
.from(ssoProviders)
.where(eq(ssoProviders.enabled, true))
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
return (
rows.find(
(row) =>
row.type === "oidc" || row.type === "github" || row.type === "google",
) ?? null
);
}
async create(provider: NewSsoProviderRecord): Promise<SsoProviderRecord> {
const rows = await insertReturning(this.context, ssoProviders, provider);
await this.afterWrite();
return rows[0];
}
async update(
id: number,
update: SsoProviderUpdate,
): Promise<SsoProviderRecord | null> {
const rows = await updateReturning(
this.context,
ssoProviders,
update,
eq(ssoProviders.id, id),
);
await this.afterWrite();
return rows[0] ?? null;
}
async delete(id: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(ssoProviders)
.where(eq(ssoProviders.id, id));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async countUsersByProviderId(providerId: number): Promise<number> {
const rows = await this.context.drizzle
.select({ id: users.id })
.from(users)
.where(eq(users.ssoProviderId, providerId));
return rows.length;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -54,6 +54,14 @@ export class UserAuthRepository {
.where(eq(userExternalIdentities.userId, userId));
}
async countUsersForProvider(providerId: string): Promise<number> {
const rows = await this.context.drizzle
.select({ userId: userExternalIdentities.userId })
.from(userExternalIdentities)
.where(eq(userExternalIdentities.providerId, providerId));
return new Set(rows.map((row) => row.userId)).size;
}
async listAllIdentities(): Promise<UserExternalIdentityRecord[]> {
return this.context.drizzle.select().from(userExternalIdentities);
}
@@ -0,0 +1,165 @@
/**
* The 2.8 login URLs that something outside Termix still depends on:
* identity providers configured with the old callback and back-channel
* logout URLs, and clients built against 2.8 such as Termix-Mobile. Each one
* forwards to the login method or plugin route that replaced it, so none of
* them can sign anyone in on its own.
*/
import type { Request, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { getRequestBasePath } from "../../utils/request-origin.js";
import {
listLegacySsoProviders,
startRedirectLogin,
verifyFormLogin,
} from "./auth-routes.js";
/**
* A path on this install, keeping the query string. Relative, so the browser
* stays on the scheme and host it used, whatever a proxy told us.
*/
function pluginUrl(req: Request, path: string): string {
const query = req.originalUrl.indexOf("?");
return `${getRequestBasePath(req)}${path}${
query >= 0 ? req.originalUrl.slice(query) : ""
}`;
}
export function registerAuthCompatRoutes(router: Router): void {
/**
* @openapi
* /users/oidc-config:
* get:
* summary: Get the default SSO provider's public configuration (2.8 route)
* description: Kept for 2.8 clients. Redirects to the sso plugin's /plugin-api/sso/config.
* tags:
* - Auth
* responses:
* 307:
* description: Redirect to the sso plugin.
*/
router.get("/oidc-config", (req, res) => {
res.redirect(307, pluginUrl(req, "/plugin-api/sso/config"));
});
/**
* @openapi
* /users/oidc/authorize:
* get:
* summary: Start an SSO login (2.8 route)
* description: Kept for 2.8 clients such as Termix-Mobile. Same as /users/auth/oidc/start, with the provider in providerId.
* tags:
* - Auth
* parameters:
* - in: query
* name: providerId
* schema:
* type: string
* - in: query
* name: rememberMe
* schema:
* type: boolean
* responses:
* 200:
* description: The authorization URL in auth_url.
* 404:
* description: SSO is not available.
*/
router.get("/oidc/authorize", (req, res) =>
startRedirectLogin(
req,
res,
"oidc",
typeof req.query.providerId === "string" && req.query.providerId
? req.query.providerId
: null,
),
);
/**
* @openapi
* /users/oidc/callback:
* get:
* summary: SSO callback (2.8 URL)
* description: The redirect URI identity providers were set up with before 2.9. Permanently redirects to /plugin-api/sso/callback with the same query, so existing provider configurations keep working.
* tags:
* - Auth
* responses:
* 308:
* description: Redirect to the sso plugin's callback.
* post:
* summary: SSO callback, form post (2.8 URL)
* description: Same as the GET form.
* tags:
* - Auth
* responses:
* 308:
* description: Redirect to the sso plugin's callback.
*/
router.all("/oidc/callback", (req, res) => {
res.redirect(308, pluginUrl(req, "/plugin-api/sso/callback"));
});
/**
* @openapi
* /users/oidc/backchannel-logout:
* post:
* summary: OIDC back-channel logout (2.8 URL)
* description: Permanently redirects to /plugin-api/sso/backchannel-logout, so identity providers set up before 2.9 keep working.
* tags:
* - Auth
* responses:
* 308:
* description: Redirect to the sso plugin.
*/
router.post("/oidc/backchannel-logout", (req, res) => {
res.redirect(308, pluginUrl(req, "/plugin-api/sso/backchannel-logout"));
});
/**
* @openapi
* /users/sso-providers:
* get:
* summary: List sign-in providers (2.8 route)
* description: Kept for 2.8 clients. Every enabled instance of every external login method, in the 2.8 shape. /users/auth/methods is the current route.
* tags:
* - Auth
* responses:
* 200:
* description: Providers.
*/
router.get("/sso-providers", async (_req, res) => {
try {
res.json(await listLegacySsoProviders());
} catch (error) {
authLogger.error("Failed to list SSO providers", error);
res.status(500).json({ error: "Failed to list SSO providers" });
}
});
/**
* @openapi
* /users/ldap/login:
* post:
* summary: LDAP login (2.8 route)
* description: Kept for 2.8 clients. Same as /users/auth/ldap/verify, with the provider in providerId.
* tags:
* - Auth
* responses:
* 200:
* description: Login successful, or a second factor is required.
* 401:
* description: Invalid credentials.
* 404:
* description: LDAP is not available.
*/
router.post("/ldap/login", (req, res) =>
verifyFormLogin(
req,
res,
"ldap",
req.body?.providerId != null ? String(req.body.providerId) : null,
),
);
}
+168 -52
View File
@@ -18,7 +18,12 @@ import {
verifySecondFactorAndRespond,
evaluateSecondFactors,
} from "../../auth/login-pipeline.js";
import { getLoginMethod, listLoginMethods } from "../../auth/registry.js";
import {
getLoginMethod,
listLoginMethods,
type LoginMethod,
} from "../../auth/registry.js";
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
export interface PublicLoginMethod {
@@ -30,6 +35,59 @@ export interface PublicLoginMethod {
instances: Array<{ id: string; label: string }>;
}
async function enabledInstances(method: LoginMethod) {
return (await method.describe!()).filter((instance) => instance.enabled);
}
/** External methods with at least one enabled instance. */
export async function listExternalLoginMethods(): Promise<LoginMethod[]> {
ensureCoreLoginProviders();
const found: LoginMethod[] = [];
for (const method of listLoginMethods()) {
if (!method.external) continue;
if (!method.describe) {
found.push(method);
continue;
}
try {
if ((await enabledInstances(method)).length > 0) found.push(method);
} catch {
// A method that cannot describe itself signs nobody in.
}
}
return found;
}
/** The 2.8 `GET /users/sso-providers` shape, built from external methods. */
export async function listLegacySsoProviders(): Promise<
Array<{
id: number | string;
name: string;
type: string;
displayOrder: number;
}>
> {
const providers: Array<{
id: number | string;
name: string;
type: string;
displayOrder: number;
}> = [];
for (const method of await listExternalLoginMethods()) {
if (!method.describe) continue;
for (const instance of await enabledInstances(method)) {
const numeric = Number(instance.id);
providers.push({
id: Number.isInteger(numeric) ? numeric : instance.id,
name: instance.label,
type: instance.type ?? method.id,
displayOrder: providers.length,
});
}
}
return providers;
}
/** What the login screen may know: ids, labels and enabled instances. */
export async function listPublicLoginMethods(): Promise<PublicLoginMethod[]> {
ensureCoreLoginProviders();
@@ -68,16 +126,111 @@ function instanceParam(req: Request): string | null {
return typeof value === "string" && value ? value : null;
}
function methodOr404(req: Request, res: Response) {
/**
* Trusted proxy login and external methods don't mix: the proxy already
* decided who the user is.
*/
function refusedByTrustedProxy(method: LoginMethod, res: Response): boolean {
if (!method.external || !isTrustedProxyAuthEnabled()) return false;
res.status(409).json({
error:
"External login is disabled while trusted proxy authentication is enabled",
});
return true;
}
function methodOr404(req: Request, res: Response, id?: string) {
ensureCoreLoginProviders();
const method = getLoginMethod(String(req.params.methodId));
const method = getLoginMethod(id ?? String(req.params.methodId));
if (!method) {
res.status(404).json({ error: "Unknown login method" });
return null;
}
if (refusedByTrustedProxy(method, res)) return null;
return method;
}
/**
* A redirect method's callback, for core's route and for a plugin's own
* public route through ctx.auth.completeRedirectLogin.
*/
export async function handleRedirectCallback(
method: LoginMethod,
req: Request,
res: Response,
): Promise<void> {
if (refusedByTrustedProxy(method, res)) return;
if (!method.callback) {
res.status(400).json({ error: "Not a redirect login method" });
return;
}
let identity: VerifiedIdentity;
try {
identity = await method.callback(req as never);
} catch (error) {
const returnTo = (error as { returnTo?: string }).returnTo;
if (returnTo) return redirectWithLoginError(res, returnTo, error);
sendLoginError(res, error);
return;
}
await respondWithRedirectLogin(
req,
res,
identity,
{ methodId: method.id, rememberMe: !!identity.rememberMe },
isOidcTokenCallback,
);
}
/** Starts a redirect method, answering with its URL. */
export async function startRedirectLogin(
req: Request,
res: Response,
methodId: string,
instanceId: string | null,
): Promise<void> {
const method = methodOr404(req, res, methodId);
if (!method) return;
if (method.kind !== "redirect" || !method.start) {
res.status(400).json({ error: "Not a redirect login method" });
return;
}
try {
const result = await method.start(req as never, instanceId);
// auth_url is the name 2.8 clients read.
res.json({ ...result, auth_url: result.redirectUrl });
} catch (error) {
sendLoginError(res, error);
}
}
/** Runs a form method and answers with a session or a second-factor step. */
export async function verifyFormLogin(
req: Request,
res: Response,
methodId: string,
instanceId: string | null,
): Promise<void> {
const method = methodOr404(req, res, methodId);
if (!method) return;
if (method.kind !== "form" || !method.verify) {
res.status(400).json({ error: "Not a form login method" });
return;
}
try {
const identity = await method.verify(req as never, instanceId);
await respondWithLogin(req, res, identity, {
methodId: method.id,
rememberMe: !!identity.rememberMe || !!req.body?.rememberMe,
});
} catch (error) {
if (!(error instanceof LoginMethodError)) {
authLogger.error("Form login failed", error, { methodId: method.id });
}
sendLoginError(res, error);
}
}
export function registerAuthRoutes(router: Router): void {
/**
* @openapi
@@ -243,40 +396,19 @@ export function registerAuthRoutes(router: Router): void {
* 404:
* description: Unknown login method.
*/
router.get("/auth/:methodId/start", async (req, res) => {
const method = methodOr404(req, res);
if (!method) return;
if (method.kind !== "redirect" || !method.start) {
return res.status(400).json({ error: "Not a redirect login method" });
}
try {
res.json(await method.start(req as never, instanceParam(req)));
} catch (error) {
sendLoginError(res, error);
}
});
router.get("/auth/:methodId/start", (req, res) =>
startRedirectLogin(
req,
res,
String(req.params.methodId),
instanceParam(req),
),
);
const callback = async (req: Request, res: Response) => {
const method = methodOr404(req, res);
if (!method) return;
if (!method.callback) {
return res.status(400).json({ error: "Not a redirect login method" });
}
let identity: VerifiedIdentity;
try {
identity = await method.callback(req as never);
} catch (error) {
const returnTo = (error as { returnTo?: string }).returnTo;
if (returnTo) return redirectWithLoginError(res, returnTo, error);
return sendLoginError(res, error);
}
await respondWithRedirectLogin(
req,
res,
identity,
{ methodId: method.id, rememberMe: !!identity.rememberMe },
isOidcTokenCallback,
);
await handleRedirectCallback(method, req, res);
};
/**
@@ -336,23 +468,7 @@ export function registerAuthRoutes(router: Router): void {
* 404:
* description: Unknown login method.
*/
router.post("/auth/:methodId/verify", async (req, res) => {
const method = methodOr404(req, res);
if (!method) return;
if (method.kind !== "form" || !method.verify) {
return res.status(400).json({ error: "Not a form login method" });
}
try {
const identity = await method.verify(req as never, instanceParam(req));
await respondWithLogin(req, res, identity, {
methodId: method.id,
rememberMe: !!identity.rememberMe || !!req.body?.rememberMe,
});
} catch (error) {
if (!(error instanceof LoginMethodError)) {
authLogger.error("Form login failed", error, { methodId: method.id });
}
sendLoginError(res, error);
}
});
router.post("/auth/:methodId/verify", (req, res) =>
verifyFormLogin(req, res, String(req.params.methodId), instanceParam(req)),
);
}
@@ -1,54 +0,0 @@
import type { Router } from "express";
import { verifyLdapLogin } from "../../auth/legacy/ldap-login.js";
import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js";
export function registerLDAPAuthRoutes(router: Router): void {
/**
* @openapi
* /users/ldap/login:
* post:
* summary: LDAP login
* description: Authenticates a user against an LDAP server, then runs the same second factors and session issuing as every other login.
* tags:
* - SSO
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* providerId:
* type: integer
* username:
* type: string
* password:
* type: string
* rememberMe:
* type: boolean
* responses:
* 200:
* description: Login successful, or a second factor is required.
* 400:
* description: Missing fields.
* 401:
* description: Invalid credentials.
* 403:
* description: User not allowed or registration disabled.
* 404:
* description: Provider not found.
* 429:
* description: Too many login attempts.
*/
router.post("/ldap/login", async (req, res) => {
try {
const identity = await verifyLdapLogin({ body: req.body, ip: req.ip });
await respondWithLogin(req, res, identity, {
methodId: "ldap",
rememberMe: !!req.body?.rememberMe,
});
} catch (error) {
sendLoginError(res, error);
}
});
}
@@ -1,488 +0,0 @@
import type {
AuthenticatedRequest,
OIDCProviderConfig,
} from "../../../types/index.js";
import type { Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { SSOProviderType } from "../../../types/index.js";
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
import {
getOIDCConfigFromEnv,
isOIDCEnvOverrideEnabled,
} from "./user-oidc-utils.js";
import {
decryptSsoConfigSecrets,
encryptSsoConfigSecrets,
} from "../../utils/system-secret-crypto.js";
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
function isOidcLike(type: SSOProviderType): boolean {
return type === "oidc" || type === "github" || type === "google";
}
export function isValidOidcIssuer(value: unknown): boolean {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return (
["http:", "https:"].includes(url.protocol) &&
!/\/userinfo\/?$/i.test(url.pathname)
);
} catch {
return false;
}
}
const authManager = AuthManager.getInstance();
/**
* SSO secrets belong to the installation, not to a user: `sso_providers` has no
* userId and the values must be readable during login, before anyone is
* authenticated. They are encrypted with the system key rather than a user DEK.
* Values written by the previous base64 scheme still decode, and are upgraded
* the next time the provider is saved.
*/
async function decryptProviderConfig(
configJson: string,
_userId: string,
): Promise<Record<string, unknown>> {
let config: Record<string, unknown>;
try {
config = JSON.parse(configJson);
} catch {
return {};
}
return decryptSsoConfigSecrets(config);
}
async function encryptProviderConfig(
config: Record<string, unknown>,
_userId: string,
_providerId: string,
): Promise<string> {
return JSON.stringify(await encryptSsoConfigSecrets(config));
}
function applyProviderDefaults(
type: SSOProviderType,
config: Partial<OIDCProviderConfig>,
): Partial<OIDCProviderConfig> {
if (type === "github") {
return {
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
issuer_url: "https://github.com",
identifier_path: "id",
name_path: "name",
scopes: "read:user user:email",
userinfo_url: "https://api.github.com/user",
...config,
};
}
if (type === "google") {
return {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
issuer_url: "https://accounts.google.com",
identifier_path: "sub",
name_path: "name",
scopes: "openid email profile",
...config,
};
}
return config;
}
/** Enabled providers as the login screen sees them: no secrets. */
export async function listPublicSsoProviders(): Promise<
Array<{ id: number; name: string; type: string; displayOrder: number }>
> {
const envConfig = getOIDCConfigFromEnv();
if (envConfig && isOIDCEnvOverrideEnabled()) {
return [{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 }];
}
const providers =
await createCurrentSsoProviderRepository().listEnabledPublic();
// With no provider rows, synthesize one from env vars so SSO login stays
// available when it is configured purely through the environment.
if (providers.length === 0 && envConfig) {
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
}
return providers;
}
export function registerSSOProviderRoutes(router: Router): void {
const requireAdmin = authManager.createAdminMiddleware();
/**
* @openapi
* /users/sso-providers:
* get:
* summary: List enabled SSO providers (public)
* description: Returns public info for all enabled SSO providers for the login page.
* tags:
* - SSO
* responses:
* 200:
* description: Array of public SSO provider objects.
*/
router.get("/sso-providers", async (_req, res) => {
try {
res.json(await listPublicSsoProviders());
} catch (err) {
authLogger.error("Failed to list SSO providers", err);
res.status(500).json({ error: "Failed to list SSO providers" });
}
});
/**
* @openapi
* /users/sso-providers/admin:
* get:
* summary: List all SSO providers (admin)
* description: Returns full SSO provider list with decrypted configs for the admin panel.
* tags:
* - SSO
* responses:
* 200:
* description: Array of full SSO provider objects with decrypted config.
*/
router.get("/sso-providers/admin", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = await createCurrentSsoProviderRepository().listAll();
const result = await Promise.all(
rows.map(async (row) => ({
...row,
config: await decryptProviderConfig(row.config, userId),
})),
);
res.json(result);
} catch (err) {
authLogger.error("Failed to list SSO providers (admin)", err);
res.status(500).json({ error: "Failed to list SSO providers" });
}
});
/**
* @openapi
* /users/sso-providers:
* post:
* summary: Create SSO provider
* description: Creates a new SSO provider configuration.
* tags:
* - SSO
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 201:
* description: Provider created.
* 400:
* description: Validation error.
*/
router.post("/sso-providers", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const {
name,
type,
enabled = true,
displayOrder = 0,
config: rawConfig = {},
} = req.body as {
name: string;
type: SSOProviderType;
enabled?: boolean;
displayOrder?: number;
config?: Record<string, unknown>;
};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "Provider name is required" });
}
const validTypes: SSOProviderType[] = [
"oidc",
"ldap",
"github",
"google",
];
if (!validTypes.includes(type)) {
return res.status(400).json({ error: "Invalid provider type" });
}
if (isTrustedProxyAuthEnabled() && enabled && isOidcLike(type)) {
return res.status(409).json({
error:
"OIDC providers cannot be enabled with trusted proxy authentication",
});
}
const configWithDefaults =
type === "github" || type === "google"
? applyProviderDefaults(
type,
rawConfig as Partial<OIDCProviderConfig>,
)
: rawConfig;
if (type === "oidc" || type === "github" || type === "google") {
const c = configWithDefaults as Partial<OIDCProviderConfig>;
const missing = [
"client_id",
"client_secret",
"issuer_url",
"authorization_url",
"token_url",
].filter((f) => !c[f as keyof OIDCProviderConfig]);
if (missing.length > 0 && type === "oidc") {
return res.status(400).json({
error: `Missing required OIDC fields: ${missing.join(", ")}`,
});
}
if (c.issuer_url && !isValidOidcIssuer(c.issuer_url)) {
return res.status(400).json({
error:
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
});
}
if (
(type === "github" || type === "google") &&
(!c.client_id || !c.client_secret)
) {
return res
.status(400)
.json({ error: "Client ID and Client Secret are required" });
}
}
if (type === "ldap") {
const c = configWithDefaults as Record<string, unknown>;
const missing = [
"host",
"port",
"bindDN",
"bindPassword",
"userSearchBase",
"userSearchFilter",
"usernameAttribute",
].filter((f) => !c[f]);
if (missing.length > 0) {
return res.status(400).json({
error: `Missing required LDAP fields: ${missing.join(", ")}`,
});
}
}
const tempId = `new-${Date.now()}`;
const encryptedConfig = await encryptProviderConfig(
configWithDefaults as Record<string, unknown>,
userId,
tempId,
);
const inserted = await createCurrentSsoProviderRepository().create({
name: name.trim(),
type,
enabled,
displayOrder,
config: encryptedConfig,
});
authLogger.info("SSO provider created", {
operation: "sso_provider_create",
userId,
type,
providerId: inserted.id,
});
res.status(201).json({
...inserted,
config: await decryptProviderConfig(inserted.config, userId),
});
} catch (err) {
authLogger.error("Failed to create SSO provider", err);
res.status(500).json({ error: "Failed to create SSO provider" });
}
});
/**
* @openapi
* /users/sso-providers/{id}:
* put:
* summary: Update SSO provider
* description: Updates an existing SSO provider configuration.
* tags:
* - SSO
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Provider updated.
* 404:
* description: Provider not found.
*/
router.put("/sso-providers/:id", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const providerId = parseInt(req.params.id as string, 10);
if (isNaN(providerId)) {
return res.status(400).json({ error: "Invalid provider ID" });
}
try {
const providerRepository = createCurrentSsoProviderRepository();
const existing = await providerRepository.findById(providerId);
if (!existing) {
return res.status(404).json({ error: "SSO provider not found" });
}
const {
name,
type,
enabled,
displayOrder,
config: rawConfig,
} = req.body as {
name?: string;
type?: SSOProviderType;
enabled?: boolean;
displayOrder?: number;
config?: Record<string, unknown>;
};
const effectiveType = type ?? (existing.type as SSOProviderType);
const effectiveEnabled = enabled ?? existing.enabled;
if (
isTrustedProxyAuthEnabled() &&
effectiveEnabled &&
isOidcLike(effectiveType)
) {
return res.status(409).json({
error:
"OIDC providers cannot be enabled with trusted proxy authentication",
});
}
let encryptedConfig = existing.config;
if (rawConfig !== undefined) {
const existingDecrypted = await decryptProviderConfig(
existing.config,
userId,
);
const mergedConfig = {
...JSON.parse(
existingDecrypted ? JSON.stringify(existingDecrypted) : "{}",
),
...rawConfig,
};
if (
isOidcLike(effectiveType) &&
mergedConfig.issuer_url &&
!isValidOidcIssuer(mergedConfig.issuer_url)
) {
return res.status(400).json({
error:
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
});
}
encryptedConfig = await encryptProviderConfig(
mergedConfig,
userId,
String(providerId),
);
}
const updated = await providerRepository.update(providerId, {
...(name !== undefined ? { name: name.trim() } : {}),
...(type !== undefined ? { type } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(displayOrder !== undefined ? { displayOrder } : {}),
config: encryptedConfig,
updatedAt: new Date().toISOString(),
});
if (!updated) {
return res.status(404).json({ error: "SSO provider not found" });
}
authLogger.info("SSO provider updated", {
operation: "sso_provider_update",
userId,
providerId,
});
res.json({
...updated,
config: await decryptProviderConfig(updated.config, userId),
});
} catch (err) {
authLogger.error("Failed to update SSO provider", err);
res.status(500).json({ error: "Failed to update SSO provider" });
}
});
/**
* @openapi
* /users/sso-providers/{id}:
* delete:
* summary: Delete SSO provider
* description: Deletes an SSO provider. Blocked if users are associated.
* tags:
* - SSO
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Provider deleted.
* 409:
* description: Users are associated with this provider.
* 404:
* description: Provider not found.
*/
router.delete("/sso-providers/:id", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const providerId = parseInt(req.params.id as string, 10);
if (isNaN(providerId)) {
return res.status(400).json({ error: "Invalid provider ID" });
}
try {
const providerRepository = createCurrentSsoProviderRepository();
const existing = await providerRepository.findById(providerId);
if (!existing) {
return res.status(404).json({ error: "SSO provider not found" });
}
const associatedUserCount =
await providerRepository.countUsersByProviderId(providerId);
if (associatedUserCount > 0) {
return res.status(409).json({
error: `Cannot delete provider: ${associatedUserCount} user(s) are associated with it`,
});
}
await providerRepository.delete(providerId);
authLogger.info("SSO provider deleted", {
operation: "sso_provider_delete",
userId,
providerId,
});
res.json({ message: "SSO provider deleted" });
} catch (err) {
authLogger.error("Failed to delete SSO provider", err);
res.status(500).json({ error: "Failed to delete SSO provider" });
}
});
}
export { decryptProviderConfig, encryptProviderConfig };
@@ -1,648 +0,0 @@
import { authLogger } from "../../utils/logger.js";
import type { SSOProviderType } from "../../../types/index.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { decryptSsoConfigSecrets } from "../../utils/system-secret-crypto.js";
import { Agent } from "undici";
import {
createCurrentSettingsRepository,
createCurrentSsoProviderRepository,
} from "../repositories/factory.js";
const BACKCHANNEL_LOGOUT_EVENT =
"http://schemas.openid.net/event/backchannel-logout";
/**
* Raised when a token cannot be verified because it is not a compact JWS,
* as opposed to a signature or claim check that actually failed.
*/
export class OIDCTokenFormatError extends Error {
constructor(message: string) {
super(message);
this.name = "OIDCTokenFormatError";
}
}
function normalizeIssuer(url: string): string {
return url
.trim()
.replace(/\/+$/, "")
.replace(/\/\.well-known\/openid-configuration$/, "");
}
export type OIDCConfig = {
client_id: string;
client_secret: string;
issuer_url: string;
authorization_url: string;
token_url: string;
userinfo_url: string;
identifier_path: string;
name_path: string;
scopes: string;
allowed_users: string;
admin_group: string;
group_claim?: string;
role_map?: string;
ca_cert?: string;
};
export function buildFetchOptions(caCert?: string): Record<string, unknown> {
if (!caCert || !caCert.trim()) return {};
return { dispatcher: new Agent({ connect: { ca: caCert } }) };
}
/**
* Renders why a fetch failed in a form an administrator can act on.
*
* undici reports every transport failure as the same "fetch failed" message
* and puts the reason that actually matters -- ENOTFOUND, ECONNREFUSED,
* UNABLE_TO_VERIFY_LEAF_SIGNATURE, a timeout -- on the cause. Reporting only
* the outer message says nothing at all.
*/
export function describeFetchFailure(error: unknown): string {
if (!(error instanceof Error)) return String(error);
const cause = (error as { cause?: unknown }).cause;
if (cause instanceof Error) {
const code = (cause as { code?: unknown }).code;
return code
? `${error.message}: ${cause.message} (${code})`
: `${error.message}: ${cause.message}`;
}
return cause ? `${error.message}: ${String(cause)}` : error.message;
}
export function getOIDCConfigFromEnv(): OIDCConfig | null {
const client_id = process.env.OIDC_CLIENT_ID;
const client_secret = process.env.OIDC_CLIENT_SECRET;
const issuer_url = process.env.OIDC_ISSUER_URL;
const authorization_url = process.env.OIDC_AUTHORIZATION_URL;
const token_url = process.env.OIDC_TOKEN_URL;
if (
!client_id ||
!client_secret ||
!issuer_url ||
!authorization_url ||
!token_url
) {
return null;
}
return {
client_id,
client_secret,
issuer_url,
authorization_url,
token_url,
userinfo_url: process.env.OIDC_USERINFO_URL || "",
identifier_path: process.env.OIDC_IDENTIFIER_PATH || "sub",
name_path: process.env.OIDC_NAME_PATH || "name",
scopes: process.env.OIDC_SCOPES || "openid email profile",
allowed_users: process.env.OIDC_ALLOWED_USERS || "",
admin_group: process.env.OIDC_ADMIN_GROUP || "",
group_claim: process.env.OIDC_GROUP_CLAIM || "",
role_map: process.env.OIDC_ROLE_MAP || "",
};
}
export function isOIDCEnvOverrideEnabled(): boolean {
return process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true";
}
/**
* Normalizes a group name for comparison. Providers are inconsistent about
* whether they emit bare names (`devops-interns`) or full paths
* (`/devops-interns`, Keycloak's "Full group path" option), so leading slashes
* are stripped and case is ignored.
*/
function normalizeGroupName(group: string): string {
return group.trim().replace(/^\/+/, "").toLowerCase();
}
/**
* Parses `OIDC_ROLE_MAP` into a group -> role-name lookup.
*
* Format is a comma- or newline-separated list of `group:role` pairs, e.g.
* `devops-interns:devops-intern,devops-seniors:devops-senior`. Group keys are
* normalized via {@link normalizeGroupName}; role names are passed through
* verbatim because they must match `roles.name` exactly.
*
* Malformed entries are skipped rather than throwing — a typo in one pair must
* not lock every user out of login.
*/
export function parseOidcRoleMap(raw?: string | null): Map<string, string> {
const map = new Map<string, string>();
if (!raw || !raw.trim()) return map;
for (const entry of raw.split(/[\n,]/)) {
const trimmed = entry.trim();
if (!trimmed) continue;
// rsplit on the last ":" so group names containing a colon still work.
const separator = trimmed.lastIndexOf(":");
if (separator <= 0 || separator === trimmed.length - 1) continue;
const group = normalizeGroupName(trimmed.slice(0, separator));
const roleName = trimmed.slice(separator + 1).trim();
if (!group || !roleName) continue;
map.set(group, roleName);
}
return map;
}
/**
* Resolves which mapped roles a user should hold, given their provider groups.
*
* Returns both the `desired` roles (mapped groups the user is actually in) and
* the full set of `managed` roles (every role named in the map). Callers must
* only ever add/remove roles within `managed` — roles assigned by hand in
* Termix, and the `admin`/`user` roles maintained by the admin-group sync, are
* deliberately left alone.
*/
export function resolveOidcMappedRoles(
groups: string[],
roleMap: Map<string, string>,
): { desired: Set<string>; managed: Set<string> } {
const managed = new Set(roleMap.values());
const desired = new Set<string>();
for (const group of groups) {
const roleName = roleMap.get(normalizeGroupName(group));
if (roleName) desired.add(roleName);
}
return { desired, managed };
}
/**
* Extracts the list of group/role names from an OIDC userInfo payload.
*
* When `groupClaim` is set, that claim is read first (useful for providers like
* Zitadel that nest roles under a custom path such as
* `urn:zitadel:iam:org:project:roles`). Otherwise the common `groups`, `roles`
* and `group` claims are tried. Values may be an array, a comma-separated
* string, or an object whose keys are the group names.
*/
export function extractOidcGroups(
userInfo: Record<string, unknown>,
groupClaim?: string,
): string[] {
let raw: unknown;
if (groupClaim && groupClaim.trim()) {
raw = userInfo[groupClaim.trim()];
}
if (raw === undefined || raw === null) {
raw = userInfo.groups ?? userInfo.roles ?? userInfo.group;
}
if (Array.isArray(raw)) {
return raw.map(String);
}
if (typeof raw === "string") {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
if (raw && typeof raw === "object") {
return Object.keys(raw as Record<string, unknown>);
}
return [];
}
/**
* OIDC providers may return group claims in the ID token, userinfo response,
* or both. Keep every verified source authoritative instead of letting a
* sparse userinfo payload overwrite claims from the ID token.
*/
export function extractOidcGroupsFromSources(
sources: Record<string, unknown>[],
groupClaim?: string,
): string[] {
return [
...new Set(
sources.flatMap((source) => extractOidcGroups(source, groupClaim)),
),
];
}
export function isOIDCUserAllowed(
allowedUsers: string,
identifier: string,
email?: string,
): boolean {
if (!allowedUsers || !allowedUsers.trim()) return true;
const patterns = allowedUsers
.split(/[\n,]/)
.map((p) => p.trim())
.filter(Boolean);
if (patterns.length === 0) return true;
const values = [
identifier,
...(email && email !== identifier ? [email] : []),
];
for (const pattern of patterns) {
if (pattern === "*") return true;
if (pattern.includes("*")) {
const escaped = pattern
.toLowerCase()
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*");
const regex = new RegExp(`^${escaped}$`);
if (values.some((v) => v && regex.test(v.toLowerCase()))) return true;
continue;
}
for (const value of values) {
if (!value) continue;
if (pattern.toLowerCase().startsWith("@")) {
if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true;
} else {
if (value.toLowerCase() === pattern.toLowerCase()) return true;
}
}
}
return false;
}
export async function verifyOIDCToken(
idToken: string,
issuerUrl: string,
clientId: string,
caCert?: string,
): Promise<Record<string, unknown>> {
const segments = idToken.split(".");
if (segments.length !== 3) {
throw new OIDCTokenFormatError(
segments.length === 5
? "Token is a JWE (encrypted). Termix cannot verify encrypted tokens; disable token encryption for this client in your OIDC provider."
: `Token is not a compact JWS: expected 3 segments, got ${segments.length}.`,
);
}
const fetchOptions = buildFetchOptions(caCert);
const configuredIssuerUrl = issuerUrl.trim().replace(/\/+$/, "");
const normalizedIssuerUrl = normalizeIssuer(issuerUrl);
const possibleIssuers = [
normalizedIssuerUrl,
normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""),
...(configuredIssuerUrl === normalizedIssuerUrl ? [issuerUrl] : []),
];
const jwksUrls = [
`${normalizedIssuerUrl}/.well-known/jwks.json`,
`${normalizedIssuerUrl}/jwks/`,
`${normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "")}/.well-known/jwks.json`,
];
// Every attempt records why it failed. Without this the only thing an
// administrator ever sees is "Failed to fetch JWKS from any URL", which
// does not distinguish an issuer URL typo from a proxy, a private CA, or
// a provider outage.
const attempts: string[] = [];
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
try {
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
if (!discoveryResponse.ok) {
attempts.push(`${discoveryUrl}: HTTP ${discoveryResponse.status}`);
} else {
const discovery = (await discoveryResponse.json()) as Record<
string,
unknown
>;
if (typeof discovery.jwks_uri === "string" && discovery.jwks_uri) {
jwksUrls.unshift(discovery.jwks_uri);
} else {
attempts.push(`${discoveryUrl}: no jwks_uri in the discovery document`);
}
}
} catch (discoveryError) {
attempts.push(`${discoveryUrl}: ${describeFetchFailure(discoveryError)}`);
}
let jwks: Record<string, unknown> | null = null;
for (const url of jwksUrls) {
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
attempts.push(`${url}: HTTP ${response.status}`);
continue;
}
const jwksData = (await response.json()) as Record<string, unknown>;
if (jwksData && Array.isArray(jwksData.keys)) {
jwks = jwksData;
break;
}
attempts.push(`${url}: response contains no "keys" array`);
} catch (error) {
attempts.push(`${url}: ${describeFetchFailure(error)}`);
}
}
if (!jwks) {
throw new Error(
`Failed to fetch JWKS from any URL. Attempts:\n ${attempts.join("\n ")}`,
);
}
if (!jwks.keys || !Array.isArray(jwks.keys)) {
throw new Error(
`Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`,
);
}
const { decodeProtectedHeader, importJWK, jwtVerify } = await import("jose");
const header = decodeProtectedHeader(idToken);
const keyId = header.kid;
const publicKey = jwks.keys.find(
(key: Record<string, unknown>) => key.kid === keyId,
);
if (!publicKey) {
throw new Error(
`No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: Record<string, unknown>) => k.kid).join(", ")}`,
);
}
const algorithm =
typeof publicKey.alg === "string" ? publicKey.alg : header.alg;
const key = await importJWK(publicKey, algorithm);
const { payload } = await jwtVerify(idToken, key, {
issuer: possibleIssuers,
audience: clientId,
});
return payload;
}
const GOOGLE_DEFAULTS = {
issuer_url: "https://accounts.google.com",
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
identifier_path: "sub",
name_path: "name",
scopes: "openid email profile",
};
const GITHUB_DEFAULTS = {
issuer_url: "https://token.actions.githubusercontent.com",
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
userinfo_url: "https://api.github.com/user",
identifier_path: "id",
name_path: "name",
scopes: "read:user user:email",
};
function applyProviderDefaults(
config: OIDCConfig,
providerType: string,
): OIDCConfig {
const defaults =
providerType === "google"
? GOOGLE_DEFAULTS
: providerType === "github"
? GITHUB_DEFAULTS
: null;
if (!defaults) return config;
return {
...config,
issuer_url: config.issuer_url || defaults.issuer_url,
authorization_url: config.authorization_url || defaults.authorization_url,
token_url: config.token_url || defaults.token_url,
userinfo_url: config.userinfo_url || defaults.userinfo_url,
identifier_path: config.identifier_path || defaults.identifier_path,
name_path: config.name_path || defaults.name_path,
scopes: config.scopes || defaults.scopes,
};
}
/**
* Reads the provider secrets. System-key encrypted values are decrypted;
* values still carrying a legacy base64 prefix are decoded so login keeps
* working until the provider is next saved.
*/
async function decryptConfigSecret(
config: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return decryptSsoConfigSecrets(config);
}
export async function loadProviderConfig(
providerId: number | null | undefined,
adminUserId?: string,
): Promise<{
config: OIDCConfig;
providerType: SSOProviderType;
providerDbId: number | null;
} | null> {
const envConfig = getOIDCConfigFromEnv();
if (envConfig && isOIDCEnvOverrideEnabled()) {
return { config: envConfig, providerType: "oidc", providerDbId: null };
}
if (providerId != null) {
try {
const row =
await createCurrentSsoProviderRepository().findById(providerId);
if (row) {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(row.config);
} catch {
parsed = {};
}
if (adminUserId) {
try {
const adminDataKey = DataCrypto.getUserDataKey(adminUserId);
if (adminDataKey) {
parsed = DataCrypto.decryptRecord(
"settings",
parsed,
adminUserId,
adminDataKey,
);
}
} catch {
parsed = await decryptConfigSecret(parsed);
}
} else {
parsed = await decryptConfigSecret(parsed);
}
const providerType = row.type as SSOProviderType;
const config = applyProviderDefaults(
parsed as unknown as OIDCConfig,
providerType,
);
return {
config,
providerType,
providerDbId: row.id,
};
}
} catch (err) {
authLogger.error("Failed to load SSO provider config by id", err, {
providerId,
});
}
}
// Fallback: env vars
if (envConfig) {
return { config: envConfig, providerType: "oidc", providerDbId: null };
}
// Fallback: first enabled OIDC-type provider in ssoProviders table
try {
const oidcRow =
await createCurrentSsoProviderRepository().findFirstEnabledOidcLike();
if (oidcRow) {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(oidcRow.config);
} catch {
parsed = {};
}
parsed = await decryptConfigSecret(parsed);
const oidcProviderType = oidcRow.type as SSOProviderType;
return {
config: applyProviderDefaults(
parsed as unknown as OIDCConfig,
oidcProviderType,
),
providerType: oidcProviderType,
providerDbId: oidcRow.id,
};
}
} catch {
// fall through to legacy
}
// Fallback: legacy settings blob
try {
const legacyValue =
await createCurrentSettingsRepository().get("oidc_config");
if (legacyValue) {
let config = JSON.parse(legacyValue) as Record<string, unknown>;
config = await decryptConfigSecret(config);
return {
config: config as unknown as OIDCConfig,
providerType: "oidc",
providerDbId: null,
};
}
} catch {
// no legacy config
}
return null;
}
export async function resolveProviderByIssuer(issuer: string): Promise<{
config: OIDCConfig;
providerType: SSOProviderType;
providerDbId: number | null;
} | null> {
const target = normalizeIssuer(issuer);
const envConfig = getOIDCConfigFromEnv();
if (
envConfig?.issuer_url &&
isOIDCEnvOverrideEnabled() &&
normalizeIssuer(envConfig.issuer_url) === target
) {
return { config: envConfig, providerType: "oidc", providerDbId: null };
}
try {
const rows = await createCurrentSsoProviderRepository().listEnabled();
for (const row of rows) {
if (!["oidc", "github", "google"].includes(row.type)) continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(row.config);
} catch {
continue;
}
parsed = await decryptConfigSecret(parsed);
const providerType = row.type as SSOProviderType;
const config = applyProviderDefaults(
parsed as unknown as OIDCConfig,
providerType,
);
if (config.issuer_url && normalizeIssuer(config.issuer_url) === target) {
return { config, providerType, providerDbId: row.id };
}
}
} catch (err) {
authLogger.error("Failed to resolve SSO provider by issuer", err, {
issuer,
});
}
if (
envConfig?.issuer_url &&
normalizeIssuer(envConfig.issuer_url) === target
) {
return { config: envConfig, providerType: "oidc", providerDbId: null };
}
return null;
}
export type LogoutTokenClaims = {
sub: string | null;
sid: string | null;
jti: string;
};
export function validateLogoutTokenClaims(
payload: Record<string, unknown>,
): LogoutTokenClaims {
if ("nonce" in payload) {
throw new Error("logout_token must not contain a nonce claim");
}
const event = (payload.events as Record<string, unknown> | undefined)?.[
BACKCHANNEL_LOGOUT_EVENT
];
if (!event || typeof event !== "object" || Array.isArray(event)) {
throw new Error("logout_token missing back-channel logout event");
}
if (!Number.isInteger(payload.iat)) {
throw new Error("logout_token missing iat claim");
}
const jti = typeof payload.jti === "string" ? payload.jti.trim() : "";
if (!jti) {
throw new Error("logout_token missing jti claim");
}
const sub = typeof payload.sub === "string" ? payload.sub : null;
const sid = typeof payload.sid === "string" ? payload.sid : null;
if (!sub && !sid) {
throw new Error("logout_token must contain sub and/or sid");
}
return { sub, sid, jti };
}
export async function validateLogoutToken(
logoutToken: string,
config: OIDCConfig,
): Promise<LogoutTokenClaims> {
const payload = await verifyOIDCToken(
logoutToken,
config.issuer_url,
config.client_id,
config.ca_cert,
);
return validateLogoutTokenClaims(payload);
}
+7 -472
View File
@@ -5,9 +5,7 @@ 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 { DataCrypto } from "../../utils/data-crypto.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
import {
isLoopbackRequest,
@@ -17,12 +15,6 @@ import {
} from "./desktop-auto-session.js";
import { shouldShowDonationModal } from "./donation-modal-utils.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
getOIDCConfigFromEnv,
loadProviderConfig,
resolveProviderByIssuer,
validateLogoutToken,
} from "./user-oidc-utils.js";
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
import { registerBrandingRoutes } from "./branding-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
@@ -32,15 +24,13 @@ import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js";
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
import { registerSSOProviderRoutes } from "./sso-provider-routes.js";
import { registerLDAPAuthRoutes } from "./ldap-auth-routes.js";
import { registerAuthRoutes } from "./auth-routes.js";
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
createCurrentSettingsRepository,
getCurrentSettingValue,
createCurrentRoleRepository,
createCurrentSsoProviderRepository,
createCurrentUserAuthRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
@@ -54,18 +44,7 @@ import {
import { getPasswordLoginStatus } from "../../auth/core-auth.js";
import { verifyPasswordLogin } from "../../auth/builtin-login-methods.js";
import {
redirectWithLoginError,
respondWithLogin,
respondWithRedirectLogin,
sendLoginError,
} from "../../auth/login-pipeline.js";
import {
handleOidcCallback,
RedirectLoginError,
startOidcLogin,
} from "../../auth/legacy/oidc-login.js";
import { LoginMethodError } from "../../auth/types.js";
import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js";
import {
isNativeAppRequest,
syncSharedCredentialsForUserRoles,
@@ -297,365 +276,6 @@ router.post("/create", async (req, res) => {
}
});
/**
* @openapi
* /users/oidc-config:
* post:
* summary: Configure OIDC provider
* description: Creates or updates the OIDC provider configuration.
* tags:
* - Users
* responses:
* 200:
* description: OIDC configuration updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update OIDC config.
*/
router.post("/oidc-config", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await requireCurrentAdmin(userId);
if (!user) {
return res.status(403).json({ error: "Not authorized" });
}
const {
client_id,
client_secret,
issuer_url,
authorization_url,
token_url,
userinfo_url,
identifier_path,
name_path,
scopes,
allowed_users,
admin_group,
group_claim,
} = req.body;
const isDisableRequest =
(client_id === "" || client_id === null || client_id === undefined) &&
(client_secret === "" ||
client_secret === null ||
client_secret === undefined) &&
(issuer_url === "" || issuer_url === null || issuer_url === undefined) &&
(authorization_url === "" ||
authorization_url === null ||
authorization_url === undefined) &&
(token_url === "" || token_url === null || token_url === undefined);
const isEnableRequest =
isNonEmptyString(client_id) &&
isNonEmptyString(client_secret) &&
isNonEmptyString(issuer_url) &&
isNonEmptyString(authorization_url) &&
isNonEmptyString(token_url) &&
isNonEmptyString(identifier_path) &&
isNonEmptyString(name_path);
if (!isDisableRequest && !isEnableRequest) {
authLogger.warn(
"OIDC validation failed - neither disable nor enable request",
{
operation: "oidc_config_update",
userId,
isDisableRequest,
isEnableRequest,
},
);
return res
.status(400)
.json({ error: "All OIDC configuration fields are required" });
}
const settingsRepository = createCurrentSettingsRepository();
if (isDisableRequest) {
await settingsRepository.delete("oidc_config");
authLogger.info("OIDC configuration disabled", {
operation: "oidc_disable",
userId,
});
res.json({ message: "OIDC configuration disabled" });
} else {
const config = {
client_id,
client_secret,
issuer_url,
authorization_url,
token_url,
userinfo_url: userinfo_url || "",
identifier_path,
name_path,
scopes: scopes || "openid email profile",
allowed_users: allowed_users || "",
admin_group: admin_group || "",
group_claim: group_claim || "",
};
let encryptedConfig;
try {
const adminDataKey = DataCrypto.getUserDataKey(userId);
if (adminDataKey) {
const configWithId = { ...config, id: `oidc-config-${userId}` };
encryptedConfig = DataCrypto.encryptRecord(
"settings",
configWithId,
userId,
adminDataKey,
);
} else {
encryptedConfig = {
...config,
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`,
};
authLogger.warn(
"OIDC configuration stored with basic encoding - admin should re-save with password",
{
operation: "oidc_config_basic_encoding",
userId,
},
);
}
} catch (encryptError) {
authLogger.error(
"Failed to encrypt OIDC configuration, storing with basic encoding",
encryptError,
{
operation: "oidc_config_encrypt_failed",
userId,
},
);
encryptedConfig = {
...config,
client_secret: `encoded:${Buffer.from(client_secret).toString("base64")}`,
};
}
await settingsRepository.set(
"oidc_config",
JSON.stringify(encryptedConfig),
);
authLogger.info("OIDC configuration updated", {
operation: "oidc_update",
userId,
hasUserinfoUrl: !!userinfo_url,
});
res.json({ message: "OIDC configuration updated" });
}
} catch (err) {
authLogger.error("Failed to update OIDC config", err);
res.status(500).json({ error: "Failed to update OIDC config" });
}
});
/**
* @openapi
* /users/oidc-config:
* delete:
* summary: Disable OIDC configuration
* description: Disables the OIDC provider configuration.
* tags:
* - Users
* responses:
* 200:
* description: OIDC configuration disabled.
* 403:
* description: Not authorized.
* 500:
* description: Failed to disable OIDC config.
*/
router.delete("/oidc-config", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await requireCurrentAdmin(userId);
if (!user) {
return res.status(403).json({ error: "Not authorized" });
}
await createCurrentSettingsRepository().delete("oidc_config");
authLogger.success("OIDC configuration disabled", {
operation: "oidc_disable",
userId,
});
res.json({ message: "OIDC configuration disabled" });
} catch (err) {
authLogger.error("Failed to disable OIDC config", err);
res.status(500).json({ error: "Failed to disable OIDC config" });
}
});
/**
* @openapi
* /users/oidc-config:
* get:
* summary: Get OIDC configuration
* description: Returns the public OIDC configuration.
* tags:
* - Users
* responses:
* 200:
* description: Public OIDC configuration.
* 500:
* description: Failed to get OIDC config.
*/
router.get("/oidc-config", async (_req, res) => {
try {
const providerResult = await loadProviderConfig(undefined);
if (!providerResult) {
return res.json(null);
}
const { config } = providerResult;
return res.json({
client_id: config.client_id,
issuer_url: config.issuer_url,
authorization_url: config.authorization_url,
scopes: config.scopes,
});
} catch (err) {
authLogger.error("Failed to get OIDC config", err);
res.status(500).json({ error: "Failed to get OIDC config" });
}
});
/**
* @openapi
* /users/oidc-config/admin:
* get:
* summary: Get OIDC configuration for admin
* description: Returns the full OIDC configuration for an admin.
* tags:
* - Users
* responses:
* 200:
* description: Full OIDC configuration.
* 500:
* description: Failed to get OIDC config for admin.
*/
router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const value = await createCurrentSettingsRepository().get("oidc_config");
if (!value) {
const envConfig = getOIDCConfigFromEnv();
return res.json(envConfig);
}
let config = JSON.parse(value);
if (config.client_secret?.startsWith("encrypted:")) {
try {
const adminDataKey = DataCrypto.getUserDataKey(userId);
if (adminDataKey) {
config = DataCrypto.decryptRecord(
"settings",
config,
userId,
adminDataKey,
);
} else {
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
}
} catch {
authLogger.warn("Failed to decrypt OIDC config for admin", {
operation: "oidc_config_decrypt_failed",
userId,
});
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
}
} else if (config.client_secret?.startsWith("encoded:")) {
try {
const decoded = Buffer.from(
config.client_secret.substring(8),
"base64",
).toString("utf8");
config.client_secret = decoded;
} catch {
authLogger.warn("Failed to decode OIDC config for admin", {
operation: "oidc_config_decode_failed",
userId,
});
config.client_secret = "[ENCODING ERROR]";
}
}
res.json(config);
} catch (err) {
authLogger.error("Failed to get OIDC config for admin", err);
res.status(500).json({ error: "Failed to get OIDC config for admin" });
}
});
/**
* @openapi
* /users/oidc/authorize:
* get:
* summary: Get OIDC authorization URL
* description: Returns the OIDC authorization URL.
* tags:
* - Users
* parameters:
* - in: query
* name: rememberMe
* schema:
* type: boolean
* description: Whether to extend the session to 30 days instead of 2 hours.
* responses:
* 200:
* description: OIDC authorization URL.
* 404:
* description: OIDC not configured.
* 500:
* description: Failed to generate authorization URL.
*/
router.get("/oidc/authorize", async (req, res) => {
try {
res.json(await startOidcLogin(req));
} catch (err) {
if (err instanceof LoginMethodError) {
return res.status(err.status).json({ error: err.message });
}
authLogger.error("Failed to generate OIDC auth URL", err);
res.status(500).json({ error: "Failed to generate authorization URL" });
}
});
/**
* @openapi
* /users/oidc/callback:
* get:
* summary: OIDC callback
* description: Handles the OIDC callback, exchanges the code for a token, and creates or logs in the user.
* tags:
* - Users
* responses:
* 302:
* description: Redirects to the frontend with a success or error message.
* 400:
* description: Code and state are required.
*/
router.get("/oidc/callback", async (req, res) => {
let identity;
try {
identity = await handleOidcCallback(req);
} catch (error) {
if (error instanceof RedirectLoginError) {
return redirectWithLoginError(res, error.returnTo, error);
}
return sendLoginError(res, error);
}
await respondWithRedirectLogin(
req,
res,
identity,
{ methodId: "oidc", rememberMe: !!identity.rememberMe },
isOidcTokenCallback,
);
});
/**
* @openapi
* /users/proxy-login:
@@ -728,17 +348,11 @@ router.post("/proxy-login", async (req, res) => {
}
try {
const [legacyOidc, enabledProviders, userRecord] = await Promise.all([
createCurrentSettingsRepository().get("oidc_config"),
createCurrentSsoProviderRepository().listEnabled(),
const [externalMethods, userRecord] = await Promise.all([
listExternalLoginMethods(),
createCurrentUserRepository().findByUsername(username),
]);
const hasOidc =
Boolean(getOIDCConfigFromEnv() || legacyOidc) ||
enabledProviders.some((provider) =>
["oidc", "github", "google"].includes(provider.type),
);
if (hasOidc) {
if (externalMethods.length > 0) {
return res
.status(409)
.json({ error: "Proxy authentication cannot be used with OIDC" });
@@ -931,84 +545,6 @@ router.post("/logout", authenticateJWT, async (req, res) => {
}
});
const seenLogoutJti = new Map<string, number>();
const LOGOUT_JTI_TTL_MS = 5 * 60 * 1000;
function pruneLogoutJti(now: number): void {
for (const [key, expiry] of seenLogoutJti) {
if (expiry <= now) seenLogoutJti.delete(key);
}
}
function isReplayedJti(jti: string): boolean {
const now = Date.now();
pruneLogoutJti(now);
return seenLogoutJti.has(jti);
}
function markLogoutJti(jti: string): void {
const now = Date.now();
pruneLogoutJti(now);
seenLogoutJti.set(jti, now + LOGOUT_JTI_TTL_MS);
}
router.post("/oidc/backchannel-logout", async (req, res) => {
res.setHeader("Cache-Control", "no-store");
try {
const logoutToken = (req.body as Record<string, unknown> | undefined)
?.logout_token;
if (typeof logoutToken !== "string" || !logoutToken) {
return res.status(400).json({ error: "missing logout_token" });
}
let issuer: string | null = null;
try {
const parts = logoutToken.split(".");
if (parts.length === 3) {
const claims = JSON.parse(Buffer.from(parts[1], "base64").toString());
issuer = typeof claims.iss === "string" ? claims.iss : null;
}
} catch {
issuer = null;
}
if (!issuer) {
return res.status(400).json({ error: "invalid logout_token" });
}
const provider = await resolveProviderByIssuer(issuer);
if (!provider) {
authLogger.warn("Back-channel logout for unknown issuer", { issuer });
return res.status(400).json({ error: "unknown issuer" });
}
const claims = await validateLogoutToken(logoutToken, provider.config);
if (claims.jti && isReplayedJti(claims.jti)) {
return res.status(200).json({ ok: true });
}
try {
await authManager.revokeSessionsByOidc({
ssoProviderId: provider.providerDbId,
sub: claims.sub,
sid: claims.sid,
});
} catch (err) {
authLogger.error("OIDC back-channel session revocation failed", err);
return res.status(500).json({ error: "logout processing failed" });
}
markLogoutJti(claims.jti);
return res.status(200).json({ ok: true });
} catch (err) {
authLogger.error("OIDC back-channel logout failed", err);
return res.status(400).json({ error: "invalid logout_token" });
}
});
/**
* @openapi
* /users/me:
@@ -2117,8 +1653,7 @@ registerAcmeSSLRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
registerBrandingRoutes(router, requireAdmin);
registerSSOProviderRoutes(router);
registerLDAPAuthRoutes(router);
registerAuthRoutes(router);
registerAuthCompatRoutes(router);
export default router;
+10 -12
View File
@@ -56,20 +56,18 @@ export async function expandOidcUsername(
const match = /^ldap:(\d+):(.+)$/.exec(oidcIdentifier);
if (match) {
// Make sure the SSO provider is actually LDAP, to prevent spoofing.
const { createCurrentSsoProviderRepository } =
// Only strip the prefix for a real LDAP identity, to prevent spoofing
// through an SSO subject that happens to look like one.
const { createCurrentUserAuthRepository } =
await import("../database/repositories/factory.js");
const claimedProviderId = Number(match[1]);
const provider =
user?.ssoProviderId != null
? await createCurrentSsoProviderRepository().findById(
user.ssoProviderId,
)
: null;
const identities =
await createCurrentUserAuthRepository().listIdentitiesForUser(userId);
if (
provider?.type === "ldap" &&
user?.ssoProviderId === claimedProviderId
identities.some(
(identity) =>
identity.providerId === `ldap:${match[1]}` &&
identity.subject === match[2],
)
) {
oidcIdentifier = match[2];
}
+122 -3
View File
@@ -8,6 +8,7 @@
import type {
PluginAuth,
PluginVerifiedIdentity,
PluginSsh,
PluginSshConnectOptions,
PluginSshHost,
@@ -28,7 +29,12 @@ import {
} from "../hosts/connect/auth-provider-registry.js";
import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js";
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import { registerLoginMethod, registerSecondFactor } from "../auth/registry.js";
import {
getLoginMethod,
registerLoginMethod,
registerSecondFactor,
} from "../auth/registry.js";
import type { VerifiedIdentity } from "../auth/types.js";
import type {
MutableConnectConfig,
SshAuthProvider,
@@ -392,6 +398,60 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
};
}
/**
* Turns what a plugin method returned into core's identity. Only the fields
* the SDK documents are copied, so a plugin cannot set core-only extras, and
* its rate limit key is kept apart from core's and other plugins'.
*/
export function toCoreIdentity(
pluginId: string,
identity: PluginVerifiedIdentity,
): VerifiedIdentity {
if (identity.kind === "user") {
return {
kind: "user",
userId: identity.userId,
mfaSatisfied: identity.mfaSatisfied,
password: identity.password,
returnTo: identity.returnTo,
rememberMe: identity.rememberMe,
};
}
return {
kind: "external",
provider: identity.provider,
subject: identity.subject,
email: identity.email,
name: identity.name,
groups: identity.groups,
isAdmin: identity.isAdmin,
allowedUsers: identity.allowedUsers,
mfaSatisfied: identity.mfaSatisfied,
returnTo: identity.returnTo,
rememberMe: identity.rememberMe,
legacyIdentifier: identity.legacy?.identifier,
ssoProviderId:
identity.logoutClaims?.providerId ??
identity.legacy?.providerRowId ??
null,
oidcSub: identity.logoutClaims?.sub ?? null,
oidcSid: identity.logoutClaims?.sid ?? null,
roleSync: identity.roles
? {
desired: [...identity.roles.desired],
managed: [...identity.roles.managed],
}
: undefined,
rateLimitUsername: identity.rateLimitKey
? rateLimitKeyFor(pluginId, identity.rateLimitKey)
: undefined,
};
}
function rateLimitKeyFor(pluginId: string, key: string): string {
return `plugin:${pluginId}:${key}`;
}
export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
const pluginId = manifest.id;
const declared = manifest.capabilities;
@@ -477,13 +537,16 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
callback: method.callback
? async (...args) => {
await granted();
return method.callback!(...args);
return toCoreIdentity(
pluginId,
await method.callback!(...args),
);
}
: undefined,
verify: method.verify
? async (...args) => {
await granted();
return method.verify!(...args);
return toCoreIdentity(pluginId, await method.verify!(...args));
}
: undefined,
});
@@ -550,5 +613,61 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
success: true,
});
},
completeRedirectLogin: async (methodId, req, res) => {
requireDeclared(contributes.loginMethods, methodId, "loginMethods");
await granted();
const method = getLoginMethod(methodId);
if (!method || method.pluginId !== pluginId) {
throw new Error(
`Plugin ${pluginId} has no registered login method "${methodId}"`,
);
}
const { handleRedirectCallback } =
await import("../database/routes/auth-routes.js");
await handleRedirectCallback(method, req as never, res as never);
},
revokeSessions: async (match) => {
await granted();
if (!match.sub && !match.sid) return 0;
const { AuthManager } = await import("../utils/auth-manager.js");
const revoked = await AuthManager.getInstance().revokeSessionsByOidc({
ssoProviderId: match.providerId ?? null,
sub: match.sub ?? null,
sid: match.sid ?? null,
});
await audit(
"auth_sessions_revoked",
`${revoked} session(s) for provider ${match.providerId ?? "none"}`,
{ success: true },
);
return revoked;
},
loginRateLimit: {
isLocked: async (ip, key) => {
await granted();
const { loginRateLimiter } =
await import("../utils/login-rate-limiter.js");
return loginRateLimiter.isLocked(ip, rateLimitKeyFor(pluginId, key));
},
recordFailure: async (ip, key) => {
await granted();
const { loginRateLimiter } =
await import("../utils/login-rate-limiter.js");
loginRateLimiter.recordFailedAttempt(
ip,
rateLimitKeyFor(pluginId, key),
);
},
},
countLinkedUsers: async (provider) => {
await granted();
const { createCurrentUserAuthRepository } =
await import("../database/repositories/factory.js");
return createCurrentUserAuthRepository().countUsersForProvider(provider);
},
};
}
+5
View File
@@ -20,6 +20,7 @@
*/
import { pluginLogger } from "../utils/logger.js";
import { getRequestBaseUrlWithForceHTTPS } from "../utils/request-origin.js";
import { pluginEvents } from "./events.js";
import * as registry from "./registry.js";
import * as serviceRegistry from "./service-registry.js";
@@ -683,6 +684,10 @@ export function createPluginContext(
);
return router as never;
},
baseUrl: (req) =>
getRequestBaseUrlWithForceHTTPS(
req as Parameters<typeof getRequestBaseUrlWithForceHTTPS>[0],
),
},
ws: {
+7 -1
View File
@@ -343,7 +343,13 @@ export class PluginLoader {
// than leave it half-running against a schema that is not there. A
// throw here lands in the catch below, which fails this plugin only.
const { migratePlugin } = await import("./data.js");
await migratePlugin(plugin.id, plugin.dir);
const applied = await migratePlugin(plugin.id, plugin.dir);
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
const { runPluginDataMoves } =
await import("../utils/crypto-migration/plugin-data-moves.js");
await runPluginDataMoves();
}
const handle = createPluginHandle(plugin.id, { activate, deactivate });
const ctx = createPluginContext(plugin.manifest, handle);
+9 -22
View File
@@ -197,31 +197,18 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
});
if (trustedProxyAuth.enabled) {
const {
createCurrentSettingsRepository,
createCurrentSsoProviderRepository,
createCurrentUserAuthRepository,
createCurrentUserRepository,
} = await import("./database/repositories/factory.js");
const [legacyOidc, providers, users, secondFactorUsers] =
await Promise.all([
createCurrentSettingsRepository().get("oidc_config"),
createCurrentSsoProviderRepository().listEnabled(),
createCurrentUserRepository().listAll(),
createCurrentUserAuthRepository().listUserIdsWithSecondFactors(),
]);
const conflictingProvider = providers.some((provider) =>
["oidc", "github", "google"].includes(provider.type),
);
// Enabled external login methods are refused at runtime instead: their
// plugins are not running yet at this point.
const { createCurrentUserAuthRepository, createCurrentUserRepository } =
await import("./database/repositories/factory.js");
const [users, secondFactorUsers] = await Promise.all([
createCurrentUserRepository().listAll(),
createCurrentUserAuthRepository().listUserIdsWithSecondFactors(),
]);
const conflictingUser = users.some(
(user) => user.isOidc || secondFactorUsers.has(user.id),
);
if (
legacyOidc ||
process.env.OIDC_CLIENT_ID ||
conflictingProvider ||
conflictingUser
) {
if (process.env.OIDC_CLIENT_ID || conflictingUser) {
throw new Error(
"Trusted proxy authentication cannot start while OIDC or a second factor is enabled",
);
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { isExternalUserAllowed } from "../../auth/allowed-users.js";
describe("isExternalUserAllowed", () => {
it("allows everyone when the allow-list is empty", () => {
expect(isExternalUserAllowed("", "alice", "alice@x.com")).toBe(true);
expect(isExternalUserAllowed(" ", "alice")).toBe(true);
});
it("allows everyone with the '*' wildcard", () => {
expect(isExternalUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
});
it("matches an exact identifier (case-insensitive)", () => {
expect(isExternalUserAllowed("alice,bob", "alice")).toBe(true);
expect(isExternalUserAllowed("Alice", "alice")).toBe(true);
expect(isExternalUserAllowed("alice", "ALICE")).toBe(true);
});
it("matches against the email as well as the identifier", () => {
expect(isExternalUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
true,
);
});
it("matches an @domain suffix pattern", () => {
expect(
isExternalUserAllowed("@company.com", "sub-1", "bob@company.com"),
).toBe(true);
expect(
isExternalUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM"),
).toBe(true);
});
it("denies users not on the list", () => {
expect(isExternalUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
false,
);
expect(
isExternalUserAllowed("@company.com", "sub-1", "bob@other.com"),
).toBe(false);
});
it("ignores blank entries and surrounding whitespace in the list", () => {
expect(isExternalUserAllowed(" alice , , bob ", "bob")).toBe(true);
});
it("does not match the email against an identifier-only pattern when email differs", () => {
expect(isExternalUserAllowed("alice", "sub-123", "alice@x.com")).toBe(
false,
);
});
it("matches *@domain.com wildcard pattern against emails", () => {
expect(
isExternalUserAllowed("*@company.com", "sub-1", "john@company.com"),
).toBe(true);
expect(
isExternalUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
).toBe(true);
expect(
isExternalUserAllowed("*@company.com", "sub-1", "user@other.com"),
).toBe(false);
});
it("matches glob patterns with multiple wildcards", () => {
expect(isExternalUserAllowed("admin*", "admin_user")).toBe(true);
expect(isExternalUserAllowed("admin*", "user_admin")).toBe(false);
});
});
+7 -3
View File
@@ -94,6 +94,12 @@ export function fakeFactory(state: AuthState) {
email: input.email ?? null,
});
},
countUsersForProvider: async (providerId: string) =>
new Set(
state.identities
.filter((row) => row.providerId === providerId)
.map((row) => row.userId),
).size,
listSecondFactors: async (userId: string) =>
state.factors.filter((row) => row.userId === userId),
hasSecondFactor: async (userId: string) =>
@@ -179,9 +185,6 @@ export function fakeFactory(state: AuthState) {
createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async () => {},
}),
createCurrentSsoProviderRepository: () => ({
listEnabledPublic: async () => [],
}),
};
}
@@ -202,6 +205,7 @@ export function fakeAuthManager(state: AuthState) {
}),
authenticateUser: vi.fn(async () => true),
authenticateOIDCUser: vi.fn(async () => true),
revokeSessionsByOidc: vi.fn(async () => 1),
unlockWithSystemKey: vi.fn(async () => true),
registerOIDCUser: vi.fn(async () => {}),
isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) =>
@@ -1,425 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createAuthState,
fakeAuthManager,
fakeRequest,
fakeResponse,
type AuthState,
} from "./auth-test-helpers.js";
const h = vi.hoisted(() => ({
state: null as unknown as AuthState,
manager: null as unknown as ReturnType<
typeof import("./auth-test-helpers.js").fakeAuthManager
>,
providerConfig: null as unknown,
providerType: "oidc",
idTokenClaims: null as unknown,
ldap: {
users: {} as Record<
string,
{ dn: string; password: string; attrs: Record<string, string> }
>,
adminDns: [] as string[],
},
ssoProvider: { id: 4, type: "ldap", enabled: true },
}));
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("./auth-test-helpers.js");
const names = [
...Object.keys(helpers.fakeFactory(helpers.createAuthState())),
];
const mocked = Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
return {
...mocked,
createCurrentSsoProviderRepository: () => ({
findById: async () => h.ssoProvider,
listEnabledPublic: async () => [],
}),
};
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => h.manager },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
h.state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return { authLogger: log, databaseLogger: log, sshLogger: log, logger: log };
});
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
},
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../database/routes/user-oidc-utils.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../database/routes/user-oidc-utils.js")
>();
return {
...actual,
loadProviderConfig: async () =>
h.providerConfig
? {
config: h.providerConfig,
providerType: h.providerType,
providerDbId: 3,
}
: null,
verifyOIDCToken: async () => h.idTokenClaims,
};
});
vi.mock("ldapjs", () => {
function createClient() {
let bound: string | null = null;
return {
bind: (dn: string, password: string, cb: (err?: Error) => void) => {
if (dn === "cn=service") {
bound = dn;
return cb();
}
const user = Object.values(h.ldap.users).find((u) => u.dn === dn);
if (user && user.password === password) {
bound = dn;
return cb();
}
cb(new Error("invalid credentials"));
},
search: (
base: string,
options: { filter: string },
cb: (err: Error | null, res: unknown) => void,
) => {
const listeners: Record<string, (value?: unknown) => void> = {};
const res = {
on: (event: string, fn: (value?: unknown) => void) => {
listeners[event] = fn;
},
};
cb(null, res);
queueMicrotask(() => {
if (base === "ou=groups") {
for (const dn of h.ldap.adminDns) {
if (options.filter.includes(dn)) {
listeners.searchEntry?.({
dn: { toString: () => "cn=admins,ou=groups" },
attributes: [{ type: "cn", values: ["admins"] }],
});
}
}
} else {
const match = /uid=([^)]+)/.exec(options.filter);
const user = match ? h.ldap.users[match[1]] : undefined;
if (user) {
listeners.searchEntry?.({
dn: { toString: () => user.dn },
attributes: Object.entries(user.attrs).map(([type, value]) => ({
type,
values: [value],
})),
});
}
}
listeners.end?.();
});
void bound;
},
unbind: () => {},
};
}
return { default: { createClient }, createClient };
});
const { handleOidcCallback } = await import("../../auth/legacy/oidc-login.js");
const { verifyLdapLogin } = await import("../../auth/legacy/ldap-login.js");
const { respondWithLogin, respondWithRedirectLogin, runLogin } =
await import("../../auth/login-pipeline.js");
beforeEach(() => {
h.state = createAuthState();
h.manager = fakeAuthManager(h.state);
h.providerType = "oidc";
h.providerConfig = {
client_id: "client",
client_secret: "secret",
issuer_url: "https://idp.example",
authorization_url: "https://idp.example/auth",
token_url: "https://idp.example/token",
identifier_path: "sub",
name_path: "name",
scopes: "openid",
admin_group: "termix-admins",
group_claim: "groups",
};
h.idTokenClaims = {
sub: "sub-1",
sid: "sid-1",
name: "Alice",
email: "alice@example.com",
nonce: "nonce-1",
groups: ["termix-admins"],
};
for (const [key, value] of Object.entries({
oidc_backend_callback_state1: "https://termix.example/users/oidc/callback",
oidc_frontend_origin_state1: "https://termix.example",
oidc_remember_me_state1: "false",
oidc_state_state1: "nonce-1",
oidc_provider_state1: "3",
})) {
h.state.settings.set(key, value);
}
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url === "https://idp.example/token") {
return new Response(
JSON.stringify({ id_token: "jwt", access_token: "at" }),
);
}
return new Response("{}", { status: 404 });
}),
);
h.ldap.users = {
bob: {
dn: "uid=bob,ou=people",
password: "hunter2",
attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" },
},
};
h.ldap.adminDns = [];
});
describe("OIDC through the pipeline", () => {
it("turns the callback into an identity with the old identifier and claims", async () => {
const identity = await handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
);
expect(identity).toMatchObject({
kind: "external",
provider: "3",
subject: "sub-1",
legacyIdentifier: "sub-1",
name: "Alice",
isAdmin: true,
oidcSub: "sub-1",
oidcSid: "sid-1",
ssoProviderId: 3,
returnTo: "https://termix.example",
});
// The one-time state is consumed.
expect(h.state.settings.has("oidc_state_state1")).toBe(false);
});
it("signs in and redirects with a cookie, like the old callback", async () => {
const req = fakeRequest({ query: { code: "c", state: "state1" } });
const identity = await handleOidcCallback(req as never);
const res = fakeResponse();
await respondWithRedirectLogin(
req as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
expect(res.cleared).toContain("jwt");
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
const [user] = [...h.state.users.values()];
expect(user).toMatchObject({ oidcIdentifier: "sub-1", isAdmin: true });
expect(h.state.audits).toContainEqual(
expect.objectContaining({ action: "login", userId: user.id }),
);
});
it("rejects a nonce mismatch without a session", async () => {
h.idTokenClaims = { ...(h.idTokenClaims as object), nonce: "other" };
await expect(
handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
),
).rejects.toMatchObject({ status: 401 });
});
it("namespaces GitHub users the way they were stored", async () => {
h.providerType = "github";
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url === "https://idp.example/token") {
return new Response(JSON.stringify({ access_token: "at" }));
}
if (url === "https://api.github.com/user") {
return new Response(JSON.stringify({ id: 42, login: "octo" }));
}
return new Response("[]");
}),
);
const identity = await handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
);
expect(identity).toMatchObject({
provider: "3",
subject: "42",
legacyIdentifier: "github:3:42",
name: "octo",
});
});
});
describe("LDAP through the pipeline", () => {
const providerConfig = {
host: "ldap.example",
bindDN: "cn=service",
bindPassword: "svc",
userSearchBase: "ou=people",
userSearchFilter: "(uid={{username}})",
groupSearchBase: "ou=groups",
adminGroup: "admins",
};
it("binds, finds the user and signs in with the old identifier", async () => {
h.providerConfig = providerConfig;
h.ldap.adminDns = ["uid=bob,ou=people"];
const req = fakeRequest({
body: { providerId: 4, username: "bob", password: "hunter2" },
headers: { "x-electron-app": "true" },
});
const identity = await verifyLdapLogin({
body: req.body as never,
ip: "10.0.0.1",
});
expect(identity).toMatchObject({
provider: "4",
subject: "bob",
legacyIdentifier: "ldap:4:bob",
isAdmin: true,
name: "Bob Builder",
});
const res = fakeResponse();
await respondWithLogin(req as never, res as never, identity, {
methodId: "ldap",
rememberMe: false,
});
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
// Native clients now get the token for LDAP too.
expect(res.body).toMatchObject({
success: true,
token: expect.any(String),
});
});
it("rejects a wrong password", async () => {
h.providerConfig = providerConfig;
await expect(
verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "wrong" },
ip: "10.0.0.2",
}),
).rejects.toMatchObject({ status: 401 });
});
it("skips second factors for an LDAP user who enrolled when the external-login setting is off", async () => {
h.providerConfig = providerConfig;
h.state.users.set("u-bob", {
id: "u-bob",
username: "Bob Builder",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
});
h.state.factors.push({
userId: "u-bob",
pluginId: "fixture",
factorId: "pin",
});
const identity = await verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "hunter2" },
ip: "10.0.0.3",
});
const result = await runLogin(fakeRequest() as never, identity, {
methodId: "ldap",
rememberMe: false,
});
expect(result.kind).toBe("session");
});
it("runs second factors for an LDAP user who enrolled when the external-login setting is on", async () => {
h.providerConfig = providerConfig;
h.state.settings.set("second_factor_after_external_login", "true");
h.state.users.set("u-bob", {
id: "u-bob",
username: "Bob Builder",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
});
h.state.factors.push({
userId: "u-bob",
pluginId: "fixture",
factorId: "pin",
});
const identity = await verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "hunter2" },
ip: "10.0.0.3",
});
const { registerSecondFactor } = await import("../../auth/registry.js");
const dispose = registerSecondFactor({
id: "pin",
pluginId: "fixture",
labelKey: "pin",
isEnrolled: async () => false,
verify: async () => true,
});
try {
const result = await runLogin(fakeRequest() as never, identity, {
methodId: "ldap",
rememberMe: false,
});
expect(result.kind).toBe("second-factor");
} finally {
dispose();
}
});
});
+14 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import bcrypt from "bcryptjs";
import {
createAuthState,
@@ -464,6 +464,19 @@ describe("external identities", () => {
ssoProviderId: 3,
};
// The sso plugin's method, which marks itself external.
let disposeOidc: () => void = () => {};
beforeEach(() => {
disposeOidc = registerLoginMethod({
id: "oidc",
pluginId: "sso",
labelKey: "loginWithSso",
kind: "redirect",
external: true,
});
});
afterEach(() => disposeOidc());
it("provisions the first user as admin and links the identity", async () => {
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
@@ -1,104 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SsoProviderRepository } from "../../../database/repositories/sso-provider-repository.js";
describe("SsoProviderRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<SsoProviderRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
return new SsoProviderRepository(context, onWrite);
}
it("creates, lists, finds, updates, and deletes providers", async () => {
const repo = await createRepository();
const disabled = await repo.create({
name: "Disabled",
type: "oidc",
enabled: false,
displayOrder: 1,
config: "{}",
});
const enabled = await repo.create({
name: "GitHub",
type: "github",
enabled: true,
displayOrder: 0,
config: '{"client_id":"id"}',
});
expect((await repo.listEnabledPublic()).map((row) => row.id)).toEqual([
enabled.id,
]);
expect((await repo.listAll()).map((row) => row.id)).toEqual([
enabled.id,
disabled.id,
]);
expect((await repo.findById(enabled.id))?.name).toBe("GitHub");
expect((await repo.findFirstEnabledOidcLike())?.id).toBe(enabled.id);
const updated = await repo.update(enabled.id, {
name: "GitHub SSO",
config: '{"client_id":"updated"}',
updatedAt: "2026-06-27T00:00:00.000Z",
});
expect(updated?.name).toBe("GitHub SSO");
expect(updated?.config).toContain("updated");
expect(await repo.delete(enabled.id)).toBe(true);
expect(await repo.findById(enabled.id)).toBeNull();
expect(await repo.delete(enabled.id)).toBe(false);
});
it("counts users associated with a provider", async () => {
const repo = await createRepository();
const provider = await repo.create({
name: "LDAP",
type: "ldap",
enabled: true,
displayOrder: 0,
config: "{}",
});
await adapter!.exec(`
INSERT INTO users (id, username, password_hash, sso_provider_id)
VALUES ('user-1', 'u1', 'hash', ${provider.id}),
('user-2', 'u2', 'hash', ${provider.id}),
('user-3', 'u3', 'hash', NULL);
`);
expect(await repo.countUsersByProviderId(provider.id)).toBe(2);
});
it("runs the write hook after provider writes", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const provider = await repo.create({
name: "OIDC",
type: "oidc",
enabled: true,
displayOrder: 0,
config: "{}",
});
await repo.update(provider.id, { enabled: false });
await repo.delete(provider.id);
await repo.delete(provider.id);
expect(writeCount).toBe(3);
});
});
@@ -0,0 +1,134 @@
/**
* The 2.8 login URLs identity providers and 2.8 clients still call. They
* forward to whatever replaced them and, with the method's plugin gone, sign
* nobody in.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return { authLogger: log, databaseLogger: log, logger: log };
});
vi.mock("../../../auth/core-auth.js", () => ({
ensureCoreLoginProviders: () => {},
}));
vi.mock("../../../utils/trusted-proxy-auth.js", () => ({
isTrustedProxyAuthEnabled: () => false,
}));
const { registerAuthCompatRoutes } =
await import("../../../database/routes/auth-compat-routes.js");
const { registerLoginMethod, resetAuthRegistryForTests } =
await import("../../../auth/registry.js");
let server: http.Server;
let base: string;
beforeEach(async () => {
resetAuthRegistryForTests();
const router = express.Router();
router.use(express.json());
registerAuthCompatRoutes(router);
const app = express();
app.use("/users", router);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function get(path: string) {
return fetch(`${base}${path}`, { redirect: "manual" });
}
function registerSso() {
return registerLoginMethod({
id: "oidc",
pluginId: "sso",
labelKey: "loginWithSso",
kind: "redirect",
external: true,
describe: async () => [
{ id: "3", label: "Keycloak", enabled: true, type: "oidc" },
],
start: async (_request, instanceId) => ({
redirectUrl: `https://idp.example/auth?provider=${instanceId}`,
}),
});
}
describe("the old identity provider URLs", () => {
it("forwards the callback to the sso plugin, query intact", async () => {
const response = await get("/users/oidc/callback?code=abc&state=xyz");
expect(response.status).toBe(308);
expect(response.headers.get("location")).toBe(
"/plugin-api/sso/callback?code=abc&state=xyz",
);
});
it("forwards a form-posted callback with a 308 so the body is resent", async () => {
const response = await fetch(`${base}/users/oidc/callback`, {
method: "POST",
redirect: "manual",
});
expect(response.status).toBe(308);
});
it("forwards back-channel logout to the sso plugin", async () => {
const response = await fetch(`${base}/users/oidc/backchannel-logout`, {
method: "POST",
redirect: "manual",
});
expect(response.status).toBe(308);
expect(response.headers.get("location")).toMatch(
/\/plugin-api\/sso\/backchannel-logout$/,
);
});
it("forwards the public config to the sso plugin", async () => {
const response = await get("/users/oidc-config");
expect(response.status).toBe(307);
expect(response.headers.get("location")).toMatch(
/\/plugin-api\/sso\/config$/,
);
});
});
describe("the old client routes", () => {
it("starts an SSO login through the oidc method", async () => {
registerSso();
const response = await get("/users/oidc/authorize?providerId=3");
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
auth_url: "https://idp.example/auth?provider=3",
});
});
it("lists providers in the 2.8 shape", async () => {
registerSso();
const response = await get("/users/sso-providers");
expect(await response.json()).toEqual([
{ id: 3, name: "Keycloak", type: "oidc", displayOrder: 0 },
]);
});
it("answers 404 once the plugins are gone", async () => {
const dispose = registerSso();
dispose();
expect((await get("/users/oidc/authorize?providerId=3")).status).toBe(404);
expect(await (await get("/users/sso-providers")).json()).toEqual([]);
const ldap = await fetch(`${base}/users/ldap/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ providerId: 4, username: "bob", password: "x" }),
});
expect(ldap.status).toBe(404);
});
});
@@ -1,24 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({ createAdminMiddleware: vi.fn() }),
},
}));
const { isValidOidcIssuer } =
await import("../../../database/routes/sso-provider-routes.js");
describe("isValidOidcIssuer", () => {
it("rejects userinfo endpoints used as issuer URLs", () => {
expect(
isValidOidcIssuer("https://auth.example/application/o/userinfo/"),
).toBe(false);
});
it("accepts an Authentik application issuer", () => {
expect(isValidOidcIssuer("https://auth.example/application/o/termix")).toBe(
true,
);
});
});
@@ -0,0 +1,210 @@
/**
* Linking an external (SSO or LDAP) account to a password account and back.
* The linked account keeps its sign-in identities, so it signs in through the
* same provider afterwards; unlinking drops them.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
interface User {
id: string;
username: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash: string;
oidcIdentifier?: string | null;
clientId?: string;
}
const h = vi.hoisted(() => ({
users: new Map<string, User>(),
identities: [] as Array<{
userId: string;
providerId: string;
subject: string;
}>,
deleted: [] as string[],
revoked: [] as string[],
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => h.users.get(id) ?? null,
findByUsername: async (username: string) =>
[...h.users.values()].find((user) => user.username === username) ?? null,
update: async (id: string, changes: Partial<User>) => {
Object.assign(h.users.get(id)!, changes);
},
}),
createCurrentUserAuthRepository: () => ({
moveIdentities: async (from: string, to: string) => {
for (const row of h.identities) if (row.userId === from) row.userId = to;
},
unlinkIdentitiesForUser: async (userId: string) => {
h.identities = h.identities.filter((row) => row.userId !== userId);
},
}),
}));
vi.mock("../../../database/routes/delete-user-data.js", () => ({
deleteUserAndRelatedData: async (userId: string) => {
h.deleted.push(userId);
h.users.delete(userId);
},
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../../utils/auth-manager.js", () => ({ AuthManager: {} }));
vi.mock("../../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
};
return { authLogger: log };
});
const { registerUserOidcAccountRoutes } =
await import("../../../database/routes/user-oidc-account-routes.js");
let server: http.Server;
let base: string;
beforeEach(async () => {
h.users = new Map([
[
"admin",
{
id: "admin",
username: "admin",
isAdmin: true,
isOidc: false,
passwordHash: "x",
},
],
[
"sso",
{
id: "sso",
username: "alice-sso",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "ldap:4:alice",
},
],
[
"local",
{
id: "local",
username: "alice",
isAdmin: false,
isOidc: false,
passwordHash: "hash",
},
],
]);
h.identities = [{ userId: "sso", providerId: "ldap:4", subject: "alice" }];
h.deleted = [];
h.revoked = [];
const router = express.Router();
router.use(express.json());
registerUserOidcAccountRoutes(router, {
authenticateJWT: (req, _res, next) => {
(req as unknown as { userId: string }).userId =
req.header("x-user") ?? "";
next();
},
authManager: {
revokeAllUserSessions: async (userId: string) => {
h.revoked.push(userId);
},
logoutUser: () => {},
} as never,
});
const app = express();
app.use("/users", router);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function post(path: string, body: unknown, user = "admin") {
return fetch(`${base}/users${path}`, {
method: "POST",
headers: { "content-type": "application/json", "x-user": user },
body: JSON.stringify(body),
});
}
describe("linking an external account to a password account", () => {
it("moves the identities and the old identifier, then removes the external account", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice",
});
expect(response.status).toBe(200);
expect(h.users.get("local")).toMatchObject({
isOidc: true,
oidcIdentifier: "ldap:4:alice",
});
expect(h.identities).toEqual([
{ userId: "local", providerId: "ldap:4", subject: "alice" },
]);
expect(h.revoked).toEqual(["sso"]);
expect(h.deleted).toEqual(["sso"]);
});
it("is for admins only", async () => {
const response = await post(
"/link-oidc-to-password",
{ oidcUserId: "sso", targetUsername: "alice" },
"local",
);
expect(response.status).toBe(403);
expect(h.identities[0].userId).toBe("sso");
});
it("refuses a target that is not a password account", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice-sso",
});
expect(response.status).toBe(400);
});
});
describe("unlinking", () => {
it("drops the identities so the provider no longer signs in as the user", async () => {
await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice",
});
const response = await post("/unlink-oidc-from-password", {
userId: "local",
});
expect(response.status).toBe(200);
expect(h.users.get("local")).toMatchObject({
isOidc: false,
oidcIdentifier: null,
});
expect(h.identities).toEqual([]);
});
it("refuses to leave an account with no way in", async () => {
const response = await post("/unlink-oidc-from-password", {
userId: "sso",
});
expect(response.status).toBe(400);
expect(h.identities).toHaveLength(1);
});
});
@@ -1,609 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// user-oidc-utils imports the logger; stub it so importing stays side-effect-free.
vi.mock("../../../utils/logger.js", () => ({
authLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const {
isOIDCUserAllowed,
getOIDCConfigFromEnv,
extractOidcGroups,
extractOidcGroupsFromSources,
validateLogoutTokenClaims,
parseOidcRoleMap,
resolveOidcMappedRoles,
verifyOIDCToken,
describeFetchFailure,
isOIDCEnvOverrideEnabled,
} = await import("../../../database/routes/user-oidc-utils.js");
const BACKCHANNEL_LOGOUT_EVENT =
"http://schemas.openid.net/event/backchannel-logout";
afterEach(() => {
vi.restoreAllMocks();
});
describe("describeFetchFailure", () => {
it("unwraps the undici cause, which carries the reason that matters", () => {
// Every transport failure surfaces as this same outer message.
const error = new TypeError("fetch failed", {
cause: Object.assign(new Error("getaddrinfo ENOTFOUND idp.example"), {
code: "ENOTFOUND",
}),
});
expect(describeFetchFailure(error)).toBe(
"fetch failed: getaddrinfo ENOTFOUND idp.example (ENOTFOUND)",
);
});
it("falls back to the outer message when there is no cause", () => {
expect(describeFetchFailure(new Error("boom"))).toBe("boom");
});
it("handles a non-Error throw", () => {
expect(describeFetchFailure("nope")).toBe("nope");
});
});
describe("verifyOIDCToken JWKS diagnostics", () => {
const issuer = "https://login.microsoftonline.com/example/v2.0";
const token = "header.payload.signature";
it("reports every attempted URL and why it failed", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("not found", { status: 404 }),
);
const error = await verifyOIDCToken(token, issuer, "client").catch(
(e) => e as Error,
);
expect(error.message).toMatch(/^Failed to fetch JWKS from any URL/);
expect(error.message).toContain(
`${issuer}/.well-known/openid-configuration: HTTP 404`,
);
expect(error.message).toContain(
`${issuer}/.well-known/jwks.json: HTTP 404`,
);
expect(error.message).toContain(`${issuer}/jwks/: HTTP 404`);
});
it("reports a transport failure with its underlying cause", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(
new TypeError("fetch failed", {
cause: Object.assign(new Error("self-signed certificate"), {
code: "SELF_SIGNED_CERT_IN_CHAIN",
}),
}),
);
const error = await verifyOIDCToken(token, issuer, "client").catch(
(e) => e as Error,
);
expect(error.message).toContain(
"self-signed certificate (SELF_SIGNED_CERT_IN_CHAIN)",
);
});
it("says so when discovery succeeds but advertises no jwks_uri", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ issuer }), { status: 200 }),
)
.mockResolvedValue(new Response("not found", { status: 404 }));
const error = await verifyOIDCToken(token, issuer, "client").catch(
(e) => e as Error,
);
expect(error.message).toContain("no jwks_uri in the discovery document");
});
it("says so when a JWKS response carries no keys array", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
status: 200,
}),
)
.mockResolvedValue(
new Response(JSON.stringify({ error: "unauthorized" }), {
status: 200,
}),
);
const error = await verifyOIDCToken(token, issuer, "client").catch(
(e) => e as Error,
);
expect(error.message).toContain(
'https://idp.example/keys: response contains no "keys" array',
);
});
});
describe("verifyOIDCToken", () => {
it("accepts a discovery document URL as the configured issuer", async () => {
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
const { publicKey, privateKey } = await generateKeyPair("RS256");
const jwk = await exportJWK(publicKey);
jwk.kid = "google-key";
const issuer = "https://accounts.google.com";
const clientId = "termix-client";
const token = await new SignJWT({ sub: "user-1" })
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
.setIssuer(issuer)
.setAudience(clientId)
.setExpirationTime("5m")
.sign(privateKey);
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ jwks_uri: `${issuer}/keys` }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
);
const payload = await verifyOIDCToken(
token,
`${issuer}/.well-known/openid-configuration`,
clientId,
);
expect(payload.sub).toBe("user-1");
expect(fetchMock).toHaveBeenNthCalledWith(
1,
`${issuer}/.well-known/openid-configuration`,
{},
);
});
it("uses the protected-header algorithm when the provider JWK omits alg", async () => {
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
const { publicKey, privateKey } = await generateKeyPair("RS256");
const jwk = await exportJWK(publicKey);
jwk.kid = "entra-key";
const issuer = "https://login.microsoftonline.com/example/v2.0";
const clientId = "termix-client";
const token = await new SignJWT({ sub: "user-1" })
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
.setIssuer(issuer)
.setAudience(clientId)
.setExpirationTime("5m")
.sign(privateKey);
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
);
const payload = await verifyOIDCToken(token, issuer, clientId);
expect(payload.sub).toBe("user-1");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe("isOIDCUserAllowed", () => {
it("allows everyone when the allow-list is empty", () => {
expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true);
expect(isOIDCUserAllowed(" ", "alice")).toBe(true);
});
it("allows everyone with the '*' wildcard", () => {
expect(isOIDCUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
});
it("matches an exact identifier (case-insensitive)", () => {
expect(isOIDCUserAllowed("alice,bob", "alice")).toBe(true);
expect(isOIDCUserAllowed("Alice", "alice")).toBe(true);
expect(isOIDCUserAllowed("alice", "ALICE")).toBe(true);
});
it("matches against the email as well as the identifier", () => {
expect(isOIDCUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
true,
);
});
it("matches an @domain suffix pattern", () => {
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@company.com")).toBe(
true,
);
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM")).toBe(
true,
);
});
it("denies users not on the list", () => {
expect(isOIDCUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
false,
);
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@other.com")).toBe(
false,
);
});
it("ignores blank entries and surrounding whitespace in the list", () => {
expect(isOIDCUserAllowed(" alice , , bob ", "bob")).toBe(true);
});
it("does not match the email against an identifier-only pattern when email differs", () => {
expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
});
it("matches *@domain.com wildcard pattern against emails", () => {
expect(
isOIDCUserAllowed("*@company.com", "sub-1", "john@company.com"),
).toBe(true);
expect(
isOIDCUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
).toBe(true);
expect(isOIDCUserAllowed("*@company.com", "sub-1", "user@other.com")).toBe(
false,
);
});
it("matches glob patterns with multiple wildcards", () => {
expect(isOIDCUserAllowed("admin*", "admin_user")).toBe(true);
expect(isOIDCUserAllowed("admin*", "user_admin")).toBe(false);
});
});
describe("getOIDCConfigFromEnv", () => {
const REQUIRED = [
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ISSUER_URL",
"OIDC_AUTHORIZATION_URL",
"OIDC_TOKEN_URL",
];
const OPTIONAL = [
"OIDC_USERINFO_URL",
"OIDC_IDENTIFIER_PATH",
"OIDC_NAME_PATH",
"OIDC_SCOPES",
"OIDC_ALLOWED_USERS",
"OIDC_ADMIN_GROUP",
"OIDC_ENV_OVERRIDE",
];
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of [...REQUIRED, ...OPTIONAL]) {
saved[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of [...REQUIRED, ...OPTIONAL]) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
it("returns null when any required variable is missing", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
// issuer/authorization/token urls intentionally missing
expect(getOIDCConfigFromEnv()).toBeNull();
});
it("builds a config with defaults when all required vars are present", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
process.env.OIDC_ISSUER_URL = "https://idp.example";
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
const config = getOIDCConfigFromEnv();
expect(config).not.toBeNull();
expect(config?.client_id).toBe("id");
expect(config?.identifier_path).toBe("sub");
expect(config?.name_path).toBe("name");
expect(config?.scopes).toBe("openid email profile");
expect(config?.userinfo_url).toBe("");
});
it("honors overrides for optional vars", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
process.env.OIDC_ISSUER_URL = "https://idp.example";
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
process.env.OIDC_IDENTIFIER_PATH = "email";
process.env.OIDC_SCOPES = "openid";
const config = getOIDCConfigFromEnv();
expect(config?.identifier_path).toBe("email");
expect(config?.scopes).toBe("openid");
});
it("only enables database recovery override when explicitly requested", () => {
expect(isOIDCEnvOverrideEnabled()).toBe(false);
process.env.OIDC_ENV_OVERRIDE = "true";
expect(isOIDCEnvOverrideEnabled()).toBe(true);
});
});
describe("extractOidcGroups", () => {
it("reads the standard groups claim as an array", () => {
expect(extractOidcGroups({ groups: ["admin", "user"] })).toEqual([
"admin",
"user",
]);
});
it("splits a comma-separated string claim", () => {
expect(extractOidcGroups({ roles: "admin, user" })).toEqual([
"admin",
"user",
]);
});
it("falls back through groups, roles, then group", () => {
expect(extractOidcGroups({ group: "ops" })).toEqual(["ops"]);
});
it("reads a custom claim path when provided", () => {
const userInfo = {
"zitadel:grants:groups:123": ["user", "admin"],
groups: ["ignored"],
};
expect(extractOidcGroups(userInfo, "zitadel:grants:groups:123")).toEqual([
"user",
"admin",
]);
});
it("uses object keys as group names (Zitadel roles object)", () => {
const userInfo = {
"urn:zitadel:iam:org:project:roles": { admin: {}, user: {} },
};
expect(
extractOidcGroups(userInfo, "urn:zitadel:iam:org:project:roles"),
).toEqual(["admin", "user"]);
});
it("falls back to defaults when the custom claim is absent", () => {
expect(extractOidcGroups({ groups: ["admin"] }, "missing")).toEqual([
"admin",
]);
});
it("returns an empty array when no groups are present", () => {
expect(extractOidcGroups({})).toEqual([]);
});
});
describe("extractOidcGroupsFromSources", () => {
it("preserves ID token groups when userinfo omits them", () => {
expect(
extractOidcGroupsFromSources([
{ groups: ["admins", "users"] },
{ sub: "user-1", name: "Example User" },
]),
).toEqual(["admins", "users"]);
});
it("combines and deduplicates groups from both verified sources", () => {
expect(
extractOidcGroupsFromSources([
{ roles: ["users", "operators"] },
{ roles: ["operators", "admins"] },
]),
).toEqual(["users", "operators", "admins"]);
});
it("supports a configured group claim across sources", () => {
expect(
extractOidcGroupsFromSources(
[{ custom_groups: ["admins"] }, { custom_groups: ["users"] }],
"custom_groups",
),
).toEqual(["admins", "users"]);
});
});
describe("validateLogoutTokenClaims", () => {
const validClaims = {
sub: "subject-1",
sid: "session-1",
iat: 1_783_641_600,
jti: "logout-1",
events: { [BACKCHANNEL_LOGOUT_EVENT]: {} },
};
it("accepts a spec-compliant back-channel logout payload", () => {
expect(validateLogoutTokenClaims(validClaims)).toEqual({
sub: "subject-1",
sid: "session-1",
jti: "logout-1",
});
});
it("requires the logout event to contain an object", () => {
expect(() =>
validateLogoutTokenClaims({
...validClaims,
events: { [BACKCHANNEL_LOGOUT_EVENT]: true },
}),
).toThrow("missing back-channel logout event");
});
it("requires iat and jti claims", () => {
expect(() =>
validateLogoutTokenClaims({ ...validClaims, iat: undefined }),
).toThrow("missing iat claim");
expect(() =>
validateLogoutTokenClaims({ ...validClaims, jti: "" }),
).toThrow("missing jti claim");
});
it("rejects nonce and requires sub or sid", () => {
expect(() =>
validateLogoutTokenClaims({ ...validClaims, nonce: "forbidden" }),
).toThrow("must not contain a nonce");
expect(() =>
validateLogoutTokenClaims({ ...validClaims, sub: null, sid: null }),
).toThrow("must contain sub and/or sid");
});
});
describe("parseOidcRoleMap", () => {
it("returns an empty map for blank input", () => {
expect(parseOidcRoleMap(undefined).size).toBe(0);
expect(parseOidcRoleMap(null).size).toBe(0);
expect(parseOidcRoleMap(" ").size).toBe(0);
});
it("parses comma-separated group:role pairs", () => {
const map = parseOidcRoleMap(
"devops-interns:devops-intern,devops-seniors:devops-senior",
);
expect(map.get("devops-interns")).toBe("devops-intern");
expect(map.get("devops-seniors")).toBe("devops-senior");
expect(map.size).toBe(2);
});
it("parses newline-separated pairs and trims whitespace", () => {
const map = parseOidcRoleMap(" a : role-a \n b:role-b \n");
expect(map.get("a")).toBe("role-a");
expect(map.get("b")).toBe("role-b");
});
it("normalizes leading slashes and case in group names", () => {
const map = parseOidcRoleMap("/DevOps-Interns:devops-intern");
expect(map.get("devops-interns")).toBe("devops-intern");
});
it("skips malformed entries instead of throwing", () => {
const map = parseOidcRoleMap("no-colon,:missing-group,missing-role:,ok:r");
expect(map.size).toBe(1);
expect(map.get("ok")).toBe("r");
});
it("splits on the last colon so group names may contain colons", () => {
const map = parseOidcRoleMap("ns:team:role-x");
expect(map.get("ns:team")).toBe("role-x");
});
it("preserves role-name case verbatim", () => {
// Role names must match roles.name exactly, so they are not lowercased.
expect(parseOidcRoleMap("g:DevOps_Senior").get("g")).toBe("DevOps_Senior");
});
});
describe("resolveOidcMappedRoles", () => {
const roleMap = parseOidcRoleMap(
"devops-interns:devops-intern,devops-seniors:devops-senior",
);
it("reports every mapped role as managed regardless of membership", () => {
const { managed } = resolveOidcMappedRoles([], roleMap);
expect([...managed].sort()).toEqual(["devops-intern", "devops-senior"]);
});
it("desires only the roles whose groups the user is in", () => {
const { desired } = resolveOidcMappedRoles(["devops-interns"], roleMap);
expect([...desired]).toEqual(["devops-intern"]);
});
it("matches full group paths emitted by Keycloak", () => {
const { desired } = resolveOidcMappedRoles(["/devops-seniors"], roleMap);
expect([...desired]).toEqual(["devops-senior"]);
});
it("ignores groups that are not mapped", () => {
const { desired } = resolveOidcMappedRoles(
["finance", "devops-interns"],
roleMap,
);
expect([...desired]).toEqual(["devops-intern"]);
});
it("supports a user in multiple mapped groups", () => {
const { desired } = resolveOidcMappedRoles(
["devops-interns", "devops-seniors"],
roleMap,
);
expect([...desired].sort()).toEqual(["devops-intern", "devops-senior"]);
});
it("desires nothing when the map is empty", () => {
const { desired, managed } = resolveOidcMappedRoles(
["devops-interns"],
new Map(),
);
expect(desired.size).toBe(0);
expect(managed.size).toBe(0);
});
});
// Imported as a namespace rather than destructured into the shared block at the
// top of the file, so this suite stays independent of what that block binds.
const oidcUtils = await import("../../../database/routes/user-oidc-utils.js");
describe("verifyOIDCToken token shape", () => {
const issuer = "https://idp.example.com/application/o/termix";
// The shape check runs before any network call, so no fetch stub is needed.
const fetchSpy = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
fetchSpy.mockReset();
});
it("reports an encrypted (JWE) token as a format error", async () => {
const jwe = ["header", "key", "iv", "ciphertext", "tag"].join(".");
await expect(
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
).rejects.toThrow(oidcUtils.OIDCTokenFormatError);
await expect(
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
).rejects.toThrow(/JWE \(encrypted\)/);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("reports any other non-JWS segment count as a format error", async () => {
await expect(
oidcUtils.verifyOIDCToken("header.payload", issuer, "client"),
).rejects.toThrow(/expected 3 segments, got 2/);
await expect(
oidcUtils.verifyOIDCToken("opaque", issuer, "client"),
).rejects.toThrow(/expected 3 segments, got 1/);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("lets a three-segment token through to key resolution", async () => {
fetchSpy.mockResolvedValue({ ok: false });
// Reaches JWKS fetching, so it fails on the key lookup rather than the shape.
await expect(
oidcUtils.verifyOIDCToken("header.payload.signature", issuer, "client"),
).rejects.not.toThrow(oidcUtils.OIDCTokenFormatError);
expect(fetchSpy).toHaveBeenCalled();
});
});
@@ -110,8 +110,10 @@ describe("expandOidcUsername", () => {
ssoProviderId: 1,
}),
}),
createCurrentSsoProviderRepository: () => ({
findById: async () => ({ type: "ldap" }),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "ldap:1", subject: "jdoe" },
],
}),
}));
@@ -128,8 +130,10 @@ describe("expandOidcUsername", () => {
ssoProviderId: 1,
}),
}),
createCurrentSsoProviderRepository: () => ({
findById: async () => ({ type: "oidc" }),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "1", subject: "ldap:1:admin" },
],
}),
}));
@@ -148,8 +152,10 @@ describe("expandOidcUsername", () => {
ssoProviderId: 5,
}),
}),
createCurrentSsoProviderRepository: () => ({
findById: async () => ({ type: "ldap" }),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "ldap:5", subject: "admin" },
],
}),
}));
@@ -0,0 +1,450 @@
/**
* A plugin's login method through core: what its identity may carry, how its
* own callback route hands off to core, and that a disabled plugin leaves no
* way in, including through the 2.8 wrapper routes.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
createAuthState,
fakeAuthManager,
fakeRequest,
fakeResponse,
type AuthState,
} from "../auth/auth-test-helpers.js";
const h = vi.hoisted(() => ({
state: null as unknown as AuthState,
manager: null as unknown as ReturnType<
typeof import("../auth/auth-test-helpers.js").fakeAuthManager
>,
granted: new Set<string>(),
trustedProxy: false,
audits: [] as string[],
}));
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("../auth/auth-test-helpers.js");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => h.manager },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
h.state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../utils/trusted-proxy-auth.js", () => ({
isTrustedProxyAuthEnabled: () => h.trustedProxy,
}));
vi.mock("../../plugins/permissions.js", async () => {
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
return {
assertCapability: async (
pluginId: string,
capability: string,
declared: readonly string[],
) => {
if (!declared.includes(capability) || !h.granted.has(capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
},
};
});
vi.mock("../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return {
authLogger: log,
databaseLogger: log,
sshLogger: log,
pluginLogger: log,
logger: log,
};
});
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
},
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
const { createPluginAuth } = await import("../../plugins/ctx-ssh-auth.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
const {
listPublicLoginMethods,
listLegacySsoProviders,
startRedirectLogin,
verifyFormLogin,
} = await import("../../database/routes/auth-routes.js");
const { runLogin } = await import("../../auth/login-pipeline.js");
const { resetAuthRegistryForTests, getLoginMethod } =
await import("../../auth/registry.js");
const { resetCoreLoginProvidersForTests } =
await import("../../auth/core-auth.js");
const manifest = {
id: "corp",
capabilities: ["auth:provide"],
contributes: { auth: { loginMethods: ["corp-sso", "corp-dir"] } },
} as unknown as PluginManifest;
function setup() {
const bag = new DisposableBag();
const auth = createPluginAuth({
manifest,
bag,
audit: async (action) => {
h.audits.push(action);
},
});
auth.registerLoginMethod({
id: "corp-sso",
labelKey: "signIn",
kind: "redirect",
external: true,
describe: async () => [
{ id: "3", label: "Corp", enabled: true, type: "oidc" },
],
start: async () => ({ redirectUrl: "https://idp.example/auth" }),
callback: async () => ({
kind: "external",
provider: "3",
subject: "sub-1",
name: "Alice",
email: "alice@example.com",
isAdmin: true,
roles: { desired: ["ops"], managed: ["ops", "dev"] },
logoutClaims: { providerId: 3, sub: "sub-1", sid: "sid-1" },
legacy: { identifier: "sub-1", providerRowId: 3 },
returnTo: "https://termix.example",
}),
});
auth.registerLoginMethod({
id: "corp-dir",
labelKey: "signIn",
kind: "form",
external: true,
describe: async () => [{ id: "4", label: "Directory", enabled: true }],
verify: async (request) => {
if (request.body.password !== "hunter2") {
await auth.loginRateLimit.recordFailure(
request.ip ?? "unknown",
"4:bob",
);
throw new (await import("@termix/plugin-sdk/backend")).LoginMethodError(
"Invalid username or password",
401,
);
}
return {
kind: "external",
provider: "ldap:4",
subject: "bob",
name: "Bob",
legacy: { identifier: "ldap:4:bob", providerRowId: 4 },
rateLimitKey: "4:bob",
};
},
});
return { auth, bag };
}
beforeEach(() => {
h.state = createAuthState();
h.manager = fakeAuthManager(h.state);
h.granted = new Set(["auth:provide"]);
h.trustedProxy = false;
h.audits = [];
resetAuthRegistryForTests();
resetCoreLoginProvidersForTests();
});
describe("a plugin's external identity", () => {
it("carries the 2.8 identifier, role map and logout claims into core", async () => {
setup();
const identity = await getLoginMethod("corp-sso")!.callback!(
fakeRequest() as never,
);
const result = await runLogin(fakeRequest() as never, identity, {
methodId: "corp-sso",
rememberMe: false,
});
expect(result.kind).toBe("session");
const [user] = [...h.state.users.values()];
expect(user).toMatchObject({ oidcIdentifier: "sub-1", ssoProviderId: 3 });
expect(h.state.identities).toContainEqual(
expect.objectContaining({ providerId: "3", subject: "sub-1" }),
);
expect(h.state.roles).toContainEqual({ userId: user.id, roleName: "ops" });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
user.id,
expect.objectContaining({
ssoProviderId: 3,
oidcSub: "sub-1",
oidcSid: "sid-1",
}),
);
});
it("cannot set core-only fields", async () => {
const { toCoreIdentity } = await import("../../plugins/ctx-ssh-auth.js");
const mapped = toCoreIdentity("corp", {
kind: "external",
provider: "3",
subject: "x",
unlockError: "spoofed",
rateLimitUsername: "someone-else",
} as never);
expect(mapped).not.toHaveProperty("unlockError", "spoofed");
expect(mapped.rateLimitUsername).toBeUndefined();
});
it("finds a 2.8 user by the old identifier and links the identity", async () => {
h.state.users.set("u-1", {
id: "u-1",
username: "Alice",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "sub-1",
});
setup();
const identity = await getLoginMethod("corp-sso")!.callback!(
fakeRequest() as never,
);
await runLogin(fakeRequest() as never, identity, {
methodId: "corp-sso",
rememberMe: false,
});
expect(h.state.users.size).toBe(1);
expect(h.state.identities).toContainEqual(
expect.objectContaining({ userId: "u-1", providerId: "3" }),
);
});
});
describe("ctx.auth.completeRedirectLogin", () => {
it("runs the method's callback and redirects back with a session", async () => {
const { auth } = setup();
const res = fakeResponse();
await auth.completeRedirectLogin(
"corp-sso",
fakeRequest({ query: { code: "c", state: "s" } }),
res,
);
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
});
it("refuses a method the plugin did not declare", async () => {
const { auth } = setup();
await expect(
auth.completeRedirectLogin("oidc", fakeRequest(), fakeResponse()),
).rejects.toThrow(/not listed/);
});
it("refuses without auth:provide", async () => {
const { auth } = setup();
h.granted.clear();
await expect(
auth.completeRedirectLogin("corp-sso", fakeRequest(), fakeResponse()),
).rejects.toMatchObject({ name: "PluginCapabilityError" });
});
it("is refused while trusted proxy login is on", async () => {
const { auth } = setup();
h.trustedProxy = true;
const res = fakeResponse();
await auth.completeRedirectLogin("corp-sso", fakeRequest(), res);
expect(res.statusCode).toBe(409);
expect(res.cookies).toHaveLength(0);
});
});
describe("the other ctx.auth helpers", () => {
it("revokes sessions by logout claims and audits it", async () => {
const { auth } = setup();
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
expect(h.manager.revokeSessionsByOidc).toHaveBeenCalledWith({
ssoProviderId: 3,
sub: null,
sid: "sid-1",
});
expect(h.audits).toContain("auth_sessions_revoked");
expect(await auth.revokeSessions({ providerId: 3 })).toBe(0);
});
it("keeps rate limit keys apart per plugin and clears them on success", async () => {
const { auth } = setup();
for (let i = 0; i < 10; i++) {
await auth.loginRateLimit.recordFailure("10.9.9.9", "4:bob");
}
expect(
(await auth.loginRateLimit.isLocked("10.9.9.9", "4:bob")).locked,
).toBe(true);
// Core's own key for the same name is untouched.
expect(loginRateLimiter.isLocked("10.9.9.8", "4:bob").locked).toBe(false);
loginRateLimiter.resetAttempts("10.9.9.9", "plugin:corp:4:bob");
});
it("counts linked users for a provider", async () => {
const { auth } = setup();
h.state.identities.push({
id: 1,
userId: "u-1",
providerId: "ldap:4",
subject: "bob",
email: null,
});
expect(await auth.countLinkedUsers("ldap:4")).toBe(1);
expect(await auth.countLinkedUsers("3")).toBe(0);
});
});
describe("a disabled plugin", () => {
it("lists its instances while active, for the login screen and 2.8 clients", async () => {
setup();
const methods = await listPublicLoginMethods();
expect(methods.map((method) => method.id)).toEqual(
expect.arrayContaining(["corp-sso", "corp-dir"]),
);
expect(await listLegacySsoProviders()).toEqual([
{ id: 3, name: "Corp", type: "oidc", displayOrder: 0 },
{ id: 4, name: "Directory", type: "corp-dir", displayOrder: 1 },
]);
});
it("hides its buttons and leaves no route that signs anyone in", async () => {
const { bag } = setup();
await bag.disposeAll();
const methods = await listPublicLoginMethods();
expect(methods.map((method) => method.id)).toEqual(["password"]);
expect(await listLegacySsoProviders()).toEqual([]);
const start = fakeResponse();
await startRedirectLogin(
fakeRequest() as never,
start as never,
"corp-sso",
"3",
);
expect(start.statusCode).toBe(404);
const verify = fakeResponse();
await verifyFormLogin(
fakeRequest({ body: { username: "bob", password: "hunter2" } }) as never,
verify as never,
"corp-dir",
"4",
);
expect(verify.statusCode).toBe(404);
expect(verify.cookies).toHaveLength(0);
expect(h.state.users.size).toBe(0);
});
});
describe("form methods and the external second factor setting", () => {
function enrolledBob() {
h.state.users.set("u-bob", {
id: "u-bob",
username: "Bob",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
});
h.state.factors.push({
userId: "u-bob",
pluginId: "fixture",
factorId: "pin",
});
}
it("skips second factors when the setting is off", async () => {
enrolledBob();
setup();
const res = fakeResponse();
await verifyFormLogin(
fakeRequest({ body: { password: "hunter2" } }) as never,
res as never,
"corp-dir",
"4",
);
expect(res.body).toMatchObject({ success: true });
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
});
it("asks for them when the setting is on", async () => {
enrolledBob();
h.state.settings.set("second_factor_after_external_login", "true");
setup();
const { registerSecondFactor } = await import("../../auth/registry.js");
registerSecondFactor({
id: "pin",
pluginId: "fixture",
labelKey: "pin",
isEnrolled: async () => false,
verify: async () => true,
});
const res = fakeResponse();
await verifyFormLogin(
fakeRequest({ body: { password: "hunter2" } }) as never,
res as never,
"corp-dir",
"4",
);
expect(res.body).toMatchObject({ requires_second_factor: true });
});
it("refuses a wrong password without a session", async () => {
setup();
const res = fakeResponse();
await verifyFormLogin(
fakeRequest({ body: { password: "nope" } }) as never,
res as never,
"corp-dir",
"4",
);
expect(res.statusCode).toBe(401);
expect(res.cookies).toHaveLength(0);
});
});
@@ -47,7 +47,7 @@ beforeEach(() => {
describe("parseLegacyIdentifier", () => {
it("splits the prefixed forms and keeps bare subjects on their provider", () => {
expect(parseLegacyIdentifier("ldap:4:bob", null)).toEqual({
providerId: "4",
providerId: "ldap:4",
subject: "bob",
});
expect(parseLegacyIdentifier("github:7:42", null)).toEqual({
@@ -55,7 +55,7 @@ describe("parseLegacyIdentifier", () => {
subject: "42",
});
expect(parseLegacyIdentifier("ldap:4:cn=a:b", null)).toEqual({
providerId: "4",
providerId: "ldap:4",
subject: "cn=a:b",
});
expect(parseLegacyIdentifier("sub-123", 3)).toEqual({
@@ -94,7 +94,7 @@ describe("runExternalIdentityMigration", () => {
subject,
})),
).toEqual([
{ userId: "ldap-user", providerId: "4", subject: "bob" },
{ userId: "ldap-user", providerId: "ldap:4", subject: "bob" },
{ userId: "gh-user", providerId: "7", subject: "42" },
{ userId: "oidc-user", providerId: "3", subject: "sub-123" },
{
@@ -0,0 +1,179 @@
/**
* The 2.8 to 2.9 LDAP split. LDAP rows in sso_providers (adopted or not yet)
* move into the ldap plugin's table with their ids, their identities carry
* the "ldap:" prefix, the SSO rows stay, and running it twice changes nothing.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({ db: null as unknown }));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runLdapProviderMigration } =
await import("../../utils/crypto-migration/ldap-provider-migration.js");
let sqlite: Database.Database;
const LEGACY_DDL = `
CREATE TABLE IF NOT EXISTS sso_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
const TARGET_DDL = `
CREATE TABLE IF NOT EXISTS "p_ldap_providers" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"name" text NOT NULL,
"enabled" integer NOT NULL DEFAULT 1,
"display_order" integer NOT NULL DEFAULT 0,
"config" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
function setup(options: { adopted: boolean; target: boolean }) {
sqlite = new Database(":memory:");
sqlite.exec(`
${LEGACY_DDL}
CREATE TABLE user_external_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
subject TEXT NOT NULL,
email TEXT
);
`);
const insert = sqlite.prepare(
"INSERT INTO sso_providers (id, name, type, enabled, config) VALUES (?, ?, ?, ?, ?)",
);
insert.run(3, "Keycloak", "oidc", 1, '{"client_id":"termix"}');
insert.run(4, "Corp LDAP", "ldap", 1, '{"host":"ldap.example"}');
insert.run(5, "Old LDAP", "ldap", 0, '{"host":"old.example"}');
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-oidc", "3", "sub-1");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-ldap", "ldap:4", "bob");
if (options.adopted) {
sqlite.exec(`ALTER TABLE sso_providers RENAME TO p_sso_providers;`);
}
if (options.target) sqlite.exec(TARGET_DDL);
h.db = drizzle(sqlite);
}
const all = (query: string) => sqlite.prepare(query).all();
describe("runLdapProviderMigration", () => {
beforeEach(() => {
vi.unstubAllEnvs();
});
it("does nothing until the ldap plugin's table exists", async () => {
setup({ adopted: false, target: false });
expect(await runLdapProviderMigration()).toEqual({
moved: 0,
renumbered: 0,
});
expect(all("SELECT id FROM sso_providers")).toHaveLength(3);
});
it.each([
["the legacy table", false],
["the adopted table", true],
])("moves LDAP rows out of %s with their ids", async (_label, adopted) => {
setup({ adopted, target: true });
const source = adopted ? "p_sso_providers" : "sso_providers";
expect(await runLdapProviderMigration()).toEqual({
moved: 2,
renumbered: 0,
});
expect(
all("SELECT id, name, enabled, config FROM p_ldap_providers ORDER BY id"),
).toEqual([
{
id: 4,
name: "Corp LDAP",
enabled: 1,
config: '{"host":"ldap.example"}',
},
{ id: 5, name: "Old LDAP", enabled: 0, config: '{"host":"old.example"}' },
]);
expect(all(`SELECT id, type FROM ${source}`)).toEqual([
{ id: 3, type: "oidc" },
]);
expect(
all("SELECT user_id, provider_id FROM user_external_identities"),
).toEqual([
{ user_id: "u-oidc", provider_id: "3" },
{ user_id: "u-ldap", provider_id: "ldap:4" },
]);
});
it("renames bare identities left from before the prefix", async () => {
setup({ adopted: true, target: true });
sqlite.exec(
"UPDATE user_external_identities SET provider_id = '4' WHERE user_id = 'u-ldap'",
);
await runLdapProviderMigration();
expect(
all(
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-ldap'",
),
).toEqual([{ provider_id: "ldap:4" }]);
});
it("gives a row a new id when the plugin already used it", async () => {
setup({ adopted: true, target: true });
sqlite.exec(
`INSERT INTO p_ldap_providers (id, name, config) VALUES (4, 'New', '{}')`,
);
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-bare", "4", "carol");
const result = await runLdapProviderMigration();
expect(result).toEqual({ moved: 2, renumbered: 1 });
const moved = all(
"SELECT id FROM p_ldap_providers WHERE name = 'Corp LDAP'",
) as Array<{ id: number }>;
expect(moved[0].id).not.toBe(4);
expect(
all(
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-bare'",
),
).toEqual([{ provider_id: `ldap:${moved[0].id}` }]);
});
it("is idempotent", async () => {
setup({ adopted: true, target: true });
await runLdapProviderMigration();
expect(await runLdapProviderMigration()).toEqual({
moved: 0,
renumbered: 0,
});
expect(all("SELECT id FROM p_ldap_providers")).toHaveLength(2);
});
});
@@ -1,23 +1,5 @@
import { describe, expect, it } from "vitest";
import {
getDesktopOidcCallbackUrl,
isOidcTokenCallback,
} from "../../utils/oidc-desktop-callback.js";
describe("getDesktopOidcCallbackUrl", () => {
it("uses localhost so browsers do not upgrade the loopback callback", () => {
expect(getDesktopOidcCallbackUrl("17850")).toBe(
"http://localhost:17850/oidc-callback",
);
});
it.each(["", "0", "65536", "17850/path", ["17850"]])(
"rejects invalid callback port %j",
(port) => {
expect(getDesktopOidcCallbackUrl(port)).toBeNull();
},
);
});
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
describe("isOidcTokenCallback", () => {
it.each([
@@ -8,14 +8,8 @@ vi.mock("../../utils/system-crypto.js", () => ({
SystemCrypto: { getInstance: () => ({ getEncryptionKey }) },
}));
const {
decryptSsoConfigSecrets,
decryptSystemSecret,
encryptSsoConfigSecrets,
encryptSystemSecret,
isSystemEncrypted,
SSO_SECRET_FIELDS,
} = await import("../../utils/system-secret-crypto.js");
const { decryptSystemSecret, encryptSystemSecret, isSystemEncrypted } =
await import("../../utils/system-secret-crypto.js");
beforeEach(() => {
getEncryptionKey.mockReset();
@@ -103,55 +97,3 @@ describe("legacy compatibility", () => {
await expect(decryptSystemSecret(sealed)).resolves.toBe("old-secret");
});
});
describe("SSO provider config", () => {
it("seals only the secret fields", async () => {
const sealed = await encryptSsoConfigSecrets({
client_id: "termix",
client_secret: "shhh",
bindPassword: "ldap-pw",
issuer_url: "https://idp.example",
});
expect(sealed.client_id).toBe("termix");
expect(sealed.issuer_url).toBe("https://idp.example");
expect(isSystemEncrypted(sealed.client_secret as string)).toBe(true);
expect(isSystemEncrypted(sealed.bindPassword as string)).toBe(true);
});
it("round-trips a whole config", async () => {
const original = {
client_id: "termix",
client_secret: "shhh",
bindPassword: "ldap-pw",
};
const restored = await decryptSsoConfigSecrets(
await encryptSsoConfigSecrets(original),
);
expect(restored).toEqual(original);
});
it("covers both secret fields", () => {
expect([...SSO_SECRET_FIELDS]).toEqual(["client_secret", "bindPassword"]);
});
it("leaves a config without secrets untouched", async () => {
const config = { client_id: "termix", scopes: "openid" };
await expect(encryptSsoConfigSecrets(config)).resolves.toEqual(config);
await expect(decryptSsoConfigSecrets(config)).resolves.toEqual(config);
});
it("does not let one unreadable secret take down the provider", async () => {
const restored = await decryptSsoConfigSecrets({
client_id: "termix",
client_secret: "sysenc:v1:bad",
});
// The rest of the config survives; login fails later with a clearer error.
expect(restored.client_id).toBe("termix");
expect(restored.client_secret).toBe("sysenc:v1:bad");
});
});
@@ -4,7 +4,8 @@
* users.oidc_identifier packed provider and subject into one string:
* "ldap:<provider>:<id>", "github:<provider>:<id>", or a bare OIDC subject
* whose provider was users.sso_provider_id. Each becomes a
* user_external_identities row. TOTP enrolment moves in totp-migration.ts.
* user_external_identities row; LDAP ones under provider "ldap:<provider>",
* the id the ldap plugin signs in with. TOTP enrolment moves in totp-migration.ts.
*
* Lossless: the old column is left as it is. Idempotent: rows that already
* exist are skipped, so it runs on every boot.
@@ -16,7 +17,7 @@ import {
createCurrentUserRepository,
} from "../../database/repositories/factory.js";
/** Matches LEGACY_OIDC_PROVIDER_ID in auth/legacy/oidc-login.ts. */
/** The provider id the sso plugin gives its env-configured provider. */
const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
export interface ExternalIdentityMigrationResult {
@@ -34,11 +35,16 @@ export function parseLegacyIdentifier(
const prefixed = /^(ldap|github):([^:]+):(.+)$/.exec(identifier);
if (prefixed) {
const provider = prefixed[2];
const missing = provider === "null" || provider === "undefined";
// LDAP providers moved to their own table in 2.9 and their identities
// carry the "ldap:" prefix so their ids cannot clash with SSO ones.
if (prefixed[1] === "ldap") {
return missing
? null
: { providerId: `ldap:${provider}`, subject: prefixed[3] };
}
return {
providerId:
provider === "null" || provider === "undefined"
? LEGACY_OIDC_PROVIDER_ID
: provider,
providerId: missing ? LEGACY_OIDC_PROVIDER_ID : provider,
subject: prefixed[3],
};
}
@@ -0,0 +1,145 @@
/**
* Moves 2.8 LDAP directories into the ldap plugin.
*
* 2.8 kept LDAP and SSO providers in one sso_providers table. The sso plugin
* adopts that table (as p_sso_providers) and ignores its LDAP rows; this
* copies them into p_ldap_providers once the ldap plugin has created it, then
* deletes them from the source.
*
* Ids are kept, because users.oidc_identifier ("ldap:<id>:<uid>") and their
* identities ("ldap:<id>") name them. Bare "<id>" identities from before the
* prefix are renamed too. It runs before the ldap plugin first activates, so
* the plugin cannot have used an id yet; if it somehow has, the old row gets
* a fresh id and only its bare identities follow it.
*
* Needs neither plugin to be running, only their tables. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { resolveDatabaseDialect } from "../../database/db/dialect.js";
import { runStatement, selectRows } from "./raw-rows.js";
const TARGET = "p_ldap_providers";
const SOURCES = ["p_sso_providers", "sso_providers"];
interface LegacyRow {
id: number;
name: string;
enabled: unknown;
display_order: number;
config: string;
created_at: string;
updated_at: string;
}
export interface LdapProviderMigrationResult {
/** Directories copied into the plugin's table. */
moved: number;
/** Of those, how many needed a new id. */
renumbered: number;
}
async function tableExists(name: string): Promise<boolean> {
try {
await selectRows(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
return true;
} catch {
return false;
}
}
function isOn(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "t";
}
async function renameIdentities(from: string, to: string): Promise<void> {
await runStatement(
sql`UPDATE user_external_identities SET provider_id = ${to} WHERE provider_id = ${from}`,
);
}
/** Explicit ids leave a Postgres sequence behind them. */
async function syncSequence(): Promise<void> {
if (resolveDatabaseDialect() !== "postgres") return;
await runStatement(
sql`SELECT setval(pg_get_serial_sequence(${TARGET}, 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${sql.identifier(TARGET)}))`,
);
}
async function insertRow(row: LegacyRow, id: number | null): Promise<number> {
const enabled = isOn(row.enabled);
const values = sql`${row.name}, ${resolveDatabaseDialect() === "sqlite" ? (enabled ? 1 : 0) : enabled}, ${row.display_order ?? 0}, ${row.config}, ${row.created_at}, ${row.updated_at}`;
if (id !== null) {
await runStatement(
sql`INSERT INTO ${sql.identifier(TARGET)} (id, name, enabled, display_order, config, created_at, updated_at) VALUES (${id}, ${values})`,
);
return id;
}
await runStatement(
sql`INSERT INTO ${sql.identifier(TARGET)} (name, enabled, display_order, config, created_at, updated_at) VALUES (${values})`,
);
const [inserted] = await selectRows<{ id: number }>(
sql`SELECT MAX(id) AS id FROM ${sql.identifier(TARGET)}`,
);
return Number(inserted.id);
}
export async function runLdapProviderMigration(): Promise<LdapProviderMigrationResult> {
const result: LdapProviderMigrationResult = { moved: 0, renumbered: 0 };
try {
if (!(await tableExists(TARGET))) return result;
for (const source of SOURCES) {
if (!(await tableExists(source))) continue;
const rows = await selectRows<LegacyRow>(
sql`SELECT id, name, enabled, display_order, config, created_at, updated_at FROM ${sql.identifier(source)} WHERE type = 'ldap' ORDER BY id`,
);
// Rows whose id is free keep it; the rest go last, so a fresh id can
// never land on one that is still waiting to be copied.
const clashing: LegacyRow[] = [];
for (const row of rows) {
const taken = await selectRows(
sql`SELECT id FROM ${sql.identifier(TARGET)} WHERE id = ${row.id}`,
);
if (taken.length > 0) {
clashing.push(row);
continue;
}
await insertRow(row, row.id);
await renameIdentities(String(row.id), `ldap:${row.id}`);
await runStatement(
sql`DELETE FROM ${sql.identifier(source)} WHERE id = ${row.id}`,
);
result.moved++;
}
if (clashing.length > 0) await syncSequence();
for (const row of clashing) {
const id = await insertRow(row, null);
// "ldap:<id>" already names the plugin's own directory, so only the
// bare ids from before the prefix can follow this one.
await renameIdentities(String(row.id), `ldap:${id}`);
await runStatement(
sql`DELETE FROM ${sql.identifier(source)} WHERE id = ${row.id}`,
);
result.moved++;
result.renumbered++;
}
}
if (result.moved > 0) await syncSequence();
if (result.moved > 0) {
databaseLogger.info("Moved LDAP directories into the ldap plugin", {
operation: "ldap_provider_migration",
...result,
});
}
} catch (error) {
databaseLogger.error("LDAP directory migration failed", error, {
operation: "ldap_provider_migration_failed",
});
}
return result;
}
@@ -0,0 +1,12 @@
/**
* Core data that moves into a plugin's tables once they exist. Runs whenever
* a plugin's migrations applied something, at boot or when a plugin is
* enabled later, so the data is in place before the plugin's activate runs. Each move is
* idempotent and does nothing until its target table exists.
*/
import { runLdapProviderMigration } from "./ldap-provider-migration.js";
export async function runPluginDataMoves(): Promise<void> {
await runLdapProviderMigration();
}
+5 -9
View File
@@ -1,14 +1,10 @@
const CALLBACK_PATH = "/oidc-callback";
export function getDesktopOidcCallbackUrl(value: unknown): string | null {
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
return `http://localhost:${port}${CALLBACK_PATH}`;
}
/**
* Whether a redirect login's return address is the desktop app's loopback
* listener or the mobile app's scheme, which get the token in the URL
* because they cannot read a cookie.
*/
export function isOidcTokenCallback(value: string): boolean {
if (value.startsWith("termix-mobile:")) return true;
-4
View File
@@ -163,10 +163,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
name: "API Keys",
description: "API key management",
},
{
name: "SSO",
description: "Single sign-on provider configuration",
},
{
name: "Vault",
description: "HashiCorp Vault SSH signing profiles",
-34
View File
@@ -85,37 +85,3 @@ export async function decryptSystemSecret(stored: string): Promise<string> {
decipher.final(),
]).toString("utf8");
}
/** Fields inside an SSO provider config that must not be stored readable. */
export const SSO_SECRET_FIELDS = ["client_secret", "bindPassword"] as const;
export async function encryptSsoConfigSecrets(
config: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const out = { ...config };
for (const field of SSO_SECRET_FIELDS) {
const value = out[field];
if (typeof value === "string" && value) {
out[field] = await encryptSystemSecret(value);
}
}
return out;
}
export async function decryptSsoConfigSecrets(
config: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const out = { ...config };
for (const field of SSO_SECRET_FIELDS) {
const value = out[field];
if (typeof value === "string" && value) {
try {
out[field] = await decryptSystemSecret(value);
} catch {
// A secret we cannot read must not take the whole provider down;
// login will fail with a clearer error downstream.
}
}
}
return out;
}