Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
BOX_SERVICE_ACCOUNT_PROVIDER_ID,
getClientCredentialAccountDescriptor,
normalizeNetSuiteSuiteTalkOrigin,
ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID,
partitionClientCredentialFields,
resolveClientCredentialAuthMethod,
resolveSalesforceAuthMethod,
Expand All @@ -12,6 +13,8 @@ import {

const salesforce = getClientCredentialAccountDescriptor(SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID)!
const box = getClientCredentialAccountDescriptor(BOX_SERVICE_ACCOUNT_PROVIDER_ID)!
const oracleEpm = getClientCredentialAccountDescriptor(ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID)
if (!oracleEpm) throw new Error('Oracle EPM credential descriptor is missing')

const ids = (fields: { id: string }[]) => fields.map((field) => field.id)

Expand All @@ -21,6 +24,20 @@ describe('partitionClientCredentialFields', () => {
const { required } = partitionClientCredentialFields(box, 'jwt_bearer')
expect(ids(required)).toEqual(['clientId', 'clientSecret', 'orgId'])
})

it('guides Oracle EPM users to the REST base URL and authentication docs', () => {
const restBaseUrl = oracleEpm.fields.find((field) => field.id === 'orgId')

expect(restBaseUrl).toMatchObject({
label: 'REST Base URL',
placeholder: 'https://example.oraclecloud.com',
})
expect(restBaseUrl?.hint).toContain('without /epmcloud')
expect(restBaseUrl?.hint).toContain('gateway prefix')
expect(oracleEpm.docsUrl).toBe(
'https://docs.oracle.com/en/cloud/saas/enterprise-performance-management-common/prest/authentication.html'
)
})
})

describe('Salesforce, which offers two grants', () => {
Expand Down
34 changes: 34 additions & 0 deletions apps/sim/lib/credentials/client-credential-accounts/descriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,15 @@ export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const
export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const
export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const
export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const
export const ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-epm-service-account' as const

export type ClientCredentialAccountProviderId =
| typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID
| typeof BOX_SERVICE_ACCOUNT_PROVIDER_ID
| typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID
| typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID
| typeof NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID
| typeof ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID

/**
* Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and
Expand Down Expand Up @@ -531,6 +533,38 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
helpText:
'Use the account-specific SuiteTalk URL and the client ID, certificate ID, and private key from one OAuth 2.0 client-credentials mapping.',
},
[ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID]: {
providerId: ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID,
serviceLabel: 'Oracle EPM Cloud',
connectNoun: 'integration user',
fields: [
{
id: 'orgId',
label: 'REST Base URL',
placeholder: 'https://example.oraclecloud.com',
secret: false,
hintPattern: /^https:\/\//,
hintMessage: 'Expected the HTTPS REST base URL for one Oracle EPM environment.',
hint: 'Enter the HTTPS base URL for your environment without /epmcloud or an API endpoint path. Include a gateway prefix only if your deployment requires it.',
},
{
id: 'clientId',
label: 'Integration username',
placeholder: 'integration.user@example.com',
secret: false,
},
{
id: 'clientSecret',
label: 'Password',
placeholder: 'Paste the integration user password',
secret: true,
},
],
docsUrl:
'https://docs.oracle.com/en/cloud/saas/enterprise-performance-management-common/prest/authentication.html',
helpText:
'The credential is bound to one EPM environment. Use a dedicated integration user with only the permissions its workflows require.',
},
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/** @vitest-environment node */
import { describe, expect, it, vi } from 'vitest'
import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm'
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'

describe('mintOracleEpmServiceAccountToken', () => {
it('mints Basic authentication locally and binds the normalized destination', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
const result = await mintOracleEpmServiceAccountToken({
orgId: ' https://EPM.example.com/gateway/ ',
clientId: 'integration.user@example.com',
clientSecret: 'password',
})
expect(Buffer.from(result.accessToken, 'base64').toString()).toBe(
'integration.user@example.com:password'
)
expect(result).toMatchObject({
expiresInSeconds: 600,
instanceUrl: 'https://epm.example.com/gateway',
identity: {
principal: null,
auditMetadata: { environmentUrl: 'https://epm.example.com/gateway' },
storedMetadata: { environmentUrl: 'https://epm.example.com/gateway' },
},
})
expect(JSON.stringify(result.identity)).not.toContain('password')
expect(JSON.stringify(result.identity)).not.toContain('integration.user')
expect(fetchSpy).not.toHaveBeenCalled()
fetchSpy.mockRestore()
})

it.each([
{ clientId: 'user:name', clientSecret: 'password' },
{ clientId: 'user\nname', clientSecret: 'password' },
{ clientId: 'user', clientSecret: 'pass\nword' },
{ clientId: 'user\uD800', clientSecret: 'password' },
{ clientId: 'user', clientSecret: 'password\uDC00' },
{ clientId: '', clientSecret: 'password' },
])('rejects unsafe Basic credential text', async (credentials) => {
await expect(
mintOracleEpmServiceAccountToken({
orgId: 'https://epm.example.com',
...credentials,
})
).rejects.toBeInstanceOf(TokenServiceAccountValidationError)
})

it('preserves valid surrogate pairs in Basic credential values', async () => {
const result = await mintOracleEpmServiceAccountToken({
orgId: 'https://epm.example.com',
clientId: 'integration-😀',
clientSecret: 'password-🔒',
})
expect(Buffer.from(result.accessToken, 'base64').toString()).toBe('integration-😀:password-🔒')
})

it('does not reflect secrets in validation errors', async () => {
const secret = 'password-with-newline\n'
const error = await mintOracleEpmServiceAccountToken({
orgId: 'https://epm.example.com',
clientId: 'user',
clientSecret: secret,
}).catch((value: unknown) => value)
expect(JSON.stringify(error)).not.toContain(secret)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type {
ClientCredentialAccountFields,
ClientCredentialAccountMintOptions,
ClientCredentialAccountMintResult,
} from '@/lib/credentials/client-credential-accounts/server'
import {
requireClientSecret,
TokenServiceAccountValidationError,
} from '@/lib/credentials/token-service-accounts/errors'
import { normalizeOracleEpmDestination } from '@/lib/internal/oracle-epm/destination'

const SYNTHETIC_TOKEN_TTL_SECONDS = 600
const MAX_USERNAME_BYTES = 255
const MAX_AUTH_VALUE_BYTES = 1_024
const FORBIDDEN_CREDENTIAL_TEXT = /[\u0000-\u001f\u007f]/
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
const MALFORMED_UTF16 = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/

function invalidCredentials(reason: string): TokenServiceAccountValidationError {
return new TokenServiceAccountValidationError('invalid_credentials', 400, {
step: 'oracle_epm_basic_auth',
reason,
})
}

/**
* Builds credential-bound Basic authentication locally. Oracle EPM does not
* expose a token mint for this v1 flow, so connect performs no network probe.
*/
export async function mintOracleEpmServiceAccountToken(
fields: ClientCredentialAccountFields,
_options?: ClientCredentialAccountMintOptions
): Promise<ClientCredentialAccountMintResult> {
let instanceUrl: string
try {
instanceUrl = normalizeOracleEpmDestination(fields.orgId)
} catch {
throw new TokenServiceAccountValidationError('site_not_found', 400, {
step: 'oracle_epm_destination_validation',
reason: 'environment URL must be a valid HTTPS Oracle EPM destination',
})
}

const username = fields.clientId.trim()
const password = requireClientSecret(
fields.clientSecret,
'oracle_epm_basic_auth',
'Oracle EPM Cloud'
)
if (
!username ||
username.includes(':') ||
FORBIDDEN_CREDENTIAL_TEXT.test(username) ||
MALFORMED_UTF16.test(username) ||
Buffer.byteLength(username, 'utf8') > MAX_USERNAME_BYTES
) {
throw invalidCredentials('integration username is invalid')
}
if (
!password ||
FORBIDDEN_CREDENTIAL_TEXT.test(password) ||
MALFORMED_UTF16.test(password) ||
Buffer.byteLength(password, 'utf8') > MAX_AUTH_VALUE_BYTES
) {
throw invalidCredentials('password is invalid')
}

const hostname = new URL(instanceUrl).hostname
return {
accessToken: Buffer.from(`${username}:${password}`, 'utf8').toString('base64'),
expiresInSeconds: SYNTHETIC_TOKEN_TTL_SECONDS,
instanceUrl,
identity: {
displayName: `Oracle EPM ${hostname}`,
principal: null,
auditMetadata: { environmentUrl: instanceUrl },
storedMetadata: { environmentUrl: instanceUrl },
},
}
}
18 changes: 18 additions & 0 deletions apps/sim/lib/credentials/client-credential-accounts/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,22 @@ describe('parseClientCredentialAccountSecretBlob', () => {
)
).toThrow(MALFORMED)
})

it('requires the complete Oracle EPM integration-user blob', () => {
const oracleBlob = blob({
providerId: 'oracle-epm-service-account',
orgId: 'https://epm.example.com/gateway',
clientId: 'integration.user@example.com',
clientSecret: 'password',
})
expect(
parseClientCredentialAccountSecretBlob(oracleBlob, 'oracle-epm-service-account')
).toMatchObject({ orgId: 'https://epm.example.com/gateway' })
expect(() =>
parseClientCredentialAccountSecretBlob(
blob({ providerId: 'oracle-epm-service-account', clientSecret: '' }),
'oracle-epm-service-account'
)
).toThrow(MALFORMED)
})
})
11 changes: 7 additions & 4 deletions apps/sim/lib/credentials/client-credential-accounts/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {
getClientCredentialAccountDescriptor,
isClientCredentialAccountProviderId,
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID,
partitionClientCredentialFields,
SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID,
ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID,
ZOOM_SERVICE_ACCOUNT_PROVIDER_ID,
} from '@/lib/credentials/client-credential-accounts/descriptors'
import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box'
import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/netsuite'
import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm'
import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce'
import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk'
import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom'
Expand All @@ -29,8 +31,8 @@ export interface ClientCredentialAccountFields {
clientSecret?: string
/**
* Provider-specific org identifier (Zoom Account ID, Box Enterprise ID,
* Salesforce My Domain host, Zoho Desk organization ID, or NetSuite
* SuiteTalk origin).
* Salesforce My Domain host, Zoho Desk organization ID, NetSuite SuiteTalk
* origin, or an Oracle EPM environment URL).
*/
orgId: string
/**
Expand Down Expand Up @@ -84,8 +86,8 @@ export interface ClientCredentialAccountMintResult {
accessToken: string
expiresInSeconds: number
/**
* Provider API origin the minted token must be used against (Salesforce or
* NetSuite), forwarded to tools alongside the token.
* Provider API destination the minted token must be used against (Salesforce,
* NetSuite, or Oracle EPM), forwarded to tools alongside the token.
*/
instanceUrl?: string
/**
Expand Down Expand Up @@ -130,6 +132,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record<
[SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: mintSalesforceServiceAccountToken,
[ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: mintZohoDeskServiceAccountToken,
[NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID]: mintNetSuiteServiceAccountToken,
[ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleEpmServiceAccountToken,
}

export function getClientCredentialAccountMinter(
Expand Down
36 changes: 35 additions & 1 deletion apps/sim/lib/credentials/service-account-secret.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({
getClientCredentialAccountMinter: (providerId: string) =>
providerId === 'zoom-service-account' ||
providerId === 'box-service-account' ||
providerId === 'netsuite-service-account'
providerId === 'netsuite-service-account' ||
providerId === 'oracle-epm-service-account'
? mockClientCredentialMinter
: undefined,
}))
Expand Down Expand Up @@ -176,6 +177,39 @@ describe('verifyAndBuildServiceAccountSecret', () => {
})
})

it('stores the Oracle EPM environment and integration-user secret through the generic path', async () => {
mockClientCredentialMinter.mockResolvedValue({
accessToken: 'basic-token',
expiresInSeconds: 600,
instanceUrl: 'https://epm.example.com/gateway',
identity: {
displayName: 'Oracle EPM epm.example.com',
principal: null,
auditMetadata: { environmentUrl: 'https://epm.example.com/gateway' },
storedMetadata: { environmentUrl: 'https://epm.example.com/gateway' },
},
})
const result = await verifyAndBuildServiceAccountSecret('oracle-epm-service-account', {
orgId: ' https://epm.example.com/gateway ',
clientId: ' integration.user@example.com ',
clientSecret: ' password ',
})

expect(mockClientCredentialMinter).toHaveBeenCalledWith({
orgId: 'https://epm.example.com/gateway',
clientId: 'integration.user@example.com',
clientSecret: 'password',
})
expect(JSON.parse(result.encryptedServiceAccountKey)).toMatchObject({
providerId: 'oracle-epm-service-account',
orgId: 'https://epm.example.com/gateway',
clientId: 'integration.user@example.com',
clientSecret: 'password',
metadata: { environmentUrl: 'https://epm.example.com/gateway' },
})
expect(result.principal).toBeNull()
})

it('throws when client-credential required fields are missing, without minting', async () => {
await expect(
verifyAndBuildServiceAccountSecret('zoom-service-account', {
Expand Down
Loading
Loading