diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 0c0c8783e1b..5366c3b7681 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -9287,6 +9287,9 @@ export function NetSuiteIcon(props: SVGProps) { ) } +/** Oracle's red oval, shared by Oracle product integrations. */ +export const OracleIcon = NetSuiteIcon + export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 0c0c8783e1b..5366c3b7681 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -9287,6 +9287,9 @@ export function NetSuiteIcon(props: SVGProps) { ) } +/** Oracle's red oval, shared by Oracle product integrations. */ +export const OracleIcon = NetSuiteIcon + export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts index 5ea3e35e79d..66d0f04042c 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts @@ -7,6 +7,8 @@ import { getClientCredentialAccountDescriptor, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID, normalizeNetSuiteSuiteTalkOrigin, + normalizeOracleFusionApplicationOrigin, + ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, partitionClientCredentialFields, resolveClientCredentialAuthMethod, resolveSalesforceAuthMethod, @@ -19,6 +21,9 @@ const salesforce = getClientCredentialAccountDescriptor(SALESFORCE_SERVICE_ACCOU const box = getClientCredentialAccountDescriptor(BOX_SERVICE_ACCOUNT_PROVIDER_ID)! const zohoDesk = getClientCredentialAccountDescriptor(ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID)! const netSuite = getClientCredentialAccountDescriptor(NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID)! +const oracleFusion = getClientCredentialAccountDescriptor( + ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID +)! const ids = (fields: { id: string }[]) => fields.map((field) => field.id) @@ -51,6 +56,17 @@ describe('partitionClientCredentialFields', () => { multiline: true, }) }) + + it('reuses the existing fields for an Oracle Fusion integration user', () => { + const { visible, required } = partitionClientCredentialFields(oracleFusion, undefined) + expect(ids(visible)).toEqual(['orgId', 'clientId', 'clientSecret']) + expect(ids(required)).toEqual(['orgId', 'clientId', 'clientSecret']) + expect(oracleFusion.fields).toEqual([ + expect.objectContaining({ id: 'orgId', label: 'Fusion Applications URL', secret: false }), + expect.objectContaining({ id: 'clientId', label: 'Integration username', secret: false }), + expect.objectContaining({ id: 'clientSecret', label: 'Password', secret: true }), + ]) + }) }) describe('Salesforce, which offers two grants', () => { @@ -109,6 +125,41 @@ describe('normalizeNetSuiteSuiteTalkOrigin', () => { }) }) +describe('normalizeOracleFusionApplicationOrigin', () => { + it.each([ + [' https://VISION.fa.us2.oraclecloud.com/ ', 'https://vision.fa.us2.oraclecloud.com'], + ['https://acme-prod.fa.ocs.oraclecloud.com', 'https://acme-prod.fa.ocs.oraclecloud.com'], + [ + 'https://pod.fa.eu-frankfurt-1.oraclecloud.com', + 'https://pod.fa.eu-frankfurt-1.oraclecloud.com', + ], + ])('normalizes the supported application origin %j', (value, expected) => { + expect(normalizeOracleFusionApplicationOrigin(value)).toBe(expected) + }) + + it.each([ + 'http://vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com/path', + 'https://vision.fa.us2.oraclecloud.com/path/..', + 'https://vision.fa.us2.oraclecloud.com/./', + 'https://vision.fa.us2.oraclecloud.com/%2e%2e/', + 'https://vision.fa.us2.oraclecloud.com:443', + 'https://vision.fa.us2.oraclecloud.com:8443', + 'https://user@vision.fa.us2.oraclecloud.com', + 'https://user:password@vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com?tenant=other', + 'https://vision.fa.us2.oraclecloud.com#fragment', + 'https://vision.fa.us2.oraclecloud.com.evil.example', + 'https://vision.fa.us2.oraclecloud.co', + 'https://fusion.example.com', + 'https://fa.us2.oraclecloud.com', + 'https://-vision.fa.us2.oraclecloud.com', + 'https://vision.fa.-us2.oraclecloud.com', + ])('rejects the noncanonical Fusion Applications URL %j', (value) => { + expect(normalizeOracleFusionApplicationOrigin(value)).toBeUndefined() + }) +}) + describe('resolveClientCredentialAuthMethod', () => { it('returns undefined for a provider that declares no method selector', () => { expect(resolveClientCredentialAuthMethod(box, 'jwt_bearer')).toBeUndefined() diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index dcd4aa26f7d..7f98eb343c6 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -111,6 +111,7 @@ 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_FUSION_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-fusion-service-account' as const export type ClientCredentialAccountProviderId = | typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID @@ -118,6 +119,7 @@ export type ClientCredentialAccountProviderId = | typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID | typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID | typeof NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID /** * Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and @@ -154,6 +156,40 @@ export function normalizeNetSuiteSuiteTalkOrigin(rawUrl: string): string | undef } } +/** Canonical Oracle-assigned Fusion Applications origin used by product REST APIs. */ +export const ORACLE_FUSION_APPLICATION_ORIGIN_REGEX = + /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.fa\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.oraclecloud\.com$/ +const ORACLE_FUSION_APPLICATION_INPUT_REGEX = + /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.fa\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.oraclecloud\.com\/?$/i + +/** + * Normalizes a Fusion Applications URL to its authoritative HTTPS origin. + * Explicit ports are rejected even when they match HTTPS's default port so a + * saved credential can never silently broaden the accepted endpoint shape. + */ +export function normalizeOracleFusionApplicationOrigin(rawUrl: string): string | undefined { + try { + const trimmed = rawUrl.trim() + if (!ORACLE_FUSION_APPLICATION_INPUT_REGEX.test(trimmed)) return undefined + const parsed = new URL(trimmed) + if ( + parsed.protocol !== 'https:' || + parsed.port || + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + (parsed.pathname !== '' && parsed.pathname !== '/') || + !ORACLE_FUSION_APPLICATION_ORIGIN_REGEX.test(parsed.origin) + ) { + return undefined + } + return parsed.origin + } catch { + return undefined + } +} + /** * Allowed My Domain host shapes: one org label (optionally with a * `--sandboxName` suffix), an optional partition label (sandbox, develop, @@ -531,6 +567,39 @@ 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_FUSION_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Oracle Fusion', + connectNoun: 'integration user', + fields: [ + { + id: 'orgId', + label: 'Fusion Applications URL', + placeholder: 'https://your-environment.fa.ocs.oraclecloud.com', + secret: false, + hintPattern: ORACLE_FUSION_APPLICATION_ORIGIN_REGEX, + hintNormalize: (value) => + normalizeOracleFusionApplicationOrigin(value) ?? value.trim().toLowerCase(), + hintMessage: + 'Expected the Oracle-assigned HTTPS application URL with no path, port, credentials, query, or fragment.', + }, + { + id: 'clientId', + label: 'Integration username', + placeholder: 'Paste the integration username', + secret: false, + }, + { + id: 'clientSecret', + label: 'Password', + placeholder: 'Paste the password', + secret: true, + }, + ], + docsUrl: 'https://docs.oracle.com/en/cloud/saas/applications-common/26b/farca/Quick_Start.html', + helpText: + 'The application URL is validated when saved. Oracle authenticates the integration user on the first product request.', + }, } /** diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts new file mode 100644 index 00000000000..4b25b6d89d8 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion' + +const FIELDS = { + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientId: 'integration-user', + clientSecret: 'password-with-symbols-!@#', +} + +describe('mintOracleFusionServiceAccountToken', () => { + it('derives an opaque Basic credential locally with a five-minute lifetime', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + + await expect(mintOracleFusionServiceAccountToken(FIELDS)).resolves.toEqual({ + instanceUrl: FIELDS.orgId, + accessToken: Buffer.from(`${FIELDS.clientId}:${FIELDS.clientSecret}`, 'utf8').toString( + 'base64' + ), + expiresInSeconds: 300, + identity: { + displayName: 'Oracle Fusion vision', + principal: null, + auditMetadata: { oracleFusionApplicationOrigin: FIELDS.orgId }, + storedMetadata: { applicationOrigin: FIELDS.orgId }, + }, + }) + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it('normalizes the origin and omits connect-time identity during resolution', async () => { + await expect( + mintOracleFusionServiceAccountToken( + { ...FIELDS, orgId: ' HTTPS://VISION.FA.OCS.ORACLECLOUD.COM/ ' }, + { skipIdentity: true } + ) + ).resolves.toEqual({ + instanceUrl: 'https://vision.fa.ocs.oraclecloud.com', + accessToken: Buffer.from(`${FIELDS.clientId}:${FIELDS.clientSecret}`, 'utf8').toString( + 'base64' + ), + expiresInSeconds: 300, + }) + }) + + it.each([ + 'http://vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com/path', + 'https://vision.fa.us2.oraclecloud.com/path/..', + 'https://vision.fa.us2.oraclecloud.com/%2e%2e/', + 'https://vision.fa.us2.oraclecloud.com:443', + 'https://user:password@vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com?tenant=other', + 'https://vision.fa.us2.oraclecloud.com#fragment', + 'https://vision.fa.us2.oraclecloud.com.evil.example', + 'https://vanity.example.com', + ])('rejects the unsafe application URL %j without a network probe', async (orgId) => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + await expect(mintOracleFusionServiceAccountToken({ ...FIELDS, orgId })).rejects.toMatchObject({ + code: 'site_not_found', + status: 400, + }) + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it.each([ + ['', FIELDS.clientSecret], + ['user:name', FIELDS.clientSecret], + ['user\nname', FIELDS.clientSecret], + ['u'.repeat(256), FIELDS.clientSecret], + [FIELDS.clientId, ''], + [FIELDS.clientId, 'password\n'], + [FIELDS.clientId, 'p'.repeat(1025)], + ])( + 'rejects malformed local credentials without exposing them', + async (clientId, clientSecret) => { + const error = await mintOracleFusionServiceAccountToken({ + ...FIELDS, + clientId, + clientSecret, + }).catch((caught: unknown) => caught) + expect(error).toMatchObject({ code: 'invalid_credentials', status: 400 }) + const serialized = JSON.stringify(error) + if (clientId) expect(serialized).not.toContain(clientId) + if (clientSecret) expect(serialized).not.toContain(clientSecret) + const encoded = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64') + if (encoded) expect(serialized).not.toContain(encoded) + } + ) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.ts new file mode 100644 index 00000000000..a69e1e49f5d --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.ts @@ -0,0 +1,68 @@ +import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const BASIC_CREDENTIAL_CACHE_TTL_SECONDS = 5 * 60 +const ORACLE_FUSION_CREDENTIAL_STEP = 'oracle_fusion_credential_validation' +const USERNAME_MAX_LENGTH = 255 +const PASSWORD_MAX_LENGTH = 1024 +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/ + +function invalidCredential(reason: string): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: ORACLE_FUSION_CREDENTIAL_STEP, + reason, + }) +} + +/** + * Resolves locally validated Oracle Basic credentials through the shared + * client-credential minter contract. Oracle does not expose a documented, + * privilege-neutral identity probe, so authentication occurs on first use. + */ +export async function mintOracleFusionServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + const instanceUrl = normalizeOracleFusionApplicationOrigin(fields.orgId) + if (!instanceUrl) { + throw new TokenServiceAccountValidationError('site_not_found', 400, { + step: ORACLE_FUSION_CREDENTIAL_STEP, + reason: 'Fusion Applications URL must be a canonical Oracle-assigned HTTPS origin', + }) + } + + const username = fields.clientId.trim() + const password = fields.clientSecret + if (!username || username.length > USERNAME_MAX_LENGTH || CONTROL_CHARACTER.test(username)) { + throw invalidCredential('integration username is invalid') + } + if (username.includes(':')) { + throw invalidCredential('integration username must not contain a colon') + } + if (!password || password.length > PASSWORD_MAX_LENGTH || CONTROL_CHARACTER.test(password)) { + throw invalidCredential('password is invalid') + } + + const accessToken = Buffer.from(`${username}:${password}`, 'utf8').toString('base64') + const tenant = new URL(instanceUrl).hostname.split('.')[0] + return { + instanceUrl, + accessToken, + expiresInSeconds: BASIC_CREDENTIAL_CACHE_TTL_SECONDS, + ...(!options?.skipIdentity + ? { + identity: { + displayName: `Oracle Fusion ${tenant}`, + principal: null, + auditMetadata: { oracleFusionApplicationOrigin: instanceUrl }, + storedMetadata: { applicationOrigin: instanceUrl }, + }, + } + : {}), + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.test.ts b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts index c91df17c197..ddf42a8cdc1 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts @@ -117,4 +117,31 @@ describe('parseClientCredentialAccountSecretBlob', () => { ) ).toThrow(MALFORMED) }) + + it('requires the three reused fields for an Oracle Fusion credential blob', () => { + const oracleBlob = blob({ + providerId: 'oracle-fusion-service-account', + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientId: 'integration-user', + clientSecret: 'password', + }) + expect( + parseClientCredentialAccountSecretBlob(oracleBlob, 'oracle-fusion-service-account') + ).toMatchObject({ + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientId: 'integration-user', + clientSecret: 'password', + }) + + expect(() => + parseClientCredentialAccountSecretBlob( + blob({ + providerId: 'oracle-fusion-service-account', + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientSecret: undefined, + }), + 'oracle-fusion-service-account' + ) + ).toThrow(MALFORMED) + }) }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index 10c829c7861..bbbaaf120b0 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -5,6 +5,7 @@ import { getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID, + ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, partitionClientCredentialFields, SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID, ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID, @@ -12,6 +13,7 @@ import { } 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 { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion' 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' @@ -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_FUSION_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleFusionServiceAccountToken, } export function getClientCredentialAccountMinter( diff --git a/apps/sim/lib/credentials/service-account-provider-ids.test.ts b/apps/sim/lib/credentials/service-account-provider-ids.test.ts index 19fa62966df..0f402a4d9da 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.test.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.test.ts @@ -16,6 +16,7 @@ describe('isServiceAccountProviderId', () => { expect(isServiceAccountProviderId('notion-service-account')).toBe(true) expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true) expect(isServiceAccountProviderId('netsuite-service-account')).toBe(true) + expect(isServiceAccountProviderId('oracle-fusion-service-account')).toBe(true) }) it('is case- and whitespace-insensitive', () => { @@ -39,6 +40,7 @@ describe('getServiceAccountGatingBlockType', () => { expect(getServiceAccountGatingBlockType('notion-service-account')).toBeNull() expect(getServiceAccountGatingBlockType('google-service-account')).toBeNull() expect(getServiceAccountGatingBlockType('salesforce-service-account')).toBeNull() + expect(getServiceAccountGatingBlockType('oracle-fusion-service-account')).toBeNull() }) }) @@ -52,6 +54,7 @@ describe('getServiceAccountConnectNoun', () => { it('names the client-credential secret', () => { expect(getServiceAccountConnectNoun('zoom-service-account')).toBe('server-to-server app') expect(getServiceAccountConnectNoun('netsuite-service-account')).toBe('OAuth certificate') + expect(getServiceAccountConnectNoun('oracle-fusion-service-account')).toBe('integration user') }) it('calls a custom Slack bot a custom bot', () => { diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index fd11efb6b33..af88b5c2b89 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -43,7 +43,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-fusion-service-account' ? mockClientCredentialMinter : undefined, })) @@ -261,6 +262,63 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) }) + it('encrypts only the Oracle Fusion fields and captures no unverified principal', async () => { + mockClientCredentialMinter.mockResolvedValue({ + accessToken: 'opaque-basic', + expiresInSeconds: 300, + instanceUrl: 'https://vision.fa.us2.oraclecloud.com', + identity: { + displayName: 'Oracle Fusion vision', + principal: null, + auditMetadata: { + oracleFusionApplicationOrigin: 'https://vision.fa.us2.oraclecloud.com', + }, + storedMetadata: { applicationOrigin: 'https://vision.fa.us2.oraclecloud.com' }, + }, + }) + + const result = await verifyAndBuildServiceAccountSecret('oracle-fusion-service-account', { + orgId: ' https://vision.fa.us2.oraclecloud.com/ ', + clientId: ' integration-user ', + clientSecret: ' password ', + certificateId: 'discard-me', + dataCenter: 'discard-me', + authMethod: 'discard-me', + privateKey: 'discard-me', + username: 'discard-me', + }) + + expect(mockClientCredentialMinter).toHaveBeenCalledWith({ + orgId: 'https://vision.fa.us2.oraclecloud.com/', + clientId: 'integration-user', + clientSecret: 'password', + certificateId: undefined, + dataCenter: undefined, + authMethod: undefined, + privateKey: undefined, + username: undefined, + }) + expect(result).toMatchObject({ + displayName: 'Oracle Fusion vision', + principal: null, + auditMetadata: { + oracleFusionApplicationOrigin: 'https://vision.fa.us2.oraclecloud.com', + principalKind: 'none', + }, + }) + expect(JSON.parse(result.encryptedServiceAccountKey)).toEqual({ + type: 'client_credential_account', + providerId: 'oracle-fusion-service-account', + clientId: 'integration-user', + clientSecret: 'password', + orgId: 'https://vision.fa.us2.oraclecloud.com/', + metadata: { + applicationOrigin: 'https://vision.fa.us2.oraclecloud.com', + principalKind: 'none', + }, + }) + }) + it('throws when client-credential required fields are missing, without minting', async () => { await expect( verifyAndBuildServiceAccountSecret('zoom-service-account', { diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 5c35210cfe3..9bf3cd273c6 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -299,7 +299,7 @@ async function buildClientCredentialAccountSecret( ? fields.certificateId?.trim() || undefined : undefined, orgId: fields.orgId?.trim() ?? '', - dataCenter: fields.dataCenter?.trim() || undefined, + dataCenter: usesField('dataCenter') ? fields.dataCenter?.trim() || undefined : undefined, authMethod: resolvedAuthMethod, clientSecret: usesField('clientSecret') ? fields.clientSecret?.trim() || undefined : undefined, privateKey: usesField('privateKey') ? fields.privateKey?.trim() || undefined : undefined, diff --git a/apps/sim/lib/internal/oracle-fusion/client.test.ts b/apps/sim/lib/internal/oracle-fusion/client.test.ts new file mode 100644 index 00000000000..400033a80a4 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/client.test.ts @@ -0,0 +1,473 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSecureFetch, mockSleep, mockValidateUrl } = vi.hoisted(() => ({ + mockSecureFetch: vi.fn(), + mockSleep: vi.fn(), + mockValidateUrl: vi.fn(), +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithPinnedIP: mockSecureFetch, + validateUrlWithDNS: mockValidateUrl, +})) +vi.mock('@sim/utils/helpers', () => ({ interruptibleSleep: mockSleep })) + +import { createTimeoutAbortController } from '@/lib/core/execution-limits' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { + type OracleFusionRequest, + type OracleFusionResolvedCredential, + requestOracleFusionEmpty, + requestOracleFusionJson, +} from '@/lib/internal/oracle-fusion/client' +import { OracleFusionProviderError } from '@/lib/internal/oracle-fusion/errors' + +const ORIGIN = 'https://vision.fa.us2.oraclecloud.com' +const BASIC = Buffer.from('integration-user:password').toString('base64') +const CREDENTIAL: OracleFusionResolvedCredential = { + instanceUrl: ORIGIN, + accessToken: BASIC, +} + +function response( + status: number, + body: string, + headers: Record = {}, + stream: ReadableStream | null = null +) { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, + body: stream, + text: vi.fn(async () => body), + json: vi.fn(async () => JSON.parse(body)), + arrayBuffer: vi.fn(async () => new TextEncoder().encode(body).buffer), + } +} + +function getRequest(): OracleFusionRequest { + return { address: { family: 'hcm', relativePath: 'workers' } } +} + +describe('Oracle Fusion client', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateUrl.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + originalHostname: 'vision.fa.us2.oraclecloud.com', + }) + mockSleep.mockResolvedValue(undefined) + mockSecureFetch.mockResolvedValue(response(200, '{"items":[]}')) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it.each([ + ['hcm', '/hcmRestApi/resources/11.13.18.05/workers'], + ['fscm', '/fscmRestApi/resources/11.13.18.05/invoices'], + ['crm', '/crmRestApi/resources/11.13.18.05/opportunities'], + ] as const)( + 'pins the %s API family, headers, DNS result, and GET method', + async (family, path) => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family, relativePath: path.split('/').at(-1)! }, + query: { + q: 'Name="A B"', + limit: 25, + expand: undefined, + onlyData: true, + }, + }) + ).resolves.toEqual({ items: [] }) + + expect(mockValidateUrl).toHaveBeenCalledWith( + ORIGIN, + 'Fusion Applications URL', + 'configuredEndpoint', + { logDetails: false } + ) + const [url, resolvedIP, init] = mockSecureFetch.mock.calls[0] + const parsedUrl = new URL(url) + expect(parsedUrl.origin + parsedUrl.pathname).toBe(`${ORIGIN}${path}`) + expect(parsedUrl.searchParams.get('q')).toBe('Name="A B"') + expect(parsedUrl.searchParams.get('limit')).toBe('25') + expect(parsedUrl.searchParams.get('onlyData')).toBe('true') + expect(parsedUrl.searchParams.has('expand')).toBe(false) + expect(resolvedIP).toBe('203.0.113.10') + expect(init).toMatchObject({ + profile: 'configuredEndpoint', + method: 'GET', + timeout: 30_000, + maxRedirects: 0, + maxResponseBytes: 5 * 1024 * 1024, + logUrlValidationDetails: false, + headers: { + Accept: 'application/json', + Authorization: `Basic ${BASIC}`, + 'REST-Framework-Version': '9', + }, + }) + expect(init.signal).toBeInstanceOf(AbortSignal) + } + ) + + it.each([ + ['POST', 'application/json'], + ['PATCH', 'application/vnd.oracle.adf.resourceitem+json'], + ['PUT', 'application/vnd.oracle.adf.action+json'], + ] as const)( + 'sends a bounded %s JSON request with a closed media type', + async (method, mediaType) => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'fscm', relativePath: 'invoices/42' }, + method, + mediaType, + body: { amount: 12, approved: true }, + operationHeaders: { + effectiveOf: 'RangeMode=UPDATE', + ifMatch: 'etag-value', + upsertMode: false, + }, + }) + ).resolves.toEqual({ items: [] }) + + const init = mockSecureFetch.mock.calls[0][2] + expect(init).toMatchObject({ + method, + body: '{"amount":12,"approved":true}', + headers: { + Accept: 'application/json', + Authorization: `Basic ${BASIC}`, + 'Content-Type': mediaType, + 'Effective-Of': 'RangeMode=UPDATE', + 'If-Match': 'etag-value', + 'REST-Framework-Version': '9', + 'Upsert-Mode': 'false', + }, + }) + } + ) + + it('sends DELETE without a body and consumes an empty-mode success without JSON parsing', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('ignored')) + controller.close() + }, + }) + const success = response(204, 'not-json', {}, stream) + mockSecureFetch.mockResolvedValueOnce(success) + + await expect( + requestOracleFusionEmpty(CREDENTIAL, { + address: { family: 'crm', relativePath: 'opportunities/42' }, + method: 'DELETE', + operationHeaders: { ifMatch: '*' }, + }) + ).resolves.toBeUndefined() + + expect(mockSecureFetch.mock.calls[0][2]).toMatchObject({ + method: 'DELETE', + headers: { 'If-Match': '*' }, + }) + expect(mockSecureFetch.mock.calls[0][2]).not.toHaveProperty('body') + expect(success.text).not.toHaveBeenCalled() + }) + + it.each([ + '', + '/workers', + '//evil.example/workers', + 'https://evil.example/workers', + 'workers/../users', + 'workers/./users', + 'workers\\users', + 'workers?limit=1', + 'workers#fragment', + 'workers/%2e%2e/users', + 'workers/%2Fusers', + 'workers/%5cusers', + ])('rejects the unsafe relative path %j before DNS or fetch', async (path) => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: path }, + }) + ).rejects.toThrow(/resource path/) + expect(mockValidateUrl).not.toHaveBeenCalled() + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it('accepts the URL-safe encoding produced for an opaque key containing a percent sign', async () => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: 'workers/key%252Fpart' }, + }) + ).resolves.toEqual({ items: [] }) + expect(new URL(mockSecureFetch.mock.calls[0][0]).pathname).toMatch(/\/workers\/key%252Fpart$/) + }) + + it('rejects unsupported methods, body modes, media types, and operation headers locally', async () => { + const invalidRequests: OracleFusionRequest[] = [ + { + ...getRequest(), + method: 'OPTIONS', + } as unknown as OracleFusionRequest, + { + ...getRequest(), + method: 'GET', + body: {}, + } as unknown as OracleFusionRequest, + { + ...getRequest(), + method: 'POST', + mediaType: 'application/json', + } as unknown as OracleFusionRequest, + { + ...getRequest(), + method: 'POST', + mediaType: 'text/plain', + body: {}, + } as unknown as OracleFusionRequest, + { + ...getRequest(), + operationHeaders: { authorization: 'secret' }, + } as unknown as OracleFusionRequest, + ] + + for (const request of invalidRequests) { + await expect(requestOracleFusionJson(CREDENTIAL, request)).rejects.toThrow(/Oracle Fusion/) + } + expect(mockValidateUrl).not.toHaveBeenCalled() + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it.each(['', ' ', ' untrimmed', 'line\nbreak', 'x'.repeat(2_049)])( + 'rejects the unsafe operation header value %j locally', + async (value) => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + ...getRequest(), + operationHeaders: { ifMatch: value }, + }) + ).rejects.toThrow('header value is invalid') + expect(mockSecureFetch).not.toHaveBeenCalled() + } + ) + + it('rejects a non-public DNS result before fetching', async () => { + mockValidateUrl.mockResolvedValueOnce({ + isValid: false, + error: 'private address', + }) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toThrow( + 'not a public endpoint' + ) + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it.each([429, 503, 504])( + 'retries GET once for HTTP %s and caps Retry-After at five seconds', + async (status) => { + mockSecureFetch + .mockResolvedValueOnce(response(status, 'secret provider body', { 'retry-after': '90' })) + .mockResolvedValueOnce(response(200, '{"ok":true}')) + + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).resolves.toEqual({ ok: true }) + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + expect(mockSleep).toHaveBeenCalledTimes(1) + expect(mockSleep).toHaveBeenCalledWith(5_000, undefined) + } + ) + + it('stops after one GET retry', async () => { + mockSecureFetch + .mockResolvedValueOnce(response(429, 'secret provider body')) + .mockResolvedValueOnce(response(503, 'secret provider body')) + + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + status: 503, + }) + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + expect(mockSleep).toHaveBeenCalledTimes(1) + }) + + it('does not retry when the caller deadline cannot fit the delay, attempt, and reserve', async () => { + const execution = createTimeoutAbortController(39_000) + mockSecureFetch.mockResolvedValueOnce( + response(429, 'secret provider body', { 'retry-after': '5' }) + ) + try { + await expect( + requestOracleFusionJson(CREDENTIAL, getRequest(), execution.signal) + ).rejects.toMatchObject({ status: 429 }) + } finally { + execution.cleanup() + } + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + expect(mockSleep).not.toHaveBeenCalled() + }) + + it.each(['POST', 'PATCH', 'PUT'] as const)('never retries %s mutations', async (method) => { + mockSecureFetch.mockResolvedValueOnce(response(503, 'secret provider body')) + await expect( + requestOracleFusionJson(CREDENTIAL, { + ...getRequest(), + method, + mediaType: 'application/json', + body: {}, + }) + ).rejects.toMatchObject({ status: 503 }) + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + expect(mockSleep).not.toHaveBeenCalled() + }) + + it('never retries DELETE', async () => { + mockSecureFetch.mockResolvedValueOnce(response(504, 'secret provider body')) + await expect( + requestOracleFusionEmpty(CREDENTIAL, { + ...getRequest(), + method: 'DELETE', + }) + ).rejects.toMatchObject({ status: 504 }) + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + expect(mockSleep).not.toHaveBeenCalled() + }) + + it('enforces the attempt deadline through response body consumption', async () => { + vi.useFakeTimers() + const success = response(200, '') + success.text.mockImplementationOnce(() => new Promise(() => {})) + mockSecureFetch.mockResolvedValueOnce(success) + + const pending = expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject( + { + message: 'Oracle Fusion request timed out', + status: 504, + } + ) + await vi.advanceTimersByTimeAsync(30_000) + await pending + }) + + it('rejects redirects without exposing their location or body', async () => { + mockSecureFetch.mockResolvedValueOnce( + response(302, `redirect ${BASIC}`, { location: 'https://evil.example' }) + ) + const error = await requestOracleFusionJson(CREDENTIAL, getRequest()).catch( + (caught: unknown) => caught + ) + expect(error).toMatchObject({ status: 302 }) + expect(String(error)).not.toContain('evil.example') + expect(String(error)).not.toContain(BASIC) + }) + + it('classifies redirects rejected by the pinned transport without exposing details', async () => { + mockSecureFetch.mockRejectedValueOnce(new Error('Too many redirects (max: 0)')) + const error = await requestOracleFusionJson(CREDENTIAL, getRequest()).catch( + (caught: unknown) => caught + ) + expect(error).toMatchObject({ + message: 'Oracle Fusion returned a redirect', + status: 502, + }) + expect(String(error)).not.toContain(ORIGIN) + expect(String(error)).not.toContain(BASIC) + }) + + it('preserves unsafe integral JSON tokens as decimal strings', async () => { + mockSecureFetch.mockResolvedValueOnce( + response( + 200, + '{"id":9007199254740993,"negative":-9007199254740993,"zeroFraction":9007199254740993.0,"exponent":9.007199254740993e15,"hugeExponent":1e999,"safe":9007199254740991,"decimal":9007199254740993.5}' + ) + ) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).resolves.toEqual({ + id: '9007199254740993', + negative: '-9007199254740993', + zeroFraction: '9007199254740993.0', + exponent: '9.007199254740993e15', + hugeExponent: '1e999', + safe: 9007199254740991, + decimal: 9007199254740994, + }) + }) + + it('returns fixed provider errors without credential or body reflection', async () => { + const password = 'provider-reflected-password' + const accessToken = Buffer.from(`integration-user:${password}`).toString('base64') + mockSecureFetch.mockResolvedValueOnce( + response(401, `integration-user ${password} ${accessToken}`) + ) + const error = await requestOracleFusionJson({ ...CREDENTIAL, accessToken }, getRequest()).catch( + (caught: unknown) => caught + ) + expect(error).toBeInstanceOf(OracleFusionProviderError) + expect(error).toMatchObject({ + message: 'Oracle Fusion authentication failed', + status: 401, + }) + expect(String(error)).not.toContain('integration-user') + expect(String(error)).not.toContain(password) + expect(String(error)).not.toContain(accessToken) + }) + + it('classifies transport timeout, response-limit, and malformed JSON failures', async () => { + mockSecureFetch.mockRejectedValueOnce(new Error('Request timed out after 30000ms')) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + message: 'Oracle Fusion request timed out', + status: 504, + }) + + mockSecureFetch.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'response', + maxBytes: 5 * 1024 * 1024, + }) + ) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + message: 'Oracle Fusion response exceeded 5 MiB', + status: 502, + }) + + mockSecureFetch.mockResolvedValueOnce(response(200, 'not-json')) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + message: 'Oracle Fusion returned malformed JSON', + status: 502, + }) + }) + + it('preserves caller aborts and never starts the request', async () => { + const controller = new AbortController() + const reason = new DOMException('cancelled', 'AbortError') + controller.abort(reason) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest(), controller.signal)).rejects.toBe( + reason + ) + expect(mockValidateUrl).not.toHaveBeenCalled() + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it('rejects malformed Basic material and non-finite query values locally', async () => { + await expect( + requestOracleFusionJson({ ...CREDENTIAL, accessToken: 'not basic\r\n' }, getRequest()) + ).rejects.toThrow('credential is malformed') + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: 'workers' }, + query: { limit: Number.POSITIVE_INFINITY }, + }) + ).rejects.toThrow('query values must be finite') + expect(mockValidateUrl).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/client.ts b/apps/sim/lib/internal/oracle-fusion/client.ts new file mode 100644 index 00000000000..6ea36ec72c3 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/client.ts @@ -0,0 +1,405 @@ +import { interruptibleSleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import { createTimeoutAbortController, getRemainingExecutionMs } from '@/lib/core/execution-limits' +import { + type SecureFetchResponse, + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { consumeOrCancelBody, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors' +import { OracleFusionProviderError } from '@/lib/internal/oracle-fusion/errors' +import { isOracleFusionIntegralJsonNumberToken } from '@/lib/internal/oracle-fusion/identifiers' +import { + buildOracleFusionResourcePath, + type OracleFusionResourceAddress, +} from '@/lib/internal/oracle-fusion/paths' +import { serializeOracleFusionJsonBody } from '@/lib/internal/oracle-fusion/request-body' + +const REQUEST_TIMEOUT_MS = 30_000 +const RETRY_AFTER_MAX_MS = 5_000 +const RETRY_RESERVE_MS = 5_000 +const RESPONSE_MAX_BYTES = 5 * 1024 * 1024 +const MAX_GET_RETRIES = 1 +const TRANSIENT_STATUSES = new Set([429, 503, 504]) +const METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE']) +const MEDIA_TYPES = new Set([ + 'application/json', + 'application/vnd.oracle.adf.resourceitem+json', + 'application/vnd.oracle.adf.action+json', +]) +const OPERATION_HEADER_KEYS = new Set(['effectiveOf', 'ifMatch', 'upsertMode']) +const HEADER_VALUE_MAX_LENGTH = 2_048 +const HEADER_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/ +const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ + +interface JsonParseContext { + source?: string +} + +type JsonParseWithSource = ( + text: string, + reviver: (this: unknown, key: string, value: unknown, context?: JsonParseContext) => unknown +) => unknown + +const jsonParseWithSource = JSON.parse as JsonParseWithSource + +export interface OracleFusionResolvedCredential { + instanceUrl: string + accessToken: string +} + +export type OracleFusionMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' + +export type OracleFusionMediaType = + | 'application/json' + | 'application/vnd.oracle.adf.resourceitem+json' + | 'application/vnd.oracle.adf.action+json' + +export interface OracleFusionOperationHeaders { + effectiveOf?: string + ifMatch?: string + upsertMode?: boolean +} + +interface OracleFusionRequestBase { + address: OracleFusionResourceAddress + query?: Record + operationHeaders?: OracleFusionOperationHeaders +} + +export type OracleFusionRequest = OracleFusionRequestBase & + ( + | { method?: 'GET'; body?: never; mediaType?: never } + | { method: 'DELETE'; body?: never; mediaType?: never } + | { + method: 'POST' | 'PATCH' | 'PUT' + body: unknown + mediaType: OracleFusionMediaType + } + ) + +interface PreparedRequest { + method: OracleFusionMethod + headers: Record + body?: string +} + +function validateBasicCredential(accessToken: string): void { + if ( + !accessToken || + accessToken.length > 4096 || + accessToken.length % 4 !== 0 || + !CANONICAL_BASE64.test(accessToken) + ) { + throw new Error('Oracle Fusion credential is malformed') + } +} + +function buildRequestUrl(origin: string, request: OracleFusionRequest): string { + const resourcePath = buildOracleFusionResourcePath(request.address) + const url = new URL(`${origin}${resourcePath}`) + for (const [key, value] of Object.entries(request.query ?? {})) { + if (value === undefined) continue + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error('Oracle Fusion query values must be finite') + } + url.searchParams.set(key, String(value)) + } + if (url.origin !== origin || url.pathname !== resourcePath) { + throw new Error('Oracle Fusion request must remain on the credential-bound API root') + } + return url.toString() +} + +function parseOracleFusionJson(body: string): unknown { + return jsonParseWithSource(body, (_key, value, context) => { + if (typeof value !== 'number' || Number.isSafeInteger(value)) return value + const source = context?.source + return source && isOracleFusionIntegralJsonNumberToken(source) ? source : value + }) +} + +function validateHeaderValue(value: unknown): string { + if (typeof value !== 'string') { + throw new Error('Oracle Fusion operation header values must be strings') + } + const normalized = value.trim() + if ( + !normalized || + normalized !== value || + normalized.length > HEADER_VALUE_MAX_LENGTH || + HEADER_CONTROL_CHARACTERS.test(normalized) + ) { + throw new Error('Oracle Fusion operation header value is invalid') + } + return normalized +} + +function appendOperationHeaders( + target: Record, + operationHeaders: OracleFusionOperationHeaders | undefined +): void { + if (operationHeaders === undefined) return + if ( + operationHeaders === null || + typeof operationHeaders !== 'object' || + Array.isArray(operationHeaders) || + (Object.getPrototypeOf(operationHeaders) !== Object.prototype && + Object.getPrototypeOf(operationHeaders) !== null) + ) { + throw new Error('Oracle Fusion operation headers must be a plain object') + } + for (const key of Reflect.ownKeys(operationHeaders)) { + if (typeof key !== 'string' || !OPERATION_HEADER_KEYS.has(key)) { + throw new Error('Oracle Fusion operation header is not supported') + } + const descriptor = Object.getOwnPropertyDescriptor(operationHeaders, key) + if (!descriptor || descriptor.get || descriptor.set) { + throw new Error('Oracle Fusion operation header is not supported') + } + } + if (operationHeaders.effectiveOf !== undefined) { + target['Effective-Of'] = validateHeaderValue(operationHeaders.effectiveOf) + } + if (operationHeaders.ifMatch !== undefined) { + target['If-Match'] = validateHeaderValue(operationHeaders.ifMatch) + } + if (operationHeaders.upsertMode !== undefined) { + if (typeof operationHeaders.upsertMode !== 'boolean') { + throw new Error('Oracle Fusion Upsert-Mode must be boolean') + } + target['Upsert-Mode'] = String(operationHeaders.upsertMode) + } +} + +function prepareRequest(accessToken: string, request: OracleFusionRequest): PreparedRequest { + const method = request.method ?? 'GET' + if (!METHODS.has(method)) throw new Error('Oracle Fusion request method is not supported') + + const headers: Record = { + Accept: 'application/json', + Authorization: `Basic ${accessToken}`, + 'REST-Framework-Version': '9', + } + appendOperationHeaders(headers, request.operationHeaders) + + const hasBody = 'body' in request && request.body !== undefined + if (method === 'GET' || method === 'DELETE') { + if (hasBody || 'mediaType' in request) { + throw new Error(`Oracle Fusion ${method} requests must not include a body`) + } + return { method, headers } + } + + if (!hasBody) throw new Error(`Oracle Fusion ${method} requests require a JSON body`) + if (!MEDIA_TYPES.has(request.mediaType)) { + throw new Error('Oracle Fusion request media type is not supported') + } + headers['Content-Type'] = request.mediaType + return { method, headers, body: serializeOracleFusionJsonBody(request.body) } +} + +function retryDelay(attempt: number, retryAfterMs: number | null): number { + return backoffWithJitter(attempt + 1, retryAfterMs, { + baseMs: 250, + maxMs: RETRY_AFTER_MAX_MS, + }) +} + +async function waitForRetry(delay: number, signal?: AbortSignal): Promise { + await interruptibleSleep(delay, signal) + signal?.throwIfAborted() +} + +function hasTimeForRetry(delay: number, signal?: AbortSignal): boolean { + const remaining = getRemainingExecutionMs(signal) + return remaining === undefined || remaining >= delay + REQUEST_TIMEOUT_MS + RETRY_RESERVE_MS +} + +async function waitWithSignal(promise: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted() + return new Promise((resolve, reject) => { + const cleanup = () => signal.removeEventListener('abort', onAbort) + const onAbort = () => { + cleanup() + reject(signal.reason ?? new DOMException('user', 'AbortError')) + } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + cleanup() + resolve(value) + }, + (error: unknown) => { + cleanup() + reject(error) + } + ) + }) +} + +async function fetchAttempt( + url: string, + resolvedIP: string, + prepared: PreparedRequest, + signal: AbortSignal +): Promise { + return waitWithSignal( + secureFetchWithPinnedIP(url, resolvedIP, { + profile: 'configuredEndpoint', + method: prepared.method, + headers: prepared.headers, + ...(prepared.body === undefined ? {} : { body: prepared.body }), + timeout: REQUEST_TIMEOUT_MS, + maxRedirects: 0, + maxResponseBytes: RESPONSE_MAX_BYTES, + signal, + logUrlValidationDetails: false, + }), + signal + ) +} + +function statusMessage(status: number): string { + if (status === 401) return 'Oracle Fusion authentication failed' + if (status === 403) return 'Oracle Fusion denied this request' + if (status === 404) return 'Oracle Fusion resource was not found' + if (status === 429) return 'Oracle Fusion rate limit exceeded' + return `Oracle Fusion request failed with HTTP ${status}` +} + +/** `maxRedirects: 0` rejects a response with Location before returning its status. */ +function isRejectedRedirect(error: unknown): boolean { + return error instanceof Error && error.message === 'Too many redirects (max: 0)' +} + +function mapAttemptError(error: unknown, timedOut: boolean, callerSignal?: AbortSignal): never { + callerSignal?.throwIfAborted() + if (error instanceof OracleFusionProviderError) throw error + if (timedOut) { + throw new OracleFusionProviderError('Oracle Fusion request timed out', 504) + } + if (isRejectedRedirect(error)) { + throw new OracleFusionProviderError('Oracle Fusion returned a redirect', 502) + } + if (isPayloadSizeLimitError(error)) { + throw new OracleFusionProviderError('Oracle Fusion response exceeded 5 MiB', 502) + } + if (error instanceof Error && error.message.includes('timed out')) { + throw new OracleFusionProviderError('Oracle Fusion request timed out', 504) + } + throw new OracleFusionProviderError('Could not reach Oracle Fusion', 502) +} + +async function readJsonResponse( + response: SecureFetchResponse, + signal: AbortSignal +): Promise { + let body: string + try { + body = await waitWithSignal(response.text(), signal) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + throw new OracleFusionProviderError('Oracle Fusion response exceeded 5 MiB', 502) + } + throw error + } + try { + return parseOracleFusionJson(body) + } catch { + throw new OracleFusionProviderError('Oracle Fusion returned malformed JSON', 502) + } +} + +async function consumeResponse(response: SecureFetchResponse, signal: AbortSignal): Promise { + await waitWithSignal(consumeOrCancelBody(response), signal) +} + +async function validateCredentialOrigin(origin: string, signal?: AbortSignal): Promise { + let validation: Awaited> + try { + validation = await validateUrlWithDNS(origin, 'Fusion Applications URL', 'configuredEndpoint', { + logDetails: false, + }) + } catch { + signal?.throwIfAborted() + throw new Error('Oracle Fusion credential application URL could not be validated') + } + signal?.throwIfAborted() + if (!validation.isValid) { + throw new Error('Oracle Fusion credential application URL is not a public endpoint') + } + return validation.resolvedIP +} + +async function requestOracleFusion( + credential: OracleFusionResolvedCredential, + request: OracleFusionRequest, + responseMode: 'json' | 'empty', + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const origin = normalizeOracleFusionApplicationOrigin(credential.instanceUrl) + if (!origin) { + throw new Error('Oracle Fusion credential is not bound to a canonical application URL') + } + validateBasicCredential(credential.accessToken) + const prepared = prepareRequest(credential.accessToken, request) + const url = buildRequestUrl(origin, request) + const resolvedIP = await validateCredentialOrigin(origin, signal) + const maxRetries = prepared.method === 'GET' ? MAX_GET_RETRIES : 0 + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + signal?.throwIfAborted() + const deadline = createTimeoutAbortController(REQUEST_TIMEOUT_MS, signal) + let delay: number | undefined + try { + const response = await fetchAttempt(url, resolvedIP, prepared, deadline.signal) + if (TRANSIENT_STATUSES.has(response.status) && attempt < maxRetries) { + const retryAfterMs = parseRetryAfter( + response.headers.get('retry-after'), + RETRY_AFTER_MAX_MS + ) + await consumeResponse(response, deadline.signal) + const candidateDelay = retryDelay(attempt, retryAfterMs) + if (hasTimeForRetry(candidateDelay, signal)) delay = candidateDelay + else throw new OracleFusionProviderError(statusMessage(response.status), response.status) + } else if (!response.ok) { + await consumeResponse(response, deadline.signal) + throw new OracleFusionProviderError(statusMessage(response.status), response.status) + } else if (responseMode === 'json') { + return await readJsonResponse(response, deadline.signal) + } else { + await consumeResponse(response, deadline.signal) + return + } + } catch (error) { + mapAttemptError(error, deadline.isTimedOut(), signal) + } finally { + deadline.cleanup() + } + + if (delay !== undefined) await waitForRetry(delay, signal) + } + + throw new OracleFusionProviderError('Oracle Fusion retry limit was exhausted', 502) +} + +/** Executes a bounded request and parses a required JSON success response losslessly. */ +export async function requestOracleFusionJson( + credential: OracleFusionResolvedCredential, + request: OracleFusionRequest, + signal?: AbortSignal +): Promise { + return requestOracleFusion(credential, request, 'json', signal) +} + +/** Executes a bounded request and consumes or cancels its success response body. */ +export async function requestOracleFusionEmpty( + credential: OracleFusionResolvedCredential, + request: OracleFusionRequest, + signal?: AbortSignal +): Promise { + await requestOracleFusion(credential, request, 'empty', signal) +} diff --git a/apps/sim/lib/internal/oracle-fusion/errors.ts b/apps/sim/lib/internal/oracle-fusion/errors.ts new file mode 100644 index 00000000000..c41b905e8dd --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/errors.ts @@ -0,0 +1,10 @@ +/** Safe caller-facing failure from an Oracle Fusion product request. */ +export class OracleFusionProviderError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'OracleFusionProviderError' + } +} diff --git a/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts b/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts new file mode 100644 index 00000000000..e366f92ff5d --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + isOracleFusionIntegralJsonNumberToken, + normalizeOracleFusionDecimalIdentifier, +} from '@/lib/internal/oracle-fusion/identifiers' + +const OPTIONS = { maxDigits: 128 } + +describe('isOracleFusionIntegralJsonNumberToken', () => { + it.each([ + '9007199254740993', + '-9007199254740993', + '9007199254740993.0', + '9.007199254740993e15', + '1e999', + `9007199254740993${'0'.repeat(100)}e-100`, + ])('recognizes the exact integral token %j', (source) => { + expect(isOracleFusionIntegralJsonNumberToken(source)).toBe(true) + }) + + it.each(['1.25', '1e-1', '1.23e1', 'not-a-number'])('rejects %j as non-integral', (source) => { + expect(isOracleFusionIntegralJsonNumberToken(source)).toBe(false) + }) +}) + +describe('normalizeOracleFusionDecimalIdentifier', () => { + it.each([ + [0, '0'], + [42, '42'], + ['9223372036854775807', '9223372036854775807'], + ['9.223372036854775807e18', '9223372036854775807'], + ['123.000', '123'], + ['1.23e2', '123'], + ['1000e-3', '1'], + ['0.001e3', '1'], + ['0e999999999999999999999999', '0'], + ])('canonicalizes %j to %s', (value, expected) => { + expect(normalizeOracleFusionDecimalIdentifier(value, OPTIONS)).toBe(expected) + }) + + it.each([ + -1, + 1.25, + Number.MAX_SAFE_INTEGER + 1, + Number.POSITIVE_INFINITY, + '-1', + '-0', + '+1', + '01', + '1.25', + '1e-1', + '1e129', + `1${'0'.repeat(128)}`, + '1'.repeat(129), + ])('rejects the non-canonical or out-of-range identifier %j', (value) => { + expect(normalizeOracleFusionDecimalIdentifier(value, OPTIONS)).toBeUndefined() + }) + + it('checks configured limits before expanding exponent notation', () => { + expect( + normalizeOracleFusionDecimalIdentifier('1e63', { maxDigits: 64, maxSourceLength: 64 }) + ).toBe(`1${'0'.repeat(63)}`) + expect( + normalizeOracleFusionDecimalIdentifier('1e64', { maxDigits: 64, maxSourceLength: 64 }) + ).toBeUndefined() + expect(() => + normalizeOracleFusionDecimalIdentifier('1', { maxDigits: 129, maxSourceLength: 128 }) + ).toThrow('limits are invalid') + }) + + it('checks the digit limit after removing an exact fractional suffix', () => { + expect( + normalizeOracleFusionDecimalIdentifier('123456000.000', { + maxDigits: 8, + }) + ).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/identifiers.ts b/apps/sim/lib/internal/oracle-fusion/identifiers.ts new file mode 100644 index 00000000000..26be97e8def --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/identifiers.ts @@ -0,0 +1,109 @@ +const INTEGRAL_JSON_NUMBER_TOKEN = /^-?(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ +const NON_NEGATIVE_INTEGRAL_TOKEN = /^(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ +const DEFAULT_MAX_SOURCE_LENGTH = 128 + +function compareDecimalMagnitudeToInteger(magnitude: string, value: number): number { + const normalizedMagnitude = magnitude.replace(/^0+/, '') || '0' + const integer = String(value) + if (normalizedMagnitude.length !== integer.length) { + return normalizedMagnitude.length < integer.length ? -1 : 1 + } + if (normalizedMagnitude === integer) return 0 + return normalizedMagnitude < integer ? -1 : 1 +} + +function trailingZeroCount(value: string): number { + let count = 0 + for (let index = value.length - 1; index >= 0 && value[index] === '0'; index--) count++ + return count +} + +/** Whether one JSON number token denotes an exact integer without expanding its exponent. */ +export function isOracleFusionIntegralJsonNumberToken(source: string): boolean { + const match = INTEGRAL_JSON_NUMBER_TOKEN.exec(source) + if (!match) return false + const coefficient = `${match[1]}${match[2] ?? ''}` + if (/^0+$/.test(coefficient)) return true + + const fractionDigits = match[2]?.length ?? 0 + const exponentSource = match[3] ?? '0' + const exponentMagnitude = exponentSource.replace(/^[+-]/, '').replace(/^0+/, '') || '0' + const availableTrailingZeros = trailingZeroCount(coefficient) + + if (exponentSource.startsWith('-')) { + if (compareDecimalMagnitudeToInteger(exponentMagnitude, availableTrailingZeros) > 0) { + return false + } + return fractionDigits + Number(exponentMagnitude) <= availableTrailingZeros + } + if (compareDecimalMagnitudeToInteger(exponentMagnitude, fractionDigits) >= 0) return true + return fractionDigits - Number(exponentMagnitude) <= availableTrailingZeros +} + +function parseBoundedExponent(exponentText: string, maximumMagnitude: number): number | undefined { + const negative = exponentText.startsWith('-') + const unsigned = exponentText.replace(/^[+-]/, '').replace(/^0+(?=\d)/, '') + const maximum = String(maximumMagnitude) + if ( + unsigned.length > maximum.length || + (unsigned.length === maximum.length && unsigned > maximum) + ) { + return undefined + } + const magnitude = Number(unsigned) + return negative ? -magnitude : magnitude +} + +export interface OracleFusionDecimalIdentifierOptions { + maxDigits: number + maxSourceLength?: number +} + +/** Canonicalizes one exact non-negative integral identifier without JS-number precision loss. */ +export function normalizeOracleFusionDecimalIdentifier( + value: unknown, + options: OracleFusionDecimalIdentifierOptions +): string | undefined { + const maxSourceLength = options.maxSourceLength ?? DEFAULT_MAX_SOURCE_LENGTH + if ( + !Number.isSafeInteger(options.maxDigits) || + options.maxDigits < 1 || + !Number.isSafeInteger(maxSourceLength) || + maxSourceLength < 1 || + options.maxDigits > maxSourceLength + ) { + throw new Error('Oracle Fusion decimal identifier limits are invalid') + } + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value >= 0 && String(value).length <= options.maxDigits + ? String(value) + : undefined + } + if (typeof value !== 'string' || value.length > maxSourceLength) return undefined + const match = NON_NEGATIVE_INTEGRAL_TOKEN.exec(value) + if (!match) return undefined + + const integer = match[1] + const fraction = match[2] ?? '' + const exponentText = match[3] ?? '0' + const coefficient = `${integer}${fraction}` + if (/^0+$/.test(coefficient)) return '0' + const significantCoefficient = coefficient.replace(/^0+/, '') + const maximumRelevantExponent = Math.max(integer.length, fraction.length + options.maxDigits) + const exponent = parseBoundedExponent(exponentText, maximumRelevantExponent) + if (exponent === undefined) return undefined + + const scale = exponent - fraction.length + if (scale >= 0) { + if (significantCoefficient.length + scale > options.maxDigits) return undefined + return `${significantCoefficient}${'0'.repeat(scale)}` + } + + const fractionalDigits = -scale + if (fractionalDigits > significantCoefficient.length) return undefined + const suffix = significantCoefficient.slice(significantCoefficient.length - fractionalDigits) + if (!/^0*$/.test(suffix)) return undefined + const normalized = + significantCoefficient.slice(0, significantCoefficient.length - fractionalDigits) || '0' + return normalized.length <= options.maxDigits ? normalized : undefined +} diff --git a/apps/sim/lib/internal/oracle-fusion/paths.test.ts b/apps/sim/lib/internal/oracle-fusion/paths.test.ts new file mode 100644 index 00000000000..13ae1a89971 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/paths.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildOracleFusionResourcePath } from '@/lib/internal/oracle-fusion/paths' + +describe('buildOracleFusionResourcePath', () => { + it.each([ + ['hcm', 'workers', '/hcmRestApi/resources/11.13.18.05/workers'], + ['fscm', 'invoices/123', '/fscmRestApi/resources/11.13.18.05/invoices/123'], + ['crm', 'opportunities', '/crmRestApi/resources/11.13.18.05/opportunities'], + ] as const)('builds the fixed %s resource root', (family, relativePath, expected) => { + expect(buildOracleFusionResourcePath({ family, relativePath })).toBe(expected) + }) + + it('preserves safe URL encoding in an opaque path segment', () => { + expect( + buildOracleFusionResourcePath({ family: 'hcm', relativePath: 'workers/key%252Fpart' }) + ).toBe('/hcmRestApi/resources/11.13.18.05/workers/key%252Fpart') + }) + + it.each([ + '', + ' workers', + 'workers ', + 'workers/bad key', + '/workers', + '//evil.example/workers', + 'https://evil.example/workers', + 'workers//assignments', + 'workers/', + 'workers/../users', + 'workers/./users', + 'workers\\users', + 'workers?limit=1', + 'workers#fragment', + 'workers/%2e%2e/users', + 'workers/%2Fusers', + 'workers/%5cusers', + 'workers/%3Fquery', + 'workers/%23fragment', + 'workers/%00control', + 'workers/%E0%A4%A', + 'workers/\ud800', + ])('rejects the unsafe relative path %j', (relativePath) => { + expect(() => buildOracleFusionResourcePath({ family: 'hcm', relativePath })).toThrow( + /resource path/ + ) + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/paths.ts b/apps/sim/lib/internal/oracle-fusion/paths.ts new file mode 100644 index 00000000000..6c791bfefb4 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/paths.ts @@ -0,0 +1,66 @@ +const API_VERSION = '11.13.18.05' +const API_ROOTS = { + hcm: `/hcmRestApi/resources/${API_VERSION}`, + fscm: `/fscmRestApi/resources/${API_VERSION}`, + crm: `/crmRestApi/resources/${API_VERSION}`, +} as const +const ABSOLUTE_PATH = /^[a-z][a-z0-9+.-]*:/i +const UNSAFE_PATH_ENCODING = /%(?:2e|2f|5c|3f|23)/i +const PATH_CONTROL = /[\u0000-\u001f\u007f]/ +const PATH_WHITESPACE = /\s/ + +export type OracleFusionApiFamily = keyof typeof API_ROOTS + +export interface OracleFusionResourceAddress { + family: OracleFusionApiFamily + relativePath: string +} + +function validateRelativePath(relativePath: string): void { + if ( + !relativePath || + relativePath !== relativePath.trim() || + relativePath.startsWith('/') || + ABSOLUTE_PATH.test(relativePath) || + relativePath.includes('\\') || + relativePath.includes('?') || + relativePath.includes('#') || + PATH_CONTROL.test(relativePath) || + PATH_WHITESPACE.test(relativePath) || + UNSAFE_PATH_ENCODING.test(relativePath) + ) { + throw new Error('Oracle Fusion resource path must be a safe relative path') + } + + for (const segment of relativePath.split('/')) { + if (!segment || segment === '.' || segment === '..') { + throw new Error('Oracle Fusion resource path must not contain empty or traversal segments') + } + let decoded: string + try { + decoded = decodeURIComponent(segment) + void encodeURIComponent(decoded) + } catch { + throw new Error('Oracle Fusion resource path contains invalid URL encoding') + } + if ( + decoded === '.' || + decoded === '..' || + decoded.includes('/') || + decoded.includes('\\') || + decoded.includes('?') || + decoded.includes('#') || + PATH_CONTROL.test(decoded) + ) { + throw new Error('Oracle Fusion resource path must be a safe relative path') + } + } +} + +/** Builds one canonical path beneath a fixed Oracle Fusion product API root. */ +export function buildOracleFusionResourcePath(address: OracleFusionResourceAddress): string { + validateRelativePath(address.relativePath) + const root = API_ROOTS[address.family] + if (!root) throw new Error('Oracle Fusion API family is unsupported') + return `${root}/${address.relativePath}` +} diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts new file mode 100644 index 00000000000..10b38619789 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts @@ -0,0 +1,260 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + encodeOracleFusionPathSegment, + extractOracleFusionOpaqueKey, + parseOracleFusionCollection, + validateOracleFusionSelfLink, +} from '@/lib/internal/oracle-fusion/protocol' + +const ORIGIN = 'https://vision.fa.us2.oraclecloud.com' +const COLLECTION = '/hcmRestApi/resources/11.13.18.05/workers' +const COLLECTION_ADDRESS = { family: 'hcm', relativePath: 'workers' } as const + +function resource(href: unknown, links: unknown[] = []): Record { + return { links: [{ rel: 'self', href }, ...links] } +} + +describe('parseOracleFusionCollection', () => { + it('projects a valid page and calculates the next offset', () => { + expect( + parseOracleFusionCollection( + { + items: [{ id: 1 }, { id: 2 }], + count: 2, + hasMore: true, + limit: 25, + offset: 50, + totalResults: 80, + }, + (item, index) => ({ ...(item as object), index }) + ) + ).toEqual({ + items: [ + { id: 1, index: 0 }, + { id: 2, index: 1 }, + ], + count: 2, + hasMore: true, + limit: 25, + offset: 50, + totalResults: 80, + nextOffset: 52, + }) + }) + + it('accepts an empty terminal page and returns its current nextOffset', () => { + expect( + parseOracleFusionCollection( + { items: [], count: 0, hasMore: false, limit: 25, offset: 10, totalResults: 5 }, + (item) => item, + { expectedOffset: 10 } + ) + ).toEqual({ + items: [], + count: 0, + hasMore: false, + limit: 25, + offset: 10, + totalResults: 5, + nextOffset: 10, + }) + }) + + it.each([ + { items: [], count: 0, hasMore: false, limit: 25, offset: 5, totalResults: 6 }, + { items: [{}], count: 1, hasMore: false, limit: 25, offset: 5, totalResults: 7 }, + { items: [{}], count: 1, hasMore: true, limit: 25, offset: 5, totalResults: 6 }, + ])('rejects pagination metadata that contradicts total results %#', (value) => { + expect(() => parseOracleFusionCollection(value, (item) => item)).toThrow( + 'hasMore contradicts totalResults' + ) + }) + + it('accepts omitted items only for an unambiguous empty terminal page', () => { + expect( + parseOracleFusionCollection( + { count: 0, hasMore: false, limit: 25, offset: 10 }, + (item) => item, + { expectedOffset: 10, maxItems: 25 } + ) + ).toEqual({ + items: [], + count: 0, + hasMore: false, + limit: 25, + offset: 10, + nextOffset: 10, + }) + }) + + it('validates expected offset and item limits before projection', () => { + const parseItem = vi.fn((item) => item) + const page = { items: [{ id: 1 }], count: 1, hasMore: false, limit: 5, offset: 4 } + expect(() => + parseOracleFusionCollection(page, parseItem, { expectedOffset: 3, maxItems: 5 }) + ).toThrow('requested offset') + expect(() => + parseOracleFusionCollection(page, parseItem, { expectedOffset: 4, maxItems: 0 }) + ).toThrow('item limit') + expect(parseItem).not.toHaveBeenCalled() + }) + + it('does not require the returned limit to equal the caller item cap', () => { + expect( + parseOracleFusionCollection( + { items: [{ id: 1 }], count: 1, hasMore: false, limit: 73, offset: 0 }, + (item) => item, + { expectedOffset: 0, maxItems: 20 } + ) + ).toMatchObject({ limit: 73, count: 1, nextOffset: 1 }) + }) + + it.each([ + [null, 'must be an object'], + [{}, 'count'], + [{ count: 1, hasMore: false, limit: 25, offset: 0 }, 'items must be an array'], + [{ count: 0, hasMore: true, limit: 25, offset: 0 }, 'items must be an array'], + [{ items: [], count: -1, hasMore: false, limit: 25, offset: 0 }, 'count'], + [{ items: [], count: 0, hasMore: 'no', limit: 25, offset: 0 }, 'hasMore'], + [{ items: [{}], count: 0, hasMore: false, limit: 25, offset: 0 }, 'match'], + [{ items: [], count: 0, hasMore: true, limit: 25, offset: 0 }, 'empty page'], + [{ items: [], count: 0, hasMore: false, limit: 0, offset: 0 }, 'positive'], + [{ items: [{}], count: 1, hasMore: false, limit: 25, offset: 4, totalResults: 4 }, 'smaller'], + [ + { items: [{}], count: 1, hasMore: true, limit: 25, offset: Number.MAX_SAFE_INTEGER }, + 'safe integer range', + ], + ])('rejects malformed collection envelope %#', (value, message) => { + expect(() => parseOracleFusionCollection(value, (item) => item)).toThrow(message as string) + }) +}) + +describe('Oracle self links', () => { + it('accepts exactly one same-origin self link for the expected path', () => { + expect(() => + validateOracleFusionSelfLink(resource(`${ORIGIN}${COLLECTION}/abc`), ORIGIN, { + family: 'hcm', + relativePath: 'workers/abc', + }) + ).not.toThrow() + }) + + it.each([ + [{}, 'exactly one'], + [{ links: [] }, 'exactly one'], + [ + resource(`${ORIGIN}${COLLECTION}/abc`, [{ rel: 'self', href: `${ORIGIN}/duplicate` }]), + 'exactly one', + ], + [resource(123), 'malformed'], + [resource('not a URL'), 'malformed'], + [resource(`https://evil.example${COLLECTION}/abc`), 'credential-bound origin'], + [resource(`${ORIGIN}${COLLECTION}/abc?secret=value`), 'credential-bound origin'], + [resource(`${ORIGIN}${COLLECTION}/other`), 'requested resource path'], + ])('rejects missing, duplicate, malformed, or unbound self links %#', (value, message) => { + expect(() => + validateOracleFusionSelfLink(value, ORIGIN, { + family: 'hcm', + relativePath: 'workers/abc', + }) + ).toThrow(message as string) + }) + + it('extracts and URL-encodes an opaque key without changing its value', () => { + const key = 'person:123,assignment=456' + const encoded = encodeOracleFusionPathSegment(key) + expect(encoded).toBe('person%3A123%2Cassignment%3D456') + expect( + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/${encoded}`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toBe(key) + }) + + it('requires spaces in self-link keys to be encoded and preserves their value', () => { + const key = 'person name ' + const encoded = encodeOracleFusionPathSegment(key) + expect(encoded).toBe('person%20name%20') + expect( + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/${encoded}`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toBe(key) + expect(() => + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/${key}`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toThrow('Oracle self link is malformed') + }) + + it.each(['', ' ', '.', '..', 'a/b', 'a\\b', 'a?b', 'a#b', 'a\nb', 'x'.repeat(2049)])( + 'rejects the unsafe opaque key %j', + (key) => { + expect(() => encodeOracleFusionPathSegment(key)).toThrow('safe opaque path segment') + } + ) + + it('rejects malformed Unicode without leaking a URI error', () => { + expect(() => encodeOracleFusionPathSegment('\ud800')).toThrow( + 'Oracle resource key contains malformed Unicode' + ) + }) + + it('rejects a self-link href containing malformed Unicode before URL parsing', () => { + expect(() => + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/bad\ud800key`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toThrow('Oracle self link is malformed') + }) + + it.each(['\t', '\n', '\r'])( + 'rejects a self-link key containing the raw control character %j before URL parsing', + (control) => { + expect(() => + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/bad${control}key`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toThrow('Oracle self link is malformed') + } + ) + + it.each([ + `${ORIGIN}${COLLECTION}/parent/../abc`, + `${ORIGIN}${COLLECTION}/parent/%2e%2e/abc`, + `${ORIGIN}${COLLECTION}/parent/.%2E/abc`, + `${ORIGIN}${COLLECTION}/parent\\..\\abc`, + ])('rejects a self-link path that URL parsing would normalize %j', (href) => { + expect(() => + validateOracleFusionSelfLink(resource(href), ORIGIN, { + family: 'hcm', + relativePath: 'workers/abc', + }) + ).toThrow('Oracle self link is malformed') + }) + + it.each([ + [`${ORIGIN}/other/abc`, 'collection path'], + [`${ORIGIN}${COLLECTION}/a/b`, 'one opaque key'], + [`${ORIGIN}${COLLECTION}/a%2Fb`, 'one opaque key'], + [`${ORIGIN}${COLLECTION}/a%5Cb`, 'one opaque key'], + [`${ORIGIN}${COLLECTION}/%E0%A4%A`, 'invalid URL encoding'], + ])('rejects an unsafe opaque-key self link %j', (href, message) => { + expect(() => extractOracleFusionOpaqueKey(resource(href), ORIGIN, COLLECTION_ADDRESS)).toThrow( + message + ) + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.ts b/apps/sim/lib/internal/oracle-fusion/protocol.ts new file mode 100644 index 00000000000..6a410cc722c --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/protocol.ts @@ -0,0 +1,231 @@ +import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors' +import { + buildOracleFusionResourcePath, + type OracleFusionResourceAddress, +} from '@/lib/internal/oracle-fusion/paths' + +const OPAQUE_KEY_MAX_LENGTH = 2048 +const UNSAFE_OPAQUE_KEY = /[\\/?#\u0000-\u001f\u007f]/ +const UNSAFE_SELF_LINK_TEXT = /[\s\u0000-\u001f\u007f]/ +const RAW_SELF_LINK_DOT_SEGMENT = /(?:^|[\\/])(?:\.|%2e){1,2}(?=[\\/?#]|$)/i + +function hasWellFormedUtf16(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const codeUnit = value.charCodeAt(index) + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) return false + index++ + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return false + } + } + return true +} + +export interface OracleFusionCollection { + items: T[] + count: number + hasMore: boolean + limit: number + offset: number + totalResults?: number + nextOffset: number +} + +export interface OracleFusionCollectionOptions { + expectedOffset?: number + maxItems?: number +} + +function asObject(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`) + } + return value as Record +} + +function nonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer`) + } + return value +} + +/** Validates and projects an Oracle collection envelope with pagination invariants. */ +export function parseOracleFusionCollection( + value: unknown, + parseItem: (item: unknown, index: number) => T, + options: OracleFusionCollectionOptions = {} +): OracleFusionCollection { + const envelope = asObject(value, 'Oracle collection') + const count = nonNegativeInteger(envelope.count, 'Oracle collection count') + const limit = nonNegativeInteger(envelope.limit, 'Oracle collection limit') + const offset = nonNegativeInteger(envelope.offset, 'Oracle collection offset') + if (limit === 0) throw new Error('Oracle collection limit must be positive') + if (typeof envelope.hasMore !== 'boolean') { + throw new Error('Oracle collection hasMore must be a boolean') + } + const items = + envelope.items === undefined && count === 0 && !envelope.hasMore ? [] : envelope.items + if (!Array.isArray(items)) throw new Error('Oracle collection items must be an array') + if (count !== items.length) { + throw new Error('Oracle collection count must match the item count') + } + if (envelope.hasMore && count === 0) { + throw new Error('Oracle collection cannot report hasMore for an empty page') + } + + const totalResults = + envelope.totalResults === undefined + ? undefined + : nonNegativeInteger(envelope.totalResults, 'Oracle collection totalResults') + const pageEnd = offset + count + if (!Number.isSafeInteger(pageEnd)) { + throw new Error('Oracle collection next offset exceeds the safe integer range') + } + if (totalResults !== undefined && count > 0 && totalResults < pageEnd) { + throw new Error('Oracle collection totalResults is smaller than the returned page') + } + if (totalResults !== undefined && !envelope.hasMore && totalResults > pageEnd) { + throw new Error('Oracle collection hasMore contradicts totalResults') + } + if (totalResults !== undefined && envelope.hasMore && totalResults <= pageEnd) { + throw new Error('Oracle collection hasMore contradicts totalResults') + } + if (options.expectedOffset !== undefined) { + const expectedOffset = nonNegativeInteger( + options.expectedOffset, + 'Oracle collection expected offset' + ) + if (offset !== expectedOffset) { + throw new Error('Oracle collection offset does not match the requested offset') + } + } + if (options.maxItems !== undefined) { + const maxItems = nonNegativeInteger(options.maxItems, 'Oracle collection item limit') + if (items.length > maxItems) { + throw new Error('Oracle collection exceeds the requested item limit') + } + } + + return { + items: items.map(parseItem), + count, + hasMore: envelope.hasMore, + limit, + offset, + ...(totalResults !== undefined ? { totalResults } : {}), + nextOffset: pageEnd, + } +} + +function getOnlySelfLink(value: unknown): URL { + const resource = asObject(value, 'Oracle resource') + if (!Array.isArray(resource.links)) { + throw new Error('Oracle response must include exactly one self link') + } + const selfLinks = resource.links.filter((link) => { + if (!link || typeof link !== 'object' || Array.isArray(link)) return false + return (link as Record).rel === 'self' + }) + if (selfLinks.length !== 1) { + throw new Error('Oracle response must include exactly one self link') + } + const href = (selfLinks[0] as Record).href + if ( + typeof href !== 'string' || + UNSAFE_SELF_LINK_TEXT.test(href) || + href.includes('\\') || + RAW_SELF_LINK_DOT_SEGMENT.test(href) || + !hasWellFormedUtf16(href) + ) { + throw new Error('Oracle self link is malformed') + } + try { + return new URL(href) + } catch { + throw new Error('Oracle self link is malformed') + } +} + +function validateSelfLinkBase(link: URL, instanceUrl: string): void { + const origin = normalizeOracleFusionApplicationOrigin(instanceUrl) + if ( + !origin || + link.origin !== origin || + link.username || + link.password || + link.search || + link.hash + ) { + throw new Error('Oracle self link does not match the credential-bound origin') + } +} + +/** Requires one canonical same-origin self link for the expected resource path. */ +export function validateOracleFusionSelfLink( + value: unknown, + instanceUrl: string, + address: OracleFusionResourceAddress +): void { + const link = getOnlySelfLink(value) + validateSelfLinkBase(link, instanceUrl) + if (link.pathname !== buildOracleFusionResourcePath(address)) { + throw new Error('Oracle response self link does not match the requested resource path') + } +} + +function validateOpaqueKey(key: string): string { + if ( + !key || + !key.trim() || + key.length > OPAQUE_KEY_MAX_LENGTH || + key === '.' || + key === '..' || + UNSAFE_OPAQUE_KEY.test(key) + ) { + throw new Error('Oracle resource key is not a safe opaque path segment') + } + if (!hasWellFormedUtf16(key)) { + throw new Error('Oracle resource key contains malformed Unicode') + } + return key +} + +/** Derives one opaque key from a canonical same-origin collection self link. */ +export function extractOracleFusionOpaqueKey( + value: unknown, + instanceUrl: string, + collectionAddress: OracleFusionResourceAddress +): string { + const link = getOnlySelfLink(value) + validateSelfLinkBase(link, instanceUrl) + const collectionPath = buildOracleFusionResourcePath(collectionAddress) + const prefix = `${collectionPath}/` + if (!link.pathname.startsWith(prefix)) { + throw new Error('Oracle self link does not match the requested collection path') + } + const encodedKey = link.pathname.slice(prefix.length) + if (!encodedKey || encodedKey.includes('/') || /%(?:2f|5c)/i.test(encodedKey)) { + throw new Error('Oracle self link does not contain one opaque key segment') + } + try { + return validateOpaqueKey(decodeURIComponent(encodedKey)) + } catch (error) { + if (error instanceof URIError) throw new Error('Oracle self link contains invalid URL encoding') + throw error + } +} + +/** Encodes a validated opaque Oracle resource key for one URL path segment. */ +export function encodeOracleFusionPathSegment(key: string): string { + try { + return encodeURIComponent(validateOpaqueKey(key)) + } catch (error) { + if (error instanceof URIError) { + throw new Error('Oracle resource key contains malformed Unicode') + } + throw error + } +} diff --git a/apps/sim/lib/internal/oracle-fusion/request-body.test.ts b/apps/sim/lib/internal/oracle-fusion/request-body.test.ts new file mode 100644 index 00000000000..fa9d48f15fe --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/request-body.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' +import { serializeOracleFusionJsonBody } from '@/lib/internal/oracle-fusion/request-body' + +describe('serializeOracleFusionJsonBody', () => { + it('serializes plain objects, arrays, null-prototype objects, and JSON scalars', () => { + const nullPrototype = Object.create(null) as Record + nullPrototype.value = 'ok' + + expect( + serializeOracleFusionJsonBody({ + string: 'value', + number: 12.5, + boolean: false, + nil: null, + array: [1, 'two'], + nullPrototype, + }) + ).toBe( + '{"string":"value","number":12.5,"boolean":false,"nil":null,"array":[1,"two"],"nullPrototype":{"value":"ok"}}' + ) + }) + + it.each([ + undefined, + () => undefined, + Symbol('value'), + 1n, + Number.NaN, + Number.POSITIVE_INFINITY, + new Date(), + ])('rejects unsupported root values %#', (value) => { + expect(() => serializeOracleFusionJsonBody(value)).toThrow('plain JSON data') + }) + + it('rejects unsupported nested values instead of applying JSON omission rules', () => { + expect(() => serializeOracleFusionJsonBody({ missing: undefined })).toThrow('plain JSON data') + expect(() => serializeOracleFusionJsonBody([undefined])).toThrow('plain JSON data') + expect(() => serializeOracleFusionJsonBody(new Array(1))).toThrow('plain JSON data') + }) + + it('rejects custom serialization, accessors, symbols, and array properties', () => { + expect(() => serializeOracleFusionJsonBody({ toJSON: () => ({}) })).toThrow('plain JSON data') + + const accessor = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => 'secret', + }) + expect(() => serializeOracleFusionJsonBody(accessor)).toThrow('plain JSON data') + + expect(() => serializeOracleFusionJsonBody({ [Symbol('secret')]: 'value' })).toThrow( + 'plain JSON data' + ) + + const array = [1] + Object.defineProperty(array, 'extra', { value: true, enumerable: true }) + expect(() => serializeOracleFusionJsonBody(array)).toThrow('plain JSON data') + + const customArray = [1] + Object.setPrototypeOf(customArray, null) + expect(() => serializeOracleFusionJsonBody(customArray)).toThrow('plain JSON data') + }) + + it('rejects inherited custom serialization before JSON.stringify can invoke it', () => { + const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'toJSON') + Object.defineProperty(Array.prototype, 'toJSON', { + configurable: true, + value: () => ({ replaced: true }), + }) + try { + expect(() => serializeOracleFusionJsonBody([1])).toThrow('plain JSON data') + } finally { + if (previous) Object.defineProperty(Array.prototype, 'toJSON', previous) + else Reflect.deleteProperty(Array.prototype, 'toJSON') + } + }) + + it('serializes the descriptor values captured from a proxy exactly once', () => { + const target = { value: 'first' } + let descriptorReads = 0 + const proxy = new Proxy(target, { + getOwnPropertyDescriptor(current, key) { + descriptorReads += 1 + const descriptor = Reflect.getOwnPropertyDescriptor(current, key) + return descriptor ? { ...descriptor, value: `read-${descriptorReads}` } : undefined + }, + }) + + expect(serializeOracleFusionJsonBody(proxy)).toBe('{"value":"read-1"}') + expect(descriptorReads).toBe(1) + + const arrayProxy = new Proxy([1], { + get(_current, key) { + if (key === 'length') throw new Error('array length getter must not run') + return undefined + }, + }) + expect(serializeOracleFusionJsonBody(arrayProxy)).toBe('[1]') + }) + + it('rejects cycles, excessive nesting, and excessive complexity', () => { + const cycle: unknown[] = [] + cycle.push(cycle) + expect(() => serializeOracleFusionJsonBody(cycle)).toThrow('must not be cyclic') + + let nested: unknown = null + for (let index = 0; index < 101; index += 1) nested = [nested] + expect(() => serializeOracleFusionJsonBody(nested)).toThrow('nesting limit') + + expect(() => serializeOracleFusionJsonBody(new Array(100_001).fill(null))).toThrow( + 'complexity limit' + ) + }) + + it('rejects UTF-8 output beyond the inline materialization limit', () => { + expect(() => + serializeOracleFusionJsonBody('x'.repeat(MAX_INLINE_MATERIALIZATION_BYTES)) + ).toThrow('inline payload limit') + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/request-body.ts b/apps/sim/lib/internal/oracle-fusion/request-body.ts new file mode 100644 index 00000000000..0d3a4f2aef9 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/request-body.ts @@ -0,0 +1,267 @@ +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' + +const MAX_JSON_NESTING_DEPTH = 100 +const MAX_JSON_NODE_COUNT = 100_000 + +class OracleFusionRequestBodyError extends Error {} + +interface JsonBudgetState { + bytes: number + nodes: number + ancestors: WeakSet + fragments: string[] +} + +type JsonBudgetFrame = + | { kind: 'value'; value: unknown; depth: number } + | { + kind: 'array' + owner: unknown[] + values: unknown[] + index: number + depth: number + } + | { + kind: 'object' + owner: Record + entries: [string, unknown][] + index: number + depth: number + } + +/** Serializes a bounded request body containing only plain JSON data. */ +export function serializeOracleFusionJsonBody(body: unknown): string { + try { + const state = serializeJsonBodyWithinLimit(body) + const serialized = state.fragments.join('') + if (Buffer.byteLength(serialized, 'utf8') > MAX_INLINE_MATERIALIZATION_BYTES) { + throwRequestBodyLimitError() + } + return serialized + } catch (error) { + if (error instanceof OracleFusionRequestBodyError) throw error + throwNonPlainJsonError() + } +} + +function serializeJsonBodyWithinLimit(body: unknown): JsonBudgetState { + const state: JsonBudgetState = { + bytes: 0, + nodes: 0, + ancestors: new WeakSet(), + fragments: [], + } + const frames: JsonBudgetFrame[] = [{ kind: 'value', value: body, depth: 0 }] + + while (frames.length > 0) { + const frame = frames.pop() + if (!frame) break + + if (frame.kind === 'array') { + if (frame.index >= frame.values.length) { + appendJsonFragment(state, ']') + state.ancestors.delete(frame.owner) + continue + } + if (frame.index > 0) appendJsonFragment(state, ',') + frames.push({ ...frame, index: frame.index + 1 }) + frames.push({ + kind: 'value', + value: frame.values[frame.index], + depth: frame.depth + 1, + }) + continue + } + + if (frame.kind === 'object') { + if (frame.index >= frame.entries.length) { + appendJsonFragment(state, '}') + state.ancestors.delete(frame.owner) + continue + } + const [key, value] = frame.entries[frame.index] + if (frame.index > 0) appendJsonFragment(state, ',') + appendJsonString(state, key) + appendJsonFragment(state, ':') + frames.push({ ...frame, index: frame.index + 1 }) + frames.push({ kind: 'value', value, depth: frame.depth + 1 }) + continue + } + + admitJsonNode(state) + const { value, depth } = frame + if (depth > MAX_JSON_NESTING_DEPTH) { + throw new OracleFusionRequestBodyError( + 'Oracle Fusion request body exceeds the JSON nesting limit' + ) + } + if (value === null) { + appendJsonFragment(state, 'null') + } else if (typeof value === 'string') { + appendJsonString(state, value) + } else if (typeof value === 'boolean') { + appendJsonFragment(state, value ? 'true' : 'false') + } else if (typeof value === 'number') { + if (!Number.isFinite(value)) throwNonPlainJsonError() + appendJsonFragment(state, JSON.stringify(value)) + } else if (Array.isArray(value)) { + const prototype = Object.getPrototypeOf(value) + if (prototype !== Array.prototype) throwNonPlainJsonError() + if (state.ancestors.has(value)) { + throw new OracleFusionRequestBodyError('Oracle Fusion request body must not be cyclic') + } + const values = captureArrayValues(value, prototype) + if (values.length * 2 + 1 > MAX_INLINE_MATERIALIZATION_BYTES - state.bytes) { + throwRequestBodyLimitError() + } + state.ancestors.add(value) + appendJsonFragment(state, '[') + frames.push({ kind: 'array', owner: value, values, index: 0, depth }) + } else if (isRecordLike(value)) { + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) throwNonPlainJsonError() + if (state.ancestors.has(value)) { + throw new OracleFusionRequestBodyError('Oracle Fusion request body must not be cyclic') + } + const entries = captureObjectEntries(value, prototype) + state.ancestors.add(value) + appendJsonFragment(state, '{') + frames.push({ kind: 'object', owner: value, entries, index: 0, depth }) + } else { + throwNonPlainJsonError() + } + } + + return state +} + +function isRecordLike(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function rejectInheritedJsonSerialization(prototype: object | null): void { + for (let candidate = prototype; candidate; candidate = Object.getPrototypeOf(candidate)) { + if (Object.hasOwn(candidate, 'toJSON')) throwNonPlainJsonError() + } +} + +function captureArrayValues(value: unknown[], prototype: object): unknown[] { + rejectInheritedJsonSerialization(prototype) + const ownKeys = Reflect.ownKeys(value) + if (ownKeys.length > MAX_JSON_NODE_COUNT + 1) throwComplexityLimitError() + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length') + const length = lengthDescriptor?.value + if ( + typeof length !== 'number' || + !Number.isSafeInteger(length) || + length < 0 || + length >= MAX_JSON_NODE_COUNT + ) { + throwComplexityLimitError() + } + const values = new Array(length) + let captured = 0 + + for (const key of ownKeys) { + if (typeof key === 'symbol') throwNonPlainJsonError() + if (key === 'length') continue + const index = Number(key) + if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= length) { + throwNonPlainJsonError() + } + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor || descriptor.get || descriptor.set) throwNonPlainJsonError() + values[index] = descriptor.value + captured += 1 + } + + if (captured !== length) throwNonPlainJsonError() + return values +} + +function captureObjectEntries( + value: Record, + prototype: object | null +): [string, unknown][] { + rejectInheritedJsonSerialization(prototype) + const ownKeys = Reflect.ownKeys(value) + if (ownKeys.length > MAX_JSON_NODE_COUNT) throwComplexityLimitError() + const entries: [string, unknown][] = [] + + for (const key of ownKeys) { + if (typeof key === 'symbol' || key === 'toJSON') throwNonPlainJsonError() + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor || descriptor.get || descriptor.set) throwNonPlainJsonError() + if (descriptor.enumerable) entries.push([key, descriptor.value]) + } + + return entries +} + +function admitJsonNode(state: JsonBudgetState): void { + state.nodes += 1 + if (state.nodes > MAX_JSON_NODE_COUNT) throwComplexityLimitError() +} + +function appendJsonFragment(state: JsonBudgetState, fragment: string): void { + reserveJsonBytes(state, Buffer.byteLength(fragment, 'utf8')) + state.fragments.push(fragment) +} + +function appendJsonString(state: JsonBudgetState, value: string): void { + reserveJsonBytes(state, jsonStringByteLength(value)) + state.fragments.push(JSON.stringify(value)) +} + +function reserveJsonBytes(state: JsonBudgetState, bytes: number): void { + state.bytes += bytes + if (state.bytes > MAX_INLINE_MATERIALIZATION_BYTES) throwRequestBodyLimitError() +} + +function jsonStringByteLength(value: string): number { + let bytes = 2 + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code === 0x22 || code === 0x5c) { + bytes += 2 + } else if (code < 0x20) { + bytes += + code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6 + } else if (code < 0x80) { + bytes += 1 + } else if (code < 0x800) { + bytes += 2 + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4 + index += 1 + } else { + bytes += 6 + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + bytes += 6 + } else { + bytes += 3 + } + } + return bytes +} + +function throwRequestBodyLimitError(): never { + throw new OracleFusionRequestBodyError( + 'Oracle Fusion request body exceeds the inline payload limit' + ) +} + +function throwComplexityLimitError(): never { + throw new OracleFusionRequestBodyError( + 'Oracle Fusion request body exceeds the JSON complexity limit' + ) +} + +function throwNonPlainJsonError(): never { + throw new OracleFusionRequestBodyError( + 'Oracle Fusion request body must contain plain JSON data without accessors or custom serialization' + ) +} diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 337b56aa435..9c0b553d33a 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -7,6 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ coalesceLocally: vi.fn(), + clientCredentialMinter: vi.fn(), + decryptSecret: vi.fn(), getFreshestSlackChain: vi.fn(), getRecentTerminalError: vi.fn(), logger: { @@ -33,6 +35,15 @@ vi.mock('@/lib/concurrency/leader-lock', () => ({ withLeaderLock: mocks.withLeaderLock, })) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: mocks.decryptSecret, +})) + +vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({ + getClientCredentialAccountMinter: () => mocks.clientCredentialMinter, + parseClientCredentialAccountSecretBlob: (decrypted: string) => JSON.parse(decrypted), +})) + vi.mock('@/lib/oauth/instagram', () => ({ isInstagramProvider: vi.fn(() => false), shouldProactivelyRefreshInstagramToken: vi.fn(() => false), @@ -64,7 +75,10 @@ vi.mock('@/lib/oauth/terminal-errors', () => ({ markCredentialDead: vi.fn(), })) -import { resolveCredentialTokenBundle } from '@/lib/oauth/credential-service' +import { + resolveCredentialTokenBundle, + resolveServiceAccountToken, +} from '@/lib/oauth/credential-service' const RAW_CREDENTIAL_ID = 'credential-raw-secret-id' const RAW_ACCOUNT_ID = 'account-raw-secret-id' @@ -200,3 +214,51 @@ describe('resolveCredentialTokenBundle selector privacy', () => { expect(slack.logs).toContain(RAW_PROVIDER_ERROR) }) }) + +describe('resolveServiceAccountToken Oracle Fusion cache', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.coalesceLocally.mockImplementation( + async (_key: string, producer: () => Promise) => producer() + ) + mocks.decryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: JSON.stringify({ + type: 'client_credential_account', + providerId: 'oracle-fusion-service-account', + clientId: 'integration-user', + clientSecret: encrypted, + orgId: 'https://vision.fa.us2.oraclecloud.com', + }), + })) + mocks.clientCredentialMinter.mockImplementation(async (fields: { clientSecret: string }) => ({ + accessToken: `basic-${fields.clientSecret}`, + expiresInSeconds: 300, + instanceUrl: 'https://vision.fa.us2.oraclecloud.com', + })) + }) + + it('reuses Basic material for five minutes and invalidates it on encrypted-secret rotation', async () => { + const credentialId = 'oracle-fusion-cache-test' + const providerId = 'oracle-fusion-service-account' + const encryptedV1 = 'encrypted-v1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + const encryptedV2 = 'encrypted-v2-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + + queueTableRows(credential, [{ encryptedServiceAccountKey: encryptedV1 }]) + await expect(resolveServiceAccountToken(credentialId, providerId)).resolves.toMatchObject({ + accessToken: `basic-${encryptedV1}`, + }) + + queueTableRows(credential, [{ encryptedServiceAccountKey: encryptedV1 }]) + await expect(resolveServiceAccountToken(credentialId, providerId)).resolves.toMatchObject({ + accessToken: `basic-${encryptedV1}`, + }) + expect(mocks.clientCredentialMinter).toHaveBeenCalledTimes(1) + + queueTableRows(credential, [{ encryptedServiceAccountKey: encryptedV2 }]) + await expect(resolveServiceAccountToken(credentialId, providerId)).resolves.toMatchObject({ + accessToken: `basic-${encryptedV2}`, + }) + expect(mocks.clientCredentialMinter).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 0962cf8cb56..5fb7b08744f 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -8,7 +8,10 @@ import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { env } from '@/lib/core/config/env' import { decryptSecret } from '@/lib/core/security/encryption' -import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' +import { + isClientCredentialAccountProviderId, + ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, +} from '@/lib/credentials/client-credential-accounts/descriptors' import { getClientCredentialAccountMinter, parseClientCredentialAccountSecretBlob, @@ -466,11 +469,13 @@ interface FailedClientCredentialMint { /** * Per-instance cache of minted client-credential access tokens (Zoom S2S, - * Box CCG, Salesforce, NetSuite), keyed by credential id. Entries are + * Box CCG, Salesforce, NetSuite, Oracle Fusion), keyed by credential id. Entries are * served while more than {@link CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS} of * validity remains, so a hot credential mints roughly once per token TTL * (~1h for Zoom/Box/NetSuite; Salesforce reports a conservative 10-minute TTL - * because its responses never carry an expiry) per instance. + * because its responses never carry an expiry) per instance. Oracle Fusion's + * locally derived, non-expiring Basic value instead uses its complete + * five-minute synthetic lifetime. * * Every resolution re-reads the credential row (a cheap indexed PK select — * the mint is the expensive part) and validates the cached entry's secret @@ -550,7 +555,10 @@ async function resolveClientCredentialAccountToken( if ( cached && cached.secretFingerprint === secretFingerprint && - cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS + cached.expiresAtMs - Date.now() > + (providerId === ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID + ? 0 + : CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS) ) { return { accessToken: cached.accessToken, diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index e06ee3c9c8d..103dd066301 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -6,8 +6,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAuthorizeCredentialUseForAuth, mockCaptureServerEvent, + mockCredentialProviderMatchesService, mockExecuteManagedToken, mockGetCredential, + mockGetServiceConfigByProviderId, + mockGetServiceConfigByServiceId, mockGetToolMetadata, mockRecordAudit, mockRefreshTokenIfNeeded, @@ -16,8 +19,11 @@ const { } = vi.hoisted(() => ({ mockAuthorizeCredentialUseForAuth: vi.fn(), mockCaptureServerEvent: vi.fn(), + mockCredentialProviderMatchesService: vi.fn(), mockExecuteManagedToken: vi.fn(), mockGetCredential: vi.fn(), + mockGetServiceConfigByProviderId: vi.fn(), + mockGetServiceConfigByServiceId: vi.fn(), mockGetToolMetadata: vi.fn(), mockRecordAudit: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), @@ -78,7 +84,10 @@ vi.mock('@/tools/metadata', () => ({ })) vi.mock('@/lib/oauth/utils', () => ({ + credentialProviderMatchesService: mockCredentialProviderMatchesService, getCanonicalScopesForProvider: vi.fn().mockReturnValue([]), + getServiceConfigByProviderId: mockGetServiceConfigByProviderId, + getServiceConfigByServiceId: mockGetServiceConfigByServiceId, })) import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -346,6 +355,12 @@ describe('resolveCredentialAccessToken', () => { beforeEach(() => { vi.clearAllMocks() mockResolveOAuthAccountId.mockResolvedValue(null) + mockCredentialProviderMatchesService.mockReturnValue(true) + mockGetServiceConfigByServiceId.mockReturnValue({ + providerId: 'google', + serviceAccountProviderId: 'google-service-account', + }) + mockGetServiceConfigByProviderId.mockReturnValue(null) authenticate.mockResolvedValue(INTERNAL_AUTH) resolveManagedPrincipal.mockResolvedValue(EXECUTOR_PRINCIPAL) mockGetToolMetadata.mockReturnValue({ @@ -411,6 +426,160 @@ describe('resolveCredentialAccessToken', () => { }) }) + it('rejects a service-account credential with no provider before authentication', async () => { + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'service-account-1', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'google', + credentialKind: 'service-account', + }, + }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'service-account-1', + toolId: 'google_service_account_tool', + authenticate, + }) + ).resolves.toEqual({ + ok: false, + status: 403, + code: 'CREDENTIAL_PROVIDER_MISMATCH', + error: 'Credential belongs to another service', + }) + expect(authenticate).not.toHaveBeenCalled() + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + + it('rejects a service-account credential from another provider before authentication', async () => { + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'service-account-1', + providerId: 'atlassian-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'google', + credentialKind: 'service-account', + }, + }) + mockCredentialProviderMatchesService.mockReturnValue(false) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'service-account-1', + toolId: 'google_service_account_tool', + authenticate, + }) + ).resolves.toEqual({ + ok: false, + status: 403, + code: 'CREDENTIAL_PROVIDER_MISMATCH', + error: 'Credential belongs to another service', + }) + expect(authenticate).not.toHaveBeenCalled() + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + + it('accepts a non-Oracle service account whose provider matches the tool service', async () => { + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'service-account-1', + providerId: 'google-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'google-email', + requiredScopes: ['scope-a'], + credentialKind: 'service-account', + }, + }) + mockGetServiceConfigByServiceId.mockReturnValue(null) + mockGetServiceConfigByProviderId.mockReturnValue({ + providerId: 'google-email', + serviceAccountProviderId: 'google-service-account', + }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + workspaceId: 'ws-1', + }) + mockResolveServiceAccountToken.mockResolvedValue({ accessToken: 'service-account-token' }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'service-account-1', + toolId: 'gmail_read', + scopes: ['scope-a'], + authenticate, + }) + ).resolves.toMatchObject({ + ok: true, + token: { accessToken: 'service-account-token', credentialType: 'service_account' }, + }) + expect(mockGetServiceConfigByProviderId).toHaveBeenCalledWith('google-email') + expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( + 'service-account-1', + 'google-service-account', + ['scope-a'], + undefined + ) + }) + + it.each([ + ['an OAuth credential for a service-account-only tool', undefined, 'service-account'], + ['a service account for an OAuth-only tool', 'service_account', 'oauth'], + ])('rejects %s before authentication', async (_label, credentialType, requiredKind) => { + mockResolveOAuthAccountId.mockResolvedValue({ + ...(credentialType ? { credentialType } : {}), + credentialId: 'credential-1', + providerId: credentialType ? 'google-service-account' : undefined, + workspaceId: 'ws-1', + accountId: credentialType ? '' : 'account-1', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'google', + credentialKind: requiredKind, + }, + }) + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'credential-1', + toolId: 'kind_restricted_tool', + authenticate, + }) + + expect(result).toEqual({ + ok: false, + status: 403, + code: 'CREDENTIAL_PROVIDER_MISMATCH', + error: 'Credential belongs to another service', + }) + expect(authenticate).not.toHaveBeenCalled() + }) + it('rejects a managed credential when no delegation resolver is wired', async () => { mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index dfd1f682b5b..64bea2c24f0 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -24,7 +24,12 @@ import { MICROSOFT_DATAVERSE_PROVIDER_ID, } from '@/lib/oauth/microsoft-dataverse' import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce' -import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { + credentialProviderMatchesService, + getCanonicalScopesForProvider, + getServiceConfigByProviderId, + getServiceConfigByServiceId, +} from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' import { getToolMetadata } from '@/tools/metadata' import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' @@ -332,6 +337,46 @@ export interface ResolveCredentialAccessTokenInput resolveManagedPrincipal?: (credentialId: string) => Promise } +function credentialProviderMismatch(): ResolveCredentialTokenResult { + return { + ok: false, + status: 403, + code: 'CREDENTIAL_PROVIDER_MISMATCH', + error: 'Credential belongs to another service', + } +} + +function validateToolCredentialBinding( + resolved: ResolvedCredential | null, + toolId: string | undefined, + toolMetadata: ReturnType +): ResolveCredentialTokenResult | null { + if (!resolved || !toolId) return null + + const oauth = toolMetadata?.oauth + const isServiceAccount = resolved.credentialType === 'service_account' + if ( + oauth?.credentialKind === 'service-account' + ? !isServiceAccount + : oauth?.credentialKind === 'oauth' && isServiceAccount + ) { + return credentialProviderMismatch() + } + if (!isServiceAccount) return null + + const service = oauth?.required + ? (getServiceConfigByServiceId(oauth.provider) ?? getServiceConfigByProviderId(oauth.provider)) + : null + if ( + !resolved.providerId || + !service || + !credentialProviderMatchesService(resolved.providerId, service) + ) { + return credentialProviderMismatch() + } + return null +} + /** * Authorized application dispatch behind `POST /api/auth/oauth/token`. Every server * surface that needs a credential token — the route and the in-process tool @@ -343,7 +388,10 @@ export async function resolveCredentialAccessToken( ): Promise { const { requestId, credentialId, toolId, auditRequest } = input + const toolMetadata = toolId ? getToolMetadata(toolId) : undefined const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null + const bindingError = validateToolCredentialBinding(resolved, toolId, toolMetadata) + if (bindingError) return bindingError if (resolved?.credentialType !== 'managed_oauth' || !resolved.credentialId) { const auth = await input.authenticate() @@ -395,7 +443,6 @@ export async function resolveCredentialAccessToken( } } - const toolMetadata = getToolMetadata(toolId) if (!toolMetadata?.oauth?.required) { logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId }) return { diff --git a/apps/sim/lib/selectors/server/credentials.test.ts b/apps/sim/lib/selectors/server/credentials.test.ts index 17138d2079a..518d53f3b41 100644 --- a/apps/sim/lib/selectors/server/credentials.test.ts +++ b/apps/sim/lib/selectors/server/credentials.test.ts @@ -125,6 +125,83 @@ describe('authorizeSelectorCredential', () => { expect(mocks.authorizeCredentialUse).not.toHaveBeenCalled() }) + it('rejects a fixed token for a service-account-only selector', async () => { + await expect( + authorizeSelectorCredential({ + principal, + context: { oauthCredential: 'xoxb-a' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + policy: { + kind: 'stored-or-fixed-token', + field: 'oauthCredential', + serviceIds: ['slack'], + tokenPrefixes: ['xoxb-'], + credentialKind: 'service-account', + }, + protectedValues: createSelectorProtectedValues(), + references: new Map(), + }) + ).rejects.toEqual(new SelectorConnectionUnavailableError()) + expect(mocks.authorizeCredentialUse).not.toHaveBeenCalled() + }) + + it.each([ + ['oauth', 'service-account'], + ['service_account', 'oauth'], + ] as const)( + 'rejects a %s credential for a %s-only selector before provider resolution', + async (credentialType, credentialKind) => { + mocks.authorizeCredentialUse.mockResolvedValue({ + ok: true, + workspaceId: 'workspace-1', + credentialOwnerUserId: 'owner-1', + resolvedCredentialId: 'credential-1', + credentialType, + }) + + await expect( + authorizeSelectorCredential({ + principal, + context: { oauthCredential: 'credential-1' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + policy: { ...policy, credentialKind }, + protectedValues: createSelectorProtectedValues(), + references: new Map(), + }) + ).rejects.toEqual(new SelectorConnectionUnavailableError()) + expect(mocks.credentialProviderMatchesService).not.toHaveBeenCalled() + } + ) + + it.each([ + ['oauth', 'oauth'], + ['service_account', 'service-account'], + ] as const)('accepts a matching %s credential kind', async (credentialType, credentialKind) => { + mocks.authorizeCredentialUse.mockResolvedValue({ + ok: true, + workspaceId: 'workspace-1', + credentialOwnerUserId: 'owner-1', + resolvedCredentialId: 'credential-1', + credentialType, + }) + queueTableRows(credential, [{ accountId: 'account-1', providerId: 'google' }]) + mocks.credentialProviderMatchesService.mockReturnValue(true) + + await expect( + authorizeSelectorCredential({ + principal, + context: { oauthCredential: 'credential-1' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + policy: { ...policy, credentialKind }, + protectedValues: createSelectorProtectedValues(), + references: new Map(), + }) + ).resolves.toMatchObject({ access: { credentialType } }) + }) + it('conceals a stored credential whose trusted provider does not match the selector service', async () => { mocks.authorizeCredentialUse.mockResolvedValue({ ok: true, diff --git a/apps/sim/lib/selectors/server/credentials.ts b/apps/sim/lib/selectors/server/credentials.ts index 2bc68c62275..5352aa21126 100644 --- a/apps/sim/lib/selectors/server/credentials.ts +++ b/apps/sim/lib/selectors/server/credentials.ts @@ -111,6 +111,9 @@ export async function authorizeSelectorCredential(input: { input.policy.kind === 'stored-or-fixed-token' && input.policy.tokenPrefixes.some((prefix) => suppliedId.startsWith(prefix)) ) { + if (input.policy.credentialKind === 'service-account') { + throw new SelectorConnectionUnavailableError() + } const reference = input.references.get(input.policy.field) if (reference && !reference.visible) { input.protectedValues.add(suppliedId, 'secret') @@ -133,6 +136,13 @@ export async function authorizeSelectorCredential(input: { if (!access.ok || access.workspaceId !== input.workspaceId) { throw new SelectorConnectionUnavailableError() } + if ( + input.policy.credentialKind === 'service-account' + ? access.credentialType !== 'service_account' + : input.policy.credentialKind === 'oauth' && access.credentialType === 'service_account' + ) { + throw new SelectorConnectionUnavailableError() + } input.protectedValues.add(access.resolvedCredentialId, 'reference') const providerId = await requireCredentialProviderBinding( diff --git a/apps/sim/lib/selectors/server/types.ts b/apps/sim/lib/selectors/server/types.ts index 4249782a7e9..5dc6283b4d3 100644 --- a/apps/sim/lib/selectors/server/types.ts +++ b/apps/sim/lib/selectors/server/types.ts @@ -34,6 +34,7 @@ export type SelectorCredentialPolicy = field: 'oauthCredential' serviceIds: readonly string[] resourceServiceId?: string + credentialKind?: 'oauth' | 'service-account' } | { kind: 'stored-or-fixed-token' @@ -41,6 +42,7 @@ export type SelectorCredentialPolicy = serviceIds: readonly string[] tokenPrefixes: readonly string[] resourceServiceId?: string + credentialKind?: 'oauth' | 'service-account' } export interface AuthorizedSelectorCredential {