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> { let config: Record; try { config = JSON.parse(configJson); } catch { return {}; } return decryptSsoConfigSecrets(config); } async function encryptProviderConfig( config: Record, _userId: string, _providerId: string, ): Promise { return JSON.stringify(await encryptSsoConfigSecrets(config)); } function applyProviderDefaults( type: SSOProviderType, config: Partial, ): Partial { 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; } 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 { const envConfig = getOIDCConfigFromEnv(); if (envConfig && isOIDCEnvOverrideEnabled()) { return res.json([ { id: 0, name: "SSO", type: "oidc", displayOrder: 0 }, ]); } const providers = await createCurrentSsoProviderRepository().listEnabledPublic(); // If no DB providers exist, synthesize one from env vars so SSO login // remains available when configured purely via environment variables. if (providers.length === 0) { if (envConfig) { providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 }); } } res.json(providers); } 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; }; 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, ) : rawConfig; if (type === "oidc" || type === "github" || type === "google") { const c = configWithDefaults as Partial; 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; 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, 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; }; 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 };