Skip to content

Commit abb64b8

Browse files
feat(oracle-fusion): add shared integration foundation
1 parent 48d6ef5 commit abb64b8

18 files changed

Lines changed: 1235 additions & 7 deletions

apps/docs/components/icons.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9287,6 +9287,9 @@ export function NetSuiteIcon(props: SVGProps<SVGSVGElement>) {
92879287
)
92889288
}
92899289

9290+
/** Oracle's red oval, shared by Oracle product integrations. */
9291+
export const OracleIcon = NetSuiteIcon
9292+
92909293
export function WizaIcon(props: SVGProps<SVGSVGElement>) {
92919294
return (
92929295
<svg {...props} viewBox='0 0 51 49' fill='none' xmlns='http://www.w3.org/2000/svg'>

apps/sim/components/icons.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9287,6 +9287,9 @@ export function NetSuiteIcon(props: SVGProps<SVGSVGElement>) {
92879287
)
92889288
}
92899289

9290+
/** Oracle's red oval, shared by Oracle product integrations. */
9291+
export const OracleIcon = NetSuiteIcon
9292+
92909293
export function WizaIcon(props: SVGProps<SVGSVGElement>) {
92919294
return (
92929295
<svg {...props} viewBox='0 0 51 49' fill='none' xmlns='http://www.w3.org/2000/svg'>

apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
getClientCredentialAccountDescriptor,
88
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
99
normalizeNetSuiteSuiteTalkOrigin,
10+
normalizeOracleFusionApplicationOrigin,
11+
ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID,
1012
partitionClientCredentialFields,
1113
resolveClientCredentialAuthMethod,
1214
resolveSalesforceAuthMethod,
@@ -19,6 +21,9 @@ const salesforce = getClientCredentialAccountDescriptor(SALESFORCE_SERVICE_ACCOU
1921
const box = getClientCredentialAccountDescriptor(BOX_SERVICE_ACCOUNT_PROVIDER_ID)!
2022
const zohoDesk = getClientCredentialAccountDescriptor(ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID)!
2123
const netSuite = getClientCredentialAccountDescriptor(NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID)!
24+
const oracleFusion = getClientCredentialAccountDescriptor(
25+
ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID
26+
)!
2227

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

@@ -51,6 +56,17 @@ describe('partitionClientCredentialFields', () => {
5156
multiline: true,
5257
})
5358
})
59+
60+
it('reuses the existing fields for an Oracle Fusion integration user', () => {
61+
const { visible, required } = partitionClientCredentialFields(oracleFusion, undefined)
62+
expect(ids(visible)).toEqual(['orgId', 'clientId', 'clientSecret'])
63+
expect(ids(required)).toEqual(['orgId', 'clientId', 'clientSecret'])
64+
expect(oracleFusion.fields).toEqual([
65+
expect.objectContaining({ id: 'orgId', label: 'Fusion Applications URL', secret: false }),
66+
expect.objectContaining({ id: 'clientId', label: 'Integration username', secret: false }),
67+
expect.objectContaining({ id: 'clientSecret', label: 'Password', secret: true }),
68+
])
69+
})
5470
})
5571

5672
describe('Salesforce, which offers two grants', () => {
@@ -109,6 +125,38 @@ describe('normalizeNetSuiteSuiteTalkOrigin', () => {
109125
})
110126
})
111127

128+
describe('normalizeOracleFusionApplicationOrigin', () => {
129+
it.each([
130+
[' https://VISION.fa.us2.oraclecloud.com/ ', 'https://vision.fa.us2.oraclecloud.com'],
131+
['https://acme-prod.fa.ocs.oraclecloud.com', 'https://acme-prod.fa.ocs.oraclecloud.com'],
132+
[
133+
'https://pod.fa.eu-frankfurt-1.oraclecloud.com',
134+
'https://pod.fa.eu-frankfurt-1.oraclecloud.com',
135+
],
136+
])('normalizes the supported application origin %j', (value, expected) => {
137+
expect(normalizeOracleFusionApplicationOrigin(value)).toBe(expected)
138+
})
139+
140+
it.each([
141+
'http://vision.fa.us2.oraclecloud.com',
142+
'https://vision.fa.us2.oraclecloud.com/path',
143+
'https://vision.fa.us2.oraclecloud.com:443',
144+
'https://vision.fa.us2.oraclecloud.com:8443',
145+
'https://user@vision.fa.us2.oraclecloud.com',
146+
'https://user:password@vision.fa.us2.oraclecloud.com',
147+
'https://vision.fa.us2.oraclecloud.com?tenant=other',
148+
'https://vision.fa.us2.oraclecloud.com#fragment',
149+
'https://vision.fa.us2.oraclecloud.com.evil.example',
150+
'https://vision.fa.us2.oraclecloud.co',
151+
'https://fusion.example.com',
152+
'https://fa.us2.oraclecloud.com',
153+
'https://-vision.fa.us2.oraclecloud.com',
154+
'https://vision.fa.-us2.oraclecloud.com',
155+
])('rejects the noncanonical Fusion Applications URL %j', (value) => {
156+
expect(normalizeOracleFusionApplicationOrigin(value)).toBeUndefined()
157+
})
158+
})
159+
112160
describe('resolveClientCredentialAuthMethod', () => {
113161
it('returns undefined for a provider that declares no method selector', () => {
114162
expect(resolveClientCredentialAuthMethod(box, 'jwt_bearer')).toBeUndefined()

apps/sim/lib/credentials/client-credential-accounts/descriptors.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,15 @@ export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const
111111
export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const
112112
export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const
113113
export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const
114+
export const ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-fusion-service-account' as const
114115

115116
export type ClientCredentialAccountProviderId =
116117
| typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID
117118
| typeof BOX_SERVICE_ACCOUNT_PROVIDER_ID
118119
| typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID
119120
| typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID
120121
| typeof NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID
122+
| typeof ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID
121123

122124
/**
123125
* Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and
@@ -154,6 +156,39 @@ export function normalizeNetSuiteSuiteTalkOrigin(rawUrl: string): string | undef
154156
}
155157
}
156158

159+
/** Canonical Oracle-assigned Fusion Applications origin used by product REST APIs. */
160+
export const ORACLE_FUSION_APPLICATION_ORIGIN_REGEX =
161+
/^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$/
162+
163+
/**
164+
* Normalizes a Fusion Applications URL to its authoritative HTTPS origin.
165+
* Explicit ports are rejected even when they match HTTPS's default port so a
166+
* saved credential can never silently broaden the accepted endpoint shape.
167+
*/
168+
export function normalizeOracleFusionApplicationOrigin(rawUrl: string): string | undefined {
169+
try {
170+
const trimmed = rawUrl.trim()
171+
const authority = /^https:\/\/([^/?#]+)/i.exec(trimmed)?.[1]
172+
if (!authority || authority.includes(':')) return undefined
173+
const parsed = new URL(trimmed)
174+
if (
175+
parsed.protocol !== 'https:' ||
176+
parsed.port ||
177+
parsed.username ||
178+
parsed.password ||
179+
parsed.search ||
180+
parsed.hash ||
181+
(parsed.pathname !== '' && parsed.pathname !== '/') ||
182+
!ORACLE_FUSION_APPLICATION_ORIGIN_REGEX.test(parsed.origin)
183+
) {
184+
return undefined
185+
}
186+
return parsed.origin
187+
} catch {
188+
return undefined
189+
}
190+
}
191+
157192
/**
158193
* Allowed My Domain host shapes: one org label (optionally with a
159194
* `--sandboxName` suffix), an optional partition label (sandbox, develop,
@@ -531,6 +566,39 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
531566
helpText:
532567
'Use the account-specific SuiteTalk URL and the client ID, certificate ID, and private key from one OAuth 2.0 client-credentials mapping.',
533568
},
569+
[ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID]: {
570+
providerId: ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID,
571+
serviceLabel: 'Oracle Fusion',
572+
connectNoun: 'integration user',
573+
fields: [
574+
{
575+
id: 'orgId',
576+
label: 'Fusion Applications URL',
577+
placeholder: 'https://your-environment.fa.ocs.oraclecloud.com',
578+
secret: false,
579+
hintPattern: ORACLE_FUSION_APPLICATION_ORIGIN_REGEX,
580+
hintNormalize: (value) =>
581+
normalizeOracleFusionApplicationOrigin(value) ?? value.trim().toLowerCase(),
582+
hintMessage:
583+
'Expected the Oracle-assigned HTTPS application URL with no path, port, credentials, query, or fragment.',
584+
},
585+
{
586+
id: 'clientId',
587+
label: 'Integration username',
588+
placeholder: 'Paste the integration username',
589+
secret: false,
590+
},
591+
{
592+
id: 'clientSecret',
593+
label: 'Password',
594+
placeholder: 'Paste the password',
595+
secret: true,
596+
},
597+
],
598+
docsUrl: 'https://docs.oracle.com/en/cloud/saas/applications-common/26b/farca/Quick_Start.html',
599+
helpText:
600+
'The application URL is validated when saved. Oracle authenticates the integration user on the first product request.',
601+
},
534602
}
535603

536604
/**
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion'
6+
7+
const FIELDS = {
8+
orgId: 'https://vision.fa.us2.oraclecloud.com',
9+
clientId: 'integration-user',
10+
clientSecret: 'password-with-symbols-!@#',
11+
}
12+
13+
describe('mintOracleFusionServiceAccountToken', () => {
14+
it('derives an opaque Basic credential locally with a five-minute lifetime', async () => {
15+
const fetchSpy = vi.spyOn(globalThis, 'fetch')
16+
17+
await expect(mintOracleFusionServiceAccountToken(FIELDS)).resolves.toEqual({
18+
instanceUrl: FIELDS.orgId,
19+
accessToken: Buffer.from(`${FIELDS.clientId}:${FIELDS.clientSecret}`, 'utf8').toString(
20+
'base64'
21+
),
22+
expiresInSeconds: 300,
23+
identity: {
24+
displayName: 'Oracle Fusion vision',
25+
principal: null,
26+
auditMetadata: { oracleFusionApplicationOrigin: FIELDS.orgId },
27+
storedMetadata: { applicationOrigin: FIELDS.orgId },
28+
},
29+
})
30+
expect(fetchSpy).not.toHaveBeenCalled()
31+
fetchSpy.mockRestore()
32+
})
33+
34+
it('normalizes the origin and omits connect-time identity during resolution', async () => {
35+
await expect(
36+
mintOracleFusionServiceAccountToken(
37+
{ ...FIELDS, orgId: ' HTTPS://VISION.FA.OCS.ORACLECLOUD.COM/ ' },
38+
{ skipIdentity: true }
39+
)
40+
).resolves.toEqual({
41+
instanceUrl: 'https://vision.fa.ocs.oraclecloud.com',
42+
accessToken: Buffer.from(`${FIELDS.clientId}:${FIELDS.clientSecret}`, 'utf8').toString(
43+
'base64'
44+
),
45+
expiresInSeconds: 300,
46+
})
47+
})
48+
49+
it.each([
50+
'http://vision.fa.us2.oraclecloud.com',
51+
'https://vision.fa.us2.oraclecloud.com/path',
52+
'https://vision.fa.us2.oraclecloud.com:443',
53+
'https://user:password@vision.fa.us2.oraclecloud.com',
54+
'https://vision.fa.us2.oraclecloud.com?tenant=other',
55+
'https://vision.fa.us2.oraclecloud.com#fragment',
56+
'https://vision.fa.us2.oraclecloud.com.evil.example',
57+
'https://vanity.example.com',
58+
])('rejects the unsafe application URL %j without a network probe', async (orgId) => {
59+
const fetchSpy = vi.spyOn(globalThis, 'fetch')
60+
await expect(mintOracleFusionServiceAccountToken({ ...FIELDS, orgId })).rejects.toMatchObject({
61+
code: 'site_not_found',
62+
status: 400,
63+
})
64+
expect(fetchSpy).not.toHaveBeenCalled()
65+
fetchSpy.mockRestore()
66+
})
67+
68+
it.each([
69+
['', FIELDS.clientSecret],
70+
['user:name', FIELDS.clientSecret],
71+
['user\nname', FIELDS.clientSecret],
72+
['u'.repeat(256), FIELDS.clientSecret],
73+
[FIELDS.clientId, ''],
74+
[FIELDS.clientId, 'password\n'],
75+
[FIELDS.clientId, 'p'.repeat(1025)],
76+
])(
77+
'rejects malformed local credentials without exposing them',
78+
async (clientId, clientSecret) => {
79+
const error = await mintOracleFusionServiceAccountToken({
80+
...FIELDS,
81+
clientId,
82+
clientSecret,
83+
}).catch((caught: unknown) => caught)
84+
expect(error).toMatchObject({ code: 'invalid_credentials', status: 400 })
85+
const serialized = JSON.stringify(error)
86+
if (clientId) expect(serialized).not.toContain(clientId)
87+
if (clientSecret) expect(serialized).not.toContain(clientSecret)
88+
const encoded = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')
89+
if (encoded) expect(serialized).not.toContain(encoded)
90+
}
91+
)
92+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors'
2+
import type {
3+
ClientCredentialAccountFields,
4+
ClientCredentialAccountMintOptions,
5+
ClientCredentialAccountMintResult,
6+
} from '@/lib/credentials/client-credential-accounts/server'
7+
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
8+
9+
const BASIC_CREDENTIAL_CACHE_TTL_SECONDS = 5 * 60
10+
const ORACLE_FUSION_CREDENTIAL_STEP = 'oracle_fusion_credential_validation'
11+
const USERNAME_MAX_LENGTH = 255
12+
const PASSWORD_MAX_LENGTH = 1024
13+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/
14+
15+
function invalidCredential(reason: string): TokenServiceAccountValidationError {
16+
return new TokenServiceAccountValidationError('invalid_credentials', 400, {
17+
step: ORACLE_FUSION_CREDENTIAL_STEP,
18+
reason,
19+
})
20+
}
21+
22+
/**
23+
* Resolves locally validated Oracle Basic credentials through the shared
24+
* client-credential minter contract. Oracle does not expose a documented,
25+
* privilege-neutral identity probe, so authentication occurs on first use.
26+
*/
27+
export async function mintOracleFusionServiceAccountToken(
28+
fields: ClientCredentialAccountFields,
29+
options?: ClientCredentialAccountMintOptions
30+
): Promise<ClientCredentialAccountMintResult> {
31+
const instanceUrl = normalizeOracleFusionApplicationOrigin(fields.orgId)
32+
if (!instanceUrl) {
33+
throw new TokenServiceAccountValidationError('site_not_found', 400, {
34+
step: ORACLE_FUSION_CREDENTIAL_STEP,
35+
reason: 'Fusion Applications URL must be a canonical Oracle-assigned HTTPS origin',
36+
})
37+
}
38+
39+
const username = fields.clientId.trim()
40+
const password = fields.clientSecret
41+
if (!username || username.length > USERNAME_MAX_LENGTH || CONTROL_CHARACTER.test(username)) {
42+
throw invalidCredential('integration username is invalid')
43+
}
44+
if (username.includes(':')) {
45+
throw invalidCredential('integration username must not contain a colon')
46+
}
47+
if (!password || password.length > PASSWORD_MAX_LENGTH || CONTROL_CHARACTER.test(password)) {
48+
throw invalidCredential('password is invalid')
49+
}
50+
51+
const accessToken = Buffer.from(`${username}:${password}`, 'utf8').toString('base64')
52+
const tenant = new URL(instanceUrl).hostname.split('.')[0]
53+
return {
54+
instanceUrl,
55+
accessToken,
56+
expiresInSeconds: BASIC_CREDENTIAL_CACHE_TTL_SECONDS,
57+
...(!options?.skipIdentity
58+
? {
59+
identity: {
60+
displayName: `Oracle Fusion ${tenant}`,
61+
principal: null,
62+
auditMetadata: { oracleFusionApplicationOrigin: instanceUrl },
63+
storedMetadata: { applicationOrigin: instanceUrl },
64+
},
65+
}
66+
: {}),
67+
}
68+
}

apps/sim/lib/credentials/client-credential-accounts/server.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,31 @@ describe('parseClientCredentialAccountSecretBlob', () => {
117117
)
118118
).toThrow(MALFORMED)
119119
})
120+
121+
it('requires the three reused fields for an Oracle Fusion credential blob', () => {
122+
const oracleBlob = blob({
123+
providerId: 'oracle-fusion-service-account',
124+
orgId: 'https://vision.fa.us2.oraclecloud.com',
125+
clientId: 'integration-user',
126+
clientSecret: 'password',
127+
})
128+
expect(
129+
parseClientCredentialAccountSecretBlob(oracleBlob, 'oracle-fusion-service-account')
130+
).toMatchObject({
131+
orgId: 'https://vision.fa.us2.oraclecloud.com',
132+
clientId: 'integration-user',
133+
clientSecret: 'password',
134+
})
135+
136+
expect(() =>
137+
parseClientCredentialAccountSecretBlob(
138+
blob({
139+
providerId: 'oracle-fusion-service-account',
140+
orgId: 'https://vision.fa.us2.oraclecloud.com',
141+
clientSecret: undefined,
142+
}),
143+
'oracle-fusion-service-account'
144+
)
145+
).toThrow(MALFORMED)
146+
})
120147
})

0 commit comments

Comments
 (0)