fix: read loginmethod (#5805)

* fix: auth for read loginmethod endpoint

* fix: review feedback

* fix: lint issues

* fix: validate auth0 domain

* fix: lint issues

* fix: wrong loginmethod endpoint in org setup page

---------

Co-authored-by: yau-wd <[email protected]>
This commit is contained in:
Ilango
2026-02-24 16:09:41 +08:00
committed by GitHub
co-authored by yau-wd
parent 5cc657b900
commit c22f2d3d25
7 changed files with 136 additions and 24 deletions
@@ -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<Record<string, unknown>> {
const { clientSecret: _, ...safe } = JSON.parse(await decrypt(encryptedConfig)) as Record<string, unknown>
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<LoginMethod>
@@ -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<string, unknown> }[]
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()
}
}
}
@@ -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)
@@ -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<string, unknown>, existing: Record<string, unknown>): Record<string, unknown> {
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<string, unknown>,
queryRunner: QueryRunner
): Promise<Record<string, unknown>> {
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<string, unknown>
return this.mergeWithStoredClientSecret(incomingConfig, existing)
}
public async createLoginMethod(data: Partial<LoginMethod>) {
let queryRunner: QueryRunner | undefined
let newLoginMethod: Partial<LoginMethod>
@@ -121,14 +150,17 @@ export class LoginMethodService {
const name = provider.providerName
const loginMethod = await queryRunner.manager.findOneBy(LoginMethod, { organizationId, name })
let configToSave: Record<string, unknown>
if (loginMethod) {
/* empty */
const existing = JSON.parse(await this.decryptLoginMethodConfig(loginMethod.config)) as Record<string, unknown>
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,
+41 -3
View File
@@ -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,
+1 -1
View File
@@ -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',
+9 -4
View File
@@ -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)
+1 -1
View File
@@ -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) => {