diff --git a/packages/server/src/enterprise/controllers/login-method.controller.ts b/packages/server/src/enterprise/controllers/login-method.controller.ts index 9e16aefe9..888d4011b 100644 --- a/packages/server/src/enterprise/controllers/login-method.controller.ts +++ b/packages/server/src/enterprise/controllers/login-method.controller.ts @@ -14,6 +14,14 @@ import GoogleSSO from '../sso/GoogleSSO' import { decrypt } from '../utils/encryption.util' export class LoginMethodController { + constructor() { + this.create = this.create.bind(this) + this.read = this.read.bind(this) + this.update = this.update.bind(this) + this.defaultMethods = this.defaultMethods.bind(this) + this.testConfig = this.testConfig.bind(this) + } + private assertEnterprisePlatform(): void { const platformType = getRunningExpressApp().identityManager.getPlatformType() if (platformType === Platform.CLOUD || platformType === Platform.OPEN_SOURCE) { @@ -21,6 +29,11 @@ export class LoginMethodController { } } + private async getSafeConfig(encryptedConfig: string): Promise> { + const { clientSecret: _, ...safe } = JSON.parse(await decrypt(encryptedConfig)) as Record + return safe + } + public async create(req: Request, res: Response, next: NextFunction) { try { this.assertEnterprisePlatform() @@ -73,6 +86,10 @@ export class LoginMethodController { let queryRunner try { this.assertEnterprisePlatform() + const user = (req as any).user + if (!user?.activeOrganizationId) { + throw new InternalFlowiseError(StatusCodes.FORBIDDEN, GeneralErrorMessage.FORBIDDEN) + } queryRunner = getRunningExpressApp().AppDataSource.createQueryRunner() await queryRunner.connect() const query = req.query as Partial @@ -91,12 +108,18 @@ export class LoginMethodController { if (query.id) { loginMethod = await loginMethodService.readLoginMethodById(query.id, queryRunner) if (!loginMethod) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, LoginMethodErrorMessage.LOGIN_METHOD_NOT_FOUND) - loginMethod.config = JSON.parse(await decrypt(loginMethod.config)) + if (loginMethod.organizationId !== user.activeOrganizationId) { + throw new InternalFlowiseError(StatusCodes.FORBIDDEN, GeneralErrorMessage.FORBIDDEN) + } + loginMethod.config = await this.getSafeConfig(loginMethod.config) } else if (query.organizationId) { + if (query.organizationId !== user.activeOrganizationId) { + throw new InternalFlowiseError(StatusCodes.FORBIDDEN, GeneralErrorMessage.FORBIDDEN) + } loginMethod = await loginMethodService.readLoginMethodByOrganizationId(query.organizationId, queryRunner) for (let method of loginMethod) { - method.config = JSON.parse(await decrypt(method.config)) + method.config = await this.getSafeConfig(method.config) } loginMethodConfig.providers = loginMethod } else { @@ -131,25 +154,39 @@ export class LoginMethodController { } } public async testConfig(req: Request, res: Response, next: NextFunction) { + let queryRunner try { - const providers = req.body.providers - if (req.body.providerName === 'azure') { - const response = await AzureSSO.testSetup(providers[0].config) + const providers = req.body.providers as { config: Record }[] + const providerName = req.body.providerName as string + const organizationId = req.body.organizationId as string | undefined + let config = providers[0]?.config ?? {} + + if (organizationId) { + queryRunner = getRunningExpressApp().AppDataSource.createQueryRunner() + await queryRunner.connect() + const loginMethodService = new LoginMethodService() + config = await loginMethodService.getConfigWithSecrets(organizationId, providerName, config, queryRunner) + } + + if (providerName === 'azure') { + const response = await AzureSSO.testSetup(config) return res.json(response) - } else if (req.body.providerName === 'google') { - const response = await GoogleSSO.testSetup(providers[0].config) + } else if (providerName === 'google') { + const response = await GoogleSSO.testSetup(config) return res.json(response) - } else if (req.body.providerName === 'auth0') { - const response = await Auth0SSO.testSetup(providers[0].config) + } else if (providerName === 'auth0') { + const response = await Auth0SSO.testSetup(config) return res.json(response) - } else if (req.body.providerName === 'github') { - const response = await GithubSSO.testSetup(providers[0].config) + } else if (providerName === 'github') { + const response = await GithubSSO.testSetup(config) return res.json(response) } else { return res.json({ error: 'Provider not supported' }) } } catch (error) { next(error) + } finally { + if (queryRunner) await queryRunner.release() } } } diff --git a/packages/server/src/enterprise/routes/login-method.route.ts b/packages/server/src/enterprise/routes/login-method.route.ts index f1c3912e2..0f4f50665 100644 --- a/packages/server/src/enterprise/routes/login-method.route.ts +++ b/packages/server/src/enterprise/routes/login-method.route.ts @@ -5,7 +5,7 @@ import { checkPermission } from '../rbac/PermissionCheck' const router = express.Router() const loginMethodController = new LoginMethodController() -router.get('/', loginMethodController.read) +router.get('/', checkPermission('sso:manage'), loginMethodController.read) router.get('/default', loginMethodController.defaultMethods) diff --git a/packages/server/src/enterprise/services/login-method.service.ts b/packages/server/src/enterprise/services/login-method.service.ts index 5523f4191..6889b2d05 100644 --- a/packages/server/src/enterprise/services/login-method.service.ts +++ b/packages/server/src/enterprise/services/login-method.service.ts @@ -71,6 +71,35 @@ export class LoginMethodService { return await queryRunner.manager.save(LoginMethod, data) } + private isPlaceholderSecret(value: unknown): boolean { + return !value || (typeof value === 'string' && /^\*+$/.test(value)) + } + + private mergeWithStoredClientSecret(incoming: Record, existing: Record): Record { + const sent = incoming.clientSecret + if (this.isPlaceholderSecret(sent) && existing.clientSecret) { + return { ...incoming, clientSecret: existing.clientSecret } + } + return { ...incoming } + } + + /** + * Returns config with clientSecret filled from stored config when the incoming value is a placeholder (empty or asterisks). + * Used for both testing and saving so logic stays in one place. + */ + public async getConfigWithSecrets( + organizationId: string, + providerName: string, + incomingConfig: Record, + queryRunner: QueryRunner + ): Promise> { + const methods = await this.readLoginMethodByOrganizationId(organizationId, queryRunner) + const existingProvider = methods?.find((m) => m.name === providerName) + if (!existingProvider?.config) return { ...incomingConfig } + const existing = JSON.parse(await this.decryptLoginMethodConfig(existingProvider.config)) as Record + return this.mergeWithStoredClientSecret(incomingConfig, existing) + } + public async createLoginMethod(data: Partial) { let queryRunner: QueryRunner | undefined let newLoginMethod: Partial @@ -121,14 +150,17 @@ export class LoginMethodService { const name = provider.providerName const loginMethod = await queryRunner.manager.findOneBy(LoginMethod, { organizationId, name }) + let configToSave: Record if (loginMethod) { - /* empty */ + const existing = JSON.parse(await this.decryptLoginMethodConfig(loginMethod.config)) as Record + configToSave = this.mergeWithStoredClientSecret(provider.config, existing) loginMethod.status = provider.status - loginMethod.config = await this.encryptLoginMethodConfig(JSON.stringify(provider.config)) + loginMethod.config = await this.encryptLoginMethodConfig(JSON.stringify(configToSave)) loginMethod.updatedBy = userId await this.saveLoginMethod(loginMethod, queryRunner) } else { - const encryptedConfig = await this.encryptLoginMethodConfig(JSON.stringify(provider.config)) + configToSave = { ...provider.config } + const encryptedConfig = await this.encryptLoginMethodConfig(JSON.stringify(configToSave)) let newLoginMethod = queryRunner.manager.create(LoginMethod, { organizationId, name, diff --git a/packages/server/src/enterprise/sso/Auth0SSO.ts b/packages/server/src/enterprise/sso/Auth0SSO.ts index 07a551f06..be748b0a5 100644 --- a/packages/server/src/enterprise/sso/Auth0SSO.ts +++ b/packages/server/src/enterprise/sso/Auth0SSO.ts @@ -10,6 +10,32 @@ import axios from 'axios' const PROVIDER_NAME_AUTH0_SSO = 'Auth0 SSO' +function validateAuth0Domain(domain: string): string | null { + if (!domain || typeof domain !== 'string') { + return null + } + + const trimmed = domain.trim() + + // Reject characters that could introduce scheme, port, path, or query + if (/[/\\?#:]/.test(trimmed)) { + return null + } + + // Basic hostname validation + const hostnameRegex = /^(?=.{1,253}$)([a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,63}$/ + if (!hostnameRegex.test(trimmed)) { + return null + } + + // Restrict to Auth0 domains + if (!trimmed.toLowerCase().endsWith('.auth0.com')) { + return null + } + + return trimmed +} + class Auth0SSO extends SSOBase { static LOGIN_URI = '/api/v1/auth0/login' static CALLBACK_URI = '/api/v1/auth0/callback' @@ -113,13 +139,19 @@ class Auth0SSO extends SSOBase { static async testSetup(ssoConfig: any) { const { domain, clientID, clientSecret } = ssoConfig + const validatedDomain = validateAuth0Domain(domain) + if (!validatedDomain) { + const errorMessage = 'Auth0 Configuration test failed. Invalid Auth0 domain.' + return { error: errorMessage } + } + try { const tokenResponse = await axios.post( - `https://${domain}/oauth/token`, + `https://${validatedDomain}/oauth/token`, { client_id: clientID, client_secret: clientSecret, - audience: `https://${domain}/api/v2/`, + audience: `https://${validatedDomain}/api/v2/`, grant_type: 'client_credentials' }, { @@ -136,9 +168,15 @@ class Auth0SSO extends SSOBase { async refreshToken(ssoRefreshToken: string) { const { domain, clientID, clientSecret } = this.ssoConfig + const validatedDomain = validateAuth0Domain(domain) + if (!validatedDomain) { + const errorMessage = 'Auth0 Configuration test failed. Invalid Auth0 domain.' + return { error: errorMessage } + } + try { const response = await axios.post( - `https://${domain}/oauth/token`, + `https://${validatedDomain}/oauth/token`, { client_id: clientID, client_secret: clientSecret, diff --git a/packages/server/src/utils/constants.ts b/packages/server/src/utils/constants.ts index caf1e4c55..443bc043e 100644 --- a/packages/server/src/utils/constants.ts +++ b/packages/server/src/utils/constants.ts @@ -33,7 +33,7 @@ export const WHITELIST_URLS = [ '/api/v1/account/forgot-password', '/api/v1/account/reset-password', '/api/v1/account/basic-auth', - '/api/v1/loginmethod', + '/api/v1/loginmethod/default', '/api/v1/pricing', '/api/v1/user/test', '/api/v1/oauth2-credential/callback', diff --git a/packages/ui/src/views/auth/ssoConfig.jsx b/packages/ui/src/views/auth/ssoConfig.jsx index b57a42eeb..b61aa40d7 100644 --- a/packages/ui/src/views/auth/ssoConfig.jsx +++ b/packages/ui/src/views/auth/ssoConfig.jsx @@ -33,6 +33,9 @@ import GithubSVG from '@/assets/images/github.svg' // const import { gridSpacing } from '@/store/constant' +// API never sends clientSecret; show asterisks when a secret is already configured +const PLACEHOLDER_SECRET = '********' + const SSOConfigPage = () => { useNotifier() const { error, setError } = useError() @@ -347,7 +350,8 @@ const SSOConfigPage = () => { if (azureConfig) { setAzureTenantID(azureConfig.config.tenantID) setAzureClientID(azureConfig.config.clientID) - setAzureClientSecret(azureConfig.config.clientSecret) + const hasConfig = azureConfig.config.tenantID || azureConfig.config.clientID + setAzureClientSecret(azureConfig.config.clientSecret ?? (hasConfig ? PLACEHOLDER_SECRET : '')) setAzureConfigEnabled(azureConfig.status === 'enable') } const googleConfig = data.providers.find((provider) => provider.name === 'google') @@ -357,7 +361,7 @@ const SSOConfigPage = () => { } if (googleConfig) { setGoogleClientID(googleConfig.config.clientID) - setGoogleClientSecret(googleConfig.config.clientSecret) + setGoogleClientSecret(googleConfig.config.clientSecret ?? (googleConfig.config.clientID ? PLACEHOLDER_SECRET : '')) setGoogleConfigEnabled(googleConfig.status === 'enable') } const auth0Config = data.providers.find((provider) => provider.name === 'auth0') @@ -369,7 +373,8 @@ const SSOConfigPage = () => { if (auth0Config) { setAuth0Domain(auth0Config.config.domain) setAuth0ClientID(auth0Config.config.clientID) - setAuth0ClientSecret(auth0Config.config.clientSecret) + const hasConfig = auth0Config.config.domain || auth0Config.config.clientID + setAuth0ClientSecret(auth0Config.config.clientSecret ?? (hasConfig ? PLACEHOLDER_SECRET : '')) setAuth0ConfigEnabled(auth0Config.status === 'enable') } @@ -380,7 +385,7 @@ const SSOConfigPage = () => { } if (githubConfig) { setGithubClientID(githubConfig.config.clientID) - setGithubClientSecret(githubConfig.config.clientSecret) + setGithubClientSecret(githubConfig.config.clientSecret ?? (githubConfig.config.clientID ? PLACEHOLDER_SECRET : '')) setGithubConfigEnabled(githubConfig.status === 'enable') } setLoading(false) diff --git a/packages/ui/src/views/organization/index.jsx b/packages/ui/src/views/organization/index.jsx index 5b07dc612..a23023d7d 100644 --- a/packages/ui/src/views/organization/index.jsx +++ b/packages/ui/src/views/organization/index.jsx @@ -104,7 +104,7 @@ const OrganizationSetupPage = () => { const getBasicAuthApi = useApi(accountApi.getBasicAuth) const navigate = useNavigate() - const getDefaultProvidersApi = useApi(loginMethodApi.getLoginMethods) + const getDefaultProvidersApi = useApi(loginMethodApi.getDefaultLoginMethods) const [configuredSsoProviders, setConfiguredSsoProviders] = useState([]) const register = async (event) => {