diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 23ddfd3d0ce..74b4f4982c8 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -9322,7 +9322,7 @@ export function NewRelicIcon(props: SVGProps) { ) } -export function NetSuiteIcon(props: SVGProps) { +export function OracleIcon(props: SVGProps) { return ( ) { ) } +export const NetSuiteIcon = OracleIcon + export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index aec2144c459..96972add528 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -113,6 +113,11 @@ Update Credential (personal API key required) | `--auth-method ` | No | Provider authentication method. | | `--private-key ` | No | Write-only PEM private key. | | `--username ` | No | Provider run-as username. | +| `--tenancy-ocid ` | No | OCI tenancy OCID. | +| `--user-ocid ` | No | OCI user OCID. | +| `--fingerprint ` | No | OCI API-key fingerprint. | +| `--private-key-passphrase ` | No | Write-only OCI private-key passphrase. | +| `--region ` | No | OCI home region. | | `--name ` | No | Alias for --display-name. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index b6c6cc0b2e5..b7e750fcd16 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -473,6 +473,11 @@ sim credentials update [options] | `--auth-method ` | No | Provider authentication method. | | `--private-key ` | No | Write-only PEM private key. | | `--username ` | No | Provider run-as username. | +| `--tenancy-ocid ` | No | OCI tenancy OCID. | +| `--user-ocid ` | No | OCI user OCID. | +| `--fingerprint ` | No | OCI API-key fingerprint. | +| `--private-key-passphrase ` | No | Write-only OCI private-key passphrase. | +| `--region ` | No | OCI home region. | | `--name ` | No | Alias for --display-name. | diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index b94dd9b6a0d..ade8e58ae49 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -8203,13 +8203,43 @@ "writeOnly": true, "type": "string", "minLength": 1, - "maxLength": 8192 + "maxLength": 65536 }, "username": { "description": "Provider run-as username.", "type": "string", "minLength": 1, "maxLength": 255 + }, + "tenancyOcid": { + "description": "OCI tenancy OCID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "userOcid": { + "description": "OCI user OCID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "fingerprint": { + "description": "OCI API-key fingerprint.", + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "privateKeyPassphrase": { + "description": "Write-only OCI private-key passphrase.", + "writeOnly": true, + "type": "string", + "maxLength": 4096 + }, + "region": { + "description": "OCI home region.", + "type": "string", + "minLength": 1, + "maxLength": 128 } }, "additionalProperties": false, diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index 78ddb6e9a1e..d743f5f1c6a 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -256,19 +256,23 @@ describe('OAuth Token API Routes', () => { describe('service account path', () => { it('threads the NetSuite SuiteTalk instance URL into the token response', async () => { const instanceUrl = 'https://1234567.suitetalk.api.netsuite.com' - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + const resolvedCredential = { accountId: '', credentialId: 'netsuite-credential-id', credentialType: 'service_account', providerId: 'netsuite-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - }) + } as const + authOAuthUtilsMockFns.mockResolveOAuthAccountId + .mockResolvedValueOnce(resolvedCredential) + .mockResolvedValueOnce(resolvedCredential) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', + resolvedCredentialId: 'netsuite-credential-id', }) mockResolveServiceAccountToken.mockResolvedValueOnce({ accessToken: 'netsuite-token', @@ -285,19 +289,23 @@ describe('OAuth Token API Routes', () => { }) it('should thread authStyle from the resolver into the response', async () => { - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + const resolvedCredential = { accountId: '', credentialId: 'sa-credential-id', credentialType: 'service_account', providerId: 'pipedrive-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - }) + } as const + authOAuthUtilsMockFns.mockResolveOAuthAccountId + .mockResolvedValueOnce(resolvedCredential) + .mockResolvedValueOnce(resolvedCredential) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', + resolvedCredentialId: 'sa-credential-id', }) mockResolveServiceAccountToken.mockResolvedValueOnce({ accessToken: 'pasted-api-token', @@ -315,19 +323,23 @@ describe('OAuth Token API Routes', () => { }) it('should omit authStyle for Bearer token-paste providers', async () => { - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + const resolvedCredential = { accountId: '', credentialId: 'sa-credential-id', credentialType: 'service_account', providerId: 'hubspot-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - }) + } as const + authOAuthUtilsMockFns.mockResolveOAuthAccountId + .mockResolvedValueOnce(resolvedCredential) + .mockResolvedValueOnce(resolvedCredential) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', + resolvedCredentialId: 'sa-credential-id', }) mockResolveServiceAccountToken.mockResolvedValueOnce({ accessToken: 'pat-token', @@ -350,19 +362,23 @@ describe('OAuth Token API Routes', () => { ] as const)( 'surfaces the %s error code with status %i when the mint fails', async (code, status) => { - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + const resolvedCredential = { accountId: '', credentialId: 'sa-credential-id', credentialType: 'service_account', providerId: 'salesforce-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - }) + } as const + authOAuthUtilsMockFns.mockResolveOAuthAccountId + .mockResolvedValueOnce(resolvedCredential) + .mockResolvedValueOnce(resolvedCredential) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', + resolvedCredentialId: 'sa-credential-id', }) mockResolveServiceAccountToken.mockRejectedValueOnce( new TokenServiceAccountValidationError(code, status, { step: 'mint' }) diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index 832a516763e..bedc7b42605 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -550,4 +550,68 @@ describe('POST /api/credentials', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) }) + + it('forwards OCI API-key fields without returning secret material', async () => { + mockVerifyAndBuildServiceAccountSecret.mockResolvedValueOnce({ + providerId: 'oci-api-key-service-account', + encryptedServiceAccountKey: 'encrypted-oci-blob', + displayName: 'ocid1.user.oc1..principal', + auditMetadata: { + principalKind: 'user', + principalId: 'ocid1.user.oc1..principal', + }, + principal: { kind: 'user', id: 'ocid1.user.oc1..principal' }, + }) + queueTableRows(credential, []) + queueTableRows(credential, []) + queueTableRows(credential, [ + { + id: 'credential-oci', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'ocid1.user.oc1..principal', + description: null, + unredacted: false, + providerId: 'oci-api-key-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-oci-blob', + createdBy: 'user-1', + createdAt: new Date('2026-08-11T00:00:00.000Z'), + updatedAt: new Date('2026-08-11T00:00:00.000Z'), + }, + ]) + + const response = await POST( + createMockRequest('POST', { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'oci-api-key-service-account', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }) + ) + const body = await response.text() + + expect(response.status).toBe(201) + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'oci-api-key-service-account', + expect.objectContaining({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }) + ) + expect(body).not.toContain('PRIVATE KEY') + expect(body).not.toContain('exact passphrase') + expect(body).not.toContain('encrypted-oci-blob') + }) }) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts index 97c34f00802..4a314175549 100644 --- a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts @@ -127,6 +127,33 @@ describe('PATCH /api/v2/credentials/[credentialId]', () => { expect(body).not.toContain('MUST_NOT_LEAK_CIPHERTEXT') }) + it('forwards a complete OCI rotation tuple and preserves explicit passphrase clearing', async () => { + const request = patchRequest({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nreplacement\n-----END PRIVATE KEY-----', + region: 'us-ashburn-1', + }) + const response = await PATCH(request, context) + + expect(response.status).toBe(200) + expect(mocks.update).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nreplacement\n-----END PRIVATE KEY-----', + region: 'us-ashburn-1', + credentialId: CREDENTIAL_ID, + assertedWorkspaceId: WORKSPACE_ID, + }, + request, + }) + expect(JSON.stringify(await response.json())).not.toContain('PRIVATE KEY') + }) + it('asserts the workspace scope and preserves the credential id', async () => { const request = patchRequest({ displayName: 'Zoom prod' }) await PATCH(request, context) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 33ee438a12b..248aff80ea0 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -288,11 +288,55 @@ describe('POST /api/v2/credentials', () => { authMethod: undefined, privateKey: undefined, username: undefined, + tenancyOcid: undefined, + userOcid: undefined, + fingerprint: undefined, + privateKeyPassphrase: undefined, + region: undefined, }, request, }) }) + it('forwards OCI credential fields from the write-only credentials envelope', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'oci-api-key-service-account', + credentials: JSON.stringify({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..user', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }), + }), + }) + const response = await POST(request) + const body = await response.text() + + expect(response.status).toBe(201) + expect(mocks.create).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: expect.objectContaining({ + providerId: 'oci-api-key-service-account', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..user', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }), + request, + }) + expect(body).not.toContain('PRIVATE KEY') + expect(body).not.toContain('exact passphrase') + }) + it('rejects an unknown service-account provider before the use case', async () => { const response = await POST( new NextRequest('http://localhost:3000/api/v2/credentials', { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 0c87db8c28d..4ab50999337 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -25,6 +25,7 @@ import { import { getServiceAccountCoverageSentence } from '@/lib/integrations/credential-display' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import { ClientCredentialAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal' @@ -44,13 +45,15 @@ export type ServiceAccountProviderId = | typeof GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID | typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID | typeof SLACK_CUSTOM_BOT_PROVIDER_ID + | typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID | TokenServiceAccountProviderId | ClientCredentialAccountProviderId -/** Sim setup guides for each provider, docked bottom-left of each modal. */ const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/atlassian-service-account' +const OCI_API_KEY_DOCS_URL = + 'https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm' function openDocs(url: string): void { window.open(url, '_blank', 'noopener,noreferrer') @@ -125,18 +128,6 @@ interface ConnectServiceAccountModalProps { onCreated?: (credentialId: string) => void } -/** - * Connect-service-account modal mounted from the per-integration detail page. - * Self-contained: takes the resolved SA provider + service metadata from the - * caller and submits via `useCreateWorkspaceCredential`. Branches the body - * based on `serviceAccountProviderId`: - * - * - `google-service-account`: JSON-paste + drag/drop. Validated client-side - * against {@link serviceAccountJsonSchema} before submitting. - * - `atlassian-service-account`: API token + site domain. Validated by the - * server against the Atlassian API; user-facing errors are mapped from the - * route's `error.code`. - */ export function ConnectServiceAccountModal({ open, onOpenChange, @@ -211,6 +202,22 @@ export function ConnectServiceAccountModal({ /> ) } + if (serviceAccountProviderId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { + return ( + + ) + } return ( void } +function OciApiKeyServiceAccountModal({ + open, + onOpenChange, + workspaceId, + serviceName, + serviceIcon: ServiceIcon, + credentialId, + initialDisplayName, + initialDescription, + onCreated, +}: ProviderModalProps) { + const [tenancyOcid, setTenancyOcid] = useState('') + const [userOcid, setUserOcid] = useState('') + const [fingerprint, setFingerprint] = useState('') + const [privateKey, setPrivateKey] = useState('') + const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('') + const [region, setRegion] = useState('') + const [displayName, setDisplayName] = useState(initialDisplayName ?? '') + const [description, setDescription] = useState(initialDescription ?? '') + const [error, setError] = useState(null) + const createCredential = useCreateWorkspaceCredential() + const updateCredential = useUpdateWorkspaceCredential() + + const isPending = createCredential.isPending || updateCredential.isPending + const isDisabled = + !tenancyOcid.trim() || + !userOcid.trim() || + !fingerprint.trim() || + !privateKey.trim() || + !region.trim() || + isPending + + const clearError = () => { + if (error) setError(null) + } + + const handleSubmit = async () => { + setError(null) + if (isDisabled) return + const fields = { + tenancyOcid: tenancyOcid.trim(), + userOcid: userOcid.trim(), + fingerprint: fingerprint.trim(), + privateKey, + ...(privateKeyPassphrase.length > 0 ? { privateKeyPassphrase } : {}), + region: region.trim(), + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + } + try { + let connectedCredentialId = credentialId + if (credentialId) { + await updateCredential.mutateAsync({ credentialId, ...fields }) + } else { + const created = await createCredential.mutateAsync({ + workspaceId, + type: 'service_account', + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + ...fields, + }) + connectedCredentialId = created.credential.id + } + if (connectedCredentialId) onCreated?.(connectedCredentialId) + onOpenChange(false) + } catch (err: unknown) { + setError(getErrorMessage(err, 'Failed to add OCI API-key credential')) + logger.error('Failed to add OCI API-key credential', err) + } + } + + return ( + + onOpenChange(false)}> + Add {serviceName} API key + + + { + setTenancyOcid(value) + clearError() + }} + placeholder='ocid1.tenancy.oc1..' + autoComplete='off' + mono + required + /> + { + setUserOcid(value) + clearError() + }} + placeholder='ocid1.user.oc1..' + autoComplete='off' + mono + required + /> + { + setFingerprint(value) + clearError() + }} + placeholder='00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff' + autoComplete='off' + mono + required + /> + { + setPrivateKey(value) + clearError() + }} + placeholder='-----BEGIN PRIVATE KEY-----' + minHeight={120} + mono + required + /> + { + setPrivateKeyPassphrase(value) + clearError() + }} + placeholder='Optional' + autoComplete='new-password' + /> + { + setRegion(value) + clearError() + }} + placeholder='us-ashburn-1' + autoComplete='off' + mono + required + /> + + + {error} + + onOpenChange(false)} + secondaryActions={[{ label: 'Setup guide', onClick: () => openDocs(OCI_API_KEY_DOCS_URL) }]} + primaryAction={{ + label: isPending ? 'Adding...' : credentialId ? 'Reconnect' : 'Add API key', + onClick: handleSubmit, + disabled: isDisabled, + }} + /> + + ) +} + /** * Google service-account flow. Accepts the raw JSON key (paste or drag/drop) * and validates against the shared `serviceAccountJsonSchema` so the same diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 23ddfd3d0ce..74b4f4982c8 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -9322,7 +9322,7 @@ export function NewRelicIcon(props: SVGProps) { ) } -export function NetSuiteIcon(props: SVGProps) { +export function OracleIcon(props: SVGProps) { return ( ) { ) } +export const NetSuiteIcon = OracleIcon + export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/api/contracts/credentials.test.ts b/apps/sim/lib/api/contracts/credentials.test.ts index ae6f6406ee7..dbb94927861 100644 --- a/apps/sim/lib/api/contracts/credentials.test.ts +++ b/apps/sim/lib/api/contracts/credentials.test.ts @@ -3,9 +3,14 @@ */ import { describe, expect, it } from 'vitest' import { + createCredentialBodySchema, updateCredentialByIdBodySchema, workspaceCredentialSchema, } from '@/lib/api/contracts/credentials' +import { + v2CreateServiceAccountCredentialBodySchema, + v2UpdateCredentialBodySchema, +} from '@/lib/api/contracts/v2/credentials' const credential = { id: 'credential-1', @@ -46,3 +51,43 @@ describe('workspaceCredentialSchema unredacted', () => { expect(workspaceCredentialSchema.safeParse(credential).success).toBe(false) }) }) + +describe('OCI API-key credential fields', () => { + const fields = { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..user', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + } + + it('accepts the stable web create field names and preserves the passphrase exactly', () => { + const parsed = createCredentialBodySchema.parse({ + workspaceId: '11111111-2222-4333-8444-555555555555', + type: 'service_account', + providerId: 'oci-api-key-service-account', + ...fields, + }) + + expect(parsed.privateKeyPassphrase).toBe(' exact passphrase ') + expect(parsed).toMatchObject(fields) + }) + + it('accepts the same fields in the V2 write-only envelope', () => { + const parsed = v2CreateServiceAccountCredentialBodySchema.parse({ + workspaceId: '11111111-2222-4333-8444-555555555555', + type: 'service_account', + providerId: 'oci-api-key-service-account', + credentials: JSON.stringify(fields), + }) + + expect(parsed.credentials).toMatchObject(fields) + }) + + it('accepts an omitted passphrase on rotation as an unencrypted replacement key', () => { + const { privateKeyPassphrase: _omitted, ...replacement } = fields + expect(v2UpdateCredentialBodySchema.parse(replacement)).toEqual(replacement) + expect(updateCredentialByIdBodySchema.parse(replacement)).toEqual(replacement) + }) +}) diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index d8a096f49ac..6e2d85649fd 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -158,9 +158,14 @@ export const createCredentialBodySchema = z */ authMethod: z.string().trim().min(1).max(64).optional(), /** PEM private key for certificate/JWT-based grants (for example Salesforce or NetSuite). */ - privateKey: z.string().trim().min(1).max(8192).optional(), + privateKey: z.string().trim().min(1).max(65_536).optional(), /** Run-as username for key-based grants (Salesforce JWT `sub`). */ username: z.string().trim().min(1).max(255).optional(), + tenancyOcid: z.string().trim().min(1).max(255).optional(), + userOcid: z.string().trim().min(1).max(255).optional(), + fingerprint: z.string().trim().min(1).max(128).optional(), + privateKeyPassphrase: z.string().max(4096).optional(), + region: z.string().trim().min(1).max(128).optional(), }) .superRefine((data, ctx) => { if (data.type === 'oauth') { @@ -240,8 +245,13 @@ export const updateCredentialByIdBodySchema = z orgId: z.string().trim().min(1).max(255).optional(), dataCenter: z.string().trim().min(1).max(32).optional(), authMethod: z.string().trim().min(1).max(64).optional(), - privateKey: z.string().trim().min(1).max(8192).optional(), + privateKey: z.string().trim().min(1).max(65_536).optional(), username: z.string().trim().min(1).max(255).optional(), + tenancyOcid: z.string().trim().min(1).max(255).optional(), + userOcid: z.string().trim().min(1).max(255).optional(), + fingerprint: z.string().trim().min(1).max(128).optional(), + privateKeyPassphrase: z.string().max(4096).optional(), + region: z.string().trim().min(1).max(128).optional(), }) .strict() .refine( @@ -261,7 +271,12 @@ export const updateCredentialByIdBodySchema = z data.dataCenter !== undefined || data.authMethod !== undefined || data.privateKey !== undefined || - data.username !== undefined, + data.username !== undefined || + data.tenancyOcid !== undefined || + data.userOcid !== undefined || + data.fingerprint !== undefined || + data.privateKeyPassphrase !== undefined || + data.region !== undefined, { message: 'At least one field must be provided', path: ['displayName'], diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 8c2bf8bc3d9..602740c9a72 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -353,11 +353,21 @@ const v2ServiceAccountCredentialFieldsSchema = z .string() .trim() .min(1) - .max(8192) + .max(65_536) .optional() .describe('Write-only PEM private key.') .meta({ writeOnly: true }), username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), + tenancyOcid: z.string().trim().min(1).max(255).optional().describe('OCI tenancy OCID.'), + userOcid: z.string().trim().min(1).max(255).optional().describe('OCI user OCID.'), + fingerprint: z.string().trim().min(1).max(128).optional().describe('OCI API-key fingerprint.'), + privateKeyPassphrase: z + .string() + .max(4096) + .optional() + .describe('Write-only OCI private-key passphrase.') + .meta({ writeOnly: true }), + region: z.string().trim().min(1).max(128).optional().describe('OCI home region.'), }) .strict() @@ -592,11 +602,21 @@ const v2ServiceAccountSecretFieldsShape = { .string() .trim() .min(1) - .max(8192) + .max(65_536) .optional() .describe('Write-only PEM private key.') .meta({ writeOnly: true }), username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), + tenancyOcid: z.string().trim().min(1).max(255).optional().describe('OCI tenancy OCID.'), + userOcid: z.string().trim().min(1).max(255).optional().describe('OCI user OCID.'), + fingerprint: z.string().trim().min(1).max(128).optional().describe('OCI API-key fingerprint.'), + privateKeyPassphrase: z + .string() + .max(4096) + .optional() + .describe('Write-only OCI private-key passphrase.') + .meta({ writeOnly: true }), + region: z.string().trim().min(1).max(128).optional().describe('OCI home region.'), } as const export const v2UpdateCredentialBodySchema = z diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index 73fc59914fc..20a3ba31180 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -220,6 +220,46 @@ describe('listCredentialProviderCatalog', () => { ) }) + it('publishes the OCI setup contract but keeps it unavailable without product metadata', async () => { + mocks.getAllOAuthServices.mockReturnValue([ + { + serviceId: 'oci', + providerId: 'oci-api-key-service-account', + serviceAccountProviderId: 'oci-api-key-service-account', + name: 'Oracle Cloud Infrastructure', + description: 'Connect to Oracle Cloud Infrastructure services.', + baseProvider: 'oci', + authType: 'service_account', + }, + ]) + mocks.createVisibility.mockReturnValue({ + isOAuthServiceVisible: vi.fn(), + isCredentialVisible: vi.fn().mockReturnValue(false), + }) + + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + + expect(catalog).toEqual([ + expect.objectContaining({ + type: 'service_account', + serviceId: 'oci-api-key-service-account', + providerId: 'oci-api-key-service-account', + name: 'OCI API key', + providerFamily: 'oci', + available: false, + requiresClientGeneratedCredentialId: false, + fields: [ + expect.objectContaining({ id: 'tenancyOcid', required: true, secret: false }), + expect.objectContaining({ id: 'userOcid', required: true, secret: false }), + expect.objectContaining({ id: 'fingerprint', required: true, secret: false }), + expect.objectContaining({ id: 'privateKey', required: true, secret: true }), + expect.objectContaining({ id: 'privateKeyPassphrase', required: false, secret: true }), + expect.objectContaining({ id: 'region', required: true, secret: false }), + ], + }), + ]) + }) + it('fails fast when a multi-server provider lacks complete labels', async () => { mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { if (serviceId === 'salesforce') { diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index b399663cf4a..f53c45c5ead 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -15,6 +15,7 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, type OAuthServiceMetadata, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' @@ -179,6 +180,63 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript ], } } + if (providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'OCI API key', + description: 'Connect Oracle Cloud Infrastructure with an API signing key.', + docsUrl: 'https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm', + fields: [ + { + id: 'tenancyOcid', + label: 'Tenancy OCID', + placeholder: 'ocid1.tenancy.oc1..', + required: true, + secret: false, + multiline: false, + }, + { + id: 'userOcid', + label: 'User OCID', + placeholder: 'ocid1.user.oc1..', + required: true, + secret: false, + multiline: false, + }, + { + id: 'fingerprint', + label: 'Fingerprint', + placeholder: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + required: true, + secret: false, + multiline: false, + }, + { + id: 'privateKey', + label: 'Private key', + placeholder: '-----BEGIN PRIVATE KEY-----', + required: true, + secret: true, + multiline: true, + }, + { + id: 'privateKeyPassphrase', + label: 'Private-key passphrase', + placeholder: 'Optional', + required: false, + secret: true, + multiline: false, + }, + { + id: 'region', + label: 'Region', + placeholder: 'us-ashburn-1', + required: true, + secret: false, + multiline: false, + }, + ], + } + } const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId) ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[ diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts new file mode 100644 index 00000000000..e44ebd35323 --- /dev/null +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts @@ -0,0 +1,196 @@ +/** + * @vitest-environment node + */ +import { createHash, createPublicKey, generateKeyPairSync, type KeyObject } from 'node:crypto' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const dependencies = vi.hoisted(() => ({ + encryptSecret: vi.fn(), + verifySetup: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: dependencies.encryptSecret })) +vi.mock('@/lib/internal/oci/client.server', () => ({ + verifyOciApiKeyCredentialForSetup: dependencies.verifySetup, +})) + +import { + OciCredentialVerificationError, + verifyAndEncryptOciApiKeyCredential, +} from '@/lib/credentials/oci-api-key-service-account.server' +import { OciClientError } from '@/lib/internal/oci/errors' +import { + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, +} from '@/lib/oauth/types' + +const TENANCY_OCID = 'ocid1.tenancy.oc1..aaaaaaaafoundationtenant' +const USER_OCID = 'ocid1.user.oc1..aaaaaaaafoundationuser' + +function fingerprintForKey(privateKey: KeyObject): string { + const der = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) + return createHash('md5').update(der).digest('hex').match(/.{2}/g)!.join(':') +} + +describe('OCI API-key credential setup', () => { + let privateKeyObject: KeyObject + let privateKey: string + let fingerprint: string + let encryptedPrivateKey: string + const passphrase = ' exact passphrase ' + + beforeAll(() => { + privateKeyObject = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey + privateKey = privateKeyObject.export({ format: 'pem', type: 'pkcs8' }).toString() + fingerprint = fingerprintForKey(privateKeyObject) + encryptedPrivateKey = privateKeyObject + .export({ + format: 'pem', + type: 'pkcs8', + cipher: 'aes-256-cbc', + passphrase, + }) + .toString() + }) + + beforeEach(() => { + vi.clearAllMocks() + dependencies.verifySetup.mockResolvedValue(new TextEncoder().encode('"namespace"')) + dependencies.encryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) + }) + + function fields(overrides: Record = {}) { + return { + tenancyOcid: TENANCY_OCID, + userOcid: USER_OCID, + fingerprint, + privateKey, + region: 'us-ashburn-1', + ...overrides, + } + } + + it('normalizes stable external fields and encrypts only after GetNamespace succeeds', async () => { + await expect( + verifyAndEncryptOciApiKeyCredential( + fields({ + tenancyOcid: ` ${TENANCY_OCID} `, + userOcid: ` ${USER_OCID} `, + fingerprint: fingerprint.toUpperCase().replaceAll(':', ' '), + privateKey: privateKey.replaceAll('\n', '\r\n'), + region: ' US-ASHBURN-1 ', + }) + ) + ).resolves.toEqual({ encryptedServiceAccountKey: 'ciphertext', userOcid: USER_OCID }) + + const serialized = dependencies.verifySetup.mock.calls[0][0] + const secret = JSON.parse(serialized) + expect(secret).toEqual({ + type: OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + tenancyOcid: TENANCY_OCID, + userOcid: USER_OCID, + fingerprint, + privateKey, + region: 'us-ashburn-1', + metadata: { principalKind: 'user', principalId: USER_OCID }, + }) + expect(dependencies.encryptSecret).toHaveBeenCalledWith(serialized) + expect(dependencies.verifySetup.mock.invocationCallOrder[0]).toBeLessThan( + dependencies.encryptSecret.mock.invocationCallOrder[0] + ) + }) + + it('accepts encrypted RSA keys only with the exact preserved passphrase', async () => { + await verifyAndEncryptOciApiKeyCredential( + fields({ privateKey: encryptedPrivateKey, privateKeyPassphrase: passphrase }) + ) + expect(JSON.parse(dependencies.verifySetup.mock.calls[0][0]).privateKeyPassphrase).toBe( + passphrase + ) + + await expect( + verifyAndEncryptOciApiKeyCredential(fields({ privateKey: encryptedPrivateKey })) + ).rejects.toThrow('private key or passphrase') + await expect( + verifyAndEncryptOciApiKeyCredential( + fields({ privateKey: encryptedPrivateKey, privateKeyPassphrase: passphrase.trim() }) + ) + ).rejects.toThrow('private key or passphrase') + }) + + it('rejects malformed, non-RSA, and undersized keys before network or encryption', async () => { + const ecKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey + const smallKey = generateKeyPairSync('rsa', { modulusLength: 1024 }).privateKey + const cases = [ + fields({ privateKey: 'not a key' }), + fields({ + privateKey: ecKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + fingerprint: fingerprintForKey(ecKey), + }), + fields({ + privateKey: smallKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + fingerprint: fingerprintForKey(smallKey), + }), + ] + for (const invalid of cases) { + await expect(verifyAndEncryptOciApiKeyCredential(invalid)).rejects.toThrow() + } + expect(dependencies.verifySetup).not.toHaveBeenCalled() + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) + + it('validates fingerprint, OCID types and realms, regions, controls, and size limits locally', async () => { + const invalidCases = [ + fields({ fingerprint: '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00' }), + fields({ tenancyOcid: USER_OCID }), + fields({ userOcid: 'ocid1.user.oc2..aaaaaaaafoundationuser' }), + fields({ region: 'us-gov-ashburn-1' }), + fields({ region: 'moon-base-1' }), + fields({ userOcid: `${USER_OCID}\n` }), + fields({ privateKey: `${privateKey}\u0000` }), + fields({ privateKeyPassphrase: 'x'.repeat(4097) }), + fields({ tenancyOcid: `ocid1.tenancy.oc1..${'a'.repeat(240)}` }), + ] + for (const invalid of invalidCases) { + await expect(verifyAndEncryptOciApiKeyCredential(invalid)).rejects.toThrow() + } + expect(dependencies.verifySetup).not.toHaveBeenCalled() + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) + + it('maps authentication, malformed-response, and transient failures without leaking details', async () => { + dependencies.verifySetup.mockRejectedValueOnce( + new OciClientError('request_failed', { status: 401 }) + ) + await expect(verifyAndEncryptOciApiKeyCredential(fields())).rejects.toEqual( + new OciCredentialVerificationError('invalid_credentials') + ) + + dependencies.verifySetup.mockResolvedValueOnce(new TextEncoder().encode('{"secret":"echo"}')) + await expect(verifyAndEncryptOciApiKeyCredential(fields())).rejects.toEqual( + new OciCredentialVerificationError('invalid_response') + ) + + dependencies.verifySetup.mockRejectedValueOnce(new Error('provider echoed a secret')) + const failure = await verifyAndEncryptOciApiKeyCredential(fields()).catch( + (error: unknown) => error + ) + expect(failure).toEqual(new OciCredentialVerificationError('service_unavailable')) + expect((failure as Error).message).not.toContain('provider') + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) + + it('forwards cancellation and never encrypts an aborted verification', async () => { + const controller = new AbortController() + const reason = new DOMException('canceled', 'AbortError') + dependencies.verifySetup.mockImplementationOnce(async (_secret, signal: AbortSignal) => { + controller.abort(reason) + throw signal.reason + }) + await expect(verifyAndEncryptOciApiKeyCredential(fields(), controller.signal)).rejects.toBe( + reason + ) + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts new file mode 100644 index 00000000000..4a1cb0e50c8 --- /dev/null +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts @@ -0,0 +1,199 @@ +import { createHash, createPrivateKey, createPublicKey } from 'node:crypto' +import { safeCompare } from '@sim/security/compare' +import { encryptSecret } from '@/lib/core/security/encryption' +import { serviceAccountPrincipalMetadata } from '@/lib/credentials/principal' +import { verifyOciApiKeyCredentialForSetup } from '@/lib/internal/oci/client.server' +import { getOciRegion } from '@/lib/internal/oci/endpoints' +import { OciClientError } from '@/lib/internal/oci/errors' +import { + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, +} from '@/lib/oauth/types' + +const MAX_OCID_LENGTH = 255 +const MAX_PRIVATE_KEY_BYTES = 64 * 1024 +const MAX_PASSPHRASE_BYTES = 4 * 1024 +const OCID_PATTERN = /^ocid1\.([a-z][a-z0-9_-]*)\.([a-z0-9]+)\.([a-z0-9-]*)\.([a-zA-Z0-9_-]+)$/ +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/ +const PEM_CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/ + +export interface OciApiKeyCredentialFields { + tenancyOcid: string + userOcid: string + fingerprint: string + privateKey: string + privateKeyPassphrase?: string + region: string +} + +interface OciApiKeyServiceAccountSecret { + readonly type: typeof OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE + readonly providerId: typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + readonly tenancyOcid: string + readonly userOcid: string + readonly fingerprint: string + readonly privateKey: string + readonly privateKeyPassphrase?: string + readonly region: string + readonly metadata: { + readonly principalKind: 'user' + readonly principalId: string + } +} + +export type OciCredentialVerificationCode = + | 'invalid_credentials' + | 'invalid_response' + | 'service_unavailable' + +/** Safe error categories for credential verification callers. */ +export class OciCredentialVerificationError extends Error { + constructor(public readonly code: OciCredentialVerificationCode) { + super(code) + this.name = 'OciCredentialVerificationError' + } +} + +function assertBoundedText( + value: unknown, + field: string, + maxBytes: number, + controlPattern = CONTROL_CHARACTER_PATTERN +): asserts value is string { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > maxBytes || + controlPattern.test(value) + ) { + throw new Error(`OCI ${field} is invalid`) + } +} + +function normalizeOcid( + value: unknown, + expectedType: 'tenancy' | 'user' +): { value: string; realmId: string } { + assertBoundedText(value, `${expectedType} OCID`, MAX_OCID_LENGTH) + const normalized = value.trim() + const match = OCID_PATTERN.exec(normalized) + if (!match || match[1] !== expectedType) { + throw new Error(`OCI ${expectedType} OCID has the wrong structure or resource type`) + } + return { value: normalized, realmId: match[2] } +} + +function normalizeFingerprint(value: unknown): string { + assertBoundedText(value, 'fingerprint', 128) + const hex = value.replace(/[:\s]/g, '').toLowerCase() + const bytes = /^[0-9a-f]{32}$/.test(hex) ? hex.match(/.{2}/g) : null + if (!bytes) throw new Error('OCI fingerprint must contain 16 MD5 bytes') + return bytes.join(':') +} + +function normalizePrivateKey(value: unknown): string { + assertBoundedText(value, 'private key', MAX_PRIVATE_KEY_BYTES, PEM_CONTROL_CHARACTER_PATTERN) + const normalized = value.replace(/\r\n?/g, '\n').trim() + if (!normalized.startsWith('-----BEGIN ') || !normalized.endsWith('-----')) { + throw new Error('OCI private key must be PEM encoded') + } + return `${normalized}\n` +} + +function validatePassphrase(value: unknown): string | undefined { + if (value === undefined) return undefined + if ( + typeof value !== 'string' || + Buffer.byteLength(value, 'utf8') > MAX_PASSPHRASE_BYTES || + CONTROL_CHARACTER_PATTERN.test(value) + ) { + throw new Error('OCI private-key passphrase is invalid') + } + return value +} + +function buildSecret(fields: OciApiKeyCredentialFields): OciApiKeyServiceAccountSecret { + const tenancy = normalizeOcid(fields.tenancyOcid, 'tenancy') + const user = normalizeOcid(fields.userOcid, 'user') + if (tenancy.realmId !== user.realmId) { + throw new Error('OCI tenancy and user OCIDs must share a realm') + } + assertBoundedText(fields.region, 'region', 128) + const region = getOciRegion(fields.region) + if (region.realm.id !== tenancy.realmId) { + throw new Error('OCI region must belong to the credential realm') + } + const fingerprint = normalizeFingerprint(fields.fingerprint) + const privateKey = normalizePrivateKey(fields.privateKey) + const privateKeyPassphrase = validatePassphrase(fields.privateKeyPassphrase) + + let key + try { + key = createPrivateKey({ + key: privateKey, + format: 'pem', + ...(privateKeyPassphrase !== undefined ? { passphrase: privateKeyPassphrase } : {}), + }) + } catch { + throw new Error('OCI private key or passphrase is invalid') + } + if (key.asymmetricKeyType !== 'rsa') throw new Error('OCI private key must use RSA') + if ( + key.asymmetricKeyDetails?.modulusLength === undefined || + key.asymmetricKeyDetails.modulusLength < 2048 + ) { + throw new Error('OCI RSA private key must be at least 2048 bits') + } + const spki = createPublicKey(key).export({ format: 'der', type: 'spki' }) + const derived = createHash('md5').update(spki).digest().toString('base64') + const submitted = Buffer.from(fingerprint.replaceAll(':', ''), 'hex').toString('base64') + if (!safeCompare(derived, submitted)) { + throw new Error('OCI fingerprint does not match the private key') + } + + const metadata = serviceAccountPrincipalMetadata({ kind: 'user', id: user.value }) + return { + type: OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + tenancyOcid: tenancy.value, + userOcid: user.value, + fingerprint, + privateKey, + ...(privateKeyPassphrase !== undefined ? { privateKeyPassphrase } : {}), + region: region.id, + metadata: { principalKind: 'user', principalId: metadata.principalId }, + } +} + +/** Validates, verifies with GetNamespace, and only then encrypts an OCI credential. */ +export async function verifyAndEncryptOciApiKeyCredential( + fields: OciApiKeyCredentialFields, + signal?: AbortSignal +): Promise<{ encryptedServiceAccountKey: string; userOcid: string }> { + const secret = buildSecret(fields) + let responseBody: Uint8Array + try { + responseBody = await verifyOciApiKeyCredentialForSetup(JSON.stringify(secret), signal) + } catch (error) { + if (signal?.aborted) throw error + if (error instanceof OciClientError && (error.status === 401 || error.status === 403)) { + throw new OciCredentialVerificationError('invalid_credentials') + } + throw new OciCredentialVerificationError('service_unavailable') + } + try { + const namespace: unknown = JSON.parse(Buffer.from(responseBody).toString('utf8')) + if ( + typeof namespace !== 'string' || + namespace.length === 0 || + Buffer.byteLength(namespace, 'utf8') > 255 || + CONTROL_CHARACTER_PATTERN.test(namespace) + ) { + throw new Error('invalid namespace') + } + } catch { + throw new OciCredentialVerificationError('invalid_response') + } + const { encrypted } = await encryptSecret(JSON.stringify(secret)) + return { encryptedServiceAccountKey: encrypted, userOcid: secret.userOcid } +} diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 3eb5903815c..ac8a95a1f59 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -78,6 +78,11 @@ export interface PerformCreateCredentialParams { authMethod?: string privateKey?: string username?: string + tenancyOcid?: string + userOcid?: string + fingerprint?: string + privateKeyPassphrase?: string + region?: string /** * Client-supplied credential id, honored only for `slack-custom-bot`: the * setup modal shows the ingest URL `/api/webhooks/slack/custom/{id}` before @@ -276,6 +281,11 @@ export async function createCredentialRecord( authMethod: params.authMethod, privateKey: params.privateKey, username: params.username, + tenancyOcid: params.tenancyOcid, + userOcid: params.userOcid, + fingerprint: params.fingerprint, + privateKeyPassphrase: params.privateKeyPassphrase, + region: params.region, }) resolvedProviderId = secret.providerId resolvedAccountId = null @@ -285,7 +295,10 @@ export async function createCredentialRecord( Object.assign(extraAuditMetadata, secret.auditMetadata) } catch (error) { if (error instanceof ServiceAccountSecretError) { - return failure(error.message, 'validation') + return failure(error.message, 'validation', { + providerErrorCode: error.providerErrorCode, + providerUnavailable: isProviderOutageCode(error.providerErrorCode), + }) } throw error } diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 54510cd4902..9c8bb276535 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -160,6 +160,58 @@ describe('performUpdateCredential — service-account secret rotation', () => { expect(result.previousDisplayName).toBe(OLD_EMAIL) }) + it('requires the complete OCI tuple and treats an omitted passphrase as clearing it', async () => { + mockCredential({ + providerId: 'oci-api-key-service-account', + displayName: 'OCI production signer', + }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'oci-api-key-service-account', + encryptedServiceAccountKey: 'new-oci-cipher', + displayName: 'ocid1.user.oc1..replacement', + auditMetadata: { + principalKind: 'user', + principalId: 'ocid1.user.oc1..replacement', + }, + principal: { kind: 'user', id: 'ocid1.user.oc1..replacement' }, + }) + + const incomplete = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + privateKey: 'replacement-key', + }) + expect(incomplete).toMatchObject({ + success: false, + errorCode: 'validation', + error: expect.stringContaining('complete signing tuple'), + }) + expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled() + + const complete = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'replacement-key', + region: 'us-ashburn-1', + }) + expect(complete.success).toBe(true) + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'oci-api-key-service-account', + expect.objectContaining({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'replacement-key', + privateKeyPassphrase: undefined, + region: 'us-ashburn-1', + }) + ) + expect(updatePayload().encryptedServiceAccountKey).toBe('new-oci-cipher') + }) + it('keeps a label the user typed instead of the derived identity', async () => { mockCredential({ displayName: 'Prod billing exporter' }) mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 47cc51e4927..199ac826a01 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -44,6 +44,7 @@ import { TokenServiceAccountValidationError } from '@/lib/credentials/token-serv import { invalidateEffectiveDecryptedEnvCache } from '@/lib/environment/utils' import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE, } from '@/lib/oauth/types' @@ -82,6 +83,11 @@ const ROTATABLE_SECRET_FIELDS: readonly ServiceAccountFieldId[] = [ 'authMethod', 'privateKey', 'username', + 'tenancyOcid', + 'userOcid', + 'fingerprint', + 'privateKeyPassphrase', + 'region', ] /** @@ -194,6 +200,11 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams { authMethod?: string privateKey?: string username?: string + tenancyOcid?: string + userOcid?: string + fingerprint?: string + privateKeyPassphrase?: string + region?: string } export interface PerformCredentialResult { @@ -285,6 +296,24 @@ export async function updateCredentialRecord( if (hasRotationSecret) { const providerId = params.credential.providerId ?? '' + if (providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { + const requiredOciFields = [ + 'tenancyOcid', + 'userOcid', + 'fingerprint', + 'privateKey', + 'region', + ] as const + const missingOciFields = requiredOciFields.filter((field) => params[field] === undefined) + if (missingOciFields.length > 0) { + return { + success: false, + error: `OCI credential rotation requires the complete signing tuple; missing ${missingOciFields.join(', ')}`, + errorCode: 'validation', + } + } + } + // A reconnect rebuilds the secret blob from the submitted fields only, and // the modal never prefills (secrets are never echoed back). For an actual // secret that is correct - the admin retypes it. But a non-secret selector @@ -370,6 +399,11 @@ export async function updateCredentialRecord( : params.authMethod, privateKey: params.privateKey, username: needsStoredUsername ? readStoredField(storedBlob, 'username') : params.username, + tenancyOcid: params.tenancyOcid, + userOcid: params.userOcid, + fingerprint: params.fingerprint, + privateKeyPassphrase: params.privateKeyPassphrase, + region: params.region, }) updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey rotatedSlackBotUserId = secret.botUserId @@ -388,7 +422,12 @@ export async function updateCredentialRecord( } } catch (error) { if (error instanceof ServiceAccountSecretError) { - return { success: false, error: error.message, errorCode: 'validation' } + return { + success: false, + error: error.message, + errorCode: 'validation', + providerErrorCode: error.providerErrorCode, + } } if (error instanceof AtlassianValidationError) { // Surface the provider code so the client maps it to the specific diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index f1bac216036..31bea7ac61b 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -3,6 +3,7 @@ import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-s import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' @@ -21,6 +22,11 @@ export type ServiceAccountFieldId = | 'authMethod' | 'privateKey' | 'username' + | 'tenancyOcid' + | 'userOcid' + | 'fingerprint' + | 'privateKeyPassphrase' + | 'region' /** * Required create-body fields per service-account provider — the client-safe @@ -36,6 +42,13 @@ export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record { expect(isServiceAccountProviderId('notion-service-account')).toBe(true) expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true) expect(isServiceAccountProviderId('netsuite-service-account')).toBe(true) + expect(isServiceAccountProviderId('oci-api-key-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('oci-api-key-service-account')).toBeNull() }) }) @@ -63,5 +65,6 @@ describe('getServiceAccountConnectNoun', () => { // token/client descriptor, so they read as a plain "service account". expect(getServiceAccountConnectNoun('google-service-account')).toBe('service account') expect(getServiceAccountConnectNoun('atlassian-service-account')).toBe('service account') + expect(getServiceAccountConnectNoun('oci-api-key-service-account')).toBe('service account') }) }) diff --git a/apps/sim/lib/credentials/service-account-provider-ids.ts b/apps/sim/lib/credentials/service-account-provider-ids.ts index b77be5d8bd9..2d4d9ded299 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.ts @@ -9,6 +9,7 @@ import { import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' @@ -29,6 +30,7 @@ export function asServiceAccountProviderId( value === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID || value === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID || value === SLACK_CUSTOM_BOT_PROVIDER_ID || + value === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || isTokenServiceAccountProviderId(value) || isClientCredentialAccountProviderId(value) ) { diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index fd11efb6b33..40f76285b99 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -9,6 +9,7 @@ const { mockValidateAtlassian, mockNormalizeDomain, mockClientCredentialMinter, + mockVerifyAndEncryptOci, } = vi.hoisted(() => ({ // Identity encryption so tests can read back the JSON blob. mockEncryptSecret: vi.fn(async (value: string) => ({ encrypted: value })), @@ -16,6 +17,7 @@ const { mockValidateAtlassian: vi.fn(), mockNormalizeDomain: vi.fn((raw: string) => raw.trim().toLowerCase()), mockClientCredentialMinter: vi.fn(), + mockVerifyAndEncryptOci: vi.fn(), })) vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret })) @@ -24,6 +26,14 @@ vi.mock('@/lib/credentials/atlassian-service-account', () => ({ validateAtlassianServiceAccount: mockValidateAtlassian, normalizeAtlassianDomain: mockNormalizeDomain, })) +vi.mock('@/lib/credentials/oci-api-key-service-account.server', () => ({ + OciCredentialVerificationError: class OciCredentialVerificationError extends Error { + constructor(readonly code: string) { + super(code) + } + }, + verifyAndEncryptOciApiKeyCredential: mockVerifyAndEncryptOci, +})) vi.mock('@/lib/api/contracts/credentials', () => ({ serviceAccountJsonSchema: { safeParse: (value: string) => { @@ -163,6 +173,74 @@ describe('verifyAndBuildServiceAccountSecret', () => { expect(result.providerId).toBe('google-service-account') }) + it('verifies and stores an OCI API-key credential with stable external fields', async () => { + mockVerifyAndEncryptOci.mockResolvedValue({ + encryptedServiceAccountKey: 'oci-ciphertext', + userOcid: 'ocid1.user.oc1..principal', + }) + + const result = await verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' preserved exactly ', + region: 'us-ashburn-1', + }) + + expect(mockVerifyAndEncryptOci).toHaveBeenCalledWith({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' preserved exactly ', + region: 'us-ashburn-1', + }) + expect(result).toEqual({ + providerId: 'oci-api-key-service-account', + encryptedServiceAccountKey: 'oci-ciphertext', + displayName: 'ocid1.user.oc1..principal', + auditMetadata: { + principalKind: 'user', + principalId: 'ocid1.user.oc1..principal', + }, + principal: { kind: 'user', id: 'ocid1.user.oc1..principal' }, + }) + }) + + it('requires the complete OCI signing tuple before verification', async () => { + await expect( + verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + }) + ).rejects.toThrow('tenancyOcid, userOcid, fingerprint, privateKey, and region are required') + expect(mockVerifyAndEncryptOci).not.toHaveBeenCalled() + }) + + it('classifies OCI verification outages without exposing provider details', async () => { + const { OciCredentialVerificationError } = await import( + '@/lib/credentials/oci-api-key-service-account.server' + ) + mockVerifyAndEncryptOci.mockRejectedValue( + new OciCredentialVerificationError('service_unavailable') + ) + + const failure = await verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'provider-secret-key', + region: 'us-ashburn-1', + }).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(ServiceAccountSecretError) + expect(failure).toMatchObject({ + message: 'OCI is temporarily unavailable for credential verification', + providerErrorCode: 'provider_unavailable', + }) + expect(JSON.stringify(failure)).not.toContain('provider-secret-key') + }) + it('rejects an unknown non-empty providerId instead of persisting it as Google', async () => { const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' }) await expect( diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 5c35210cfe3..ed750c97b0c 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -20,6 +20,10 @@ import { getClientCredentialAccountMinter, } from '@/lib/credentials/client-credential-accounts/server' import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' +import { + OciCredentialVerificationError, + verifyAndEncryptOciApiKeyCredential, +} from '@/lib/credentials/oci-api-key-service-account.server' import { type ServiceAccountPrincipal, serviceAccountPrincipalMetadata, @@ -37,6 +41,7 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE, } from '@/lib/oauth/types' @@ -57,6 +62,11 @@ export interface ServiceAccountSecretFields { authMethod?: string privateKey?: string username?: string + tenancyOcid?: string + userOcid?: string + fingerprint?: string + privateKeyPassphrase?: string + region?: string } export interface ServiceAccountSecretResult { @@ -77,7 +87,10 @@ export interface ServiceAccountSecretResult { /** Thrown when a service-account secret is missing or fails provider verification. */ export class ServiceAccountSecretError extends Error { - constructor(message: string) { + constructor( + message: string, + readonly providerErrorCode?: string + ) { super(message) this.name = 'ServiceAccountSecretError' } @@ -217,6 +230,46 @@ async function buildGoogleServiceAccountSecret( } } +async function buildOciApiKeyServiceAccountSecret( + fields: ServiceAccountSecretFields +): Promise { + const { tenancyOcid, userOcid, fingerprint, privateKey, privateKeyPassphrase, region } = fields + if (!tenancyOcid || !userOcid || !fingerprint || !privateKey || !region) { + throw new ServiceAccountSecretError( + 'tenancyOcid, userOcid, fingerprint, privateKey, and region are required for OCI API-key credentials' + ) + } + try { + const result = await verifyAndEncryptOciApiKeyCredential({ + tenancyOcid, + userOcid, + fingerprint, + privateKey, + ...(privateKeyPassphrase !== undefined ? { privateKeyPassphrase } : {}), + region, + }) + const principal: ServiceAccountPrincipal = { kind: 'user', id: result.userOcid } + const metadata = serviceAccountPrincipalMetadata(principal) + return { + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + encryptedServiceAccountKey: result.encryptedServiceAccountKey, + displayName: result.userOcid, + auditMetadata: metadata, + principal, + } + } catch (error) { + if (error instanceof OciCredentialVerificationError) { + throw new ServiceAccountSecretError( + error.code === 'service_unavailable' + ? 'OCI is temporarily unavailable for credential verification' + : 'OCI rejected the API-key credential', + error.code === 'service_unavailable' ? 'provider_unavailable' : 'invalid_credentials' + ) + } + throw new ServiceAccountSecretError('OCI API-key credential is invalid') + } +} + /** * Builds a token-paste service-account secret for any provider registered in * `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS`: verifies the pasted token via the @@ -350,6 +403,7 @@ const SERVICE_ACCOUNT_SECRET_BUILDERS: Record = { 'linear-service-account': ['linear'], 'monday-service-account': ['monday'], 'notion-service-account': ['notion'], + // OCI owns reusable credential setup but intentionally exposes no product + // integration until a native OCI product supplies visible catalog metadata. + 'oci-api-key-service-account': [], // NetSuite remains an API-key catalog integration, like Snowflake, while its // block uses the shared reusable-credential selector. 'netsuite-service-account': [], diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts new file mode 100644 index 00000000000..5b11384fac4 --- /dev/null +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -0,0 +1,811 @@ +/** + * @vitest-environment node + */ +import { createPublicKey, verify } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + backoff: vi.fn(), + decryptSecret: vi.fn(), + predicates: undefined as unknown, + rows: [] as { encryptedServiceAccountKey: string | null }[], + secureFetch: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn((predicate: unknown) => { + mocks.predicates = predicate + return { limit: vi.fn(async () => mocks.rows) } + }), + })), + })), + }, +})) + +vi.mock('@sim/db/schema', () => ({ + credential: { + encryptedServiceAccountKey: 'credential.encryptedServiceAccountKey', + id: 'credential.id', + providerId: 'credential.providerId', + type: 'credential.type', + workspaceId: 'credential.workspaceId', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...predicates: unknown[]) => predicates), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mocks.decryptSecret })) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + DEFAULT_MAX_RESPONSE_BYTES: 100 * 1024 * 1024, + secureFetchWithValidation: mocks.secureFetch, +})) + +vi.mock('@sim/utils/retry', () => ({ + backoffWithJitter: mocks.backoff, + parseRetryAfter: vi.fn(() => null), +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getServiceConfigByServiceId: vi.fn((serviceId: string) => + serviceId === 'oci' + ? { serviceAccountProviderId: 'oci-api-key-service-account' } + : serviceId === 'slack' + ? { serviceAccountProviderId: 'slack-custom-bot' } + : null + ), +})) + +import { + createOciClient, + type OciAuthenticatedResponse, + type OciClient, + type OciRequest, +} from '@/lib/internal/oci/client.server' +import { + createOciDiscoveredEndpointPolicy, + createOciStaticEndpointPolicy, +} from '@/lib/internal/oci/endpoints' +import { OciClientError } from '@/lib/internal/oci/errors' +import { OCI_SERVICE_ID } from '@/lib/oauth/types' + +// Fixed test material. The expected signatures were generated independently with +// OpenSSL 3 against Oracle's Request Signatures specification (retrieved 2026-09-03): +// https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm +// The canonical header order is cross-checked against oci-common 2.140.0. +// Keep the synthetic fixture's PEM delimiters split so secret scanners do not +// mistake checked-in conformance material for a deployable credential. +const PRIVATE_KEY = `${['-----BEGIN', 'PRIVATE KEY-----'].join(' ')} +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGu21M7TuK4Jr6 +s8luoTzVRltBhYM078Z0JNpg3/uwqLIYtmNFDLg9AJ4NY9piBfZoE4b9EhrVzwkW ++wIWdSflJPfnlWFD7nLBk+n69dyU1wwUuEw0PYZOliFvCmlegg9qE+vZK13o5e1m +08ZEq7oxfArlHH3NZXuwoZJiraP/mtGurDrcAJLUKuTMfEp+zUOUdmupeZjmNWj9 +B8xbgRoQ3vQVk+7q+ltMvsUdZB2La+IEhTg6PMCrSsRV0v/xqJiSQ34iPkxq2LrD +AUKxypwmX8X0c2VWYQh/ho3x3pT5XPxC3x/plkM8DxC7Ejjg1qa0jyl0JLzMWNnh +V79UvkKRAgMBAAECggEAO3ueG4hmagsQWDm38QUR0ERezB3KR+382IavVo+0JgxY +Qk1VKTXFb3zf0eIxW2WtezldDiJ9JcHyVo6K8W3foxaNnSN5GXwlnQtI3XT5sRMs +6oa/SGOh76PAHhxfrYoAUx/jV/1C/pLTnBOHJMbB1E3sdOcyQGg/vX6e8ipHDBoj +24tljd5fvmDWkR/WYHwjn2xaY8Ee3/EfIoBw5r+WrXLjpj5FuGUo+pxyqbSI2qE/ +mpOMEi/+KprpUU8N5e33+cihyrneAKLyqyxS7NPWmbc5+ut0g4uzIu1NmyAhfa2o +c1MbQqh+C2R96tbhPAJQHeRClV1YKUpOiXj6EvpmAQKBgQDmEoNkMSWfX0gJMOdM +8kh641t3KBqyyGt3kx2xTaeybq8MFilQCahSTjfndkT8tlW1eRh2BiMUvvdSpCPM +wRH7BGW4h8J6ALmMnj0nsl8ebJc7g0hzacRG+SAVD8IbQqIzc0rY/DUfuoIuL5Ce +R0l9p85r2ZBGNrnM9dIUkfNj/QKBgQDdIMNXzKGPRUUkdN5kskfCEV3a0geVFaU0 +ZOiZf6TRidcl5RTaTcJbRJ2pXsealDlURdmrk8lGgy0uTE181Zn71bBPKjN1xmct +H8SMQvxcI62OYaUbEpzgp83TZXtRpqmVA2v+0BjhrjPPjVKsT5YwkHRPb5DyHOW8 +D8HB/dO7JQKBgQDbW6lknKtHUZwoDzVpGtPaPu2VJWqXLRmxr1WvF+Ac8wT43CRV +iG+w0ZzhldTesaX0WVnmJaHLBOxgIdl0Ply7XQzzLJVSp2BB3xllwN6J7nUeq+Qn +Dh+yn5JkIlsqjJSDw5gIXCb2cmfuSzFyh3tdT+Iy2AODvmfWMEY1kJZjrQKBgDUO +wHBXtEg5Ob7mn9oPgPJK0ndHv/QArpQkxj7WhsiUR2BbWCaNU94sV5wlFsW7XQog +fHsTyc62eOfL/Se/5OOtQVGtcY2H3ofQQIvbIsxE70bjnQci7ytkeBmKFw3fbH9J +w+bvLZkxAFODuFuJ+SKL9qx8u42sa181dKtEaUJVAoGBALuFS1q/ihZw8M5AoofY +llBvP7/pHwT8XR2gWl5sZFOt6kvrMQqcI3u/9BkVR9au1I2K7xJOQmt9KEL4HkgP +6cqql61lZNv8GgYlJPu8ipN0IUxf1V7K+9xw0t1am57WATCW+bqkfyvYoBXhLwx6 +7z8JESybW/3kkmWIOy5WHvzv +${['-----END', 'PRIVATE KEY-----'].join(' ')} +` + +const SECRET = JSON.stringify({ + type: 'oci_api_signing_key_v1', + providerId: 'oci-api-key-service-account', + tenancyOcid: 'ocid1.tenancy.oc1..aaaaaaaafixedvector', + userOcid: 'ocid1.user.oc1..aaaaaaaafixedvector', + fingerprint: '25:53:22:62:aa:db:ff:ef:f5:77:08:d1:a2:ed:8b:e6', + privateKey: PRIVATE_KEY, + region: 'us-ashburn-1', + metadata: { + principalKind: 'user', + principalId: 'ocid1.user.oc1..aaaaaaaafixedvector', + }, +}) + +const STATIC_POLICY = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'identity', +}) + +function secureResponse(params: { + status?: number + body?: Uint8Array | string + headers?: Record +}) { + const bytes = + typeof params.body === 'string' + ? new TextEncoder().encode(params.body) + : (params.body ?? new Uint8Array()) + return { + ok: (params.status ?? 200) >= 200 && (params.status ?? 200) < 300, + status: params.status ?? 200, + statusText: '', + headers: new Headers({ 'content-length': String(bytes.byteLength), ...params.headers }), + body: new ReadableStream({ + start(controller) { + if (bytes.byteLength > 0) controller.enqueue(bytes) + controller.close() + }, + }), + text: vi.fn(async () => Buffer.from(bytes).toString('utf8')), + json: vi.fn(async () => JSON.parse(Buffer.from(bytes).toString('utf8'))), + arrayBuffer: vi.fn(async () => bytes.buffer.slice(0)), + } +} + +async function createPreparedClient(params: { region?: string } = {}): Promise<{ + client: OciClient + endpoint: Awaited> +}> { + const client = await createOciClient({ + credentialId: 'credential-authoritative', + workspaceId: 'workspace-trusted', + serviceId: OCI_SERVICE_ID, + ...params, + }) + const endpoint = await client.prepareStaticEndpoint(STATIC_POLICY) + return { client, endpoint } +} + +function authorizationFromLastRequest(): string { + const options = mocks.secureFetch.mock.calls.at(-1)?.[1] as { headers: Record } + return options.headers.authorization +} + +describe('credential-bound OCI client', () => { + beforeEach(() => { + mocks.predicates = undefined + mocks.rows = [{ encryptedServiceAccountKey: 'encrypted-secret' }] + mocks.decryptSecret.mockReset().mockResolvedValue({ decrypted: SECRET }) + mocks.backoff.mockReset().mockReturnValue(0) + mocks.secureFetch.mockReset().mockResolvedValue(secureResponse({})) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('loads only an exact credential/workspace/type/provider row before decryption', async () => { + await createPreparedClient() + + expect(mocks.predicates).toEqual([ + { field: 'credential.id', value: 'credential-authoritative' }, + { field: 'credential.workspaceId', value: 'workspace-trusted' }, + { field: 'credential.type', value: 'service_account' }, + { field: 'credential.providerId', value: 'oci-api-key-service-account' }, + ]) + expect(mocks.decryptSecret).toHaveBeenCalledOnce() + }) + + it.each([ + ['missing row', () => (mocks.rows = [])], + ['null secret', () => (mocks.rows = [{ encryptedServiceAccountKey: null }])], + ['decrypt failure', () => mocks.decryptSecret.mockRejectedValueOnce(new Error('ciphertext'))], + ['malformed secret', () => mocks.decryptSecret.mockResolvedValueOnce({ decrypted: '{}' })], + ])('projects %s as the same credential-unavailable failure', async (_name, arrange) => { + arrange() + const client = await createOciClient({ + credentialId: 'raw-id-is-not-authority', + workspaceId: 'wrong-or-right-workspace', + serviceId: OCI_SERVICE_ID, + }) + await expect(client.prepareStaticEndpoint(STATIC_POLICY)).rejects.toMatchObject({ + code: 'credential_unavailable', + message: 'OCI credential is unavailable', + }) + }) + + it('fails a registered-service mismatch before loading or network work', async () => { + await expect( + createOciClient({ + credentialId: 'credential-authoritative', + workspaceId: 'workspace-trusted', + serviceId: 'slack', + }) + ).rejects.toMatchObject({ code: 'invalid_endpoint' }) + expect(mocks.decryptSecret).not.toHaveBeenCalled() + expect(mocks.secureFetch).not.toHaveBeenCalled() + }) + + it('fails a policy/client owner mismatch before loading or network work', async () => { + const client = await createOciClient({ + credentialId: 'credential-authoritative', + workspaceId: 'workspace-trusted', + serviceId: OCI_SERVICE_ID, + }) + const wrongPolicy = createOciStaticEndpointPolicy({ + serviceId: 'slack', + serviceName: 'identity', + }) + await expect(client.prepareStaticEndpoint(wrongPolicy)).rejects.toMatchObject({ + code: 'invalid_endpoint', + }) + expect(mocks.decryptSecret).not.toHaveBeenCalled() + expect(mocks.secureFetch).not.toHaveBeenCalled() + }) + + it('enforces realm-compatible region overrides', async () => { + await expect(createPreparedClient({ region: 'us-gov-ashburn-1' })).rejects.toMatchObject({ + code: 'invalid_endpoint', + }) + expect((await createPreparedClient({ region: 'eu-frankfurt-1' })).endpoint.origin).toBe( + 'https://identity.eu-frankfurt-1.oraclecloud.com' + ) + }) + + it('matches the fixed Oracle canonical signing fixture', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) + const { client, endpoint } = await createPreparedClient() + await client.request({ + endpoint, + method: 'GET', + encodedPath: '/20160918/users', + queryPairs: [ + ['limit', '10'], + ['name', 'Team X'], + ], + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + + const authorization = authorizationFromLastRequest() + expect(authorization).toBe( + 'Signature version="1",keyId="ocid1.tenancy.oc1..aaaaaaaafixedvector/ocid1.user.oc1..aaaaaaaafixedvector/25:53:22:62:aa:db:ff:ef:f5:77:08:d1:a2:ed:8b:e6",algorithm="rsa-sha256",headers="x-date (request-target) host",signature="pcMhip57/dPnKl/dfg5usN7oT/illXEGUp9Oj2d9bpGb0aRMBJclgVFKRYdYXciUGPM/9vKluD5/eGPBO1Oh7w/6NCB8UX2Ejh/lw8merU1QalZ/OfHyj+wKNVOpqwQjNqettRUzSVMhCqImDnvgx8ygmVCvdc0CeLXf2ZF9iT1bYlDjOiuxOcWreN2rs1ZmfLCfal204nAjrNAvoBSgHCPVquAYnfsT2auOWP4QeHN/Hd/v7TvNqsWBFIaLCyWZOvRzpsw/ZLgLzB+jkuPTdL7l4hOZATUd7xy1QPFTJ0P1RlLHjZE1sH7hbrqVGORNXrVhA1LaArObz6GWPOOghA=="' + ) + + const signature = /signature="([^"]+)"/.exec(authorization)?.[1] + expect(signature).toBeDefined() + expect( + verify( + 'RSA-SHA256', + 'x-date: Thu, 03 Sep 2026 19:00:00 GMT\n(request-target): get /20160918/users?limit=10&name=Team%20X\nhost: identity.us-ashburn-1.oraclecloud.com', + createPublicKey(PRIVATE_KEY), + Buffer.from(signature!, 'base64') + ) + ).toBe(true) + }) + + it('matches the fixed Oracle body-signing fixture for an empty body', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) + const { client, endpoint } = await createPreparedClient() + await client.request({ + endpoint, + method: 'POST', + encodedPath: '/20160918/users', + body: new Uint8Array(), + contentType: 'application/json', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + + expect(authorizationFromLastRequest()).toBe( + 'Signature version="1",keyId="ocid1.tenancy.oc1..aaaaaaaafixedvector/ocid1.user.oc1..aaaaaaaafixedvector/25:53:22:62:aa:db:ff:ef:f5:77:08:d1:a2:ed:8b:e6",algorithm="rsa-sha256",headers="x-date (request-target) host content-type content-length x-content-sha256",signature="vyhrwd21evtwFet82VT1FvKEeZV+JSa3VZuS5p4Pj8K2zeU88GO+tGx/voUK9TFHijF7eG5gGS6WWc6tigrByTocbVOHpLtPNgBo2+1NbTbGHGUZIzCOR5CZ1ite74Ak43xZjyKBm+vZHrvS22leVOJe43V/HjqCxqyPn3WkKd7npqo9eFM1sibdj1h3Cmi79b5nXSPFe5KE+rnMRPTOB4nl7iFELvubg/Y7Y8w5hRYEe13w09zw9tTBdGJtZIuMoYwZYzPdZo5wbrN5WM6ylHC2euVh2PSazZZU99q55uhxiR6OaCQWLM0buytCqja8FeiEY8Iw3GuEbKUECKaM8Q=="' + ) + expect(mocks.secureFetch.mock.calls[0][1].headers).toMatchObject({ + 'content-length': '0', + 'content-type': 'application/json', + 'x-content-sha256': '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', + }) + }) + + it('preserves ordered duplicate queries and exact binary request bytes', async () => { + const { client, endpoint } = await createPreparedClient() + const body = new Uint8Array([0, 255, 1, 240, 159, 140, 131]) + await client.request({ + endpoint, + method: 'POST', + encodedPath: '/v1/%E2%98%83', + queryPairs: [ + ['z', 'last'], + ['a', ''], + ['a', " !'()*"], + ], + headers: { accept: 'application/json' }, + body, + contentType: 'application/octet-stream', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + + const [url, options] = mocks.secureFetch.mock.calls[0] as [ + string, + { body: Uint8Array; headers: Record }, + ] + expect(url).toBe( + 'https://identity.us-ashburn-1.oraclecloud.com/v1/%E2%98%83?z=last&a=&a=%20%21%27%28%29%2A' + ) + expect([...options.body]).toEqual([...body]) + expect(options.body).not.toBe(body) + expect(options.headers).toMatchObject({ + 'content-length': '7', + 'content-type': 'application/octet-stream', + 'x-content-sha256': 'ujM2KRiewv2gytZWgW9aE6ZPWa2LOxmcemXv0wuwcrs=', + }) + }) + + it.each(['GET', 'HEAD', 'DELETE'] as const)('rejects bodies for %s', async (method) => { + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method, + encodedPath: '/v1/test', + body: new Uint8Array(), + contentType: 'application/json', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'invalid_request' }) + }) + + it.each(['GET', 'HEAD', 'DELETE'] as const)( + 'sends a bodyless %s without body signing headers', + async (method) => { + const { client, endpoint } = await createPreparedClient() + await client.request({ + endpoint, + method, + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + const options = mocks.secureFetch.mock.calls.at(-1)?.[1] + expect(options.method).toBe(method) + expect(options).not.toHaveProperty('body') + expect(options.headers).not.toHaveProperty('content-length') + expect(options.headers).not.toHaveProperty('x-content-sha256') + } + ) + + it.each(['POST', 'PUT', 'PATCH'] as const)( + 'requires an exact body and content type for %s, including empty bodies', + async (method) => { + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method, + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'invalid_request' }) + await client.request({ + endpoint, + method, + encodedPath: '/v1/test', + body: new Uint8Array(), + contentType: 'application/json', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect(mocks.secureFetch.mock.calls.at(-1)?.[1].headers['content-length']).toBe('0') + } + ) + + it.each([ + 'relative', + '//host/path', + '/double//slash', + '/query?x=1', + '/back\\slash', + '/encoded%2Fslash', + '/encoded%5Cbackslash', + '/encoded%00control', + '/encoded%1fcontrol', + '/encoded%7Fcontrol', + '/bad%2', + ])('rejects ambiguous encoded paths: %s', async (encodedPath) => { + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'invalid_request' }) + }) + + it('rejects signing-controlled headers', async () => { + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + headers: { Authorization: 'forged' }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'invalid_request' }) + }) + + it('fails closed on malformed runtime request shapes', async () => { + const { client, endpoint } = await createPreparedClient() + const base = { + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + } + const invalidRequests = [ + { ...base, method: 'TRACE' }, + { ...base, encodedPath: 42 }, + { ...base, headers: [] }, + { ...base, queryPairs: [['only-key']] }, + { ...base, queryPairs: [['\ud800', 'value']] }, + { ...base, retry: { kind: 'unknown', maxAttempts: 2 } }, + { ...base, retry: { kind: 'safe', maxAttempts: 2, retryToken: 'forged' } }, + { ...base, responseHeaders: [42] }, + ] + + for (const request of invalidRequests) { + await expect(client.request(request as unknown as OciRequest)).rejects.toMatchObject({ + code: 'invalid_request', + }) + } + expect(mocks.secureFetch).not.toHaveBeenCalled() + }) + + it('does not retry unless the operation opts in', async () => { + mocks.secureFetch.mockResolvedValue( + secureResponse({ status: 503, body: '{"message":"secret"}' }) + ) + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'request_failed', status: 503 }) + expect(mocks.secureFetch).toHaveBeenCalledOnce() + }) + + it('re-signs every retry while preserving exact bytes and retry token', async () => { + mocks.secureFetch + .mockResolvedValueOnce(secureResponse({ status: 503, body: '{"code":"Busy"}' })) + .mockResolvedValueOnce(secureResponse({ status: 200, body: 'ok' })) + const { client, endpoint } = await createPreparedClient() + const body = new Uint8Array([9, 8, 7]) + await client.request({ + endpoint, + method: 'PUT', + encodedPath: '/v1/test', + body, + contentType: 'application/octet-stream', + retry: { kind: 'tokenized', maxAttempts: 2, retryToken: 'operation-token' }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + + const first = mocks.secureFetch.mock.calls[0][1] + const second = mocks.secureFetch.mock.calls[1][1] + expect([...first.body]).toEqual([...second.body]) + expect(first.headers['opc-retry-token']).toBe('operation-token') + expect(second.headers['opc-retry-token']).toBe('operation-token') + expect(first.headers['x-date']).not.toBe(second.headers['x-date']) + expect(first.headers.authorization).not.toBe(second.headers.authorization) + }) + + it('retries only the exact internal IncorrectState 409 classification', async () => { + mocks.secureFetch + .mockResolvedValueOnce(secureResponse({ status: 409, body: '{"code":"IncorrectState"}' })) + .mockResolvedValueOnce(secureResponse({ status: 200 })) + const { client, endpoint } = await createPreparedClient() + await client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect(mocks.secureFetch).toHaveBeenCalledTimes(2) + }) + + it('does not retry another provider 409 classification', async () => { + mocks.secureFetch.mockResolvedValue( + secureResponse({ status: 409, body: '{"code":"Conflict"}' }) + ) + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'request_failed', status: 409 }) + expect(mocks.secureFetch).toHaveBeenCalledOnce() + }) + + it('retries eligible transport failures and rejects unclassified failures', async () => { + const retryable = Object.assign(new Error('socket reset'), { code: 'ECONNRESET' }) + mocks.secureFetch + .mockRejectedValueOnce(retryable) + .mockResolvedValueOnce(secureResponse({ status: 200 })) + const { client, endpoint } = await createPreparedClient() + await client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect(mocks.secureFetch).toHaveBeenCalledTimes(2) + + mocks.secureFetch.mockReset().mockRejectedValue(new Error('provider diagnostic')) + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'request_failed', message: 'OCI request failed' }) + expect(mocks.secureFetch).toHaveBeenCalledOnce() + }) + + it('discards provider messages and exposes only safe status and request IDs', async () => { + mocks.secureFetch.mockResolvedValueOnce( + secureResponse({ + status: 401, + body: JSON.stringify({ message: PRIVATE_KEY, nested: { authorization: 'secret' } }), + headers: { 'opc-request-id': 'request-401' }, + }) + ) + const { client, endpoint } = await createPreparedClient() + const failure = await client + .request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + .catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OciClientError) + expect(failure).toMatchObject({ + code: 'request_failed', + message: 'OCI request failed', + status: 401, + opcRequestId: 'request-401', + }) + expect(JSON.stringify(failure)).not.toContain('BEGIN PRIVATE KEY') + expect(JSON.stringify(failure)).not.toContain('authorization') + }) + + it('returns only selected safe headers and bounded Uint8Array bodies', async () => { + mocks.secureFetch.mockResolvedValueOnce( + secureResponse({ + status: 200, + body: new Uint8Array([1, 2, 3]), + headers: { etag: 'etag-1', 'x-provider-secret': 'hidden' }, + }) + ) + const { client, endpoint } = await createPreparedClient() + const result = await client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + responseHeaders: ['etag'], + timeoutMs: 10_000, + maxResponseBytes: 3, + }) + expect([...result.body]).toEqual([1, 2, 3]) + expect(result.headers.etag).toBe('etag-1') + expect(result.headers).not.toHaveProperty('x-provider-secret') + }) + + it('cancels and classifies a success body beyond the operation limit', async () => { + const cancel = vi.fn() + mocks.secureFetch.mockResolvedValueOnce({ + ...secureResponse({ body: new Uint8Array([1, 2, 3, 4]) }), + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3, 4])) + }, + cancel, + }), + }) + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 3, + }) + ).rejects.toMatchObject({ code: 'response_too_large' }) + expect(cancel).toHaveBeenCalled() + }) + + it('rejects fabricated and cross-client authenticated discovery responses', async () => { + const policy = createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'database', + responsePolicy: STATIC_POLICY, + source: { kind: 'json', path: ['endpoint'] }, + }) + const first = await createPreparedClient() + const second = await createPreparedClient() + mocks.secureFetch.mockResolvedValueOnce( + secureResponse({ + body: JSON.stringify({ + endpoint: 'https://resource.database.us-ashburn-1.oraclecloud.com', + }), + }) + ) + const response = await first.client.request({ + endpoint: first.endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect((await first.client.prepareDiscoveredEndpoint(policy, response)).origin).toBe( + 'https://resource.database.us-ashburn-1.oraclecloud.com' + ) + await expect(second.client.prepareDiscoveredEndpoint(policy, response)).rejects.toMatchObject({ + code: 'invalid_endpoint', + }) + const otherPolicy = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'compute', + }) + const otherEndpoint = await first.client.prepareStaticEndpoint(otherPolicy) + mocks.secureFetch.mockResolvedValueOnce( + secureResponse({ + body: JSON.stringify({ + endpoint: 'https://resource.database.us-ashburn-1.oraclecloud.com', + }), + }) + ) + const wrongResourceResponse = await first.client.request({ + endpoint: otherEndpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + await expect( + first.client.prepareDiscoveredEndpoint(policy, wrongResourceResponse) + ).rejects.toMatchObject({ code: 'invalid_endpoint' }) + await expect( + first.client.prepareDiscoveredEndpoint(policy, { + status: 200, + headers: {}, + body: new Uint8Array(), + } as OciAuthenticatedResponse) + ).rejects.toMatchObject({ code: 'invalid_endpoint' }) + }) + + it('propagates caller abort without leaking a transport failure', async () => { + const controller = new AbortController() + mocks.secureFetch.mockImplementationOnce( + (_url: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { + once: true, + }) + }) + ) + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + signal: controller.signal, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + controller.abort() + await expect(pending).rejects.toMatchObject({ code: 'aborted' }) + }) + + it('applies one deadline to in-flight transport work', async () => { + vi.useFakeTimers() + mocks.secureFetch.mockImplementationOnce( + (_url: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { + once: true, + }) + }) + ) + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 100, + maxResponseBytes: 1024, + }) + const assertion = expect(pending).rejects.toMatchObject({ code: 'deadline_exceeded' }) + await vi.advanceTimersByTimeAsync(101) + await assertion + }) + + it('applies the same deadline while reading the response body', async () => { + vi.useFakeTimers() + const cancel = vi.fn() + mocks.secureFetch.mockResolvedValueOnce({ + ...secureResponse({}), + headers: new Headers(), + body: new ReadableStream({ cancel }), + }) + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 100, + maxResponseBytes: 1024, + }) + const assertion = expect(pending).rejects.toMatchObject({ code: 'deadline_exceeded' }) + await vi.advanceTimersByTimeAsync(101) + await assertion + expect(cancel).toHaveBeenCalled() + }) + + it('propagates caller abort during retry backoff', async () => { + vi.useFakeTimers() + mocks.backoff.mockReturnValue(1000) + mocks.secureFetch.mockResolvedValueOnce( + secureResponse({ status: 503, body: '{"code":"Busy"}' }) + ) + const controller = new AbortController() + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + signal: controller.signal, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + const assertion = expect(pending).rejects.toMatchObject({ code: 'aborted' }) + await vi.advanceTimersByTimeAsync(1) + controller.abort() + await assertion + expect(mocks.secureFetch).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts new file mode 100644 index 00000000000..45f8082e204 --- /dev/null +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -0,0 +1,926 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + createSign, + type KeyObject, +} from 'node:crypto' +import { db } from '@sim/db' +import { credential } from '@sim/db/schema' +import { safeCompare } from '@sim/security/compare' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import { and, eq } from 'drizzle-orm' +import { decryptSecret } from '@/lib/core/security/encryption' +import { + DEFAULT_MAX_RESPONSE_BYTES, + type SecureFetchResponse, + secureFetchWithValidation, +} from '@/lib/core/security/input-validation.server' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { + createOciStaticEndpointPolicy, + type OciDiscoveredEndpointPolicy, + type OciEndpointPolicy, + type OciPreparedEndpoint, + type OciRegion, + type OciStaticEndpointPolicy, + resolveDiscoveredOciEndpoint, + resolveEffectiveOciRegion, + resolveStaticOciEndpoint, +} from '@/lib/internal/oci/endpoints' +import { OciClientError } from '@/lib/internal/oci/errors' +import { + type OAuthService, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, + OCI_SERVICE_ID, +} from '@/lib/oauth/types' +import { getServiceConfigByServiceId } from '@/lib/oauth/utils' + +export type OciRequestMethod = 'GET' | 'HEAD' | 'DELETE' | 'POST' | 'PUT' | 'PATCH' + +export type OciRetryPolicy = + | { readonly kind: 'safe'; readonly maxAttempts: number } + | { readonly kind: 'tokenized'; readonly maxAttempts: number; readonly retryToken: string } + +export interface OciRequest { + readonly endpoint: OciPreparedEndpoint + readonly method: OciRequestMethod + readonly encodedPath: string + readonly queryPairs?: readonly (readonly [string, string])[] + readonly headers?: Readonly> + readonly body?: Uint8Array + readonly contentType?: string + readonly timeoutMs: number + readonly maxResponseBytes: number + readonly responseHeaders?: readonly string[] + readonly retry?: OciRetryPolicy + readonly signal?: AbortSignal +} + +declare const authenticatedOciResponseBrand: unique symbol + +export interface OciAuthenticatedResponse { + readonly status: number + readonly headers: Readonly> + readonly opcRequestId?: string + readonly body: Uint8Array + readonly [authenticatedOciResponseBrand]: true +} + +export interface OciClient { + prepareStaticEndpoint(policy: OciStaticEndpointPolicy): Promise + prepareDiscoveredEndpoint( + policy: OciDiscoveredEndpointPolicy, + response: OciAuthenticatedResponse + ): Promise + request(request: OciRequest): Promise +} + +/** + * Trusted binding supplied by a server-side operation after normal credential + * authorization. `credentialId` must be `authz.resolvedCredentialId` (or the + * selector equivalent), and `workspaceId` must come from the operation's + * trusted execution context. A caller-controlled database ID is not authority. + */ +export interface CreateOciClientParams { + readonly credentialId: string + readonly workspaceId: string + readonly serviceId: OAuthService + readonly region?: string +} + +interface OciCredentialMaterial { + readonly tenancyOcid: string + readonly userOcid: string + readonly fingerprint: string + readonly privateKey: KeyObject + readonly region: string +} + +interface BoundResponseSnapshot { + readonly status: number + readonly headers: Readonly> + readonly body: Uint8Array + readonly region: OciRegion + readonly policy: OciEndpointPolicy +} + +interface SignedOciRequest { + readonly url: string + readonly headers: Readonly> + readonly body?: Uint8Array +} + +const BODY_METHODS: ReadonlySet = new Set(['POST', 'PUT', 'PATCH']) +const REQUEST_METHODS: ReadonlySet = new Set([ + 'GET', + 'HEAD', + 'DELETE', + 'POST', + 'PUT', + 'PATCH', +]) +const SIGNING_CONTROLLED_HEADERS: ReadonlySet = new Set([ + 'authorization', + 'host', + 'date', + 'x-date', + 'content-length', + 'content-type', + 'x-content-sha256', +]) +const RESPONSE_HEADER_ALLOWLIST: ReadonlySet = new Set([ + 'content-type', + 'etag', + 'location', + 'opc-next-page', + 'opc-request-id', + 'opc-work-request-id', + 'retry-after', +]) +const RETRYABLE_STATUSES: ReadonlySet = new Set([429, 500, 502, 503, 504]) +const RETRYABLE_TRANSPORT_CODES: ReadonlySet = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'EHOSTUNREACH', + 'ENETDOWN', + 'ENETUNREACH', + 'ETIMEDOUT', +]) +const MAX_OCID_LENGTH = 255 +const MAX_PRIVATE_KEY_BYTES = 64 * 1024 +const MAX_PASSPHRASE_BYTES = 4 * 1024 +const MAX_TIMEOUT_MS = 5 * 60 * 1000 +const MAX_ATTEMPTS = 5 +const MAX_RETRY_TOKEN_BYTES = 512 +const SETUP_VERIFICATION_TIMEOUT_MS = 10_000 +const SETUP_VERIFICATION_RESPONSE_BYTES = 64 * 1024 +const OCID_PATTERN = /^ocid1\.([a-z][a-z0-9_-]*)\.([a-z0-9]+)\.([a-z0-9-]*)\.([a-zA-Z0-9_-]+)$/ +const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/ +const PEM_CONTROL_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/ + +function credentialUnavailable(): OciClientError { + return new OciClientError('credential_unavailable') +} + +function assertExactKeys( + record: Record, + required: readonly string[], + optional: readonly string[] = [] +): void { + const keys = Object.keys(record) + if ( + required.some((key) => !Object.hasOwn(record, key)) || + keys.some((key) => !required.includes(key) && !optional.includes(key)) + ) { + throw credentialUnavailable() + } +} + +function parseOcid(value: unknown, type: 'tenancy' | 'user'): { value: string; realm: string } { + if ( + typeof value !== 'string' || + value !== value.trim() || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > MAX_OCID_LENGTH || + CONTROL_PATTERN.test(value) + ) { + throw credentialUnavailable() + } + const match = OCID_PATTERN.exec(value) + if (!match || match[1] !== type) throw credentialUnavailable() + return { value, realm: match[2] } +} + +function normalizeFingerprint(value: unknown): string { + if (typeof value !== 'string' || value.length > 128 || CONTROL_PATTERN.test(value)) { + throw credentialUnavailable() + } + const hex = value.replace(/[:\s]/g, '').toLowerCase() + const bytes = /^[0-9a-f]{32}$/.test(hex) ? hex.match(/.{2}/g) : null + if (!bytes) throw credentialUnavailable() + return bytes.join(':') +} + +function parseCredentialMaterial(serialized: string): OciCredentialMaterial { + let parsed: unknown + try { + parsed = JSON.parse(serialized) + } catch { + throw credentialUnavailable() + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw credentialUnavailable() + } + const record = parsed as Record + assertExactKeys( + record, + [ + 'type', + 'providerId', + 'tenancyOcid', + 'userOcid', + 'fingerprint', + 'privateKey', + 'region', + 'metadata', + ], + ['privateKeyPassphrase'] + ) + if ( + record.type !== OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE || + record.providerId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || + !record.metadata || + typeof record.metadata !== 'object' || + Array.isArray(record.metadata) + ) { + throw credentialUnavailable() + } + const metadata = record.metadata as Record + assertExactKeys(metadata, ['principalKind', 'principalId']) + const tenancy = parseOcid(record.tenancyOcid, 'tenancy') + const user = parseOcid(record.userOcid, 'user') + if (tenancy.realm !== user.realm) throw credentialUnavailable() + + const fingerprint = normalizeFingerprint(record.fingerprint) + if (record.fingerprint !== fingerprint) throw credentialUnavailable() + if ( + typeof record.privateKey !== 'string' || + record.privateKey.length === 0 || + Buffer.byteLength(record.privateKey, 'utf8') > MAX_PRIVATE_KEY_BYTES || + PEM_CONTROL_PATTERN.test(record.privateKey) + ) { + throw credentialUnavailable() + } + const normalizedPrivateKey = `${record.privateKey.replace(/\r\n?/g, '\n').trim()}\n` + if (record.privateKey !== normalizedPrivateKey) throw credentialUnavailable() + + let passphrase: string | undefined + if (Object.hasOwn(record, 'privateKeyPassphrase')) { + if ( + typeof record.privateKeyPassphrase !== 'string' || + Buffer.byteLength(record.privateKeyPassphrase, 'utf8') > MAX_PASSPHRASE_BYTES || + CONTROL_PATTERN.test(record.privateKeyPassphrase) + ) { + throw credentialUnavailable() + } + passphrase = record.privateKeyPassphrase + } + if ( + typeof record.region !== 'string' || + record.region !== record.region.trim().toLowerCase() || + metadata.principalKind !== 'user' || + metadata.principalId !== user.value + ) { + throw credentialUnavailable() + } + const region = resolveEffectiveOciRegion(record.region) + if (region.realm.id !== tenancy.realm) throw credentialUnavailable() + + let privateKey: KeyObject + try { + privateKey = createPrivateKey({ + key: normalizedPrivateKey, + format: 'pem', + ...(passphrase !== undefined ? { passphrase } : {}), + }) + } catch { + throw credentialUnavailable() + } + if ( + privateKey.asymmetricKeyType !== 'rsa' || + privateKey.asymmetricKeyDetails?.modulusLength === undefined || + privateKey.asymmetricKeyDetails.modulusLength < 2048 + ) { + throw credentialUnavailable() + } + const publicKey = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) + const derivedFingerprint = createHash('md5').update(publicKey).digest() + const submittedFingerprint = Buffer.from(fingerprint.replaceAll(':', ''), 'hex') + if ( + !safeCompare(derivedFingerprint.toString('base64'), submittedFingerprint.toString('base64')) + ) { + throw credentialUnavailable() + } + + return { + tenancyOcid: tenancy.value, + userOcid: user.value, + fingerprint, + privateKey, + region: region.id, + } +} + +async function loadCredentialMaterial(params: { + credentialId: string + workspaceId: string +}): Promise { + try { + const [row] = await db + .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) + .from(credential) + .where( + and( + eq(credential.id, params.credentialId), + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) + ) + ) + .limit(1) + if (!row?.encryptedServiceAccountKey) throw credentialUnavailable() + const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) + return parseCredentialMaterial(decrypted) + } catch { + throw credentialUnavailable() + } +} + +function serializeQueryPairs(pairs: readonly (readonly [string, string])[]): string { + const encode = (value: string) => + (() => { + try { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ) + } catch { + throw new OciClientError('invalid_request') + } + })() + return pairs.map(([key, value]) => `${encode(key)}=${encode(value)}`).join('&') +} + +function buildRequestUrl( + endpoint: OciPreparedEndpoint, + encodedPath: string, + queryPairs: readonly (readonly [string, string])[] +): string { + if ( + typeof encodedPath !== 'string' || + !encodedPath.startsWith('/') || + encodedPath.startsWith('//') || + encodedPath.includes('//') || + /[?#\\\u0000-\u001f\u007f]/.test(encodedPath) || + /%(?:0[0-9a-f]|1[0-9a-f]|2f|5c|7f)/i.test(encodedPath) || + /%(?![0-9a-f]{2})/i.test(encodedPath) + ) { + throw new OciClientError('invalid_request') + } + let url: URL + try { + url = new URL(`${endpoint.origin}${encodedPath}`) + } catch { + throw new OciClientError('invalid_request') + } + if (url.pathname !== encodedPath) throw new OciClientError('invalid_request') + const query = serializeQueryPairs(queryPairs) + return `${endpoint.origin}${encodedPath}${query ? `?${query}` : ''}` +} + +function validateHeaders(headers: Readonly>): Record { + const normalized: Record = {} + for (const [name, value] of Object.entries(headers)) { + const lowerName = name.toLowerCase() + if ( + SIGNING_CONTROLLED_HEADERS.has(lowerName) || + lowerName === 'opc-retry-token' || + Object.hasOwn(normalized, lowerName) || + !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || + typeof value !== 'string' || + CONTROL_PATTERN.test(value) + ) { + throw new OciClientError('invalid_request') + } + normalized[lowerName] = value + } + return normalized +} + +function validateRequest(request: OciRequest): { + body?: Uint8Array + headers: Record + queryPairs: readonly (readonly [string, string])[] + attempts: number + retryToken?: string +} { + if ( + !REQUEST_METHODS.has(request.method) || + typeof request.encodedPath !== 'string' || + !Number.isSafeInteger(request.timeoutMs) || + request.timeoutMs <= 0 || + request.timeoutMs > MAX_TIMEOUT_MS || + !Number.isSafeInteger(request.maxResponseBytes) || + request.maxResponseBytes <= 0 || + request.maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES + ) { + throw new OciClientError('invalid_request') + } + if ( + request.headers !== undefined && + (!request.headers || typeof request.headers !== 'object' || Array.isArray(request.headers)) + ) { + throw new OciClientError('invalid_request') + } + const bodyMethod = BODY_METHODS.has(request.method) + if ( + (bodyMethod && (!(request.body instanceof Uint8Array) || request.contentType === undefined)) || + (!bodyMethod && (request.body !== undefined || request.contentType !== undefined)) + ) { + throw new OciClientError('invalid_request') + } + if ( + request.contentType !== undefined && + (typeof request.contentType !== 'string' || + request.contentType.length === 0 || + request.contentType.length > 256 || + CONTROL_PATTERN.test(request.contentType)) + ) { + throw new OciClientError('invalid_request') + } + const headers = validateHeaders(request.headers ?? {}) + if (request.queryPairs !== undefined && !Array.isArray(request.queryPairs)) { + throw new OciClientError('invalid_request') + } + const queryPairs = (request.queryPairs ?? []).map((pair) => { + if ( + !Array.isArray(pair) || + pair.length !== 2 || + typeof pair[0] !== 'string' || + typeof pair[1] !== 'string' + ) { + throw new OciClientError('invalid_request') + } + return Object.freeze([pair[0], pair[1]] as const) + }) + let attempts = 1 + let retryToken: string | undefined + if (request.retry) { + if ( + typeof request.retry !== 'object' || + Array.isArray(request.retry) || + (request.retry.kind !== 'safe' && request.retry.kind !== 'tokenized') || + Object.keys(request.retry).some( + (key) => + key !== 'kind' && + key !== 'maxAttempts' && + !(request.retry?.kind === 'tokenized' && key === 'retryToken') + ) || + !Number.isSafeInteger(request.retry.maxAttempts) || + request.retry.maxAttempts < 2 || + request.retry.maxAttempts > MAX_ATTEMPTS + ) { + throw new OciClientError('invalid_request') + } + attempts = request.retry.maxAttempts + if (request.retry.kind === 'tokenized') { + if ( + typeof request.retry.retryToken !== 'string' || + request.retry.retryToken.length === 0 || + Buffer.byteLength(request.retry.retryToken, 'utf8') > MAX_RETRY_TOKEN_BYTES || + CONTROL_PATTERN.test(request.retry.retryToken) + ) { + throw new OciClientError('invalid_request') + } + retryToken = request.retry.retryToken + } + } + if (request.responseHeaders !== undefined && !Array.isArray(request.responseHeaders)) { + throw new OciClientError('invalid_request') + } + for (const name of request.responseHeaders ?? []) { + if (typeof name !== 'string' || !RESPONSE_HEADER_ALLOWLIST.has(name.toLowerCase())) { + throw new OciClientError('invalid_request') + } + } + return { + ...(request.body !== undefined ? { body: new Uint8Array(request.body) } : {}), + headers, + queryPairs, + attempts, + ...(retryToken !== undefined ? { retryToken } : {}), + } +} + +function signRequest(params: { + material: OciCredentialMaterial + method: OciRequestMethod + url: string + headers: Readonly> + body?: Uint8Array + contentType?: string + signingDate: Date +}): SignedOciRequest { + const url = new URL(params.url) + const headers: Record = { + ...params.headers, + host: url.host, + 'x-date': params.signingDate.toUTCString(), + } + const headerNames = ['x-date', '(request-target)', 'host'] + if (params.body !== undefined) { + headers['content-type'] = params.contentType! + headers['content-length'] = String(params.body.byteLength) + headers['x-content-sha256'] = createHash('sha256').update(params.body).digest('base64') + headerNames.push('content-type', 'content-length', 'x-content-sha256') + } + const target = `${url.pathname}${url.search}` + const signingString = headerNames + .map((name) => + name === '(request-target)' + ? `(request-target): ${params.method.toLowerCase()} ${target}` + : `${name}: ${headers[name]}` + ) + .join('\n') + const signature = createSign('RSA-SHA256') + .update(signingString) + .end() + .sign(params.material.privateKey, 'base64') + const keyId = `${params.material.tenancyOcid}/${params.material.userOcid}/${params.material.fingerprint}` + headers.authorization = `Signature version="1",keyId="${keyId}",algorithm="rsa-sha256",headers="${headerNames.join(' ')}",signature="${signature}"` + return { + url: params.url, + headers, + ...(params.body !== undefined ? { body: new Uint8Array(params.body) } : {}), + } +} + +function selectedResponseHeaders( + response: SecureFetchResponse, + requested: readonly string[] +): Readonly> { + const selected = new Set(['content-type', 'etag', 'opc-request-id', ...requested.map(String)]) + const result: Record = {} + for (const name of selected) { + const normalized = name.toLowerCase() + if (!RESPONSE_HEADER_ALLOWLIST.has(normalized)) continue + const value = response.headers.get(normalized) + if (value !== null) result[normalized] = value + } + return Object.freeze(result) +} + +async function readFailureCode( + response: SecureFetchResponse, + signal: AbortSignal +): Promise { + try { + const body = await readResponseToBufferWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'OCI error response', + signal, + allowNoBodyFallback: true, + }) + const parsed: unknown = JSON.parse(body.toString('utf8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined + const code = (parsed as Record).code + return typeof code === 'string' && code.length <= 128 ? code : undefined + } catch { + await response.body?.cancel().catch(() => {}) + return undefined + } +} + +function isRetryableTransportFailure(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const code = (error as { code?: unknown }).code + return typeof code === 'string' && RETRYABLE_TRANSPORT_CODES.has(code) +} + +function extractDiscoveredOrigin( + policy: OciDiscoveredEndpointPolicy, + snapshot: BoundResponseSnapshot +): string { + if (policy.source.kind === 'header') { + const value = snapshot.headers[policy.source.name] + if (!value) throw new OciClientError('invalid_endpoint') + return value + } + let value: unknown + try { + value = JSON.parse(Buffer.from(snapshot.body).toString('utf8')) + } catch { + throw new OciClientError('invalid_endpoint') + } + for (const segment of policy.source.path) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new OciClientError('invalid_endpoint') + } + value = (value as Record)[segment] + } + if (typeof value !== 'string') throw new OciClientError('invalid_endpoint') + return value +} + +function createDeadline( + timeoutMs: number, + callerSignal?: AbortSignal +): { + signal: AbortSignal + deadlineAt: number + expired: () => boolean + cleanup: () => void +} { + const controller = new AbortController() + let deadlineExpired = false + const deadlineAt = Date.now() + timeoutMs + const timer = setTimeout(() => { + deadlineExpired = true + controller.abort(new OciClientError('deadline_exceeded')) + }, timeoutMs) + const abortFromCaller = () => controller.abort(callerSignal?.reason) + if (callerSignal?.aborted) abortFromCaller() + else callerSignal?.addEventListener('abort', abortFromCaller, { once: true }) + return { + signal: controller.signal, + deadlineAt, + expired: () => deadlineExpired, + cleanup: () => { + clearTimeout(timer) + callerSignal?.removeEventListener('abort', abortFromCaller) + }, + } +} + +async function waitForRetry(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) throw toError(signal.reason) + let rejectAbort: ((reason?: unknown) => void) | undefined + const aborted = new Promise((_, reject) => { + rejectAbort = reject + }) + const onAbort = () => rejectAbort?.(signal.reason) + signal.addEventListener('abort', onAbort, { once: true }) + try { + await Promise.race([sleep(delayMs), aborted]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} + +/** Creates a lazily loaded OCI client bound to trusted workspace and service context. */ +export async function createOciClient(params: CreateOciClientParams): Promise { + const service = getServiceConfigByServiceId(params.serviceId) + if (service?.serviceAccountProviderId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { + throw new OciClientError('invalid_endpoint') + } + + let materialPromise: Promise | undefined + let lastSigningTime = 0 + const preparedEndpoints = new WeakSet() + const endpointPolicies = new WeakMap() + const responseSnapshots = new WeakMap() + + const getMaterial = () => { + materialPromise ??= loadCredentialMaterial({ + credentialId: params.credentialId, + workspaceId: params.workspaceId, + }) + return materialPromise + } + const assertPolicyOwner = (policy: OciEndpointPolicy) => { + if (policy.serviceId !== params.serviceId) throw new OciClientError('invalid_endpoint') + } + const effectiveRegion = async () => { + const material = await getMaterial() + return resolveEffectiveOciRegion(material.region, params.region) + } + const nextSigningDate = () => { + const now = Math.max(Date.now(), lastSigningTime + 1000) + lastSigningTime = now + return new Date(now) + } + + const client: OciClient = { + async prepareStaticEndpoint(policy) { + assertPolicyOwner(policy) + try { + const endpoint = resolveStaticOciEndpoint(policy, await effectiveRegion()) + preparedEndpoints.add(endpoint) + endpointPolicies.set(endpoint, policy) + return endpoint + } catch (error) { + if (error instanceof OciClientError) throw error + throw new OciClientError('invalid_endpoint') + } + }, + + async prepareDiscoveredEndpoint(policy, response) { + assertPolicyOwner(policy) + const snapshot = responseSnapshots.get(response) + if (!snapshot || snapshot.policy !== policy.responsePolicy) { + throw new OciClientError('invalid_endpoint') + } + try { + const origin = extractDiscoveredOrigin(policy, snapshot) + const endpoint = resolveDiscoveredOciEndpoint(policy, snapshot.region, origin) + preparedEndpoints.add(endpoint) + endpointPolicies.set(endpoint, policy) + return endpoint + } catch (error) { + if (error instanceof OciClientError) throw error + throw new OciClientError('invalid_endpoint') + } + }, + + async request(request) { + if ( + !preparedEndpoints.has(request.endpoint) || + request.endpoint.serviceId !== params.serviceId + ) { + throw new OciClientError('invalid_endpoint') + } + const endpointPolicy = endpointPolicies.get(request.endpoint) + if (!endpointPolicy) throw new OciClientError('invalid_endpoint') + const validated = validateRequest(request) + const url = buildRequestUrl(request.endpoint, request.encodedPath, validated.queryPairs) + const deadline = createDeadline(request.timeoutMs, request.signal) + try { + const material = await getMaterial() + for (let attempt = 1; attempt <= validated.attempts; attempt += 1) { + if (deadline.signal.aborted) { + throw new OciClientError(deadline.expired() ? 'deadline_exceeded' : 'aborted') + } + const remainingMs = deadline.deadlineAt - Date.now() + if (remainingMs <= 0) throw new OciClientError('deadline_exceeded') + const signed = signRequest({ + material, + method: request.method, + url, + headers: { + ...validated.headers, + ...(validated.retryToken ? { 'opc-retry-token': validated.retryToken } : {}), + }, + body: validated.body, + contentType: request.contentType, + signingDate: nextSigningDate(), + }) + + let response: SecureFetchResponse + try { + response = await secureFetchWithValidation( + signed.url, + { + method: request.method, + headers: { ...signed.headers }, + ...(signed.body !== undefined ? { body: new Uint8Array(signed.body) } : {}), + timeout: Math.max(1, Math.floor(remainingMs)), + maxResponseBytes: request.maxResponseBytes, + maxRedirects: 0, + signal: deadline.signal, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }, + 'OCI destination' + ) + } catch (error) { + if (deadline.signal.aborted) { + throw new OciClientError(deadline.expired() ? 'deadline_exceeded' : 'aborted') + } + if (attempt < validated.attempts && isRetryableTransportFailure(error)) { + const delay = backoffWithJitter(attempt, null, { baseMs: 200, maxMs: 5000 }) + if (delay >= deadline.deadlineAt - Date.now()) { + throw new OciClientError('deadline_exceeded') + } + await waitForRetry(delay, deadline.signal) + continue + } + throw new OciClientError('request_failed') + } + + const opcRequestId = response.headers.get('opc-request-id') + if (!response.ok) { + const providerCode = await readFailureCode(response, deadline.signal) + const retryable = + RETRYABLE_STATUSES.has(response.status) || + (response.status === 409 && providerCode === 'IncorrectState') + if (retryable && attempt < validated.attempts) { + const retryAfter = parseRetryAfter(response.headers.get('retry-after'), 5000) + const delay = backoffWithJitter(attempt, retryAfter, { baseMs: 200, maxMs: 5000 }) + if (delay >= deadline.deadlineAt - Date.now()) { + throw new OciClientError('deadline_exceeded') + } + await waitForRetry(delay, deadline.signal) + continue + } + throw new OciClientError('request_failed', { + status: response.status, + opcRequestId, + }) + } + + let body: Uint8Array + try { + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: request.maxResponseBytes, + label: 'OCI response', + signal: deadline.signal, + requestMethod: request.method, + allowNoBodyFallback: true, + }) + body = new Uint8Array(buffer) + } catch (error) { + if (deadline.signal.aborted) { + throw new OciClientError(deadline.expired() ? 'deadline_exceeded' : 'aborted') + } + if (isPayloadSizeLimitError(error)) throw new OciClientError('response_too_large') + throw new OciClientError('request_failed') + } + const headers = selectedResponseHeaders(response, request.responseHeaders ?? []) + const result = Object.freeze({ + status: response.status, + headers, + ...(opcRequestId ? { opcRequestId } : {}), + body: new Uint8Array(body), + }) as OciAuthenticatedResponse + responseSnapshots.set(result, { + status: response.status, + headers, + body: new Uint8Array(body), + region: request.endpoint.region, + policy: endpointPolicy, + }) + return result + } + throw new OciClientError('request_failed') + } catch (error) { + if (error instanceof OciClientError) throw error + if (deadline.signal.aborted) { + throw new OciClientError(deadline.expired() ? 'deadline_exceeded' : 'aborted') + } + throw new OciClientError('request_failed') + } finally { + deadline.cleanup() + } + }, + } + + return Object.freeze(client) +} + +/** @internal Performs only the fixed GetNamespace check used during credential setup. */ +export async function verifyOciApiKeyCredentialForSetup( + serializedSecret: string, + signal?: AbortSignal +): Promise { + const material = parseCredentialMaterial(serializedSecret) + const policy = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'objectstorage', + }) + const endpoint = resolveStaticOciEndpoint(policy, resolveEffectiveOciRegion(material.region)) + const url = buildRequestUrl(endpoint, '/n/', []) + const deadline = createDeadline(SETUP_VERIFICATION_TIMEOUT_MS, signal) + try { + const signed = signRequest({ + material, + method: 'GET', + url, + headers: { accept: 'application/json' }, + signingDate: new Date(), + }) + const response = await secureFetchWithValidation( + signed.url, + { + method: 'GET', + headers: { ...signed.headers }, + timeout: SETUP_VERIFICATION_TIMEOUT_MS, + maxResponseBytes: SETUP_VERIFICATION_RESPONSE_BYTES, + maxRedirects: 0, + signal: deadline.signal, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }, + 'OCI credential verification destination' + ) + if (!response.ok) { + await readFailureCode(response, deadline.signal) + throw new OciClientError('request_failed', { + status: response.status, + opcRequestId: response.headers.get('opc-request-id'), + }) + } + const body = await readResponseToBufferWithLimit(response, { + maxBytes: SETUP_VERIFICATION_RESPONSE_BYTES, + label: 'OCI credential verification response', + signal: deadline.signal, + allowNoBodyFallback: true, + }) + return new Uint8Array(body) + } catch (error) { + if (error instanceof OciClientError) throw error + if (deadline.signal.aborted) { + throw new OciClientError(deadline.expired() ? 'deadline_exceeded' : 'aborted') + } + throw new OciClientError('request_failed') + } finally { + deadline.cleanup() + } +} diff --git a/apps/sim/lib/internal/oci/endpoints.test.ts b/apps/sim/lib/internal/oci/endpoints.test.ts new file mode 100644 index 00000000000..94a8a1ca4af --- /dev/null +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -0,0 +1,133 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + createOciDiscoveredEndpointPolicy, + createOciStaticEndpointPolicy, + getOciRegion, + OCI_REGION_IDS, + regionalOciHostname, + resolveDiscoveredOciEndpoint, + resolveEffectiveOciRegion, + resolveStaticOciEndpoint, +} from '@/lib/internal/oci/endpoints' +import { OCI_SERVICE_ID } from '@/lib/oauth/types' + +const staticPolicy = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'identity', +}) +const discoveryPolicy = createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'database', + responsePolicy: staticPolicy, + source: { kind: 'json', path: ['endpoint'] }, +}) + +describe('OCI region registry', () => { + it('resolves every snapshotted region to a known realm domain', () => { + expect(OCI_REGION_IDS.length).toBeGreaterThan(80) + for (const id of OCI_REGION_IDS) { + const region = getOciRegion(id) + expect(region.id).toBe(id) + expect(region.realm.id).toMatch(/^oc\d+$/) + expect(region.realm.domain).toMatch(/^(?:oraclecloud|oraclegovcloud)/) + expect(regionalOciHostname('identity', region)).toBe(`identity.${id}.${region.realm.domain}`) + } + }) + + it('normalizes known regions and fails closed for unknown regions', () => { + expect(getOciRegion(' US-ASHBURN-1 ').id).toBe('us-ashburn-1') + expect(() => getOciRegion('moon-base-1')).toThrow('not recognized') + expect(() => getOciRegion('constructor')).toThrow('not recognized') + }) + + it('allows only same-realm region overrides', () => { + expect(resolveEffectiveOciRegion('us-ashburn-1', 'eu-frankfurt-1').id).toBe('eu-frankfurt-1') + expect(() => resolveEffectiveOciRegion('us-ashburn-1', 'us-gov-ashburn-1')).toThrow( + 'credential realm' + ) + }) +}) + +describe('OCI endpoint policies', () => { + const region = getOciRegion('us-ashburn-1') + + it('freezes declarative policies and derives exact static origins', () => { + expect(Object.isFrozen(staticPolicy)).toBe(true) + expect(resolveStaticOciEndpoint(staticPolicy, region)).toMatchObject({ + origin: 'https://identity.us-ashburn-1.oraclecloud.com', + hostname: 'identity.us-ashburn-1.oraclecloud.com', + serviceId: OCI_SERVICE_ID, + serviceName: 'identity', + provenance: 'static', + }) + }) + + it('accepts discovered resource hosts only beneath the declared service, region, and realm', () => { + expect( + resolveDiscoveredOciEndpoint( + discoveryPolicy, + region, + 'https://resource.database.us-ashburn-1.oraclecloud.com' + ) + ).toMatchObject({ + serviceName: 'database', + provenance: 'authenticated-discovery', + }) + }) + + it.each([ + 'http://resource.database.us-ashburn-1.oraclecloud.com', + 'https://resource.database.us-ashburn-1.oraclecloud.com:8443', + 'https://user@resource.database.us-ashburn-1.oraclecloud.com', + 'https://resource.database.us-ashburn-1.oraclecloud.com/path', + 'https://127.0.0.1', + 'https://database.us-ashburn-1.oraclecloud.com', + 'https://resource.database.eu-frankfurt-1.oraclecloud.com', + 'https://resource.database.us-ashburn-1.oraclegovcloud.com', + 'https://resource.database.us-ashburn-1.example.com', + ])('rejects an origin outside the discovery policy: %s', (origin) => { + expect(() => resolveDiscoveredOciEndpoint(discoveryPolicy, region, origin)).toThrow() + }) + + it('can explicitly permit the regional service host for authenticated discovery', () => { + const policy = createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'database', + responsePolicy: staticPolicy, + source: { kind: 'header', name: 'Endpoint' }, + allowRegionalHost: true, + }) + expect( + resolveDiscoveredOciEndpoint(policy, region, 'https://database.us-ashburn-1.oraclecloud.com') + .origin + ).toBe('https://database.us-ashburn-1.oraclecloud.com') + expect(policy.source).toEqual({ kind: 'header', name: 'endpoint' }) + expect(Object.isFrozen(policy.source)).toBe(true) + }) + + it('rejects malformed policy declarations and forged region mappings', () => { + expect(() => + createOciStaticEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'bad.name' }) + ).toThrow('service name') + expect(() => + resolveStaticOciEndpoint(staticPolicy, { + id: region.id, + realm: { id: 'oc2', domain: 'oraclegovcloud.com' }, + }) + ).toThrow('known registry') + expect(() => + createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'database', + responsePolicy: createOciStaticEndpointPolicy({ + serviceId: 'slack', + serviceName: 'identity', + }), + source: { kind: 'json', path: ['endpoint'] }, + }) + ).toThrow('same owning service') + }) +}) diff --git a/apps/sim/lib/internal/oci/endpoints.ts b/apps/sim/lib/internal/oci/endpoints.ts new file mode 100644 index 00000000000..e9e0ddc5154 --- /dev/null +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -0,0 +1,351 @@ +import { isIpLiteral, unwrapIpv6Brackets } from '@sim/security/ssrf' +import type { OAuthService } from '@/lib/oauth/types' + +export type OciDestinationProvenance = 'static' | 'authenticated-discovery' + +export interface OciRealm { + readonly id: string + readonly domain: string +} + +export interface OciRegion { + readonly id: string + readonly realm: OciRealm +} + +declare const preparedOciEndpointBrand: unique symbol + +/** An OCI endpoint prepared from a declarative product policy. */ +export interface OciPreparedEndpoint { + readonly origin: string + readonly hostname: string + readonly serviceId: OAuthService + readonly serviceName: string + readonly region: OciRegion + readonly provenance: OciDestinationProvenance + readonly [preparedOciEndpointBrand]: true +} + +declare const ociEndpointPolicyBrand: unique symbol + +export interface OciStaticEndpointPolicy { + readonly kind: 'static' + readonly serviceId: OAuthService + readonly serviceName: string + readonly [ociEndpointPolicyBrand]: true +} + +export type OciDiscoverySource = + | { readonly kind: 'header'; readonly name: string } + | { readonly kind: 'json'; readonly path: readonly string[] } + +export interface OciDiscoveredEndpointPolicy { + readonly kind: 'authenticated-discovery' + readonly serviceId: OAuthService + readonly serviceName: string + readonly responsePolicy: OciEndpointPolicy + readonly source: OciDiscoverySource + readonly allowRegionalHost: boolean + readonly [ociEndpointPolicyBrand]: true +} + +export type OciEndpointPolicy = OciStaticEndpointPolicy | OciDiscoveredEndpointPolicy + +/** + * Realm and region snapshot copied from `oci-common@2.140.0` files + * `lib/realm.js` and `lib/region.js`, and verified byte-for-byte against the + * same registry files in `2.140.1`. Unknown runtime metadata is deliberately + * excluded so credentials cannot weaken endpoint trust with local OCI config. + */ +const REALM_DOMAINS = { + oc1: 'oraclecloud.com', + oc2: 'oraclegovcloud.com', + oc3: 'oraclegovcloud.com', + oc4: 'oraclegovcloud.uk', + oc8: 'oraclecloud8.com', + oc9: 'oraclecloud9.com', + oc10: 'oraclecloud10.com', + oc14: 'oraclecloud14.com', + oc15: 'oraclecloud15.com', + oc19: 'oraclecloud.eu', + oc20: 'oraclecloud20.com', + oc21: 'oraclecloud21.com', + oc23: 'oraclecloud23.com', + oc24: 'oraclecloud24.com', + oc26: 'oraclecloud26.com', + oc29: 'oraclecloud29.com', + oc35: 'oraclecloud35.com', + oc42: 'oraclecloud42.com', + oc51: 'oraclecloud51.com', + oc52: 'oraclecloud52.com', +} as const + +type OciRealmId = keyof typeof REALM_DOMAINS + +const REGION_REALMS = { + 'ap-chuncheon-1': 'oc1', + 'ap-mumbai-1': 'oc1', + 'ap-hyderabad-1': 'oc1', + 'ap-seoul-1': 'oc1', + 'ap-sydney-1': 'oc1', + 'ap-melbourne-1': 'oc1', + 'ap-osaka-1': 'oc1', + 'ap-tokyo-1': 'oc1', + 'ca-montreal-1': 'oc1', + 'ca-toronto-1': 'oc1', + 'eu-frankfurt-1': 'oc1', + 'eu-zurich-1': 'oc1', + 'sa-saopaulo-1': 'oc1', + 'uk-cardiff-1': 'oc1', + 'uk-london-1': 'oc1', + 'us-ashburn-1': 'oc1', + 'us-phoenix-1': 'oc1', + 'eu-amsterdam-1': 'oc1', + 'me-jeddah-1': 'oc1', + 'us-sanjose-1': 'oc1', + 'me-dubai-1': 'oc1', + 'sa-santiago-1': 'oc1', + 'sa-vinhedo-1': 'oc1', + 'il-jerusalem-1': 'oc1', + 'eu-marseille-1': 'oc1', + 'ap-singapore-1': 'oc1', + 'me-abudhabi-1': 'oc1', + 'eu-milan-1': 'oc1', + 'eu-stockholm-1': 'oc1', + 'af-johannesburg-1': 'oc1', + 'eu-paris-1': 'oc1', + 'mx-queretaro-1': 'oc1', + 'eu-madrid-1': 'oc1', + 'us-chicago-1': 'oc1', + 'mx-monterrey-1': 'oc1', + 'us-saltlake-2': 'oc1', + 'sa-bogota-1': 'oc1', + 'sa-valparaiso-1': 'oc1', + 'ap-singapore-2': 'oc1', + 'me-riyadh-1': 'oc1', + 'ap-delhi-1': 'oc1', + 'ap-batam-1': 'oc1', + 'eu-madrid-3': 'oc1', + 'eu-turin-1': 'oc1', + 'ap-kulai-2': 'oc1', + 'af-casablanca-1': 'oc1', + 'us-langley-1': 'oc2', + 'us-luke-1': 'oc2', + 'us-gov-ashburn-1': 'oc3', + 'us-gov-chicago-1': 'oc3', + 'us-gov-phoenix-1': 'oc3', + 'uk-gov-london-1': 'oc4', + 'uk-gov-cardiff-1': 'oc4', + 'ap-chiyoda-1': 'oc8', + 'ap-ibaraki-1': 'oc8', + 'me-dcc-muscat-1': 'oc9', + 'me-ibri-1': 'oc9', + 'ap-dcc-canberra-1': 'oc10', + 'eu-dcc-milan-1': 'oc14', + 'eu-dcc-milan-2': 'oc14', + 'eu-dcc-dublin-2': 'oc14', + 'eu-dcc-rating-2': 'oc14', + 'eu-dcc-rating-1': 'oc14', + 'eu-dcc-dublin-1': 'oc14', + 'ap-dcc-gazipur-1': 'oc15', + 'eu-madrid-2': 'oc19', + 'eu-frankfurt-2': 'oc19', + 'eu-jovanovac-1': 'oc20', + 'me-dcc-doha-1': 'oc21', + 'me-alrayyan-1': 'oc21', + 'us-somerset-1': 'oc23', + 'us-thames-1': 'oc23', + 'eu-dcc-zurich-1': 'oc24', + 'eu-crissier-1': 'oc24', + 'me-abudhabi-3': 'oc26', + 'me-alain-1': 'oc26', + 'me-abudhabi-2': 'oc29', + 'me-abudhabi-4': 'oc29', + 'ap-seoul-2': 'oc35', + 'ap-suwon-1': 'oc35', + 'ap-chuncheon-2': 'oc35', + 'us-ashburn-2': 'oc42', + 'us-newark-1': 'oc42', + 'eu-budapest-1': 'oc51', + 'sa-riodejaneiro-1': 'oc52', +} as const satisfies Record + +export const OCI_REGION_IDS = Object.freeze(Object.keys(REGION_REALMS)) + +function normalizeRegionId(regionId: string): string { + return regionId.trim().toLowerCase() +} + +export function getOciRegion(regionId: string): OciRegion { + const normalized = normalizeRegionId(regionId) + const realmId = Object.hasOwn(REGION_REALMS, normalized) + ? REGION_REALMS[normalized as keyof typeof REGION_REALMS] + : undefined + if (!realmId) throw new Error('OCI region is not recognized') + return { + id: normalized, + realm: { id: realmId, domain: REALM_DOMAINS[realmId] }, + } +} + +export function resolveEffectiveOciRegion(defaultRegion: string, override?: string): OciRegion { + const configured = getOciRegion(defaultRegion) + const effective = override === undefined ? configured : getOciRegion(override) + if (configured.realm.id !== effective.realm.id) { + throw new Error('OCI region override must remain in the credential realm') + } + return effective +} + +function assertServiceName(value: string): void { + if (!/^[a-z][a-z0-9-]{0,62}$/.test(value)) { + throw new Error('OCI endpoint policy service name is invalid') + } +} + +function assertDiscoverySource(source: OciDiscoverySource): void { + if (source.kind === 'header') { + if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(source.name)) { + throw new Error('OCI discovery header name is invalid') + } + return + } + if ( + source.kind !== 'json' || + source.path.length === 0 || + source.path.length > 8 || + source.path.some( + (segment) => + segment.length === 0 || segment.length > 128 || /[\u0000-\u001f\u007f]/.test(segment) + ) + ) { + throw new Error('OCI discovery JSON path is invalid') + } +} + +/** Creates a frozen exact regional-host policy owned by one registered service. */ +export function createOciStaticEndpointPolicy(params: { + serviceId: OAuthService + serviceName: string +}): OciStaticEndpointPolicy { + assertServiceName(params.serviceName) + return Object.freeze({ + kind: 'static', + serviceId: params.serviceId, + serviceName: params.serviceName, + }) as OciStaticEndpointPolicy +} + +/** Creates a frozen authenticated-discovery policy without executable hostname callbacks. */ +export function createOciDiscoveredEndpointPolicy(params: { + serviceId: OAuthService + serviceName: string + responsePolicy: OciEndpointPolicy + source: OciDiscoverySource + allowRegionalHost?: boolean +}): OciDiscoveredEndpointPolicy { + assertServiceName(params.serviceName) + assertDiscoverySource(params.source) + if (params.responsePolicy.serviceId !== params.serviceId) { + throw new Error('OCI discovery source policy must have the same owning service') + } + const source = + params.source.kind === 'json' + ? Object.freeze({ ...params.source, path: Object.freeze([...params.source.path]) }) + : Object.freeze({ ...params.source, name: params.source.name.toLowerCase() }) + return Object.freeze({ + kind: 'authenticated-discovery', + serviceId: params.serviceId, + serviceName: params.serviceName, + responsePolicy: params.responsePolicy, + source, + allowRegionalHost: params.allowRegionalHost ?? false, + }) as OciDiscoveredEndpointPolicy +} + +export function regionalOciHostname(serviceName: string, region: OciRegion): string { + assertServiceName(serviceName) + return `${serviceName}.${region.id}.${region.realm.domain}` +} + +function validateOciOrigin(params: { + origin: string + policy: OciEndpointPolicy + region: OciRegion + provenance: OciDestinationProvenance +}): OciPreparedEndpoint { + const knownRegion = getOciRegion(params.region.id) + if ( + knownRegion.realm.id !== params.region.realm.id || + knownRegion.realm.domain !== params.region.realm.domain + ) { + throw new Error('OCI destination region and realm must match the known registry') + } + let url: URL + try { + url = new URL(params.origin) + } catch { + throw new Error('OCI destination must be a valid HTTPS origin') + } + if ( + params.policy.kind !== params.provenance || + url.protocol !== 'https:' || + url.port !== '' || + url.username !== '' || + url.password !== '' || + url.pathname !== '/' || + url.search !== '' || + url.hash !== '' || + isIpLiteral(unwrapIpv6Brackets(url.hostname)) || + url.origin !== params.origin + ) { + throw new Error('OCI destination must be an exact HTTPS origin with the default port') + } + const regionalHostname = regionalOciHostname(params.policy.serviceName, knownRegion) + const hostnameMatches = + params.provenance === 'static' + ? url.hostname === regionalHostname + : url.hostname.endsWith(`.${regionalHostname}`) || + (params.policy.kind === 'authenticated-discovery' && + params.policy.allowRegionalHost && + url.hostname === regionalHostname) + if (!hostnameMatches) { + throw new Error('OCI destination hostname is not owned by the requested service') + } + return { + origin: url.origin, + hostname: url.hostname, + serviceId: params.policy.serviceId, + serviceName: params.policy.serviceName, + region: knownRegion, + provenance: params.provenance, + } as OciPreparedEndpoint +} + +/** Resolves a static policy exclusively from its service and validated region. */ +export function resolveStaticOciEndpoint( + policy: OciStaticEndpointPolicy, + region: OciRegion +): OciPreparedEndpoint { + const hostname = regionalOciHostname(policy.serviceName, region) + return validateOciOrigin({ + origin: `https://${hostname}`, + policy, + region, + provenance: 'static', + }) +} + +/** Structurally validates an origin extracted from an authenticated response. */ +export function resolveDiscoveredOciEndpoint( + policy: OciDiscoveredEndpointPolicy, + region: OciRegion, + origin: string +): OciPreparedEndpoint { + return validateOciOrigin({ + origin, + policy, + region, + provenance: 'authenticated-discovery', + }) +} diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts new file mode 100644 index 00000000000..d4781e4d7cc --- /dev/null +++ b/apps/sim/lib/internal/oci/errors.ts @@ -0,0 +1,52 @@ +export type OciClientErrorCode = + | 'credential_unavailable' + | 'invalid_request' + | 'invalid_endpoint' + | 'deadline_exceeded' + | 'aborted' + | 'response_too_large' + | 'request_failed' + +const ERROR_MESSAGES: Record = { + credential_unavailable: 'OCI credential is unavailable', + invalid_request: 'OCI request is invalid', + invalid_endpoint: 'OCI endpoint is invalid', + deadline_exceeded: 'OCI request deadline exceeded', + aborted: 'OCI request was canceled', + response_too_large: 'OCI response exceeded the configured limit', + request_failed: 'OCI request failed', +} + +function safeRequestId(value: unknown): string | undefined { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 255 || + /[^\x20-\x7e]/.test(value) + ) { + return undefined + } + return value +} + +/** Stable, provider-message-free failure projected by the native OCI client. */ +export class OciClientError extends Error { + readonly code: OciClientErrorCode + readonly status?: number + readonly opcRequestId?: string + + constructor(code: OciClientErrorCode, options: { status?: number; opcRequestId?: unknown } = {}) { + super(ERROR_MESSAGES[code]) + this.name = 'OciClientError' + this.code = code + if ( + options.status !== undefined && + Number.isInteger(options.status) && + options.status >= 100 && + options.status <= 599 + ) { + this.status = options.status + } + this.opcRequestId = safeRequestId(options.opcRequestId) + } +} diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 337b56aa435..f65327c58a6 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -64,7 +64,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 +203,16 @@ describe('resolveCredentialTokenBundle selector privacy', () => { expect(slack.logs).toContain(RAW_PROVIDER_ERROR) }) }) + +describe('OCI service-account resolver', () => { + it('returns only the authoritative resolved credential ID for hidden in-process handoff', async () => { + await expect( + resolveServiceAccountToken( + 'credential-authoritative', + 'oci-api-key-service-account', + [], + undefined + ) + ).resolves.toEqual({ accessToken: 'credential-authoritative' }) + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 0962cf8cb56..a82fa654edb 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -45,6 +45,7 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' @@ -630,6 +631,9 @@ type ServiceAccountTokenResolver = ( * generically: the stored token IS the access token. */ const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record = { + [OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId) => ({ + accessToken: credentialId, + }), [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId) => { const secret = await getAtlassianServiceAccountSecret(credentialId) return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain } diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 692a59510fe..b1e7ed02ad9 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -47,6 +47,7 @@ import { MondayIcon, NetSuiteIcon, NotionIcon, + OracleIcon, OutlookIcon, PipedriveIcon, RedditIcon, @@ -1028,6 +1029,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'netsuite', }, + oci: { + name: 'Oracle Cloud Infrastructure', + icon: OracleIcon, + services: { + oci: { + name: 'Oracle Cloud Infrastructure', + description: 'Connect OCI services with an API signing key.', + providerId: 'oci', + serviceAccountProviderId: 'oci-api-key-service-account', + icon: OracleIcon, + baseProviderIcon: OracleIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'oci', + }, reddit: { name: 'Reddit', icon: RedditIcon, diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index e06ee3c9c8d..0037fd1863d 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -8,6 +8,7 @@ const { mockCaptureServerEvent, mockExecuteManagedToken, mockGetCredential, + mockGetServiceConfigByServiceId, mockGetToolMetadata, mockRecordAudit, mockRefreshTokenIfNeeded, @@ -18,6 +19,7 @@ const { mockCaptureServerEvent: vi.fn(), mockExecuteManagedToken: vi.fn(), mockGetCredential: vi.fn(), + mockGetServiceConfigByServiceId: vi.fn(), mockGetToolMetadata: vi.fn(), mockRecordAudit: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), @@ -79,6 +81,7 @@ vi.mock('@/tools/metadata', () => ({ vi.mock('@/lib/oauth/utils', () => ({ getCanonicalScopesForProvider: vi.fn().mockReturnValue([]), + getServiceConfigByServiceId: mockGetServiceConfigByServiceId, })) import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -284,7 +287,20 @@ describe('resolveCredentialToken', () => { }) it('surfaces the classified service-account failure code', async () => { - mockAuthorizeCredentialUseForAuth.mockResolvedValue({ ok: true, requesterUserId: 'user-1' }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'sa-authoritative', + }) + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'sa-authoritative', + providerId: 'atlassian', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) mockResolveServiceAccountToken.mockRejectedValue( new TokenServiceAccountValidationError('invalid_credentials', 401) ) @@ -308,6 +324,12 @@ describe('resolveCredentialToken', () => { code: 'invalid_credentials', error: 'Credential rejected by the provider — reconnect the credential', }) + expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( + 'sa-authoritative', + 'atlassian', + [], + undefined + ) }) it('rejects a malformed impersonation subject before touching the credential', async () => { @@ -346,6 +368,7 @@ describe('resolveCredentialAccessToken', () => { beforeEach(() => { vi.clearAllMocks() mockResolveOAuthAccountId.mockResolvedValue(null) + mockGetServiceConfigByServiceId.mockReturnValue(null) authenticate.mockResolvedValue(INTERNAL_AUTH) resolveManagedPrincipal.mockResolvedValue(EXECUTOR_PRINCIPAL) mockGetToolMetadata.mockReturnValue({ @@ -411,6 +434,129 @@ describe('resolveCredentialAccessToken', () => { }) }) + it('hands an authorized OCI credential to the resolver by authoritative ID only', async () => { + const supplied = { + credentialType: 'service_account', + credentialId: 'caller-controlled-alias', + providerId: 'google-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + } as const + const authoritative = { + ...supplied, + credentialId: 'credential-authoritative', + providerId: 'oci-api-key-service-account', + } as const + mockResolveOAuthAccountId.mockResolvedValueOnce(supplied).mockResolvedValueOnce(authoritative) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'oci', + credentialKind: 'service-account', + }, + }) + mockGetServiceConfigByServiceId.mockReturnValue({ + serviceAccountProviderId: 'oci-api-key-service-account', + }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'credential-authoritative', + }) + mockResolveServiceAccountToken.mockResolvedValue({ accessToken: 'credential-authoritative' }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-oci', + credentialId: 'caller-controlled-alias', + toolId: 'future_oci_tool', + authenticate, + }) + ).resolves.toEqual({ + ok: true, + token: expect.objectContaining({ accessToken: 'credential-authoritative' }), + }) + expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( + 'credential-authoritative', + 'oci-api-key-service-account', + [], + undefined + ) + }) + + it('rejects OCI credentials when trusted tool metadata is not provider-bound', async () => { + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'credential-authoritative', + providerId: 'oci-api-key-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + oauth: { required: true, provider: 'oci', credentialKind: 'service-account' }, + }) + mockGetServiceConfigByServiceId.mockReturnValue({ + serviceAccountProviderId: 'different-provider', + }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-oci', + credentialId: 'credential-authoritative', + toolId: 'future_oci_tool', + authenticate, + }) + ).resolves.toMatchObject({ + ok: false, + status: 500, + code: 'OCI_CREDENTIAL_TOOL_UNSUPPORTED', + }) + expect(authenticate).not.toHaveBeenCalled() + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + + it('cannot use a non-OCI alias to bypass trusted OCI tool metadata checks', async () => { + const supplied = { + credentialType: 'service_account', + credentialId: 'caller-controlled-alias', + providerId: 'google-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + } as const + const authoritative = { + ...supplied, + credentialId: 'credential-authoritative', + providerId: 'oci-api-key-service-account', + } as const + mockResolveOAuthAccountId.mockResolvedValueOnce(supplied).mockResolvedValueOnce(authoritative) + mockGetToolMetadata.mockReturnValue({ + oauth: { required: true, provider: 'slack', credentialKind: 'service-account' }, + }) + mockGetServiceConfigByServiceId.mockReturnValue({ + serviceAccountProviderId: 'slack-custom-bot', + }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'credential-authoritative', + }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-oci', + credentialId: 'caller-controlled-alias', + toolId: 'non_oci_tool', + authenticate, + }) + ).resolves.toEqual({ ok: false, status: 403, error: 'Unauthorized' }) + expect(mockResolveServiceAccountToken).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..4dab17d4dea 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -24,7 +24,8 @@ import { MICROSOFT_DATAVERSE_PROVIDER_ID, } from '@/lib/oauth/microsoft-dataverse' import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce' -import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { type OAuthService, OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' +import { getCanonicalScopesForProvider, getServiceConfigByServiceId } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' import { getToolMetadata } from '@/tools/metadata' import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' @@ -59,6 +60,8 @@ export interface ResolveCredentialTokenInput { auditRequest?: CredentialAuditRequest /** Credential lookup already performed by {@link resolveCredentialAccessToken}'s dispatch. */ resolvedCredential: ResolvedCredential | null + /** Trusted provider binding derived from registered tool metadata. */ + expectedServiceAccountProviderId?: string } export type ResolveCredentialTokenResult = @@ -212,13 +215,27 @@ export async function resolveCredentialToken( return { ok: false, status: 403, error: authz.error || 'Unauthorized' } } + const authoritativeId = authz.resolvedCredentialId + if (!authoritativeId) return { ok: false, status: 403, error: 'Unauthorized' } + const authoritative = await resolveOAuthAccountId(authoritativeId) + if ( + authoritative?.credentialType !== 'service_account' || + authoritative.credentialId !== authoritativeId || + (authoritative.providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID && + input.expectedServiceAccountProviderId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) || + (input.expectedServiceAccountProviderId !== undefined && + authoritative.providerId !== input.expectedServiceAccountProviderId) + ) { + return { ok: false, status: 403, error: 'Unauthorized' } + } + const saActorId = authz.requesterUserId - const saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null + const saWorkspaceId = authz.workspaceId ?? null try { const result = await resolveServiceAccountToken( - resolved.credentialId, - resolved.providerId, + authoritativeId, + authoritative.providerId, scopes ?? [], impersonateEmail ) @@ -227,8 +244,8 @@ export async function resolveCredentialToken( recordCredentialAccess({ actorId: saActorId, workspaceId: saWorkspaceId, - resourceId: resolved.credentialId, - providerId: resolved.providerId, + resourceId: authoritativeId, + providerId: authoritative.providerId, credentialType: 'service_account', auditRequest, }) @@ -346,6 +363,34 @@ export async function resolveCredentialAccessToken( const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null if (resolved?.credentialType !== 'managed_oauth' || !resolved.credentialId) { + const toolMetadata = toolId ? getToolMetadata(toolId) : undefined + const serviceId = toolMetadata?.oauth?.provider as OAuthService | undefined + const service = serviceId ? getServiceConfigByServiceId(serviceId) : null + const isOciServiceAccountTool = + toolMetadata?.oauth?.required === true && + toolMetadata.oauth.credentialKind === 'service-account' && + service?.serviceAccountProviderId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + const expectedServiceAccountProviderId = isOciServiceAccountTool + ? OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + : undefined + + if ( + resolved?.credentialType === 'service_account' && + resolved.providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID && + !isOciServiceAccountTool + ) { + logger.error(`[${requestId}] Tool is not configured for OCI API-key credentials`, { + toolId, + serviceId, + }) + return { + ok: false, + status: 500, + code: 'OCI_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use OCI API-key credentials', + } + } + const auth = await input.authenticate() return resolveCredentialToken(auth, { requestId, @@ -361,6 +406,7 @@ export async function resolveCredentialAccessToken( callerUserId: input.callerUserId, auditRequest, resolvedCredential: resolved, + expectedServiceAccountProviderId, }) } diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index d7b5b668f2a..3aba8b37536 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -14,6 +14,15 @@ export const ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID = 'atlassian-service-account' */ export const GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID = 'google-service-account' as const +/** Stable identifier for an OCI API-key user-principal credential. */ +export const OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID = 'oci-api-key-service-account' as const + +/** Discriminator stored inside the encrypted OCI API signing-key secret blob. */ +export const OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE = 'oci_api_signing_key_v1' as const + +/** Registered credential-family owner for OCI API-key credentials. */ +export const OCI_SERVICE_ID = 'oci' as const satisfies OAuthService + /** * Discriminator stored inside the encrypted Atlassian service account secret blob. */ @@ -93,6 +102,7 @@ export type OAuthProvider = | 'zoho-desk' export type OAuthService = + | 'oci' | 'google' | 'google-email' | 'google-drive' diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index ab0e34a9273..472d211888a 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -115,6 +115,10 @@ describe('getAllOAuthServices', () => { serviceId: 'gmail', authType: 'oauth', }) + expect(getServiceConfigByServiceId('oci')).toMatchObject({ + authType: 'service_account', + serviceAccountProviderId: 'oci-api-key-service-account', + }) }) }) diff --git a/apps/sim/lib/selectors/server/credentials.test.ts b/apps/sim/lib/selectors/server/credentials.test.ts index 17138d2079a..28d53da212d 100644 --- a/apps/sim/lib/selectors/server/credentials.test.ts +++ b/apps/sim/lib/selectors/server/credentials.test.ts @@ -3,7 +3,7 @@ */ import { credential } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -89,6 +89,9 @@ describe('authorizeSelectorCredential', () => { workspaceId: 'workspace-1', }) ) + const providerPredicate = JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(providerPredicate).toContain('account-1') + expect(providerPredicate).not.toContain('credential-1') }) it('promotes a hidden fixed token to an authentication secret at every length', async () => { diff --git a/apps/sim/lib/selectors/server/credentials.ts b/apps/sim/lib/selectors/server/credentials.ts index 2bc68c62275..8dfad516583 100644 --- a/apps/sim/lib/selectors/server/credentials.ts +++ b/apps/sim/lib/selectors/server/credentials.ts @@ -130,13 +130,13 @@ export async function authorizeSelectorCredential(input: { ...(input.scope.kind === 'workspace' ? { workspaceId: input.workspaceId } : {}), } ) - if (!access.ok || access.workspaceId !== input.workspaceId) { + if (!access.ok || access.workspaceId !== input.workspaceId || !access.resolvedCredentialId) { throw new SelectorConnectionUnavailableError() } input.protectedValues.add(access.resolvedCredentialId, 'reference') const providerId = await requireCredentialProviderBinding( - suppliedId, + access.resolvedCredentialId, access, input.policy.serviceIds ) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 2aaf93bf18e..aba1a77a5fd 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -3125,6 +3125,51 @@ describe('Internal Route Trust', () => { } }) + it('unconditionally overwrites a caller-supplied hidden credential value', async () => { + const toolId = 'test_hidden_credential_authority' + const mockTool = { + id: toolId, + name: 'Hidden Credential Authority Test', + description: 'Verifies authoritative hidden credential injection', + version: '1.0.0', + oauth: { + required: true, + provider: 'google', + credentialKind: 'oauth' as const, + }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + }, + request: { + url: () => 'https://www.googleapis.com/test', + method: 'GET' as const, + headers: (params: Record) => ({ + Authorization: `Bearer ${params.accessToken}`, + }), + }, + transformResponse: vi.fn().mockResolvedValue({ success: true, output: {} }), + } + ;(tools as Record)[toolId] = mockTool + mockResolveExecutorCredentialToken.mockResolvedValue({ + accessToken: 'authorized-value', + credentialType: 'oauth', + }) + + try { + const result = await executeTool(toolId, { + credential: 'selected-credential', + accessToken: 'caller-forged-value', + }) + + expect(result.success).toBe(true) + const requestOptions = mockSecureFetchWithPinnedIP.mock.calls.at(-1)?.[2] + expect(requestOptions?.headers.authorization).toBe('Bearer authorized-value') + expect(JSON.stringify(requestOptions)).not.toContain('caller-forged-value') + } finally { + Reflect.deleteProperty(tools, toolId) + } + }) + it('transports only active provenance selected for an internal model input', async () => { const registry = new ResolvedSecretTraceRegistry([ { diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 5425be0ecf8..7556611353d 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -7999,6 +7999,11 @@ export type UpdateCredentialBody = { authMethod?: string privateKey?: string username?: string + tenancyOcid?: string + userOcid?: string + fingerprint?: string + privateKeyPassphrase?: string + region?: string } type UpdateCredentialResponseRef0 = { @@ -13862,6 +13867,11 @@ export const V2_OPERATIONS = { authMethod: { kind: 'string', describe: 'Provider authentication method.' }, privateKey: { kind: 'string', describe: 'Write-only PEM private key.' }, username: { kind: 'string', describe: 'Provider run-as username.' }, + tenancyOcid: { kind: 'string', describe: 'OCI tenancy OCID.' }, + userOcid: { kind: 'string', describe: 'OCI user OCID.' }, + fingerprint: { kind: 'string', describe: 'OCI API-key fingerprint.' }, + privateKeyPassphrase: { kind: 'string', describe: 'Write-only OCI private-key passphrase.' }, + region: { kind: 'string', describe: 'OCI home region.' }, }, }, updateCustomTool: {