Skip to content

Commit 830a5ad

Browse files
Bill Leoutsakoswaleedlatif1
authored andcommitted
feat(oracle-fusion): add validated integration foundation
1 parent 684b228 commit 830a5ad

24 files changed

Lines changed: 2879 additions & 11 deletions

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ function ClientCredentialAccountModalForm({
189189
// stored on a credential whose grant never reads it.
190190
const secretFields: FieldValues = {}
191191
for (const field of visibleFields) {
192-
const value = values[field.id]?.trim()
192+
const value = field.preserveWhitespace ? values[field.id] : values[field.id]?.trim()
193193
if (value) secretFields[field.id] = value
194194
}
195195
if (credentialId) {

‎apps/sim/lib/api/contracts/credentials.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export const createCredentialFieldsSchema = z.object({
156156
signingSecret: z.string().trim().min(1).optional(),
157157
botToken: z.string().trim().min(1).optional(),
158158
clientId: z.string().trim().min(1).max(512).optional(),
159-
clientSecret: z.string().trim().min(1).max(1024).optional(),
159+
clientSecret: z.string().min(1).max(1024).optional(),
160160
certificateId: z.string().trim().min(1).max(512).optional(),
161161
orgId: z.string().trim().min(1).max(255).optional(),
162162
/** Optional provider region selector (Zoho Desk data center). */
@@ -276,7 +276,7 @@ export const updateCredentialByIdBodySchema = z
276276
atlassianProduct: atlassianProductSchema.optional(),
277277
/** Client-credential service-account secret rotation (reconnect). */
278278
clientId: z.string().trim().min(1).max(512).optional(),
279-
clientSecret: z.string().trim().min(1).max(1024).optional(),
279+
clientSecret: z.string().min(1).max(1024).optional(),
280280
certificateId: z.string().trim().min(1).max(512).optional(),
281281
orgId: z.string().trim().min(1).max(255).optional(),
282282
dataCenter: z.string().trim().min(1).max(32).optional(),

‎apps/sim/lib/api/contracts/v2/credentials.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,6 @@ const v2ServiceAccountCredentialFieldsSchema = z
444444
clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'),
445445
clientSecret: z
446446
.string()
447-
.trim()
448447
.min(1)
449448
.max(1024)
450449
.optional()
@@ -693,7 +692,6 @@ const v2ServiceAccountSecretFieldsShape = {
693692
clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'),
694693
clientSecret: z
695694
.string()
696-
.trim()
697695
.min(1)
698696
.max(1024)
699697
.optional()

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
BOX_SERVICE_ACCOUNT_PROVIDER_ID,
44
getClientCredentialAccountDescriptor,
55
normalizeNetSuiteSuiteTalkOrigin,
6+
normalizeOracleFusionApplicationOrigin,
67
partitionClientCredentialFields,
78
resolveClientCredentialAuthMethod,
89
resolveSalesforceAuthMethod,
@@ -70,6 +71,41 @@ describe('normalizeNetSuiteSuiteTalkOrigin', () => {
7071
})
7172
})
7273

74+
describe('normalizeOracleFusionApplicationOrigin', () => {
75+
it.each([
76+
[' https://VISION.fa.us2.oraclecloud.com/ ', 'https://vision.fa.us2.oraclecloud.com'],
77+
['https://acme-prod.fa.ocs.oraclecloud.com', 'https://acme-prod.fa.ocs.oraclecloud.com'],
78+
[
79+
'https://pod.fa.eu-frankfurt-1.oraclecloud.com',
80+
'https://pod.fa.eu-frankfurt-1.oraclecloud.com',
81+
],
82+
])('normalizes the supported application origin %j', (value, expected) => {
83+
expect(normalizeOracleFusionApplicationOrigin(value)).toBe(expected)
84+
})
85+
86+
it.each([
87+
'http://vision.fa.us2.oraclecloud.com',
88+
'https://vision.fa.us2.oraclecloud.com/path',
89+
'https://vision.fa.us2.oraclecloud.com/path/..',
90+
'https://vision.fa.us2.oraclecloud.com/./',
91+
'https://vision.fa.us2.oraclecloud.com/%2e%2e/',
92+
'https://vision.fa.us2.oraclecloud.com:443',
93+
'https://vision.fa.us2.oraclecloud.com:8443',
94+
'https://user@vision.fa.us2.oraclecloud.com',
95+
'https://user:password@vision.fa.us2.oraclecloud.com',
96+
'https://vision.fa.us2.oraclecloud.com?tenant=other',
97+
'https://vision.fa.us2.oraclecloud.com#fragment',
98+
'https://vision.fa.us2.oraclecloud.com.evil.example',
99+
'https://vision.fa.us2.oraclecloud.co',
100+
'https://fusion.example.com',
101+
'https://fa.us2.oraclecloud.com',
102+
'https://-vision.fa.us2.oraclecloud.com',
103+
'https://vision.fa.-us2.oraclecloud.com',
104+
])('rejects the noncanonical Fusion Applications URL %j', (value) => {
105+
expect(normalizeOracleFusionApplicationOrigin(value)).toBeUndefined()
106+
})
107+
})
108+
73109
describe('resolveClientCredentialAuthMethod', () => {
74110
it('returns undefined for a provider that declares no method selector', () => {
75111
expect(resolveClientCredentialAuthMethod(box, 'jwt_bearer')).toBeUndefined()

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export interface ClientCredentialAccountField {
4242
placeholder: string
4343
/** Rendered with SecretInput and never echoed back. */
4444
secret: boolean
45+
/** Preserve exact password bytes instead of normalizing surrounding whitespace. */
46+
preserveWhitespace?: boolean
4547
/**
4648
* Renders a multi-line control instead of a single-line one. Required for
4749
* PEM-encoded material (a private key spans ~28 newline-separated lines and
@@ -111,13 +113,15 @@ export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const
111113
export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const
112114
export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const
113115
export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const
116+
export const ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-fusion-service-account' as const
114117

115118
export type ClientCredentialAccountProviderId =
116119
| typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID
117120
| typeof BOX_SERVICE_ACCOUNT_PROVIDER_ID
118121
| typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID
119122
| typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID
120123
| typeof NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID
124+
| typeof ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID
121125

122126
/**
123127
* Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and
@@ -154,6 +158,40 @@ export function normalizeNetSuiteSuiteTalkOrigin(rawUrl: string): string | undef
154158
}
155159
}
156160

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

536608
/**
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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/path/..',
53+
'https://vision.fa.us2.oraclecloud.com/%2e%2e/',
54+
'https://vision.fa.us2.oraclecloud.com:443',
55+
'https://user:password@vision.fa.us2.oraclecloud.com',
56+
'https://vision.fa.us2.oraclecloud.com?tenant=other',
57+
'https://vision.fa.us2.oraclecloud.com#fragment',
58+
'https://vision.fa.us2.oraclecloud.com.evil.example',
59+
'https://vanity.example.com',
60+
])('rejects the unsafe application URL %j without a network probe', async (orgId) => {
61+
const fetchSpy = vi.spyOn(globalThis, 'fetch')
62+
await expect(mintOracleFusionServiceAccountToken({ ...FIELDS, orgId })).rejects.toMatchObject({
63+
code: 'site_not_found',
64+
status: 400,
65+
})
66+
expect(fetchSpy).not.toHaveBeenCalled()
67+
fetchSpy.mockRestore()
68+
})
69+
70+
it.each([
71+
['', FIELDS.clientSecret],
72+
['user:name', FIELDS.clientSecret],
73+
['user\nname', FIELDS.clientSecret],
74+
['u'.repeat(256), FIELDS.clientSecret],
75+
[FIELDS.clientId, ''],
76+
[FIELDS.clientId, 'password\n'],
77+
[FIELDS.clientId, 'p'.repeat(1025)],
78+
])(
79+
'rejects malformed local credentials without exposing them',
80+
async (clientId, clientSecret) => {
81+
const error = await mintOracleFusionServiceAccountToken({
82+
...FIELDS,
83+
clientId,
84+
clientSecret,
85+
}).catch((caught: unknown) => caught)
86+
expect(error).toMatchObject({ code: 'invalid_credentials', status: 400 })
87+
const serialized = JSON.stringify(error)
88+
if (clientId) expect(serialized).not.toContain(clientId)
89+
if (clientSecret) expect(serialized).not.toContain(clientSecret)
90+
const encoded = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')
91+
if (encoded) expect(serialized).not.toContain(encoded)
92+
}
93+
)
94+
})
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+
}

0 commit comments

Comments
 (0)