mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: move sso and ldap logins into plugins (C2)
This commit is contained in:
1 parent
5c18435105
commit
925da4a661
223 files changed
+25778
-6820
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user