Fix FLOWISE 566 OAuth2 (#6276)

* fix: flowise 556 oauth2

* fix(flowise-566): code review suggestions from Gemini

* feat(oauth2Security.test.ts): add test script

* feat(constants.ts): add Pipedream to default OAuth2 allowed domains
This commit is contained in:
yau-wd
2026-04-28 21:22:48 +08:00
committed by GitHub
parent d81483b70c
commit da8b251a9a
11 changed files with 465 additions and 27 deletions
+2
View File
@@ -198,6 +198,8 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
# CUSTOM_MCP_SECURITY_CHECK=true
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
# TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses)
# OAUTH2_SECURITY_CHECK=true
# OAUTH2_ALLOWED_TOKEN_DOMAINS= #(comma-separated list of additional OAuth2 provider domains to allow, e.g. keycloak.mycompany.com,auth.custom-idp.com)
############################################################################################################
+4
View File
@@ -158,6 +158,8 @@ services:
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
- TRUST_PROXY=${TRUST_PROXY}
- OAUTH2_SECURITY_CHECK=${OAUTH2_SECURITY_CHECK}
- OAUTH2_ALLOWED_TOKEN_DOMAINS=${OAUTH2_ALLOWED_TOKEN_DOMAINS}
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:${PORT:-3000}/api/v1/ping']
interval: 10s
@@ -308,6 +310,8 @@ services:
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
- TRUST_PROXY=${TRUST_PROXY}
- OAUTH2_SECURITY_CHECK=${OAUTH2_SECURITY_CHECK}
- OAUTH2_ALLOWED_TOKEN_DOMAINS=${OAUTH2_ALLOWED_TOKEN_DOMAINS}
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:${WORKER_PORT:-5566}/healthz']
interval: 10s
+2
View File
@@ -143,6 +143,8 @@ services:
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
- TRUST_PROXY=${TRUST_PROXY}
- OAUTH2_SECURITY_CHECK=${OAUTH2_SECURITY_CHECK}
- OAUTH2_ALLOWED_TOKEN_DOMAINS=${OAUTH2_ALLOWED_TOKEN_DOMAINS}
ports:
- '${PORT}:${PORT}'
healthcheck:
+2
View File
@@ -197,6 +197,8 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
# CUSTOM_MCP_SECURITY_CHECK=true
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
# TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses)
# OAUTH2_SECURITY_CHECK=true
# OAUTH2_ALLOWED_TOKEN_DOMAINS= #(comma-separated list of additional OAuth2 provider domains to allow, e.g. keycloak.mycompany.com,auth.custom-idp.com)
############################################################################################################
+2
View File
@@ -142,6 +142,8 @@ services:
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
- TRUST_PROXY=${TRUST_PROXY}
- OAUTH2_SECURITY_CHECK=${OAUTH2_SECURITY_CHECK}
- OAUTH2_ALLOWED_TOKEN_DOMAINS=${OAUTH2_ALLOWED_TOKEN_DOMAINS}
ports:
- '${WORKER_PORT}:${WORKER_PORT}'
healthcheck:
+2
View File
@@ -197,6 +197,8 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
# CUSTOM_MCP_SECURITY_CHECK=true
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
# TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses)
# OAUTH2_SECURITY_CHECK=true
# OAUTH2_ALLOWED_TOKEN_DOMAINS= #(comma-separated list of additional OAuth2 provider domains to allow, e.g. keycloak.mycompany.com,auth.custom-idp.com)
############################################################################################################
+2
View File
@@ -111,6 +111,8 @@ export abstract class BaseCommand extends Command {
HTTP_SECURITY_CHECK: Flags.string(),
PATH_TRAVERSAL_SAFETY: Flags.string(),
TRUST_PROXY: Flags.string(),
OAUTH2_SECURITY_CHECK: Flags.string(),
OAUTH2_ALLOWED_TOKEN_DOMAINS: Flags.string(),
// Auth
APP_URL: Flags.string(),
+67 -27
View File
@@ -56,17 +56,18 @@
* - token_received_at: When token was received (ISO string)
*/
import express from 'express'
import axios from 'axios'
import { Request, Response, NextFunction } from 'express'
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
import express, { NextFunction, Request, Response } from 'express'
import { secureAxiosRequest } from 'flowise-components'
import { StatusCodes } from 'http-status-codes'
import { Credential } from '../../database/entities/Credential'
import { WorkspaceShared } from '../../enterprise/database/entities/EnterpriseEntities'
import { getActiveWorkspaceIdForRequest } from '../../enterprise/utils/tenantRequestGuards'
import { decryptCredentialData, encryptCredentialData } from '../../utils'
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
import { StatusCodes } from 'http-status-codes'
import { generateSuccessPage, generateErrorPage } from './templates'
import { decryptCredentialData, encryptCredentialData } from '../../utils'
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
import { extractOAuth2TokenFields, validateOAuth2Url } from '../../utils/oauth2Security'
import { generateErrorPage, generateSuccessPage } from './templates'
const router = express.Router()
@@ -126,6 +127,15 @@ router.post('/authorize/:credentialId', async (req: Request, res: Response, next
})
}
try {
validateOAuth2Url(authorizationUrl)
} catch (err) {
return res.status(400).json({
success: false,
message: err instanceof Error ? err.message : 'Invalid authorization URL'
})
}
const defaultRedirectUri = `${req.protocol}://${req.get('host')}/api/v1/oauth2-credential/callback`
const finalRedirectUri = redirect_uri || defaultRedirectUri
@@ -236,6 +246,19 @@ router.get('/callback', async (req: Request, res: Response) => {
return res.status(400).send(errorHtml)
}
try {
validateOAuth2Url(tokenUrl)
} catch (err) {
const errorHtml = generateErrorPage(
'Invalid token endpoint URL',
err instanceof Error ? err.message : 'Token endpoint URL is not allowed',
'Please check your credential configuration.'
)
res.setHeader('Content-Type', 'text/html')
return res.status(400).send(errorHtml)
}
const defaultRedirectUri = `${req.protocol}://${req.get('host')}/api/v1/oauth2-credential/callback`
const finalRedirectUri = redirect_uri || defaultRedirectUri
@@ -251,28 +274,34 @@ router.get('/callback', async (req: Request, res: Response) => {
tokenRequestData.scope = scope
}
const tokenResponse = await axios.post(tokenUrl, new URLSearchParams(tokenRequestData).toString(), {
const tokenResponse = await secureAxiosRequest({
method: 'POST',
url: tokenUrl,
data: new URLSearchParams(tokenRequestData).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
}
})
const tokenData = tokenResponse.data
if (tokenResponse.status >= 400) {
const errorHtml = generateErrorPage(
tokenResponse.data?.error || 'token_exchange_failed',
tokenResponse.data?.error_description || 'Token exchange failed',
tokenResponse.data?.error_description ? `Description: ${tokenResponse.data.error_description}` : undefined
)
res.setHeader('Content-Type', 'text/html')
return res.status(tokenResponse.status).send(errorHtml)
}
const tokenData = extractOAuth2TokenFields(tokenResponse.data)
// Update the credential data with token information
const updatedCredentialData: any = {
...decryptedData,
...tokenData,
token_received_at: new Date().toISOString()
}
// Add refresh token if provided
if (tokenData.refresh_token) {
updatedCredentialData.refresh_token = tokenData.refresh_token
}
// Calculate token expiry time
if (tokenData.expires_in) {
const expiryTime = new Date(Date.now() + tokenData.expires_in * 1000)
updatedCredentialData.expires_at = expiryTime.toISOString()
@@ -355,6 +384,15 @@ router.post('/refresh/:credentialId', async (req: Request, res: Response, next:
})
}
try {
validateOAuth2Url(tokenUrl)
} catch (err) {
return res.status(400).json({
success: false,
message: err instanceof Error ? err.message : 'Token endpoint URL is not allowed'
})
}
const refreshRequestData: any = {
client_id: clientId,
client_secret: clientSecret,
@@ -366,29 +404,32 @@ router.post('/refresh/:credentialId', async (req: Request, res: Response, next:
refreshRequestData.scope = scope
}
const tokenResponse = await axios.post(tokenUrl, new URLSearchParams(refreshRequestData).toString(), {
const tokenResponse = await secureAxiosRequest({
method: 'POST',
url: tokenUrl,
data: new URLSearchParams(refreshRequestData).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
}
})
// Extract token data from response
const tokenData = tokenResponse.data
if (tokenResponse.status >= 400) {
return res.status(tokenResponse.status).json({
success: false,
message: `Token refresh failed: ${tokenResponse.data?.error_description || tokenResponse.statusText}`,
details: tokenResponse.data
})
}
const tokenData = extractOAuth2TokenFields(tokenResponse.data)
// Update the credential data with new token information
const updatedCredentialData: any = {
...decryptedData,
...tokenData,
token_received_at: new Date().toISOString()
}
// Update refresh token if a new one was provided
if (tokenData.refresh_token) {
updatedCredentialData.refresh_token = tokenData.refresh_token
}
// Calculate token expiry time
if (tokenData.expires_in) {
const expiryTime = new Date(Date.now() + tokenData.expires_in * 1000)
updatedCredentialData.expires_at = expiryTime.toISOString()
@@ -403,13 +444,12 @@ router.post('/refresh/:credentialId', async (req: Request, res: Response, next:
updatedDate: new Date()
})
// Return success response
res.json({
success: true,
message: 'OAuth2 token refreshed successfully',
credentialId: credential.id,
tokenInfo: {
...tokenData,
token_type: tokenData.token_type,
has_new_refresh_token: !!tokenData.refresh_token,
expires_at: updatedCredentialData.expires_at
}
+43
View File
@@ -108,6 +108,49 @@ export const INPUT_PARAMS_TYPE = [
'tabs'
]
export const ALLOWED_OAUTH2_TOKEN_FIELDS = [
'access_token',
'refresh_token',
'token_type',
'expires_in',
'scope',
'id_token',
'granted_scope'
]
export const DEFAULT_ALLOWED_OAUTH2_DOMAINS = [
'login.microsoftonline.com',
'oauth2.googleapis.com',
'accounts.google.com',
'github.com',
'login.salesforce.com',
'test.salesforce.com',
'oauth2.hubapi.com',
'api.hubapi.com',
'oauth.pipedrive.com',
'app.clickup.com',
'api.clickup.com',
'login.xero.com',
'identity.xero.com',
'oauth2.sky.blackbaud.com',
'app.asana.com',
'todoist.com',
'api.todoist.com',
'slack.com',
'oauth.pocketsmith.com',
'api.notion.com',
'api.dropboxapi.com',
'api.box.com',
'zoom.us',
'auth.atlassian.com',
'login.zoho.com',
'accounts.zoho.com',
'airtable.com',
'api.linear.app',
'discord.com',
'api.pipedream.com'
]
export const LICENSE_QUOTAS = {
// Renew per month
PREDICTIONS_LIMIT: 'quota:predictions',
@@ -0,0 +1,249 @@
// Mock the constants module to avoid pulling in the full application dependency graph
jest.mock('./constants', () => ({
ALLOWED_OAUTH2_TOKEN_FIELDS: ['access_token', 'refresh_token', 'token_type', 'expires_in', 'scope', 'id_token', 'granted_scope'],
DEFAULT_ALLOWED_OAUTH2_DOMAINS: [
'login.microsoftonline.com',
'oauth2.googleapis.com',
'accounts.google.com',
'github.com',
'login.salesforce.com',
'test.salesforce.com',
'oauth2.hubapi.com',
'api.hubapi.com',
'oauth.pipedrive.com',
'app.clickup.com',
'api.clickup.com',
'login.xero.com',
'identity.xero.com',
'oauth2.sky.blackbaud.com',
'app.asana.com',
'todoist.com',
'api.todoist.com',
'slack.com',
'oauth.pocketsmith.com',
'api.notion.com',
'api.dropboxapi.com',
'api.box.com',
'zoom.us',
'auth.atlassian.com',
'login.zoho.com',
'accounts.zoho.com',
'airtable.com',
'api.linear.app',
'discord.com'
]
}))
import { getOAuth2AllowedDomains, validateOAuth2Url, extractOAuth2TokenFields } from './oauth2Security'
import { ALLOWED_OAUTH2_TOKEN_FIELDS, DEFAULT_ALLOWED_OAUTH2_DOMAINS } from './constants'
const originalEnv = process.env
beforeEach(() => {
process.env = { ...originalEnv }
delete process.env.OAUTH2_SECURITY_CHECK
delete process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS
})
afterAll(() => {
process.env = originalEnv
})
// ---------------------------------------------------------------------------
// getOAuth2AllowedDomains
// ---------------------------------------------------------------------------
describe('getOAuth2AllowedDomains', () => {
it('returns default domains when security check is enabled and no custom domains', () => {
const domains = getOAuth2AllowedDomains()
expect(domains).toEqual(expect.arrayContaining(DEFAULT_ALLOWED_OAUTH2_DOMAINS))
expect(domains.length).toBe(DEFAULT_ALLOWED_OAUTH2_DOMAINS.length)
})
it('merges custom domains with defaults when security check is enabled', () => {
process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'custom.example.com, another.dev'
const domains = getOAuth2AllowedDomains()
expect(domains).toContain('custom.example.com')
expect(domains).toContain('another.dev')
for (const d of DEFAULT_ALLOWED_OAUTH2_DOMAINS) {
expect(domains).toContain(d)
}
})
it('deduplicates domains that overlap with defaults', () => {
process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'github.com'
const domains = getOAuth2AllowedDomains()
const githubCount = domains.filter((d) => d === 'github.com').length
expect(githubCount).toBe(1)
})
it('returns only custom domains when security check is disabled', () => {
process.env.OAUTH2_SECURITY_CHECK = 'false'
process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'my.corp.dev'
const domains = getOAuth2AllowedDomains()
expect(domains).toEqual(['my.corp.dev'])
})
it('returns empty array when security check is disabled and no custom domains', () => {
process.env.OAUTH2_SECURITY_CHECK = 'false'
const domains = getOAuth2AllowedDomains()
expect(domains).toEqual([])
})
it('trims whitespace and lowercases custom domains', () => {
process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = ' MyDomain.COM , UPPER.IO '
const domains = getOAuth2AllowedDomains()
expect(domains).toContain('mydomain.com')
expect(domains).toContain('upper.io')
})
it('ignores empty segments from trailing/leading commas', () => {
process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = ',,,valid.com,,,'
const domains = getOAuth2AllowedDomains()
expect(domains).toContain('valid.com')
expect(domains.every((d) => d.length > 0)).toBe(true)
})
})
// ---------------------------------------------------------------------------
// validateOAuth2Url
// ---------------------------------------------------------------------------
describe('validateOAuth2Url', () => {
describe('with security check enabled (default)', () => {
it('accepts HTTPS URLs on default allowed domains', () => {
expect(() => validateOAuth2Url('https://login.microsoftonline.com/token')).not.toThrow()
expect(() => validateOAuth2Url('https://oauth2.googleapis.com/token')).not.toThrow()
expect(() => validateOAuth2Url('https://github.com/login/oauth/access_token')).not.toThrow()
})
it('accepts subdomains of allowed domains', () => {
expect(() => validateOAuth2Url('https://accounts.zoom.us/oauth/token')).not.toThrow()
expect(() => validateOAuth2Url('https://sub.login.microsoftonline.com/token')).not.toThrow()
})
it('rejects HTTP URLs', () => {
expect(() => validateOAuth2Url('http://login.microsoftonline.com/token')).toThrow('OAuth2 URL must use HTTPS')
})
it('rejects domains not in the allowed list', () => {
expect(() => validateOAuth2Url('https://evil.example.com/token')).toThrow('not in the allowed list')
})
it('rejects domain that only partially matches (no dot boundary)', () => {
// "notgithub.com" should NOT match "github.com"
expect(() => validateOAuth2Url('https://notgithub.com/token')).toThrow('not in the allowed list')
})
it('accepts custom domains added via env var', () => {
process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'custom-idp.example.com'
expect(() => validateOAuth2Url('https://custom-idp.example.com/token')).not.toThrow()
})
it('throws on invalid URL', () => {
expect(() => validateOAuth2Url('not-a-url')).toThrow()
})
})
describe('with security check disabled', () => {
beforeEach(() => {
process.env.OAUTH2_SECURITY_CHECK = 'false'
})
it('skips validation entirely when no custom domains are configured', () => {
expect(() => validateOAuth2Url('http://anything.local:8080/token')).not.toThrow()
})
it('allows HTTP when custom domains are configured', () => {
process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'local.dev'
expect(() => validateOAuth2Url('http://local.dev/token')).not.toThrow()
})
it('still rejects domains not matching custom list when custom domains exist', () => {
process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'local.dev'
expect(() => validateOAuth2Url('http://other.dev/token')).toThrow('not in the allowed list')
})
it('supports subdomain matching against custom domains', () => {
process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'myidp.local'
expect(() => validateOAuth2Url('http://auth.myidp.local/token')).not.toThrow()
})
})
})
// ---------------------------------------------------------------------------
// extractOAuth2TokenFields
// ---------------------------------------------------------------------------
describe('extractOAuth2TokenFields', () => {
it('extracts all recognised fields from a complete token response', () => {
const data: Record<string, any> = {
access_token: 'abc123',
refresh_token: 'ref456',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile',
id_token: 'jwt.token.here',
granted_scope: 'openid'
}
const result = extractOAuth2TokenFields(data)
expect(result).toEqual(data)
})
it('omits fields not in the allowed list', () => {
const data = {
access_token: 'abc',
malicious_field: 'should be removed',
secret_key: '12345'
}
const result = extractOAuth2TokenFields(data)
expect(result).toEqual({ access_token: 'abc' })
expect(result).not.toHaveProperty('malicious_field')
expect(result).not.toHaveProperty('secret_key')
})
it('returns empty object when no recognised fields are present', () => {
const result = extractOAuth2TokenFields({ foo: 'bar', baz: 42 })
expect(result).toEqual({})
})
it('preserves undefined-valued allowed fields as absent (not included)', () => {
const result = extractOAuth2TokenFields({ access_token: undefined })
expect(result).not.toHaveProperty('access_token')
})
it('preserves null-valued allowed fields', () => {
const result = extractOAuth2TokenFields({ access_token: null })
expect(result).toEqual({ access_token: null })
})
it('only includes fields from ALLOWED_OAUTH2_TOKEN_FIELDS constant', () => {
const allFieldData: Record<string, any> = {}
for (const field of ALLOWED_OAUTH2_TOKEN_FIELDS) {
allFieldData[field] = `value_${field}`
}
allFieldData['extra'] = 'nope'
const result = extractOAuth2TokenFields(allFieldData)
expect(Object.keys(result).sort()).toEqual([...ALLOWED_OAUTH2_TOKEN_FIELDS].sort())
})
describe('throws on invalid input', () => {
it('throws on null', () => {
expect(() => extractOAuth2TokenFields(null as any)).toThrow('expected an object')
})
it('throws on undefined', () => {
expect(() => extractOAuth2TokenFields(undefined as any)).toThrow('expected an object')
})
it('throws on array', () => {
expect(() => extractOAuth2TokenFields([] as any)).toThrow('expected an object')
})
it('throws on string', () => {
expect(() => extractOAuth2TokenFields('string' as any)).toThrow('expected an object')
})
it('throws on number', () => {
expect(() => extractOAuth2TokenFields(42 as any)).toThrow('expected an object')
})
})
})
@@ -0,0 +1,90 @@
import { ALLOWED_OAUTH2_TOKEN_FIELDS, DEFAULT_ALLOWED_OAUTH2_DOMAINS } from './constants'
function getCustomDomains(): string[] {
return (process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS ?? '')
.split(',')
.map((d) => d.trim().toLowerCase())
.filter(Boolean)
}
/**
* Builds the effective list of allowed OAuth2 token-endpoint domains.
*
* When `OAUTH2_SECURITY_CHECK` is enabled (default), the returned list is the union
* of `DEFAULT_ALLOWED_OAUTH2_DOMAINS` and any extra domains specified in the
* `OAUTH2_ALLOWED_TOKEN_DOMAINS` env var. When disabled (`OAUTH2_SECURITY_CHECK=false`),
* only the custom env var entries are used (empty list if none are configured).
*
* @returns Array of lowercase domain strings that OAuth2 URLs are permitted to contact
*/
export function getOAuth2AllowedDomains(): string[] {
const securityCheckEnabled = process.env.OAUTH2_SECURITY_CHECK !== 'false'
const customDomains = getCustomDomains()
if (securityCheckEnabled) {
return [...new Set([...DEFAULT_ALLOWED_OAUTH2_DOMAINS, ...customDomains])]
}
return customDomains
}
/**
* Validates an OAuth2 URL against the allowed domain list.
*
* This is a defence-in-depth measure against SSRF: even if the caller also uses
* `secureAxiosRequest` (which blocks private/internal IPs), this function ensures
* the server only contacts known, legitimate OAuth2 provider domains.
*
* Subdomain matching is supported — e.g. allowing `zoom.us` also permits
* `accounts.zoom.us`. When `OAUTH2_SECURITY_CHECK=false` and no custom domains
* are configured, validation is skipped entirely.
*
* HTTPS is enforced only when `OAUTH2_SECURITY_CHECK` is enabled (the default).
* Setting `OAUTH2_SECURITY_CHECK=false` allows HTTP, which is intended for local
* development and testing against non-TLS OAuth2 servers.
*
* @param url - The OAuth2 authorization or token endpoint URL to validate
* @throws Error if the URL's domain is not in the allowed list, or if HTTPS is
* required (`OAUTH2_SECURITY_CHECK` enabled) and the URL uses a non-HTTPS protocol
*/
export function validateOAuth2Url(url: string): void {
const securityCheckEnabled = process.env.OAUTH2_SECURITY_CHECK !== 'false'
const allowedDomains = getOAuth2AllowedDomains()
if (!securityCheckEnabled && allowedDomains.length === 0) return
const parsed = new URL(url)
if (securityCheckEnabled && parsed.protocol !== 'https:') {
throw new Error(`OAuth2 URL must use HTTPS: ${url}`)
}
const hostname = parsed.hostname.toLowerCase()
const isAllowed = allowedDomains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`))
if (!isAllowed) {
throw new Error(`OAuth2 URL domain "${hostname}" is not in the allowed list.`)
}
}
/**
* Extracts only recognised OAuth2 token fields from an arbitrary response object.
*
* Prevents data exfiltration by discarding any unexpected keys that a malicious or
* misconfigured token endpoint might return. Only fields listed in
* `ALLOWED_OAUTH2_TOKEN_FIELDS` are copied to the result.
*
* @param data - The raw JSON response body from the OAuth2 token endpoint
* @returns A new object containing only the allowed OAuth2 token fields
* @throws Error if the data is not a valid object (null, undefined, array, or primitive type)
*/
export function extractOAuth2TokenFields(data: Record<string, any>): Record<string, any> {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Invalid OAuth2 token response: expected an object')
}
const result: Record<string, any> = {}
for (const key of ALLOWED_OAUTH2_TOKEN_FIELDS) {
if (data[key] !== undefined) {
result[key] = data[key]
}
}
return result
}