From 017b91626520eac2cea71c9e66439842adf59cf0 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:24:52 -0700 Subject: [PATCH 01/11] feat(oci): add native foundation --- ...oci-api-key-service-account.server.test.ts | 357 ++++++++++++++++ .../oci-api-key-service-account.server.ts | 381 ++++++++++++++++++ .../lib/internal/oci/client.server.test.ts | 266 ++++++++++++ apps/sim/lib/internal/oci/client.server.ts | 129 ++++++ apps/sim/lib/internal/oci/endpoints.test.ts | 119 ++++++ apps/sim/lib/internal/oci/endpoints.ts | 254 ++++++++++++ apps/sim/lib/internal/oci/errors.ts | 59 +++ .../lib/internal/oci/signing.server.test.ts | 225 +++++++++++ apps/sim/lib/internal/oci/signing.server.ts | 104 +++++ apps/sim/lib/oauth/types.ts | 6 + apps/sim/package.json | 1 + bun.lock | 57 +++ 12 files changed, 1958 insertions(+) create mode 100644 apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts create mode 100644 apps/sim/lib/credentials/oci-api-key-service-account.server.ts create mode 100644 apps/sim/lib/internal/oci/client.server.test.ts create mode 100644 apps/sim/lib/internal/oci/client.server.ts create mode 100644 apps/sim/lib/internal/oci/endpoints.test.ts create mode 100644 apps/sim/lib/internal/oci/endpoints.ts create mode 100644 apps/sim/lib/internal/oci/errors.ts create mode 100644 apps/sim/lib/internal/oci/signing.server.test.ts create mode 100644 apps/sim/lib/internal/oci/signing.server.ts 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..f9dc8f0afb4 --- /dev/null +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts @@ -0,0 +1,357 @@ +/** + * @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(() => { + const rows: Array<{ + type: string + providerId: string | null + encryptedServiceAccountKey: string | null + }> = [] + return { + rows, + decryptSecret: vi.fn(), + encryptSecret: vi.fn(), + sendOciRequest: vi.fn(), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit: vi.fn(async () => rows) })), + })), + })), + } +}) + +vi.mock('@sim/db', () => ({ db: { select: dependencies.select } })) +vi.mock('@sim/db/schema', () => ({ + credential: { + id: 'credential.id', + type: 'credential.type', + providerId: 'credential.providerId', + encryptedServiceAccountKey: 'credential.encryptedServiceAccountKey', + }, +})) +vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => 'predicate') })) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: dependencies.decryptSecret, + encryptSecret: dependencies.encryptSecret, +})) +vi.mock('@/lib/internal/oci/client.server', () => ({ + sendOciRequest: dependencies.sendOciRequest, +})) + +import { + buildOciApiKeyServiceAccountSecret, + loadOciApiKeyCredential, + normalizeOciFingerprint, + OciCredentialVerificationError, + parseOciApiKeyServiceAccountSecret, + serializeOciApiKeyServiceAccountSecret, + verifyAndEncryptOciApiKeyCredential, + verifyOciApiKeyCredential, +} from '@/lib/credentials/oci-api-key-service-account.server' +import type { OciRequestResult } from '@/lib/internal/oci/client.server' +import { OciRequestError } 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_ID = 'ocid1.tenancy.oc1..aaaaaaaafoundationtenant' +const USER_ID = '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(':') +} + +function responseResult(body: string): OciRequestResult { + return { + response: { text: vi.fn().mockResolvedValue(body) } as unknown as OciRequestResult['response'], + } +} + +describe('OCI API-key credential foundation', () => { + 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(() => { + dependencies.rows.splice(0) + dependencies.decryptSecret.mockReset() + dependencies.encryptSecret.mockReset() + dependencies.sendOciRequest.mockReset() + dependencies.select.mockClear() + }) + + function fields(overrides: Record = {}) { + return { + tenancyId: TENANCY_ID, + userId: USER_ID, + fingerprint, + privateKey, + defaultRegion: 'us-ashburn-1', + ...overrides, + } + } + + it('builds a normalized, versioned, provider-bound user-principal secret', () => { + const secret = buildOciApiKeyServiceAccountSecret( + fields({ fingerprint: fingerprint.toUpperCase().replaceAll(':', ' ') }) + ) + expect(secret).toEqual({ + type: OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + tenancyId: TENANCY_ID, + userId: USER_ID, + fingerprint, + privateKey, + defaultRegion: 'us-ashburn-1', + metadata: { principalKind: 'user', principalId: USER_ID }, + }) + expect(secret).not.toHaveProperty('compartmentId') + expect(secret).not.toHaveProperty('namespace') + expect(secret).not.toHaveProperty('endpoint') + expect(secret).not.toHaveProperty('realm') + }) + + it('accepts encrypted RSA PEM only with the exact passphrase', () => { + expect( + buildOciApiKeyServiceAccountSecret(fields({ privateKey: encryptedPrivateKey, passphrase })) + .passphrase + ).toBe(passphrase) + expect(() => + buildOciApiKeyServiceAccountSecret(fields({ privateKey: encryptedPrivateKey })) + ).toThrow('private key or passphrase') + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ privateKey: encryptedPrivateKey, passphrase: passphrase.trim() }) + ) + ).toThrow('private key or passphrase') + }) + + it('rejects malformed, non-RSA, and undersized private keys', () => { + expect(() => buildOciApiKeyServiceAccountSecret(fields({ privateKey: 'not a key' }))).toThrow( + 'PEM encoded' + ) + const ecKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ + privateKey: ecKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + fingerprint: fingerprintForKey(ecKey), + }) + ) + ).toThrow('must use RSA') + const smallKey = generateKeyPairSync('rsa', { modulusLength: 1024 }).privateKey + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ + privateKey: smallKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + fingerprint: fingerprintForKey(smallKey), + }) + ) + ).toThrow('at least 2048 bits') + }) + + it('normalizes fingerprints and compares them to the key', () => { + expect(normalizeOciFingerprint(` ${fingerprint.toUpperCase()} `)).toBe(fingerprint) + expect(normalizeOciFingerprint(fingerprint.replaceAll(':', ''))).toBe(fingerprint) + expect(() => normalizeOciFingerprint('aa:bb')).toThrow('16 MD5 bytes') + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ fingerprint: '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00' }) + ) + ).toThrow('does not match') + }) + + it('enforces size and control-character limits', () => { + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ tenancyId: `ocid1.tenancy.oc1..${'a'.repeat(240)}` }) + ) + ).toThrow('tenancy OCID') + expect(() => buildOciApiKeyServiceAccountSecret(fields({ userId: `${USER_ID}\n` }))).toThrow( + 'user OCID' + ) + expect(() => + buildOciApiKeyServiceAccountSecret(fields({ privateKey: `${privateKey}\u0000` })) + ).toThrow('private key') + expect(() => + buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'x'.repeat(4097) })) + ).toThrow('passphrase') + expect(() => buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'line\nbreak' }))).toThrow( + 'passphrase' + ) + }) + + it('enforces OCID resource type, realm matching, and region membership', () => { + expect(() => buildOciApiKeyServiceAccountSecret(fields({ tenancyId: USER_ID }))).toThrow( + 'wrong structure or resource type' + ) + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ userId: 'ocid1.user.oc2..aaaaaaaafoundationuser' }) + ) + ).toThrow('share a realm') + expect(() => + buildOciApiKeyServiceAccountSecret(fields({ defaultRegion: 'unknown-region-1' })) + ).toThrow('not recognized') + expect(() => + buildOciApiKeyServiceAccountSecret(fields({ defaultRegion: 'us-gov-ashburn-1' })) + ).toThrow('credential realm') + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ + tenancyId: 'ocid1.tenancy.oc99..aaaaaaaafoundationtenant', + userId: 'ocid1.user.oc99..aaaaaaaafoundationuser', + }) + ) + ).toThrow('credential realm') + }) + + it('strictly parses only canonical version-one secrets', () => { + const secret = buildOciApiKeyServiceAccountSecret(fields()) + const serialized = serializeOciApiKeyServiceAccountSecret(secret) + expect(parseOciApiKeyServiceAccountSecret(serialized)).toEqual(secret) + expect(() => + parseOciApiKeyServiceAccountSecret(JSON.stringify({ ...secret, compartmentId: TENANCY_ID })) + ).toThrow('malformed') + expect(() => + parseOciApiKeyServiceAccountSecret( + JSON.stringify({ ...secret, providerId: 'another-provider' }) + ) + ).toThrow('malformed') + expect(() => + parseOciApiKeyServiceAccountSecret( + JSON.stringify({ + ...secret, + metadata: { principalKind: 'tenant', principalId: TENANCY_ID }, + }) + ) + ).toThrow('malformed') + expect(() => + parseOciApiKeyServiceAccountSecret( + JSON.stringify({ ...secret, defaultRegion: ' US-ASHBURN-1 ' }) + ) + ).toThrow('malformed') + expect(() => + parseOciApiKeyServiceAccountSecret(JSON.stringify({ ...secret, tenancyId: null })) + ).toThrow('malformed') + }) + + it('verifies with the exact permissionless GetNamespace request and forwards bounds', async () => { + const secret = buildOciApiKeyServiceAccountSecret(fields()) + const controller = new AbortController() + dependencies.sendOciRequest.mockResolvedValue(responseResult('"tenant-namespace"')) + await expect(verifyOciApiKeyCredential(secret, controller.signal)).resolves.toEqual({ + namespace: 'tenant-namespace', + }) + expect(dependencies.sendOciRequest).toHaveBeenCalledWith({ + destination: expect.objectContaining({ + origin: 'https://objectstorage.us-ashburn-1.oraclecloud.com', + }), + credentials: secret, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 64 * 1024, + signal: controller.signal, + serviceHeaders: { accept: 'application/json' }, + }) + expect(dependencies.sendOciRequest.mock.calls[0][0]).not.toHaveProperty('queryPairs') + expect(dependencies.sendOciRequest.mock.calls[0][0]).not.toHaveProperty('compartmentId') + }) + + it('maps authentication, malformed-response, and transient failures to secret-safe errors', async () => { + const secret = buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'very-secret' })) + const cases = [ + { + failure: new OciRequestError({ + status: 401, + message: `echo ${privateKey} very-secret`, + }), + code: 'invalid_credentials', + }, + { failure: responseResult('{malformed'), code: 'invalid_response' }, + { failure: new Error(`temporary ${privateKey} very-secret`), code: 'service_unavailable' }, + ] as const + for (const testCase of cases) { + if (testCase.failure instanceof Error) { + dependencies.sendOciRequest.mockRejectedValueOnce(testCase.failure) + } else { + dependencies.sendOciRequest.mockResolvedValueOnce(testCase.failure) + } + const failure = await verifyOciApiKeyCredential(secret).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OciCredentialVerificationError) + expect((failure as OciCredentialVerificationError).code).toBe(testCase.code) + expect((failure as Error).message).not.toContain('very-secret') + expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY') + } + }) + + it('encrypts only after local validation and remote verification succeed', async () => { + const order: string[] = [] + dependencies.sendOciRequest.mockImplementation(async () => { + order.push('verify') + return responseResult('"namespace"') + }) + dependencies.encryptSecret.mockImplementation(async () => { + order.push('encrypt') + return { encrypted: 'ciphertext', iv: 'iv' } + }) + await expect(verifyAndEncryptOciApiKeyCredential(fields())).resolves.toEqual({ + encryptedServiceAccountKey: 'ciphertext', + namespace: 'namespace', + }) + expect(order).toEqual(['verify', 'encrypt']) + + dependencies.sendOciRequest.mockClear() + dependencies.encryptSecret.mockClear() + await expect( + verifyAndEncryptOciApiKeyCredential(fields({ fingerprint: 'invalid' })) + ).rejects.toThrow() + expect(dependencies.sendOciRequest).not.toHaveBeenCalled() + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) + + it('checks both outer and inner provider binding before returning decrypted material', async () => { + dependencies.rows.push({ + type: 'service_account', + providerId: 'another-provider', + encryptedServiceAccountKey: 'ciphertext', + }) + dependencies.decryptSecret.mockResolvedValue({ decrypted: 'should-not-be-read' }) + await expect(loadOciApiKeyCredential('credential-1')).rejects.toThrow('provider-mismatched') + expect(dependencies.decryptSecret).not.toHaveBeenCalled() + + const secret = buildOciApiKeyServiceAccountSecret(fields()) + dependencies.rows.splice(0) + dependencies.rows.push({ + type: 'service_account', + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + encryptedServiceAccountKey: 'ciphertext', + }) + dependencies.decryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify({ ...secret, providerId: 'another-provider' }), + }) + await expect(loadOciApiKeyCredential('credential-1')).rejects.toThrow('malformed') + }) +}) 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..6f0f7de6d38 --- /dev/null +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts @@ -0,0 +1,381 @@ +import { createHash, createPrivateKey, createPublicKey } from 'node:crypto' +import { db } from '@sim/db' +import { credential } from '@sim/db/schema' +import { safeCompare } from '@sim/security/compare' +import { eq } from 'drizzle-orm' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { serviceAccountPrincipalMetadata } from '@/lib/credentials/principal' +import { sendOciRequest } from '@/lib/internal/oci/client.server' +import { + getOciRegion, + objectStorageOciDestination, + resolveEffectiveOciRegion, +} from '@/lib/internal/oci/endpoints' +import { OciRequestError } from '@/lib/internal/oci/errors' +import type { OciSigningCredentials } from '@/lib/internal/oci/signing.server' +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 OCI_VERIFICATION_TIMEOUT_MS = 10_000 +const OCI_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_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/ +const PEM_CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/ + +export interface OciApiKeyCredentialFields { + tenancyId: string + userId: string + fingerprint: string + privateKey: string + passphrase?: string + defaultRegion: string +} + +export interface OciApiKeyServiceAccountSecret extends OciSigningCredentials { + readonly type: typeof OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE + readonly providerId: typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + readonly defaultRegion: 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] } +} + +export function normalizeOciFingerprint(value: unknown): string { + assertBoundedText(value, 'fingerprint', 128) + const hex = value.replace(/[:\s]/g, '').toLowerCase() + if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error('OCI fingerprint must contain 16 MD5 bytes') + const bytes = hex.match(/.{2}/g) + 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 validatePrivateKeyAndFingerprint(params: { + privateKey: string + passphrase?: string + fingerprint: string +}): void { + let key + try { + key = createPrivateKey({ + key: params.privateKey, + format: 'pem', + ...(params.passphrase !== undefined ? { passphrase: params.passphrase } : {}), + }) + } catch { + throw new Error('OCI private key or passphrase is invalid') + } + if (key.asymmetricKeyType !== 'rsa') throw new Error('OCI private key must use RSA') + const modulusLength = key.asymmetricKeyDetails?.modulusLength + if (modulusLength === undefined || 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 derivedHex = createHash('md5').update(spki).digest('hex') + const submittedHex = params.fingerprint.replaceAll(':', '') + const fingerprintsMatch = safeCompare( + Buffer.from(derivedHex, 'hex').toString('base64'), + Buffer.from(submittedHex, 'hex').toString('base64') + ) + if (!fingerprintsMatch) throw new Error('OCI fingerprint does not match the private key') +} + +/** Validates and normalizes credential fields without performing I/O. */ +export function buildOciApiKeyServiceAccountSecret( + fields: OciApiKeyCredentialFields +): OciApiKeyServiceAccountSecret { + const tenancy = normalizeOcid(fields.tenancyId, 'tenancy') + const user = normalizeOcid(fields.userId, 'user') + if (tenancy.realmId !== user.realmId) + throw new Error('OCI tenancy and user OCIDs must share a realm') + + assertBoundedText(fields.defaultRegion, 'default region', 128) + const defaultRegion = fields.defaultRegion.trim().toLowerCase() + const region = getOciRegion(defaultRegion) + if (region.realm.id !== tenancy.realmId) { + throw new Error('OCI default region must belong to the credential realm') + } + + const fingerprint = normalizeOciFingerprint(fields.fingerprint) + const privateKey = normalizePrivateKey(fields.privateKey) + const passphrase = validatePassphrase(fields.passphrase) + validatePrivateKeyAndFingerprint({ privateKey, passphrase, fingerprint }) + 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, + tenancyId: tenancy.value, + userId: user.value, + fingerprint, + privateKey, + ...(passphrase !== undefined ? { passphrase } : {}), + defaultRegion, + metadata: { principalKind: 'user', principalId: metadata.principalId }, + } +} + +export function serializeOciApiKeyServiceAccountSecret( + secret: OciApiKeyServiceAccountSecret +): string { + return JSON.stringify(secret) +} + +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 new Error('Stored OCI API-key credential is malformed') + } +} + +/** Strictly parses and revalidates an encrypted OCI credential payload. */ +export function parseOciApiKeyServiceAccountSecret( + serialized: string, + expectedProviderId: string = OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID +): OciApiKeyServiceAccountSecret { + let parsed: unknown + try { + parsed = JSON.parse(serialized) + } catch { + throw new Error('Stored OCI API-key credential is malformed') + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Stored OCI API-key credential is malformed') + } + const record = parsed as Record + assertExactKeys( + record, + [ + 'type', + 'providerId', + 'tenancyId', + 'userId', + 'fingerprint', + 'privateKey', + 'defaultRegion', + 'metadata', + ], + ['passphrase'] + ) + if ( + record.type !== OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE || + record.providerId !== expectedProviderId || + expectedProviderId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || + !record.metadata || + typeof record.metadata !== 'object' || + Array.isArray(record.metadata) + ) { + throw new Error('Stored OCI API-key credential is malformed') + } + const metadata = record.metadata as Record + assertExactKeys(metadata, ['principalKind', 'principalId']) + let passphrase: string | undefined + if (Object.hasOwn(record, 'passphrase')) { + if (typeof record.passphrase !== 'string') { + throw new Error('Stored OCI API-key credential is malformed') + } + passphrase = record.passphrase + } + if ( + typeof record.tenancyId !== 'string' || + typeof record.userId !== 'string' || + typeof record.fingerprint !== 'string' || + typeof record.privateKey !== 'string' || + typeof record.defaultRegion !== 'string' + ) { + throw new Error('Stored OCI API-key credential is malformed') + } + let rebuilt: OciApiKeyServiceAccountSecret + try { + rebuilt = buildOciApiKeyServiceAccountSecret({ + tenancyId: record.tenancyId, + userId: record.userId, + fingerprint: record.fingerprint, + privateKey: record.privateKey, + ...(passphrase !== undefined ? { passphrase } : {}), + defaultRegion: record.defaultRegion, + }) + } catch { + throw new Error('Stored OCI API-key credential is malformed') + } + if ( + metadata.principalKind !== 'user' || + metadata.principalId !== rebuilt.userId || + record.tenancyId !== rebuilt.tenancyId || + record.userId !== rebuilt.userId || + record.fingerprint !== rebuilt.fingerprint || + record.privateKey !== rebuilt.privateKey || + record.defaultRegion !== rebuilt.defaultRegion || + record.passphrase !== rebuilt.passphrase + ) { + throw new Error('Stored OCI API-key credential is malformed') + } + return rebuilt +} + +/** Verifies a locally valid credential with Object Storage GetNamespace. */ +export async function verifyOciApiKeyCredential( + secret: OciApiKeyServiceAccountSecret, + signal?: AbortSignal +): Promise<{ namespace: string }> { + const region = resolveEffectiveOciRegion(secret.defaultRegion) + try { + const result = await sendOciRequest({ + destination: objectStorageOciDestination(region), + credentials: secret, + method: 'GET', + encodedPath: '/n/', + timeout: OCI_VERIFICATION_TIMEOUT_MS, + maxResponseBytes: OCI_VERIFICATION_RESPONSE_BYTES, + signal, + serviceHeaders: { accept: 'application/json' }, + }) + const parsed: unknown = JSON.parse(await result.response.text()) + if ( + typeof parsed !== 'string' || + parsed.length === 0 || + Buffer.byteLength(parsed, 'utf8') > 255 || + CONTROL_CHARACTER_PATTERN.test(parsed) + ) { + throw new OciCredentialVerificationError('invalid_response') + } + return { namespace: parsed } + } catch (error) { + if (error instanceof OciCredentialVerificationError) throw error + if (signal?.aborted) throw error + if (error instanceof OciRequestError && (error.status === 401 || error.status === 403)) { + throw new OciCredentialVerificationError('invalid_credentials') + } + if (error instanceof SyntaxError) { + throw new OciCredentialVerificationError('invalid_response') + } + throw new OciCredentialVerificationError('service_unavailable') + } +} + +/** Validates, verifies, then encrypts an OCI credential in that order. */ +export async function verifyAndEncryptOciApiKeyCredential( + fields: OciApiKeyCredentialFields, + signal?: AbortSignal +): Promise<{ encryptedServiceAccountKey: string; namespace: string }> { + const secret = buildOciApiKeyServiceAccountSecret(fields) + const { namespace } = await verifyOciApiKeyCredential(secret, signal) + const { encrypted } = await encryptSecret(serializeOciApiKeyServiceAccountSecret(secret)) + return { encryptedServiceAccountKey: encrypted, namespace } +} + +interface OciCredentialRowProjection { + type: string + providerId: string | null + encryptedServiceAccountKey: string | null +} + +async function findOciCredentialById( + credentialId: string +): Promise { + const [row] = await db + .select({ + type: credential.type, + providerId: credential.providerId, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + return row ?? null +} + +/** Loads one provider-bound OCI credential, checking outer binding before decryption. */ +export async function loadOciApiKeyCredential( + credentialId: string +): Promise { + const row = await findOciCredentialById(credentialId) + if ( + !row || + row.type !== 'service_account' || + row.providerId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || + !row.encryptedServiceAccountKey + ) { + throw new Error('OCI API-key credential is unavailable or provider-mismatched') + } + const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) + return parseOciApiKeyServiceAccountSecret(decrypted, row.providerId) +} 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..4190166cd0d --- /dev/null +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -0,0 +1,266 @@ +/** + * @vitest-environment node + */ +import { generateKeyPairSync } from 'node:crypto' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const secureFetchMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + DEFAULT_MAX_RESPONSE_BYTES: 100 * 1024 * 1024, + secureFetchWithValidation: secureFetchMock, +})) + +import { + buildOciRequestUrl, + sendOciRequest, + serializeOciQueryPairs, +} from '@/lib/internal/oci/client.server' +import { getOciRegion, objectStorageOciDestination } from '@/lib/internal/oci/endpoints' +import { OciRequestError } from '@/lib/internal/oci/errors' +import type { OciSigningCredentials } from '@/lib/internal/oci/signing.server' + +function secureResponse(params: { + ok: boolean + status: number + body?: string + opcRequestId?: string +}) { + return { + ok: params.ok, + status: params.status, + statusText: '', + headers: { + get: (name: string) => + name.toLowerCase() === 'opc-request-id' ? (params.opcRequestId ?? null) : null, + }, + body: null, + text: vi.fn().mockResolvedValue(params.body ?? ''), + json: vi.fn(), + arrayBuffer: vi.fn(), + } +} + +describe('OCI request client', () => { + let credentials: OciSigningCredentials + const destination = objectStorageOciDestination(getOciRegion('us-ashburn-1')) + + beforeAll(() => { + const pair = generateKeyPairSync('rsa', { modulusLength: 2048 }) + credentials = { + tenancyId: 'ocid1.tenancy.oc1..clienttest', + userId: 'ocid1.user.oc1..clienttest', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: pair.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + passphrase: 'client-secret-passphrase', + } + }) + + beforeEach(() => { + secureFetchMock.mockReset() + secureFetchMock.mockResolvedValue(secureResponse({ ok: true, status: 200 })) + }) + + it('serializes ordered duplicate and Unicode query pairs with RFC 3986 encoding', () => { + expect( + serializeOciQueryPairs([ + ['z', 'last'], + ['a', 'one'], + ['a', ''], + ['space', 'a b'], + ['unicode', '☃'], + ["!'()*", "!'()*"], + ]) + ).toBe('z=last&a=one&a=&space=a%20b&unicode=%E2%98%83&%21%27%28%29%2A=%21%27%28%29%2A') + }) + + it('transmits the exact URL, finalized body, and headers that were signed', async () => { + const body = '{"message":"héllo ☃"}' + await sendOciRequest({ + destination, + credentials, + method: 'POST', + encodedPath: '/n/tenant/b', + queryPairs: [ + ['z', 'last'], + ['a', 'one'], + ['a', ''], + ['unicode', '☃'], + ], + timeout: 12_345, + maxResponseBytes: 54_321, + serviceHeaders: { accept: 'application/json', 'opc-retry-token': 'fixed-token' }, + body, + }) + + expect(secureFetchMock).toHaveBeenCalledOnce() + const [url, options, paramName] = secureFetchMock.mock.calls[0] + expect(url).toBe( + 'https://objectstorage.us-ashburn-1.oraclecloud.com/n/tenant/b?z=last&a=one&a=&unicode=%E2%98%83' + ) + expect(paramName).toBe('OCI destination') + expect(options).toMatchObject({ + method: 'POST', + body, + timeout: 12_345, + maxResponseBytes: 54_321, + maxRedirects: 0, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }) + expect(options.headers.accept).toBe('application/json') + expect(options.headers['opc-retry-token']).toBe('fixed-token') + expect(options.headers.authorization).toContain('Signature version="1"') + expect(options.headers['content-length']).toBe(String(Buffer.byteLength(body, 'utf8'))) + expect(options.headers).not.toHaveProperty('date') + }) + + it('forwards cancellation and always disables redirects', async () => { + const controller = new AbortController() + await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + signal: controller.signal, + }) + expect(secureFetchMock.mock.calls[0][1]).toMatchObject({ + signal: controller.signal, + timeout: 10_000, + maxResponseBytes: 65_536, + maxRedirects: 0, + }) + }) + + it('returns bounded successful responses and the OCI request id without imposing a schema', async () => { + const response = secureResponse({ + ok: true, + status: 202, + body: 'service-specific bytes', + opcRequestId: 'request-123', + }) + secureFetchMock.mockResolvedValueOnce(response) + const result = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }) + expect(result).toEqual({ response, opcRequestId: 'request-123' }) + expect(response.text).not.toHaveBeenCalled() + }) + + it('retains bounded OCI error fields and request ids while redacting echoed secrets', async () => { + const echoedUrl = 'https://objectstorage.us-ashburn-1.oraclecloud.com/n/' + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + opcRequestId: 'request-401', + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed ${credentials.passphrase} ${credentials.privateKey} ${echoedUrl}\n`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OciRequestError) + expect(failure).toMatchObject({ + status: 401, + code: 'NotAuthenticated', + opcRequestId: 'request-401', + }) + expect((failure as Error).message).toContain('[redacted]') + expect((failure as Error).message).not.toContain('client-secret-passphrase') + expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY') + expect((failure as Error).message).not.toContain('objectstorage.us-ashburn-1') + expect((failure as Error).message.length).toBeLessThanOrEqual(1050) + }) + + it('does not expose malformed response bodies or signed request details', async () => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 502, + opcRequestId: 'request-502', + body: `${credentials.privateKey}`, + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OciRequestError) + expect((failure as Error).message).toBe('OCI request failed with status 502') + expect((failure as OciRequestError).opcRequestId).toBe('request-502') + }) + + it.each([ + '//attacker.example/path', + '/safe//attacker', + '/path?injected=true', + '/path#fragment', + '/path\\replacement', + '/path%ZZ', + ])('rejects unsafe encoded paths: %s', (encodedPath) => { + expect(() => buildOciRequestUrl(destination, encodedPath)).toThrow( + 'single encoded absolute path' + ) + }) + + it('rejects invalid transport bounds before signing or sending', async () => { + for (const invalid of [0, -1, Number.NaN, 300_001]) { + await expect( + sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: invalid, + maxResponseBytes: 65_536, + }) + ).rejects.toThrow('timeout') + } + await expect( + sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 100 * 1024 * 1024 + 1, + }) + ).rejects.toThrow('response ceiling') + expect(secureFetchMock).not.toHaveBeenCalled() + }) + + it('propagates a bounded response-ceiling failure without adding request material', async () => { + secureFetchMock.mockRejectedValueOnce(new Error('Response exceeded the configured byte limit')) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 64, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('Response exceeded the configured byte limit') + expect((failure as Error).message).not.toContain('authorization') + expect((failure as Error).message).not.toContain(destination.hostname) + }) +}) 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..f6a32b91908 --- /dev/null +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -0,0 +1,129 @@ +import { + DEFAULT_MAX_RESPONSE_BYTES, + type SecureFetchResponse, + secureFetchWithValidation, +} from '@/lib/core/security/input-validation.server' +import type { ValidatedOciDestination } from '@/lib/internal/oci/endpoints' +import { OciRequestError, parseOciErrorBody } from '@/lib/internal/oci/errors' +import { + type OciRequestMethod, + type OciSigningCredentials, + signOciRequest, +} from '@/lib/internal/oci/signing.server' + +const MAX_OCI_TIMEOUT_MS = 5 * 60 * 1000 + +export interface OciRequestResult { + readonly response: SecureFetchResponse + readonly opcRequestId?: string +} + +function encodeRfc3986(value: string): string { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ) +} + +export function serializeOciQueryPairs(pairs: readonly (readonly [string, string])[]): string { + return pairs.map(([key, value]) => `${encodeRfc3986(key)}=${encodeRfc3986(value)}`).join('&') +} + +export function buildOciRequestUrl( + destination: ValidatedOciDestination, + encodedPath: string, + queryPairs: readonly (readonly [string, string])[] = [] +): string { + if ( + !encodedPath.startsWith('/') || + encodedPath.startsWith('//') || + encodedPath.includes('//') || + /[?#\\\u0000-\u001f\u007f]/.test(encodedPath) || + /%(?![0-9a-f]{2})/i.test(encodedPath) + ) { + throw new Error('OCI request path must be a single encoded absolute path') + } + const query = serializeOciQueryPairs(queryPairs) + return `${destination.origin}${encodedPath}${query ? `?${query}` : ''}` +} + +function validateRequestLimits(timeout: number, maxResponseBytes: number): void { + if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > MAX_OCI_TIMEOUT_MS) { + throw new Error('OCI timeout is outside the supported range') + } + if ( + !Number.isSafeInteger(maxResponseBytes) || + maxResponseBytes <= 0 || + maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES + ) { + throw new Error('OCI response ceiling is outside the supported range') + } +} + +function sensitiveRequestValues( + credentials: OciSigningCredentials, + authorization: string | undefined +): string[] { + return [ + credentials.tenancyId, + credentials.userId, + credentials.fingerprint, + credentials.privateKey, + credentials.passphrase ?? '', + authorization ?? '', + ].filter(Boolean) +} + +/** Sends one bounded, redirect-free OCI request to an already validated destination. */ +export async function sendOciRequest(params: { + destination: ValidatedOciDestination + credentials: OciSigningCredentials + method: OciRequestMethod + encodedPath: string + queryPairs?: readonly (readonly [string, string])[] + timeout: number + maxResponseBytes: number + signal?: AbortSignal + serviceHeaders?: Readonly> + body?: string + contentType?: string +}): Promise { + validateRequestLimits(params.timeout, params.maxResponseBytes) + const url = buildOciRequestUrl(params.destination, params.encodedPath, params.queryPairs) + const signed = await signOciRequest({ + credentials: params.credentials, + method: params.method, + url, + serviceHeaders: params.serviceHeaders, + body: params.body, + contentType: params.contentType, + }) + const response = await secureFetchWithValidation( + signed.url, + { + method: signed.method, + headers: { ...signed.headers }, + ...(signed.body !== undefined ? { body: signed.body } : {}), + timeout: params.timeout, + maxResponseBytes: params.maxResponseBytes, + maxRedirects: 0, + signal: params.signal, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }, + 'OCI destination' + ) + const opcRequestId = response.headers.get('opc-request-id') ?? undefined + if (response.ok) return { response, opcRequestId } + + const sensitiveValues = sensitiveRequestValues(params.credentials, signed.headers.authorization) + const body = await response.text() + const error = parseOciErrorBody(body, sensitiveValues) + throw new OciRequestError({ + status: response.status, + code: error.code, + message: error.message, + opcRequestId, + sensitiveValues, + }) +} 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..cd789b00f25 --- /dev/null +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -0,0 +1,119 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getOciRegion, + isObjectStorageOciHostname, + OCI_REGION_IDS, + objectStorageOciDestination, + objectStorageOciHostname, + resolveEffectiveOciRegion, + validateOciDestination, +} from '@/lib/internal/oci/endpoints' + +describe('OCI region registry', () => { + it('resolves every snapshotted entry to a consistent realm and 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(objectStorageOciHostname(region)).toBe(`objectstorage.${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') + }) + + it('allows only same-realm effective-region overrides', () => { + expect(resolveEffectiveOciRegion('us-ashburn-1').id).toBe('us-ashburn-1') + 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' + ) + expect(() => resolveEffectiveOciRegion('us-ashburn-1', 'unknown-1')).toThrow('not recognized') + }) +}) + +describe('validateOciDestination', () => { + const region = getOciRegion('us-ashburn-1') + const origin = 'https://objectstorage.us-ashburn-1.oraclecloud.com' + + it.each(['static', 'authenticated-discovery'] as const)( + 'brands a service-owned %s destination', + (provenance) => { + expect(objectStorageOciDestination(region, provenance)).toMatchObject({ + origin, + hostname: 'objectstorage.us-ashburn-1.oraclecloud.com', + service: 'objectstorage', + region, + provenance, + }) + } + ) + + it.each([ + 'http://objectstorage.us-ashburn-1.oraclecloud.com', + 'https://objectstorage.us-ashburn-1.oraclecloud.com:8443', + 'https://user@objectstorage.us-ashburn-1.oraclecloud.com', + 'https://objectstorage.us-ashburn-1.oraclecloud.com/path', + 'https://objectstorage.us-ashburn-1.oraclecloud.com?query=1', + 'https://objectstorage.us-ashburn-1.oraclecloud.com#fragment', + 'https://127.0.0.1', + ])('rejects a non-origin destination: %s', (candidate) => { + expect(() => + validateOciDestination({ + origin: candidate, + service: 'objectstorage', + region, + provenance: 'static', + isServiceHostname: isObjectStorageOciHostname, + }) + ).toThrow() + }) + + it.each([ + 'https://identity.us-ashburn-1.oraclecloud.com', + 'https://objectstorage.eu-frankfurt-1.oraclecloud.com', + 'https://objectstorage.us-ashburn-1.oraclegovcloud.com', + 'https://objectstorage.us-ashburn-1.example.com', + ])('rejects a hostname outside the service and effective region: %s', (candidate) => { + expect(() => + validateOciDestination({ + origin: candidate, + service: 'objectstorage', + region, + provenance: 'authenticated-discovery', + isServiceHostname: isObjectStorageOciHostname, + }) + ).toThrow('not owned') + }) + + it('binds the hostname predicate to its service constant', () => { + expect(() => + validateOciDestination({ + origin, + service: 'identity', + region, + provenance: 'static', + isServiceHostname: isObjectStorageOciHostname, + }) + ).toThrow('not owned') + }) + + it('rejects a forged region-to-realm association', () => { + expect(() => + validateOciDestination({ + origin, + service: 'objectstorage', + region: { id: region.id, realm: { id: 'oc2', domain: 'oraclegovcloud.com' } }, + provenance: 'static', + isServiceHostname: isObjectStorageOciHostname, + }) + ).toThrow('known registry') + }) +}) diff --git a/apps/sim/lib/internal/oci/endpoints.ts b/apps/sim/lib/internal/oci/endpoints.ts new file mode 100644 index 00000000000..25062fc8745 --- /dev/null +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -0,0 +1,254 @@ +import { isIpLiteral } from '@sim/security/ssrf' + +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 validatedOciDestinationBrand: unique symbol + +/** An OCI origin that passed both structural and service-owned hostname validation. */ +export interface ValidatedOciDestination { + readonly origin: string + readonly hostname: string + readonly service: string + readonly region: OciRegion + readonly provenance: OciDestinationProvenance + readonly [validatedOciDestinationBrand]: true +} + +declare const ociServiceHostnamePredicateBrand: unique symbol + +export type OciServiceHostnamePredicate = ((params: { + hostname: string + service: string + region: OciRegion + provenance: OciDestinationProvenance +}) => boolean) & { readonly [ociServiceHostnamePredicateBrand]: true } + +/** + * 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 = REGION_REALMS[normalized as keyof typeof REGION_REALMS] + 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 +} + +export function objectStorageOciHostname(region: OciRegion): string { + return `objectstorage.${region.id}.${region.realm.domain}` +} + +export function validateOciDestination(params: { + origin: string + service: string + region: OciRegion + provenance: OciDestinationProvenance + isServiceHostname: OciServiceHostnamePredicate +}): ValidatedOciDestination { + 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.provenance !== 'static' && params.provenance !== 'authenticated-discovery') || + !/^[a-z][a-z0-9-]{0,62}$/.test(params.service) || + url.protocol !== 'https:' || + url.port !== '' || + url.username !== '' || + url.password !== '' || + url.pathname !== '/' || + url.search !== '' || + url.hash !== '' || + isIpLiteral(url.hostname) || + url.origin !== params.origin + ) { + throw new Error('OCI destination must be an exact HTTPS origin with the default port') + } + if ( + !params.isServiceHostname({ + hostname: url.hostname, + service: params.service, + region: knownRegion, + provenance: params.provenance, + }) + ) { + throw new Error('OCI destination hostname is not owned by the requested service') + } + return { + origin: url.origin, + hostname: url.hostname, + service: params.service, + region: knownRegion, + provenance: params.provenance, + } as ValidatedOciDestination +} + +export const isObjectStorageOciHostname = (({ hostname, service, region }) => + service === 'objectstorage' && + hostname === objectStorageOciHostname(region)) as OciServiceHostnamePredicate + +export function objectStorageOciDestination( + region: OciRegion, + provenance: OciDestinationProvenance = 'static' +): ValidatedOciDestination { + const hostname = objectStorageOciHostname(region) + return validateOciDestination({ + origin: `https://${hostname}`, + service: 'objectstorage', + region, + provenance, + isServiceHostname: isObjectStorageOciHostname, + }) +} diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts new file mode 100644 index 00000000000..8e5e21fc283 --- /dev/null +++ b/apps/sim/lib/internal/oci/errors.ts @@ -0,0 +1,59 @@ +const MAX_OCI_ERROR_FIELD_LENGTH = 1024 + +function sanitizeOciErrorField( + value: unknown, + sensitiveValues: readonly string[] = [] +): string | undefined { + if (typeof value !== 'string') return undefined + let sanitized = value + .replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]') + .replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]') + .replace(/Signature\s+version="1",[^\r\n]*/gi, '[redacted-authorization]') + for (const sensitiveValue of sensitiveValues) { + if (sensitiveValue.length > 0) sanitized = sanitized.split(sensitiveValue).join('[redacted]') + } + sanitized = sanitized.replace(/[\u0000-\u001f\u007f]/g, ' ').trim() + return sanitized ? sanitized.slice(0, MAX_OCI_ERROR_FIELD_LENGTH) : undefined +} + +/** A bounded, credential-safe projection of an OCI service error. */ +export class OciRequestError extends Error { + readonly status: number + readonly code?: string + readonly opcRequestId?: string + + constructor(params: { + status: number + code?: unknown + message?: unknown + opcRequestId?: unknown + sensitiveValues?: readonly string[] + }) { + const code = sanitizeOciErrorField(params.code, params.sensitiveValues) + const message = sanitizeOciErrorField(params.message, params.sensitiveValues) + super( + message ? `OCI request failed: ${message}` : `OCI request failed with status ${params.status}` + ) + this.name = 'OciRequestError' + this.status = params.status + this.code = code + this.opcRequestId = sanitizeOciErrorField(params.opcRequestId, params.sensitiveValues) + } +} + +export function parseOciErrorBody( + body: string, + sensitiveValues: readonly string[] = [] +): { code?: string; message?: string } { + try { + const parsed: unknown = JSON.parse(body) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + const record = parsed as Record + return { + code: sanitizeOciErrorField(record.code, sensitiveValues), + message: sanitizeOciErrorField(record.message, sensitiveValues), + } + } catch { + return {} + } +} diff --git a/apps/sim/lib/internal/oci/signing.server.test.ts b/apps/sim/lib/internal/oci/signing.server.test.ts new file mode 100644 index 00000000000..b8abd179775 --- /dev/null +++ b/apps/sim/lib/internal/oci/signing.server.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + */ +import { createHash, createPublicKey, createVerify, generateKeyPairSync } from 'node:crypto' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { + type OciRequestMethod, + type OciSigningCredentials, + signOciRequest, +} from '@/lib/internal/oci/signing.server' + +/** Oracle's public request-signing fixture from the OCI Request Signatures documentation. */ +const ORACLE_FIXTURE_PRIVATE_KEY = `${['-----BEGIN', 'RSA PRIVATE KEY-----'].join(' ')} +MIICXgIBAAKBgQDCFENGw33yGihy92pDjZQhl0C36rPJj+CvfSC8+q28hxA161QF +NUd13wuCTUcq0Qd2qsBe/2hFyc2DCJJg0h1L78+6Z4UMR7EOcpfdUE9Hf3m/hs+F +UR45uBJeDK1HSFHD8bHKD6kv8FPGfJTotc+2xjJwoYi+1hqp1fIekaxsyQIDAQAB +AoGBAJR8ZkCUvx5kzv+utdl7T5MnordT1TvoXXJGXK7ZZ+UuvMNUCdN2QPc4sBiA +QWvLw1cSKt5DsKZ8UETpYPy8pPYnnDEz2dDYiaew9+xEpubyeW2oH4Zx71wqBtOK +kqwrXa/pzdpiucRRjk6vE6YY7EBBs/g7uanVpGibOVAEsqH1AkEA7DkjVH28WDUg +f1nqvfn2Kj6CT7nIcE3jGJsZZ7zlZmBmHFDONMLUrXR/Zm3pR5m0tCmBqa5RK95u +412jt1dPIwJBANJT3v8pnkth48bQo/fKel6uEYyboRtA5/uHuHkZ6FQF7OUkGogc +mSJluOdc5t6hI1VsLn0QZEjQZMEOWr+wKSMCQQCC4kXJEsHAve77oP6HtG/IiEn7 +kpyUXRNvFsDE0czpJJBvL/aRFUJxuRK91jhjC68sA7NsKMGg5OXb5I5Jj36xAkEA +gIT7aFOYBFwGgQAQkWNKLvySgKbAZRTeLBacpHMuQdl1DfdntvAyqpAZ0lY0RKmW +G6aFKaqQfOXKCyWoUiVknQJAXrlgySFci/2ueKlIE1QqIiLSZ8V8OlpFLRnb1pzI +7U1yQXnTAEFYM560yJlzUpOb1V4cScGd365tiSMvxLOvTA== +${['-----END', 'RSA PRIVATE KEY-----'].join(' ')}` + +const BASE_CREDENTIALS: OciSigningCredentials = { + tenancyId: 'ocid1.tenancy.oc1..oraclefixture', + userId: 'ocid1.user.oc1..oraclefixture', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: ORACLE_FIXTURE_PRIVATE_KEY, +} + +function authorizationParameter(authorization: string, name: string): string { + const match = new RegExp(`${name}="([^"]+)"`).exec(authorization) + if (!match?.[1]) throw new Error(`Missing ${name} authorization parameter`) + return match[1] +} + +function expectValidSignature(params: { + request: Awaited> + publicKey: ReturnType +}): void { + const authorization = params.request.headers.authorization + expect(authorization).toBeDefined() + const headerNames = authorizationParameter(authorization!, 'headers').split(' ') + const url = new URL(params.request.url) + const signingString = headerNames + .map((name) => { + if (name === '(request-target)') { + return `(request-target): ${params.request.method.toLowerCase()} ${url.pathname}${url.search}` + } + const value = params.request.headers[name.toLowerCase()] + if (value === undefined) throw new Error(`Signed header ${name} is absent`) + return `${name.toLowerCase()}: ${value}` + }) + .join('\n') + const signature = authorizationParameter(authorization!, 'signature') + const verifier = createVerify('RSA-SHA256').update(signingString).end() + expect(verifier.verify(params.publicKey, signature, 'base64')).toBe(true) +} + +describe('signOciRequest', () => { + let generatedCredentials: OciSigningCredentials + let encryptedCredentials: OciSigningCredentials + let generatedPublicKey: ReturnType + + beforeAll(() => { + const pair = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const privateKey = pair.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString() + generatedPublicKey = createPublicKey(pair.privateKey) + generatedCredentials = { ...BASE_CREDENTIALS, privateKey } + encryptedCredentials = { + ...BASE_CREDENTIALS, + privateKey: pair.privateKey + .export({ + format: 'pem', + type: 'pkcs8', + cipher: 'aes-256-cbc', + passphrase: 'signing-test-passphrase', + }) + .toString(), + passphrase: 'signing-test-passphrase', + } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('signs Oracle’s published RSA fixture entirely in memory', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) + const request = await signOciRequest({ + credentials: BASE_CREDENTIALS, + method: 'GET', + url: 'https://iaas.us-phoenix-1.oraclecloud.com/20160918/instances?displayName=Team%20X', + }) + expectValidSignature({ request, publicKey: createPublicKey(ORACLE_FIXTURE_PRIVATE_KEY) }) + expect(request.headers.authorization).toContain( + `keyId="${BASE_CREDENTIALS.tenancyId}/${BASE_CREDENTIALS.userId}/${BASE_CREDENTIALS.fingerprint}"` + ) + }) + + it('signs with an independently generated encrypted PKCS#8 key', async () => { + const request = await signOciRequest({ + credentials: encryptedCredentials, + method: 'GET', + url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users', + }) + expectValidSignature({ request, publicKey: generatedPublicKey }) + }) + + it.each(['GET', 'HEAD', 'DELETE'] as const)('signs %s without body headers', async (method) => { + const request = await signOciRequest({ + credentials: generatedCredentials, + method, + url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users?a=1&a=&name=%E2%98%83', + serviceHeaders: { accept: 'application/json' }, + }) + expect(request.body).toBeUndefined() + expect(request.headers['content-length']).toBeUndefined() + expect(request.headers['x-content-sha256']).toBeUndefined() + expect(request.headers.date).toBeUndefined() + expectValidSignature({ request, publicKey: generatedPublicKey }) + }) + + it.each(['POST', 'PUT', 'PATCH'] as const)( + 'signs empty and Unicode %s bodies with byte-correct headers', + async (method) => { + for (const body of ['', '{"message":"héllo ☃"}']) { + const request = await signOciRequest({ + credentials: generatedCredentials, + method, + url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users', + body, + }) + expect(request.body).toBe(body) + expect(request.headers['content-length']).toBe(String(Buffer.byteLength(body, 'utf8'))) + expect(request.headers['x-content-sha256']).toBe( + createHash('sha256').update(body, 'utf8').digest('base64') + ) + expect(request.headers['content-type']).toBe('application/json') + expect(request.headers.date).toBeUndefined() + expectValidSignature({ request, publicKey: generatedPublicKey }) + } + } + ) + + it('preserves finalized URL/query bytes in the signed request target', async () => { + const url = + 'https://identity.us-ashburn-1.oraclecloud.com/resource?z=last&a=one&a=&unicode=%E2%98%83' + const request = await signOciRequest({ credentials: generatedCredentials, method: 'GET', url }) + expect(request.url).toBe(url) + expectValidSignature({ request, publicKey: generatedPublicKey }) + }) + + it('creates a fresh x-date and removes the signer’s unsigned date header', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) + const first = await signOciRequest({ + credentials: generatedCredentials, + method: 'GET', + url: 'https://identity.us-ashburn-1.oraclecloud.com/a', + }) + vi.setSystemTime(new Date('2026-09-03T19:00:01.000Z')) + const second = await signOciRequest({ + credentials: generatedCredentials, + method: 'GET', + url: 'https://identity.us-ashburn-1.oraclecloud.com/a', + }) + expect(first.headers['x-date']).not.toBe(second.headers['x-date']) + expect(first.headers.date).toBeUndefined() + expect(second.headers.date).toBeUndefined() + }) + + it.each(['GET', 'HEAD', 'DELETE'] as OciRequestMethod[])( + 'rejects a body on %s', + async (method) => { + await expect( + signOciRequest({ + credentials: generatedCredentials, + method, + url: 'https://identity.us-ashburn-1.oraclecloud.com/a', + body: '', + }) + ).rejects.toThrow('must not include a body') + } + ) + + it.each([Buffer.from('body'), new Uint8Array([1, 2, 3])])( + 'rejects non-string request bodies', + async (body) => { + await expect( + signOciRequest({ + credentials: generatedCredentials, + method: 'POST', + url: 'https://identity.us-ashburn-1.oraclecloud.com/a', + body: body as unknown as string, + }) + ).rejects.toThrow('finalized strings') + } + ) + + it.each([ + 'Authorization', + 'HOST', + 'date', + 'x-date', + 'content-length', + 'content-type', + 'x-content-sha256', + ])('blocks callers from overriding %s', async (header) => { + await expect( + signOciRequest({ + credentials: generatedCredentials, + method: 'GET', + url: 'https://identity.us-ashburn-1.oraclecloud.com/a', + serviceHeaders: { [header]: 'attacker-controlled' }, + }) + ).rejects.toThrow('signing-controlled') + }) +}) diff --git a/apps/sim/lib/internal/oci/signing.server.ts b/apps/sim/lib/internal/oci/signing.server.ts new file mode 100644 index 00000000000..7382c87ed36 --- /dev/null +++ b/apps/sim/lib/internal/oci/signing.server.ts @@ -0,0 +1,104 @@ +import { DefaultRequestSigner, SimpleAuthenticationDetailsProvider } from 'oci-common' + +export type OciRequestMethod = 'GET' | 'HEAD' | 'DELETE' | 'POST' | 'PUT' | 'PATCH' + +export interface OciSigningCredentials { + readonly tenancyId: string + readonly userId: string + readonly fingerprint: string + readonly privateKey: string + readonly passphrase?: string +} + +export interface SignedOciRequest { + readonly method: OciRequestMethod + readonly url: string + readonly headers: Readonly> + readonly body?: string +} + +const BODY_METHODS: ReadonlySet = new Set(['POST', 'PUT', 'PATCH']) + +export const OCI_SIGNING_CONTROLLED_HEADERS: ReadonlySet = new Set([ + 'authorization', + 'host', + 'date', + 'x-date', + 'content-length', + 'content-type', + 'x-content-sha256', +]) + +function assertServiceHeaders(headers: Readonly>): void { + for (const [name, value] of Object.entries(headers)) { + if (OCI_SIGNING_CONTROLLED_HEADERS.has(name.toLowerCase())) { + throw new Error(`OCI service header is signing-controlled: ${name}`) + } + if ( + typeof value !== 'string' || + !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || + /[\u0000-\u001f\u007f]/.test(value) + ) { + throw new Error('OCI service headers must not contain control characters') + } + } +} + +/** Signs one finalized OCI request without consulting local OCI configuration. */ +export async function signOciRequest(params: { + credentials: OciSigningCredentials + method: OciRequestMethod + url: string + serviceHeaders?: Readonly> + body?: string + contentType?: string +}): Promise { + const serviceHeaders = params.serviceHeaders ?? {} + assertServiceHeaders(serviceHeaders) + const hasBodyMethod = BODY_METHODS.has(params.method) + if (!hasBodyMethod && params.body !== undefined) { + throw new Error(`${params.method} requests must not include a body`) + } + if (params.body !== undefined && typeof params.body !== 'string') { + throw new Error('OCI request bodies must be finalized strings') + } + if (params.contentType !== undefined && !hasBodyMethod) { + throw new Error('OCI content type is only valid for requests with signed bodies') + } + if ( + params.contentType !== undefined && + (params.contentType.length === 0 || + params.contentType.length > 256 || + /[\u0000-\u001f\u007f]/.test(params.contentType)) + ) { + throw new Error('OCI content type must not contain control characters') + } + + const body = hasBodyMethod ? (params.body ?? '') : undefined + const headers = new Headers(serviceHeaders) + headers.set('x-date', new Date().toUTCString()) + if (hasBodyMethod) headers.set('content-type', params.contentType ?? 'application/json') + + const provider = new SimpleAuthenticationDetailsProvider( + params.credentials.tenancyId, + params.credentials.userId, + params.credentials.fingerprint, + params.credentials.privateKey, + params.credentials.passphrase ?? null + ) + const signer = new DefaultRequestSigner(provider) + await signer.signHttpRequest({ + method: params.method, + uri: params.url, + headers, + ...(body !== undefined ? { body } : {}), + }) + headers.delete('date') + + return { + method: params.method, + url: params.url, + headers: Object.fromEntries(headers.entries()), + ...(body !== undefined ? { body } : {}), + } +} diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index d7b5b668f2a..b5b55dff5ad 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -14,6 +14,12 @@ 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 + /** * Discriminator stored inside the encrypted Atlassian service account secret blob. */ diff --git a/apps/sim/package.json b/apps/sim/package.json index b7d20522da1..269347f1122 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -208,6 +208,7 @@ "next-themes": "^0.4.6", "nodemailer": "9.0.1", "nuqs": "2.8.9", + "oci-common": "2.140.0", "officeparser": "5.2.2", "openai": "7.0.0", "opentype.js": "1.3.4", diff --git a/bun.lock b/bun.lock index a80c11c21c1..08f4701d31d 100644 --- a/bun.lock +++ b/bun.lock @@ -318,6 +318,7 @@ "next-themes": "^0.4.6", "nodemailer": "9.0.1", "nuqs": "2.8.9", + "oci-common": "2.140.0", "officeparser": "5.2.2", "openai": "7.0.0", "opentype.js": "1.3.4", @@ -2222,12 +2223,16 @@ "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], + "@types/isomorphic-fetch": ["@types/isomorphic-fetch@0.0.35", "", {}, "sha512-DaZNUvLDCAnCTjgwxgiL1eQdxIKEpNLOlTNtAgnZc50bG2copGhRrFN9/PxPBuJe+tZVLCbQ7ls0xveXVRPkvw=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], "@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-b0jGiOgHtZ2jqdPgPnP6WLCXZk1T8p06A/vPGzUvxpFGgKMbjXJDjC5m52ErqBnIuWZFgGoIJyRdeG5AyreJjA=="], + "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], @@ -2250,6 +2255,8 @@ "@types/opentype.js": ["@types/opentype.js@1.3.10", "", {}, "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ=="], + "@types/opossum": ["@types/opossum@4.1.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-9TMnd8AWRVtnZMqBbbzceQoJdafErgUViogFaQ3eetsbeLtiFFZ695mepNaLtlfJi4uRP3GmHfe3CJ2DZKaxYA=="], + "@types/pako": ["@types/pako@1.0.7", "", {}, "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A=="], "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], @@ -2270,6 +2277,8 @@ "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], + "@types/sshpk": ["@types/sshpk@1.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-cru1waDhHZnZuB18E6Dgf2UXf8U93mdOEDcKYe5jTri+fpucidSs7DLmGICpLxN+95aYkwtgeyny9fBFzQVdmA=="], + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -2278,6 +2287,8 @@ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/uuid": ["@types/uuid@8.3.4", "", {}, "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw=="], + "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], "@types/whatwg-url": ["@types/whatwg-url@11.0.5", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ=="], @@ -2440,6 +2451,8 @@ "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="], @@ -2800,6 +2813,8 @@ "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + "dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], @@ -2924,6 +2939,8 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="], @@ -2990,6 +3007,8 @@ "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + "es6-promise": ["es6-promise@4.2.6", "", {}, "sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q=="], + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], @@ -3058,6 +3077,8 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "extsprintf": ["extsprintf@1.3.0", "", {}, "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], @@ -3172,6 +3193,8 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="], + "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], @@ -3284,6 +3307,8 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http-signature": ["http-signature@1.3.1", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.14.1" } }, "sha512-Y29YKEc8MQsjch/VzkUVJ+2MXd9WcR42fK5u36CZf4G8bXw2DXMTWuESiB0R6m59JAWxlPPw5/Fri/t/AyyueA=="], + "http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="], "https": ["https@1.0.0", "", {}, "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="], @@ -3384,6 +3409,8 @@ "isolated-vm": ["isolated-vm@6.2.0", "", { "dependencies": { "node-gyp-build": "^4.8.4" } }, "sha512-UuSlxSHWt2QuJ5WvBhzlIJx2VVZN/a44SqBbEZFKNdvuSyhOvhmyDo8SQ+njVbhnh/njoL/aW0bUTiFYlpweGQ=="], + "isomorphic-fetch": ["isomorphic-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA=="], + "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], @@ -3418,6 +3445,8 @@ "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + "jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="], + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], @@ -3448,6 +3477,10 @@ "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + "jsprim": ["jsprim@1.4.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw=="], + + "jssha": ["jssha@3.3.1", "", {}, "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -3848,6 +3881,8 @@ "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "oci-common": ["oci-common@2.140.0", "", { "dependencies": { "@types/isomorphic-fetch": "0.0.35", "@types/jsonwebtoken": "9.0.3", "@types/opossum": "4.1.1", "@types/sshpk": "1.10.3", "@types/uuid": "8.3.4", "es6-promise": "4.2.6", "http-signature": "1.3.1", "isomorphic-fetch": "3.0.0", "jsonwebtoken": "9.0.3", "jssha": "3.3.1", "opossum": "5.0.1", "sshpk": "1.18.0", "uuid": "11.1.1" } }, "sha512-yHdfmB0gIx0QYC7sNvgll4HEw+w+fVo/eldhZfN2VxLJK+oqVHDlr6rT/ytwK8Fc8bS5XPkofIpCStPNR10MdQ=="], + "officeparser": ["officeparser@5.2.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.10", "concat-stream": "^2.0.0", "file-type": "^16.5.4", "node-ensure": "^0.0.0", "pdfjs-dist": "^5.3.31", "yauzl": "^3.1.3" }, "bin": { "officeparser": "officeParser.js" } }, "sha512-5JrV1CZFqTv/27fXy2bcf+3g6BpDZiJ3XoSRW3fb2i2EFex0DduqjTxiU2RsJ08WBsk4Hp0nZoGi9ZtHMZFaPA=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], @@ -3878,6 +3913,8 @@ "opentype.js": ["opentype.js@1.3.4", "", { "dependencies": { "string.prototype.codepointat": "^0.2.1", "tiny-inflate": "^1.0.3" }, "bin": { "ot": "bin/ot" } }, "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw=="], + "opossum": ["opossum@5.0.1", "", {}, "sha512-iUDUQmFl3RanaBVLMDTZ6WtXj/Hk84pwJ5JWoJaQd1lXGifdApHhszI3biZvdBDdpTERCmB6x+7+uNvzhzVZIg=="], + "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], "ora": ["ora@4.1.1", "", { "dependencies": { "chalk": "^3.0.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.2.0", "is-interactive": "^1.0.0", "log-symbols": "^3.0.0", "mute-stream": "0.0.8", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A=="], @@ -4346,6 +4383,8 @@ "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], + "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], @@ -4598,6 +4637,8 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], @@ -4638,6 +4679,8 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], @@ -5040,6 +5083,8 @@ "@types/fs-extra/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/jsonwebtoken/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/keyv/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/mssql/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5048,6 +5093,8 @@ "@types/nodemailer/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/opossum/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/readable-stream/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/readdir-glob/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5060,6 +5107,8 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@types/sshpk/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -5460,6 +5509,8 @@ "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "widest-line/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -5674,6 +5725,8 @@ "@types/fs-extra/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/jsonwebtoken/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/keyv/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/mssql/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5682,6 +5735,8 @@ "@types/nodemailer/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/opossum/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/readable-stream/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/readdir-glob/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5694,6 +5749,8 @@ "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@types/sshpk/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], From 729cd14eff4a92a63f3d27923b8c2e76be72f373 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:40:36 -0700 Subject: [PATCH 02/11] fix(oci): harden endpoint and error validation --- .../lib/internal/oci/client.server.test.ts | 28 +++++++++++ apps/sim/lib/internal/oci/endpoints.test.ts | 15 ++++++ apps/sim/lib/internal/oci/endpoints.ts | 8 +-- apps/sim/lib/internal/oci/errors.ts | 49 ++++++++++++++++++- 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 4190166cd0d..1d3fae3cb4d 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -210,6 +210,34 @@ describe('OCI request client', () => { expect((failure as OciRequestError).opcRequestId).toBe('request-502') }) + it('redacts authorization material embedded in a serialized JSON message', async () => { + const echoedAuthorization = + 'Signature version="1",keyId="tenant/user/fingerprint",headers="(request-target) host x-date",signature="provider-echo"' + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: JSON.stringify({ authorization: echoedAuthorization }), + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OciRequestError) + expect((failure as Error).message).toContain('[redacted]') + expect((failure as Error).message).not.toContain('provider-echo') + expect((failure as Error).message).not.toContain('(request-target)') + expect((failure as Error).message).not.toContain('tenant/user/fingerprint') + }) + it.each([ '//attacker.example/path', '/safe//attacker', diff --git a/apps/sim/lib/internal/oci/endpoints.test.ts b/apps/sim/lib/internal/oci/endpoints.test.ts index cd789b00f25..628ddb1adb9 100644 --- a/apps/sim/lib/internal/oci/endpoints.test.ts +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -6,6 +6,7 @@ import { getOciRegion, isObjectStorageOciHostname, OCI_REGION_IDS, + type OciServiceHostnamePredicate, objectStorageOciDestination, objectStorageOciHostname, resolveEffectiveOciRegion, @@ -27,6 +28,7 @@ describe('OCI region registry', () => { 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 effective-region overrides', () => { @@ -105,6 +107,19 @@ describe('validateOciDestination', () => { ).toThrow('not owned') }) + it('rejects a bracketed IPv6 literal before applying the service predicate', () => { + const acceptsEveryHostname = (() => true) as OciServiceHostnamePredicate + expect(() => + validateOciDestination({ + origin: 'https://[2606:4700::1111]', + service: 'objectstorage', + region, + provenance: 'static', + isServiceHostname: acceptsEveryHostname, + }) + ).toThrow('exact HTTPS origin') + }) + it('rejects a forged region-to-realm association', () => { expect(() => validateOciDestination({ diff --git a/apps/sim/lib/internal/oci/endpoints.ts b/apps/sim/lib/internal/oci/endpoints.ts index 25062fc8745..2e576890113 100644 --- a/apps/sim/lib/internal/oci/endpoints.ts +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -1,4 +1,4 @@ -import { isIpLiteral } from '@sim/security/ssrf' +import { isIpLiteral, unwrapIpv6Brackets } from '@sim/security/ssrf' export type OciDestinationProvenance = 'static' | 'authenticated-discovery' @@ -160,7 +160,9 @@ function normalizeRegionId(regionId: string): string { export function getOciRegion(regionId: string): OciRegion { const normalized = normalizeRegionId(regionId) - const realmId = REGION_REALMS[normalized as keyof typeof REGION_REALMS] + 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, @@ -211,7 +213,7 @@ export function validateOciDestination(params: { url.pathname !== '/' || url.search !== '' || url.hash !== '' || - isIpLiteral(url.hostname) || + isIpLiteral(unwrapIpv6Brackets(url.hostname)) || url.origin !== params.origin ) { throw new Error('OCI destination must be an exact HTTPS origin with the default port') diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index 8e5e21fc283..a74cd527cf2 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -1,11 +1,58 @@ const MAX_OCI_ERROR_FIELD_LENGTH = 1024 +const MAX_OCI_ERROR_INPUT_LENGTH = 8192 +const MAX_NESTED_JSON_DEPTH = 3 +const SENSITIVE_JSON_FIELDS = new Set([ + 'authorization', + 'passphrase', + 'privatekey', + 'proxyauthorization', + 'signingstring', +]) + +function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined { + if (depth > MAX_NESTED_JSON_DEPTH || value === null) return undefined + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (Array.isArray(value)) { + return value + .map((entry) => flattenJsonDiagnostic(entry, depth + 1)) + .filter((entry): entry is string => entry !== undefined) + .join(' ') + } + if (typeof value !== 'object') return undefined + return Object.entries(value) + .map(([key, entry]) => { + const normalizedKey = key.replace(/[^a-z]/gi, '').toLowerCase() + if (SENSITIVE_JSON_FIELDS.has(normalizedKey)) return `${key}: [redacted]` + const flattened = flattenJsonDiagnostic(entry, depth + 1) + return flattened === undefined ? undefined : `${key}: ${flattened}` + }) + .filter((entry): entry is string => entry !== undefined) + .join(' ') +} + +function decodeNestedJsonDiagnostic(value: string): string { + let decoded = value.slice(0, MAX_OCI_ERROR_INPUT_LENGTH) + for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { + let parsed: unknown + try { + parsed = JSON.parse(decoded) + } catch { + break + } + const flattened = flattenJsonDiagnostic(parsed) + if (flattened === undefined || flattened === decoded) break + decoded = flattened.slice(0, MAX_OCI_ERROR_INPUT_LENGTH) + } + return decoded +} function sanitizeOciErrorField( value: unknown, sensitiveValues: readonly string[] = [] ): string | undefined { if (typeof value !== 'string') return undefined - let sanitized = value + let sanitized = decodeNestedJsonDiagnostic(value) .replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]') .replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]') .replace(/Signature\s+version="1",[^\r\n]*/gi, '[redacted-authorization]') From da171324ef585ab33ce3a7391ce83a64c79a6b6c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:09:48 -0700 Subject: [PATCH 03/11] fix(oci): fail closed on encoded diagnostics --- .../lib/internal/oci/client.server.test.ts | 141 +++++++++++++++++- apps/sim/lib/internal/oci/client.server.ts | 10 +- apps/sim/lib/internal/oci/errors.ts | 137 +++++++++++------ 3 files changed, 242 insertions(+), 46 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 1d3fae3cb4d..77553358300 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -181,7 +181,7 @@ describe('OCI request client', () => { code: 'NotAuthenticated', opcRequestId: 'request-401', }) - expect((failure as Error).message).toContain('[redacted]') + expect((failure as Error).message).toContain('[REDACTED]') expect((failure as Error).message).not.toContain('client-secret-passphrase') expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY') expect((failure as Error).message).not.toContain('objectstorage.us-ashburn-1') @@ -232,12 +232,149 @@ describe('OCI request client', () => { maxResponseBytes: 65_536, }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(OciRequestError) - expect((failure as Error).message).toContain('[redacted]') + expect((failure as Error).message).toContain('[REDACTED]') expect((failure as Error).message).not.toContain('provider-echo') expect((failure as Error).message).not.toContain('(request-target)') expect((failure as Error).message).not.toContain('tenant/user/fingerprint') }) + it('redacts encoded credentials and request URLs echoed by the provider', async () => { + const encodedFingerprint = encodeURIComponent(credentials.fingerprint) + const requestUrl = `${destination.origin}/n/` + const encodedRequestUrl = encodeURIComponent(requestUrl) + const escapedPassphrase = 'secret "pass"' + const escapedPassphraseEcho = JSON.stringify(escapedPassphrase).slice(1, -1) + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed ${encodedFingerprint} ${encodedRequestUrl} ${escapedPassphraseEcho}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials: { ...credentials, passphrase: escapedPassphrase }, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).not.toContain(encodedFingerprint) + expect((failure as Error).message).not.toContain(encodedRequestUrl) + expect((failure as Error).message).not.toContain(escapedPassphraseEcho) + expect((failure as Error).message).not.toContain(escapedPassphrase) + }) + + it('redacts a maximum-size passphrase before bounding an encoded diagnostic', async () => { + const longPassphrase = ' '.repeat(4096) + const encodedPassphrase = new URLSearchParams({ value: longPassphrase }) + .toString() + .slice('value='.length) + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed ${encodedPassphrase}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials: { ...credentials, passphrase: longPassphrase }, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).not.toContain('+'.repeat(1024)) + }) + + it.each([ + encodeURIComponent('-----BEGIN PRIVATE KEY-----\ntruncated'), + encodeURIComponent(`${destination.origin}/n/truncated`), + ])('fails closed for encoded key or URL prefixes', async (message) => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ code: 'NotAuthenticated', message }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + + it('redacts generic and percent-encoded sensitive JSON fields', async () => { + const echoedSecrets = [ + 'access-value', + 'token-value', + 'secret-value', + 'password-value', + '(request-target) host x-date', + ] + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: JSON.stringify({ + access_token: echoedSecrets[0], + token: echoedSecrets[1], + secret: echoedSecrets[2], + 'pass%70hrase': echoedSecrets[3], + signing_string: echoedSecrets[4], + }), + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + for (const secret of echoedSecrets) expect((failure as Error).message).not.toContain(secret) + }) + + it.each([ + '{"authorization":"Signature version=\\"1\\",signature=\\"echoed\\"', + JSON.stringify({ level1: { level2: { level3: { authorization: 'echoed' } } } }), + JSON.stringify({ 'pass%25252570hrase': 'echoed' }), + ])('fails closed for malformed or over-depth structured diagnostics', async (message) => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ code: 'NotAuthenticated', message }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + it.each([ '//attacker.example/path', '/safe//attacker', diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index f6a32b91908..42466fc4ba0 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -62,7 +62,8 @@ function validateRequestLimits(timeout: number, maxResponseBytes: number): void function sensitiveRequestValues( credentials: OciSigningCredentials, - authorization: string | undefined + authorization: string | undefined, + requestUrl: string ): string[] { return [ credentials.tenancyId, @@ -71,6 +72,7 @@ function sensitiveRequestValues( credentials.privateKey, credentials.passphrase ?? '', authorization ?? '', + requestUrl, ].filter(Boolean) } @@ -116,7 +118,11 @@ export async function sendOciRequest(params: { const opcRequestId = response.headers.get('opc-request-id') ?? undefined if (response.ok) return { response, opcRequestId } - const sensitiveValues = sensitiveRequestValues(params.credentials, signed.headers.authorization) + const sensitiveValues = sensitiveRequestValues( + params.credentials, + signed.headers.authorization, + signed.url + ) const body = await response.text() const error = parseOciErrorBody(body, sensitiveValues) throw new OciRequestError({ diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index a74cd527cf2..e6aea6e2277 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -1,50 +1,91 @@ +import { + isSensitiveKey, + REDACTED_MARKER, + redactExactSensitiveValues, +} from '@/lib/core/security/redaction' + const MAX_OCI_ERROR_FIELD_LENGTH = 1024 -const MAX_OCI_ERROR_INPUT_LENGTH = 8192 +const MAX_OCI_ERROR_INPUT_LENGTH = 65_536 const MAX_NESTED_JSON_DEPTH = 3 -const SENSITIVE_JSON_FIELDS = new Set([ - 'authorization', - 'passphrase', - 'privatekey', - 'proxyauthorization', - 'signingstring', -]) +const OCI_SENSITIVE_JSON_FIELDS = new Set(['signingstring']) +const ENCODED_DIAGNOSTIC_SENTINELS = ['-----BEGIN', 'https://'] + +function normalizeJsonDiagnosticKey(key: string): string | undefined { + let normalized = key + for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { + if (!normalized.includes('%')) return normalized + if (!/%[0-9a-f]{2}/i.test(normalized)) return undefined + try { + normalized = decodeURIComponent(normalized) + } catch { + return undefined + } + } + return normalized.includes('%') ? undefined : normalized +} + +function looksLikeStructuredJson(value: string): boolean { + const first = value.trimStart()[0] + return first === '{' || first === '[' || first === '"' +} + +function isSensitiveOciJsonKey(key: string): boolean { + const compactKey = key.replace(/[^a-z]/gi, '').toLowerCase() + return OCI_SENSITIVE_JSON_FIELDS.has(compactKey) || isSensitiveKey(key) +} + +function containsEncodedDiagnosticSentinel(value: string): boolean { + const lowerValue = value.toLowerCase() + return ENCODED_DIAGNOSTIC_SENTINELS.some((sentinel) => { + let encoded = sentinel + for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { + encoded = encodeURIComponent(encoded) + if (encoded !== sentinel && lowerValue.includes(encoded.toLowerCase())) return true + } + return false + }) +} function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined { - if (depth > MAX_NESTED_JSON_DEPTH || value === null) return undefined - if (typeof value === 'string') return value + if (depth > MAX_NESTED_JSON_DEPTH) return undefined + if (value === null) return 'null' + if (typeof value === 'string') { + if (!looksLikeStructuredJson(value)) return value + if (depth === MAX_NESTED_JSON_DEPTH) return undefined + try { + return flattenJsonDiagnostic(JSON.parse(value), depth + 1) + } catch { + return undefined + } + } if (typeof value === 'number' || typeof value === 'boolean') return String(value) if (Array.isArray(value)) { - return value - .map((entry) => flattenJsonDiagnostic(entry, depth + 1)) - .filter((entry): entry is string => entry !== undefined) - .join(' ') + if (depth === MAX_NESTED_JSON_DEPTH) return undefined + const flattened = value.map((entry) => flattenJsonDiagnostic(entry, depth + 1)) + if (flattened.some((entry) => entry === undefined)) return undefined + return flattened.join(' ') } if (typeof value !== 'object') return undefined - return Object.entries(value) - .map(([key, entry]) => { - const normalizedKey = key.replace(/[^a-z]/gi, '').toLowerCase() - if (SENSITIVE_JSON_FIELDS.has(normalizedKey)) return `${key}: [redacted]` - const flattened = flattenJsonDiagnostic(entry, depth + 1) - return flattened === undefined ? undefined : `${key}: ${flattened}` - }) - .filter((entry): entry is string => entry !== undefined) - .join(' ') + if (depth === MAX_NESTED_JSON_DEPTH) return undefined + const flattened = Object.entries(value).map(([key, entry]) => { + const normalizedKey = normalizeJsonDiagnosticKey(key) + if (normalizedKey === undefined) return undefined + if (isSensitiveOciJsonKey(normalizedKey)) return `${key}: ${REDACTED_MARKER}` + const nested = flattenJsonDiagnostic(entry, depth + 1) + return nested === undefined ? undefined : `${key}: ${nested}` + }) + if (flattened.some((entry) => entry === undefined)) return undefined + return flattened.join(' ') } -function decodeNestedJsonDiagnostic(value: string): string { - let decoded = value.slice(0, MAX_OCI_ERROR_INPUT_LENGTH) - for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { - let parsed: unknown - try { - parsed = JSON.parse(decoded) - } catch { - break - } - const flattened = flattenJsonDiagnostic(parsed) - if (flattened === undefined || flattened === decoded) break - decoded = flattened.slice(0, MAX_OCI_ERROR_INPUT_LENGTH) +function decodeNestedJsonDiagnostic(value: string): string | undefined { + if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined + if (!looksLikeStructuredJson(value)) return value + try { + return flattenJsonDiagnostic(JSON.parse(value)) + } catch { + return undefined } - return decoded } function sanitizeOciErrorField( @@ -52,14 +93,26 @@ function sanitizeOciErrorField( sensitiveValues: readonly string[] = [] ): string | undefined { if (typeof value !== 'string') return undefined - let sanitized = decodeNestedJsonDiagnostic(value) + if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined + if (containsEncodedDiagnosticSentinel(value)) return undefined + const decoded = decodeNestedJsonDiagnostic(value) + if (decoded === undefined) return undefined + const exactValues = sensitiveValues.flatMap((sensitiveValue) => { + const jsonEncoded = JSON.stringify(sensitiveValue).slice(1, -1) + return jsonEncoded === sensitiveValue ? [sensitiveValue] : [sensitiveValue, jsonEncoded] + }) + let exactRedacted: string + try { + exactRedacted = redactExactSensitiveValues(decoded, exactValues) + } catch { + return undefined + } + const sanitized = exactRedacted .replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]') .replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]') - .replace(/Signature\s+version="1",[^\r\n]*/gi, '[redacted-authorization]') - for (const sensitiveValue of sensitiveValues) { - if (sensitiveValue.length > 0) sanitized = sanitized.split(sensitiveValue).join('[redacted]') - } - sanitized = sanitized.replace(/[\u0000-\u001f\u007f]/g, ' ').trim() + .replace(/Signature\s+version=\\*"1\\*",[^\r\n]*/gi, '[redacted-authorization]') + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .trim() return sanitized ? sanitized.slice(0, MAX_OCI_ERROR_FIELD_LENGTH) : undefined } From 47f846149b09a65c8d23727b372ad29ddd0a969e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:28:28 -0700 Subject: [PATCH 04/11] fix(oci): reject ambiguous diagnostics --- .../lib/internal/oci/client.server.test.ts | 52 ++++++++++++++++++- apps/sim/lib/internal/oci/client.server.ts | 1 + apps/sim/lib/internal/oci/errors.ts | 27 +++++----- 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 77553358300..e3e81a05ff5 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -232,7 +232,7 @@ describe('OCI request client', () => { maxResponseBytes: 65_536, }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(OciRequestError) - expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).toBe('OCI request failed with status 401') expect((failure as Error).message).not.toContain('provider-echo') expect((failure as Error).message).not.toContain('(request-target)') expect((failure as Error).message).not.toContain('tenant/user/fingerprint') @@ -298,6 +298,8 @@ describe('OCI request client', () => { it.each([ encodeURIComponent('-----BEGIN PRIVATE KEY-----\ntruncated'), encodeURIComponent(`${destination.origin}/n/truncated`), + '----%2DBEGIN PRIVATE KEY-----', + 'https:%2F%2Fobjectstorage.us-ashburn-1.oraclecloud.com/n/', ])('fails closed for encoded key or URL prefixes', async (message) => { secureFetchMock.mockResolvedValueOnce( secureResponse({ @@ -324,6 +326,8 @@ describe('OCI request client', () => { 'secret-value', 'password-value', '(request-target) host x-date', + 'private-key-value', + 'api-key-value', ] secureFetchMock.mockResolvedValueOnce( secureResponse({ @@ -337,6 +341,8 @@ describe('OCI request client', () => { secret: echoedSecrets[2], 'pass%70hrase': echoedSecrets[3], signing_string: echoedSecrets[4], + 'private key': echoedSecrets[5], + 'api key': echoedSecrets[6], }), }), }) @@ -352,6 +358,50 @@ describe('OCI request client', () => { for (const secret of echoedSecrets) expect((failure as Error).message).not.toContain(secret) }) + it('fails closed when structured JSON follows a plain-text prefix', async () => { + const message = `provider failed: ${JSON.stringify({ authorization: 'provider-echo' })}` + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ code: 'NotAuthenticated', message }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + + it.each([ + `provider failed: ${JSON.stringify(JSON.stringify({ authorization: 'provider-echo' }))}`, + 'provider failed: \\"authorization\\":\\"provider-echo\\"', + 'signed headers: (request-target) host x-date', + 'signed headers: host x-content-sha256', + ])('fails closed for escaped structured or signing diagnostics', async (message) => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ code: 'NotAuthenticated', message }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + it.each([ '{"authorization":"Signature version=\\"1\\",signature=\\"echoed\\"', JSON.stringify({ level1: { level2: { level3: { authorization: 'echoed' } } } }), diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 42466fc4ba0..6c51be0e5d1 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -69,6 +69,7 @@ function sensitiveRequestValues( credentials.tenancyId, credentials.userId, credentials.fingerprint, + credentials.fingerprint.toUpperCase(), credentials.privateKey, credentials.passphrase ?? '', authorization ?? '', diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index e6aea6e2277..13de92cbab7 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -8,7 +8,6 @@ const MAX_OCI_ERROR_FIELD_LENGTH = 1024 const MAX_OCI_ERROR_INPUT_LENGTH = 65_536 const MAX_NESTED_JSON_DEPTH = 3 const OCI_SENSITIVE_JSON_FIELDS = new Set(['signingstring']) -const ENCODED_DIAGNOSTIC_SENTINELS = ['-----BEGIN', 'https://'] function normalizeJsonDiagnosticKey(key: string): string | undefined { let normalized = key @@ -31,26 +30,24 @@ function looksLikeStructuredJson(value: string): boolean { function isSensitiveOciJsonKey(key: string): boolean { const compactKey = key.replace(/[^a-z]/gi, '').toLowerCase() - return OCI_SENSITIVE_JSON_FIELDS.has(compactKey) || isSensitiveKey(key) + return OCI_SENSITIVE_JSON_FIELDS.has(compactKey) || isSensitiveKey(compactKey) } -function containsEncodedDiagnosticSentinel(value: string): boolean { - const lowerValue = value.toLowerCase() - return ENCODED_DIAGNOSTIC_SENTINELS.some((sentinel) => { - let encoded = sentinel - for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { - encoded = encodeURIComponent(encoded) - if (encoded !== sentinel && lowerValue.includes(encoded.toLowerCase())) return true - } - return false - }) +function containsEmbeddedStructuredText(value: string): boolean { + return ( + !looksLikeStructuredJson(value) && + (/[[{]\s*\\*(?:["{[\]}]|-?\d|true\b|false\b|null\b)/.test(value) || + /\\*"[^"\\\r\n]{1,128}\\*"\s*:\s*/.test(value)) + ) } function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined { if (depth > MAX_NESTED_JSON_DEPTH) return undefined if (value === null) return 'null' if (typeof value === 'string') { - if (!looksLikeStructuredJson(value)) return value + if (!looksLikeStructuredJson(value)) { + return containsEmbeddedStructuredText(value) ? undefined : value + } if (depth === MAX_NESTED_JSON_DEPTH) return undefined try { return flattenJsonDiagnostic(JSON.parse(value), depth + 1) @@ -80,6 +77,7 @@ function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined { function decodeNestedJsonDiagnostic(value: string): string | undefined { if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined + if (containsEmbeddedStructuredText(value)) return undefined if (!looksLikeStructuredJson(value)) return value try { return flattenJsonDiagnostic(JSON.parse(value)) @@ -94,7 +92,8 @@ function sanitizeOciErrorField( ): string | undefined { if (typeof value !== 'string') return undefined if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined - if (containsEncodedDiagnosticSentinel(value)) return undefined + if (/%[0-9a-f]{2}/i.test(value)) return undefined + if (/\(request-target\)|x-content-sha256/i.test(value)) return undefined const decoded = decodeNestedJsonDiagnostic(value) if (decoded === undefined) return undefined const exactValues = sensitiveValues.flatMap((sensitiveValue) => { From a48813a51c360a22eec392e160f0fb0d782044a1 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:45:10 -0700 Subject: [PATCH 05/11] fix(oci): harden signed request boundaries --- .../lib/internal/oci/client.server.test.ts | 117 ++++++++++++++++-- apps/sim/lib/internal/oci/client.server.ts | 17 ++- apps/sim/lib/internal/oci/errors.ts | 1 + 3 files changed, 122 insertions(+), 13 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index e3e81a05ff5..c9bc799f76c 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -211,8 +211,7 @@ describe('OCI request client', () => { }) it('redacts authorization material embedded in a serialized JSON message', async () => { - const echoedAuthorization = - 'Signature version="1",keyId="tenant/user/fingerprint",headers="(request-target) host x-date",signature="provider-echo"' + const echoedAuthorization = 'opaque-authorization-value' secureFetchMock.mockResolvedValueOnce( secureResponse({ ok: false, @@ -232,10 +231,8 @@ describe('OCI request client', () => { maxResponseBytes: 65_536, }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(OciRequestError) - expect((failure as Error).message).toBe('OCI request failed with status 401') - expect((failure as Error).message).not.toContain('provider-echo') - expect((failure as Error).message).not.toContain('(request-target)') - expect((failure as Error).message).not.toContain('tenant/user/fingerprint') + expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).not.toContain(echoedAuthorization) }) it('redacts encoded credentials and request URLs echoed by the provider', async () => { @@ -268,6 +265,57 @@ describe('OCI request client', () => { expect((failure as Error).message).not.toContain(escapedPassphrase) }) + it('redacts an echoed finalized request body from provider diagnostics', async () => { + const requestBody = 'opaque-request-body-secret' + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 400, + body: JSON.stringify({ + code: 'InvalidParameter', + message: `provider echoed ${requestBody}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'POST', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + body: requestBody, + }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).not.toContain(requestBody) + }) + + it('fails closed instead of redacting an unbounded request body', async () => { + const requestBody = 's'.repeat(65_537) + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 400, + opcRequestId: 'request-body-echo', + body: JSON.stringify({ + code: 'InvalidParameter', + message: `provider echoed ${requestBody.slice(0, 1024)}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'POST', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + body: requestBody, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 400') + expect((failure as OciRequestError).opcRequestId).toBeUndefined() + }) + it('redacts a maximum-size passphrase before bounding an encoded diagnostic', async () => { const longPassphrase = ' '.repeat(4096) const encodedPassphrase = new URLSearchParams({ value: longPassphrase }) @@ -319,13 +367,13 @@ describe('OCI request client', () => { expect((failure as Error).message).toBe('OCI request failed with status 401') }) - it('redacts generic and percent-encoded sensitive JSON fields', async () => { + it('redacts generic, spaced, and OCI-specific sensitive JSON fields', async () => { const echoedSecrets = [ 'access-value', 'token-value', 'secret-value', 'password-value', - '(request-target) host x-date', + 'signing-string-value', 'private-key-value', 'api-key-value', ] @@ -339,7 +387,7 @@ describe('OCI request client', () => { access_token: echoedSecrets[0], token: echoedSecrets[1], secret: echoedSecrets[2], - 'pass%70hrase': echoedSecrets[3], + passphrase: echoedSecrets[3], signing_string: echoedSecrets[4], 'private key': echoedSecrets[5], 'api key': echoedSecrets[6], @@ -355,9 +403,32 @@ describe('OCI request client', () => { timeout: 10_000, maxResponseBytes: 65_536, }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('[REDACTED]') for (const secret of echoedSecrets) expect((failure as Error).message).not.toContain(secret) }) + it('fails closed for a percent-encoded sensitive JSON key', async () => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: JSON.stringify({ 'pass%70hrase': 'provider-echo' }), + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + it('fails closed when structured JSON follows a plain-text prefix', async () => { const message = `provider failed: ${JSON.stringify({ authorization: 'provider-echo' })}` secureFetchMock.mockResolvedValueOnce( @@ -402,6 +473,29 @@ describe('OCI request client', () => { expect((failure as Error).message).toBe('OCI request failed with status 401') }) + it.each([ + 'provider echoed \\u0028request-target\\u0029 host x-date', + 'provider echoed \\u0068ttps\\u003a\\u002f\\u002fexample.com', + 'provider echoed \\x28request-target\\x29', + ])('fails closed for Unicode-escaped diagnostics', async (message) => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ code: 'NotAuthenticated', message }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + it.each([ '{"authorization":"Signature version=\\"1\\",signature=\\"echoed\\"', JSON.stringify({ level1: { level2: { level3: { authorization: 'echoed' } } } }), @@ -432,6 +526,11 @@ describe('OCI request client', () => { '/path#fragment', '/path\\replacement', '/path%ZZ', + '/n/../tenant', + '/n/./tenant', + '/n/%2e/tenant', + '/n/%2E%2E/tenant', + '/n/.%2e/tenant', ])('rejects unsafe encoded paths: %s', (encodedPath) => { expect(() => buildOciRequestUrl(destination, encodedPath)).toThrow( 'single encoded absolute path' diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 6c51be0e5d1..4773d27ce84 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -12,6 +12,7 @@ import { } from '@/lib/internal/oci/signing.server' const MAX_OCI_TIMEOUT_MS = 5 * 60 * 1000 +const MAX_OCI_REDACTABLE_BODY_LENGTH = 65_536 export interface OciRequestResult { readonly response: SecureFetchResponse @@ -43,6 +44,9 @@ export function buildOciRequestUrl( ) { throw new Error('OCI request path must be a single encoded absolute path') } + if (new URL(`${destination.origin}${encodedPath}`).pathname !== encodedPath) { + throw new Error('OCI request path must be a single encoded absolute path') + } const query = serializeOciQueryPairs(queryPairs) return `${destination.origin}${encodedPath}${query ? `?${query}` : ''}` } @@ -63,7 +67,8 @@ function validateRequestLimits(timeout: number, maxResponseBytes: number): void function sensitiveRequestValues( credentials: OciSigningCredentials, authorization: string | undefined, - requestUrl: string + requestUrl: string, + requestBody: string | undefined ): string[] { return [ credentials.tenancyId, @@ -74,6 +79,7 @@ function sensitiveRequestValues( credentials.passphrase ?? '', authorization ?? '', requestUrl, + requestBody ?? '', ].filter(Boolean) } @@ -119,18 +125,21 @@ export async function sendOciRequest(params: { const opcRequestId = response.headers.get('opc-request-id') ?? undefined if (response.ok) return { response, opcRequestId } + const requestBodyIsRedactable = + signed.body === undefined || signed.body.length <= MAX_OCI_REDACTABLE_BODY_LENGTH const sensitiveValues = sensitiveRequestValues( params.credentials, signed.headers.authorization, - signed.url + signed.url, + requestBodyIsRedactable ? signed.body : undefined ) const body = await response.text() - const error = parseOciErrorBody(body, sensitiveValues) + const error = requestBodyIsRedactable ? parseOciErrorBody(body, sensitiveValues) : {} throw new OciRequestError({ status: response.status, code: error.code, message: error.message, - opcRequestId, + opcRequestId: requestBodyIsRedactable ? opcRequestId : undefined, sensitiveValues, }) } diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index 13de92cbab7..8e10a0fb0ab 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -93,6 +93,7 @@ function sanitizeOciErrorField( if (typeof value !== 'string') return undefined if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined if (/%[0-9a-f]{2}/i.test(value)) return undefined + if (/\\(?:u[0-9a-f]{4}|x[0-9a-f]{2})/i.test(value)) return undefined if (/\(request-target\)|x-content-sha256/i.test(value)) return undefined const decoded = decodeNestedJsonDiagnostic(value) if (decoded === undefined) return undefined From 230ab8d59ac2462036aa81b6edd93b9691f3871a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:10:08 -0700 Subject: [PATCH 06/11] fix(oci): bound and sanitize provider errors --- .../lib/internal/oci/client.server.test.ts | 120 ++++++++++++++++-- apps/sim/lib/internal/oci/client.server.ts | 76 +++++++++-- apps/sim/lib/internal/oci/errors.ts | 22 +++- 3 files changed, 195 insertions(+), 23 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index c9bc799f76c..4f1917771c4 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -24,6 +24,7 @@ function secureResponse(params: { ok: boolean status: number body?: string + responseBody?: ReadableStream | null opcRequestId?: string }) { return { @@ -34,7 +35,7 @@ function secureResponse(params: { get: (name: string) => name.toLowerCase() === 'opc-request-id' ? (params.opcRequestId ?? null) : null, }, - body: null, + body: params.responseBody ?? null, text: vi.fn().mockResolvedValue(params.body ?? ''), json: vi.fn(), arrayBuffer: vi.fn(), @@ -235,19 +236,17 @@ describe('OCI request client', () => { expect((failure as Error).message).not.toContain(echoedAuthorization) }) - it('redacts encoded credentials and request URLs echoed by the provider', async () => { + it('redacts encoded credentials instead of falling through to a status-only error', async () => { const encodedFingerprint = encodeURIComponent(credentials.fingerprint) - const requestUrl = `${destination.origin}/n/` - const encodedRequestUrl = encodeURIComponent(requestUrl) const escapedPassphrase = 'secret "pass"' - const escapedPassphraseEcho = JSON.stringify(escapedPassphrase).slice(1, -1) + const encodedPassphrase = encodeURIComponent(escapedPassphrase) secureFetchMock.mockResolvedValueOnce( secureResponse({ ok: false, status: 401, body: JSON.stringify({ code: 'NotAuthenticated', - message: `provider echoed ${encodedFingerprint} ${encodedRequestUrl} ${escapedPassphraseEcho}`, + message: `provider echoed ${encodedFingerprint} ${encodedPassphrase}`, }), }) ) @@ -259,12 +258,63 @@ describe('OCI request client', () => { timeout: 10_000, maxResponseBytes: 65_536, }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('provider echoed') + expect((failure as Error).message).toContain('[REDACTED]') expect((failure as Error).message).not.toContain(encodedFingerprint) - expect((failure as Error).message).not.toContain(encodedRequestUrl) - expect((failure as Error).message).not.toContain(escapedPassphraseEcho) + expect((failure as Error).message).not.toContain(encodedPassphrase) expect((failure as Error).message).not.toContain(escapedPassphrase) }) + it('redacts an encoded signed request URL instead of returning it', async () => { + const encodedRequestUrl = encodeURIComponent(`${destination.origin}/n/`) + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed ${encodedRequestUrl}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('provider echoed') + expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).not.toContain(encodedRequestUrl) + }) + + it('redacts caller-supplied service header values echoed by the provider', async () => { + const serviceHeaderSecret = 'opaque-service-header-secret' + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed ${serviceHeaderSecret}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + serviceHeaders: { 'opc-client-info': serviceHeaderSecret }, + }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).not.toContain(serviceHeaderSecret) + }) + it('redacts an echoed finalized request body from provider diagnostics', async () => { const requestBody = 'opaque-request-body-secret' secureFetchMock.mockResolvedValueOnce( @@ -376,6 +426,7 @@ describe('OCI request client', () => { 'signing-string-value', 'private-key-value', 'api-key-value', + 'signature-value', ] secureFetchMock.mockResolvedValueOnce( secureResponse({ @@ -391,6 +442,7 @@ describe('OCI request client', () => { signing_string: echoedSecrets[4], 'private key': echoedSecrets[5], 'api key': echoedSecrets[6], + signature: echoedSecrets[7], }), }), }) @@ -407,6 +459,58 @@ describe('OCI request client', () => { for (const secret of echoedSecrets) expect((failure as Error).message).not.toContain(secret) }) + it('fails closed for authorization signatures with flexible parameter spacing', async () => { + const echoedSignature = 'unknown-provider-signature' + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed Signature version = "1", keyId = "unknown", signature = "${echoedSignature}"`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + expect((failure as Error).message).not.toContain(echoedSignature) + }) + + it('bounds non-success response bodies independently of the caller response ceiling', async () => { + const cancel = vi.fn() + const response = secureResponse({ + ok: false, + status: 502, + opcRequestId: 'request-oversized', + responseBody: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(65_537)) + }, + cancel, + }), + }) + secureFetchMock.mockResolvedValueOnce(response) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 1024 * 1024, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 502') + expect((failure as OciRequestError).opcRequestId).toBe('request-oversized') + expect(cancel).toHaveBeenCalledOnce() + expect(response.text).not.toHaveBeenCalled() + }) + it('fails closed for a percent-encoded sensitive JSON key', async () => { secureFetchMock.mockResolvedValueOnce( secureResponse({ diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 4773d27ce84..8b15b349929 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -3,6 +3,10 @@ import { type SecureFetchResponse, secureFetchWithValidation, } from '@/lib/core/security/input-validation.server' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' import type { ValidatedOciDestination } from '@/lib/internal/oci/endpoints' import { OciRequestError, parseOciErrorBody } from '@/lib/internal/oci/errors' import { @@ -12,7 +16,7 @@ import { } from '@/lib/internal/oci/signing.server' const MAX_OCI_TIMEOUT_MS = 5 * 60 * 1000 -const MAX_OCI_REDACTABLE_BODY_LENGTH = 65_536 +const MAX_OCI_REDACTABLE_REQUEST_MATERIAL_LENGTH = 65_536 export interface OciRequestResult { readonly response: SecureFetchResponse @@ -68,7 +72,8 @@ function sensitiveRequestValues( credentials: OciSigningCredentials, authorization: string | undefined, requestUrl: string, - requestBody: string | undefined + requestBody: string | undefined, + serviceHeaderValues: readonly string[] ): string[] { return [ credentials.tenancyId, @@ -80,9 +85,51 @@ function sensitiveRequestValues( authorization ?? '', requestUrl, requestBody ?? '', + ...serviceHeaderValues, ].filter(Boolean) } +function getSignedServiceHeaderValues( + serviceHeaders: Readonly> | undefined, + signedHeaders: Readonly> +): string[] { + return Object.keys(serviceHeaders ?? {}).flatMap((name) => { + const value = signedHeaders[name.toLowerCase()] + return value === undefined ? [] : [value] + }) +} + +function isRedactableRequestMaterial(values: readonly (string | undefined)[]): boolean { + let totalLength = 0 + for (const value of values) { + if (value === undefined) continue + totalLength += value.length + if (totalLength > MAX_OCI_REDACTABLE_REQUEST_MATERIAL_LENGTH) return false + } + return true +} + +async function readOciErrorBody( + response: SecureFetchResponse, + method: OciRequestMethod, + maxResponseBytes: number, + signal: AbortSignal | undefined +): Promise { + try { + return await readResponseTextWithLimit(response, { + maxBytes: Math.min(DEFAULT_MAX_ERROR_BODY_BYTES, maxResponseBytes), + label: 'OCI error response', + signal, + allowNoBodyFallback: true, + requestMethod: method, + }) + } catch (error) { + if (signal?.aborted) throw error + await response.body?.cancel().catch(() => {}) + return undefined + } +} + /** Sends one bounded, redirect-free OCI request to an already validated destination. */ export async function sendOciRequest(params: { destination: ValidatedOciDestination @@ -125,21 +172,34 @@ export async function sendOciRequest(params: { const opcRequestId = response.headers.get('opc-request-id') ?? undefined if (response.ok) return { response, opcRequestId } - const requestBodyIsRedactable = - signed.body === undefined || signed.body.length <= MAX_OCI_REDACTABLE_BODY_LENGTH + const serviceHeaderValues = getSignedServiceHeaderValues(params.serviceHeaders, signed.headers) + const requestMaterialIsRedactable = isRedactableRequestMaterial([ + signed.body, + ...serviceHeaderValues, + ]) + if (!requestMaterialIsRedactable) { + await response.body?.cancel().catch(() => {}) + throw new OciRequestError({ status: response.status }) + } const sensitiveValues = sensitiveRequestValues( params.credentials, signed.headers.authorization, signed.url, - requestBodyIsRedactable ? signed.body : undefined + signed.body, + serviceHeaderValues + ) + const body = await readOciErrorBody( + response, + signed.method, + params.maxResponseBytes, + params.signal ) - const body = await response.text() - const error = requestBodyIsRedactable ? parseOciErrorBody(body, sensitiveValues) : {} + const error = body === undefined ? {} : parseOciErrorBody(body, sensitiveValues) throw new OciRequestError({ status: response.status, code: error.code, message: error.message, - opcRequestId: requestBodyIsRedactable ? opcRequestId : undefined, + opcRequestId, sensitiveValues, }) } diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index 8e10a0fb0ab..020e4b74afc 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -2,12 +2,13 @@ import { isSensitiveKey, REDACTED_MARKER, redactExactSensitiveValues, + redactKnownSensitiveValues, } from '@/lib/core/security/redaction' const MAX_OCI_ERROR_FIELD_LENGTH = 1024 const MAX_OCI_ERROR_INPUT_LENGTH = 65_536 const MAX_NESTED_JSON_DEPTH = 3 -const OCI_SENSITIVE_JSON_FIELDS = new Set(['signingstring']) +const OCI_SENSITIVE_JSON_FIELDS = new Set(['signature', 'signingstring']) function normalizeJsonDiagnosticKey(key: string): string | undefined { let normalized = key @@ -92,15 +93,22 @@ function sanitizeOciErrorField( ): string | undefined { if (typeof value !== 'string') return undefined if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined - if (/%[0-9a-f]{2}/i.test(value)) return undefined - if (/\\(?:u[0-9a-f]{4}|x[0-9a-f]{2})/i.test(value)) return undefined - if (/\(request-target\)|x-content-sha256/i.test(value)) return undefined - const decoded = decodeNestedJsonDiagnostic(value) - if (decoded === undefined) return undefined const exactValues = sensitiveValues.flatMap((sensitiveValue) => { const jsonEncoded = JSON.stringify(sensitiveValue).slice(1, -1) return jsonEncoded === sensitiveValue ? [sensitiveValue] : [sensitiveValue, jsonEncoded] }) + let knownRedacted: string + try { + knownRedacted = redactKnownSensitiveValues(value, exactValues) + } catch { + return undefined + } + if (/%[0-9a-f]{2}/i.test(knownRedacted)) return undefined + if (/\\(?:u[0-9a-f]{4}|x[0-9a-f]{2})/i.test(knownRedacted)) return undefined + if (/\(request-target\)|x-content-sha256/i.test(knownRedacted)) return undefined + if (/\bsignature\s*(?:version\s*)?=/i.test(knownRedacted)) return undefined + const decoded = decodeNestedJsonDiagnostic(knownRedacted) + if (decoded === undefined) return undefined let exactRedacted: string try { exactRedacted = redactExactSensitiveValues(decoded, exactValues) @@ -110,7 +118,7 @@ function sanitizeOciErrorField( const sanitized = exactRedacted .replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]') .replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]') - .replace(/Signature\s+version=\\*"1\\*",[^\r\n]*/gi, '[redacted-authorization]') + .replace(/Signature\s+version\s*=\s*\\*"1\\*"\s*,[^\r\n]*/gi, '[redacted-authorization]') .replace(/[\u0000-\u001f\u007f]/g, ' ') .trim() return sanitized ? sanitized.slice(0, MAX_OCI_ERROR_FIELD_LENGTH) : undefined From 51a12426d937e0364bfad6d8125f423a640f1f7c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 19:06:07 -0700 Subject: [PATCH 07/11] refactor(oci): bind requests to authorized credentials --- apps/sim/lib/internal/oci/client.server.ts | 1017 ++++++++++++++--- apps/sim/lib/internal/oci/endpoints.ts | 179 ++- apps/sim/lib/internal/oci/errors.ts | 199 +--- .../lib/internal/oci/signing.server.test.ts | 225 ---- apps/sim/lib/internal/oci/signing.server.ts | 104 -- apps/sim/lib/oauth/credential-service.ts | 4 + apps/sim/lib/oauth/token-resolution.ts | 58 +- apps/sim/lib/oauth/types.ts | 4 + apps/sim/lib/selectors/server/credentials.ts | 4 +- apps/sim/package.json | 1 - bun.lock | 57 - 11 files changed, 1110 insertions(+), 742 deletions(-) delete mode 100644 apps/sim/lib/internal/oci/signing.server.test.ts delete mode 100644 apps/sim/lib/internal/oci/signing.server.ts diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 8b15b349929..45f8082e204 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -1,3 +1,18 @@ +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, @@ -5,201 +20,907 @@ import { } from '@/lib/core/security/input-validation.server' import { DEFAULT_MAX_ERROR_BODY_BYTES, - readResponseTextWithLimit, + isPayloadSizeLimitError, + readResponseToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -import type { ValidatedOciDestination } from '@/lib/internal/oci/endpoints' -import { OciRequestError, parseOciErrorBody } from '@/lib/internal/oci/errors' import { - type OciRequestMethod, - type OciSigningCredentials, - signOciRequest, -} from '@/lib/internal/oci/signing.server' + 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' -const MAX_OCI_TIMEOUT_MS = 5 * 60 * 1000 -const MAX_OCI_REDACTABLE_REQUEST_MATERIAL_LENGTH = 65_536 +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 +} -export interface OciRequestResult { - readonly response: SecureFetchResponse +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 encodeRfc3986(value: string): string { - return encodeURIComponent(value).replace( - /[!'()*]/g, - (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` +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() + } } -export function serializeOciQueryPairs(pairs: readonly (readonly [string, string])[]): string { - return pairs.map(([key, value]) => `${encodeRfc3986(key)}=${encodeRfc3986(value)}`).join('&') +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('&') } -export function buildOciRequestUrl( - destination: ValidatedOciDestination, +function buildRequestUrl( + endpoint: OciPreparedEndpoint, encodedPath: string, - queryPairs: readonly (readonly [string, 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 Error('OCI request path must be a single encoded absolute path') + throw new OciClientError('invalid_request') } - if (new URL(`${destination.origin}${encodedPath}`).pathname !== encodedPath) { - throw new Error('OCI request path must be a single encoded absolute path') + 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 } - const query = serializeOciQueryPairs(queryPairs) - return `${destination.origin}${encodedPath}${query ? `?${query}` : ''}` + return normalized } -function validateRequestLimits(timeout: number, maxResponseBytes: number): void { - if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > MAX_OCI_TIMEOUT_MS) { - throw new Error('OCI timeout is outside the supported range') +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 ( - !Number.isSafeInteger(maxResponseBytes) || - maxResponseBytes <= 0 || - maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES + request.headers !== undefined && + (!request.headers || typeof request.headers !== 'object' || Array.isArray(request.headers)) ) { - throw new Error('OCI response ceiling is outside the supported range') - } -} - -function sensitiveRequestValues( - credentials: OciSigningCredentials, - authorization: string | undefined, - requestUrl: string, - requestBody: string | undefined, - serviceHeaderValues: readonly string[] -): string[] { - return [ - credentials.tenancyId, - credentials.userId, - credentials.fingerprint, - credentials.fingerprint.toUpperCase(), - credentials.privateKey, - credentials.passphrase ?? '', - authorization ?? '', - requestUrl, - requestBody ?? '', - ...serviceHeaderValues, - ].filter(Boolean) -} - -function getSignedServiceHeaderValues( - serviceHeaders: Readonly> | undefined, - signedHeaders: Readonly> -): string[] { - return Object.keys(serviceHeaders ?? {}).flatMap((name) => { - const value = signedHeaders[name.toLowerCase()] - return value === undefined ? [] : [value] + 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 isRedactableRequestMaterial(values: readonly (string | undefined)[]): boolean { - let totalLength = 0 - for (const value of values) { - if (value === undefined) continue - totalLength += value.length - if (totalLength > MAX_OCI_REDACTABLE_REQUEST_MATERIAL_LENGTH) return false +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 true + return Object.freeze(result) } -async function readOciErrorBody( +async function readFailureCode( response: SecureFetchResponse, - method: OciRequestMethod, - maxResponseBytes: number, - signal: AbortSignal | undefined + signal: AbortSignal ): Promise { try { - return await readResponseTextWithLimit(response, { - maxBytes: Math.min(DEFAULT_MAX_ERROR_BODY_BYTES, maxResponseBytes), + const body = await readResponseToBufferWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, label: 'OCI error response', signal, allowNoBodyFallback: true, - requestMethod: method, }) - } catch (error) { - if (signal?.aborted) throw error + 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 } } -/** Sends one bounded, redirect-free OCI request to an already validated destination. */ -export async function sendOciRequest(params: { - destination: ValidatedOciDestination - credentials: OciSigningCredentials - method: OciRequestMethod - encodedPath: string - queryPairs?: readonly (readonly [string, string])[] - timeout: number - maxResponseBytes: number - signal?: AbortSignal - serviceHeaders?: Readonly> - body?: string - contentType?: string -}): Promise { - validateRequestLimits(params.timeout, params.maxResponseBytes) - const url = buildOciRequestUrl(params.destination, params.encodedPath, params.queryPairs) - const signed = await signOciRequest({ - credentials: params.credentials, - method: params.method, - url, - serviceHeaders: params.serviceHeaders, - body: params.body, - contentType: params.contentType, +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 response = await secureFetchWithValidation( - signed.url, - { - method: signed.method, - headers: { ...signed.headers }, - ...(signed.body !== undefined ? { body: signed.body } : {}), - timeout: params.timeout, - maxResponseBytes: params.maxResponseBytes, - maxRedirects: 0, - signal: params.signal, - profile: 'configuredEndpoint', - logUrlValidationDetails: false, + 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') + } }, - 'OCI destination' - ) - const opcRequestId = response.headers.get('opc-request-id') ?? undefined - if (response.ok) return { response, opcRequestId } - - const serviceHeaderValues = getSignedServiceHeaderValues(params.serviceHeaders, signed.headers) - const requestMaterialIsRedactable = isRedactableRequestMaterial([ - signed.body, - ...serviceHeaderValues, - ]) - if (!requestMaterialIsRedactable) { - await response.body?.cancel().catch(() => {}) - throw new OciRequestError({ status: response.status }) - } - const sensitiveValues = sensitiveRequestValues( - params.credentials, - signed.headers.authorization, - signed.url, - signed.body, - serviceHeaderValues - ) - const body = await readOciErrorBody( - response, - signed.method, - params.maxResponseBytes, - params.signal - ) - const error = body === undefined ? {} : parseOciErrorBody(body, sensitiveValues) - throw new OciRequestError({ - status: response.status, - code: error.code, - message: error.message, - opcRequestId, - sensitiveValues, + + 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.ts b/apps/sim/lib/internal/oci/endpoints.ts index 2e576890113..e9e0ddc5154 100644 --- a/apps/sim/lib/internal/oci/endpoints.ts +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -1,4 +1,5 @@ import { isIpLiteral, unwrapIpv6Brackets } from '@sim/security/ssrf' +import type { OAuthService } from '@/lib/oauth/types' export type OciDestinationProvenance = 'static' | 'authenticated-discovery' @@ -12,26 +13,43 @@ export interface OciRegion { readonly realm: OciRealm } -declare const validatedOciDestinationBrand: unique symbol +declare const preparedOciEndpointBrand: unique symbol -/** An OCI origin that passed both structural and service-owned hostname validation. */ -export interface ValidatedOciDestination { +/** An OCI endpoint prepared from a declarative product policy. */ +export interface OciPreparedEndpoint { readonly origin: string readonly hostname: string - readonly service: string + readonly serviceId: OAuthService + readonly serviceName: string readonly region: OciRegion readonly provenance: OciDestinationProvenance - readonly [validatedOciDestinationBrand]: true + readonly [preparedOciEndpointBrand]: true } -declare const ociServiceHostnamePredicateBrand: unique symbol +declare const ociEndpointPolicyBrand: unique symbol -export type OciServiceHostnamePredicate = ((params: { - hostname: string - service: string - region: OciRegion - provenance: OciDestinationProvenance -}) => boolean) & { readonly [ociServiceHostnamePredicateBrand]: true } +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 @@ -179,17 +197,83 @@ export function resolveEffectiveOciRegion(defaultRegion: string, override?: stri return effective } -export function objectStorageOciHostname(region: OciRegion): string { - return `objectstorage.${region.id}.${region.realm.domain}` +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}` } -export function validateOciDestination(params: { +function validateOciOrigin(params: { origin: string - service: string + policy: OciEndpointPolicy region: OciRegion provenance: OciDestinationProvenance - isServiceHostname: OciServiceHostnamePredicate -}): ValidatedOciDestination { +}): OciPreparedEndpoint { const knownRegion = getOciRegion(params.region.id) if ( knownRegion.realm.id !== params.region.realm.id || @@ -204,8 +288,7 @@ export function validateOciDestination(params: { throw new Error('OCI destination must be a valid HTTPS origin') } if ( - (params.provenance !== 'static' && params.provenance !== 'authenticated-discovery') || - !/^[a-z][a-z0-9-]{0,62}$/.test(params.service) || + params.policy.kind !== params.provenance || url.protocol !== 'https:' || url.port !== '' || url.username !== '' || @@ -218,39 +301,51 @@ export function validateOciDestination(params: { ) { throw new Error('OCI destination must be an exact HTTPS origin with the default port') } - if ( - !params.isServiceHostname({ - hostname: url.hostname, - service: params.service, - region: knownRegion, - provenance: params.provenance, - }) - ) { + 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, - service: params.service, + serviceId: params.policy.serviceId, + serviceName: params.policy.serviceName, region: knownRegion, provenance: params.provenance, - } as ValidatedOciDestination + } as OciPreparedEndpoint } -export const isObjectStorageOciHostname = (({ hostname, service, region }) => - service === 'objectstorage' && - hostname === objectStorageOciHostname(region)) as OciServiceHostnamePredicate +/** 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', + }) +} -export function objectStorageOciDestination( +/** Structurally validates an origin extracted from an authenticated response. */ +export function resolveDiscoveredOciEndpoint( + policy: OciDiscoveredEndpointPolicy, region: OciRegion, - provenance: OciDestinationProvenance = 'static' -): ValidatedOciDestination { - const hostname = objectStorageOciHostname(region) - return validateOciDestination({ - origin: `https://${hostname}`, - service: 'objectstorage', + origin: string +): OciPreparedEndpoint { + return validateOciOrigin({ + origin, + policy, region, - provenance, - isServiceHostname: isObjectStorageOciHostname, + provenance: 'authenticated-discovery', }) } diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index 020e4b74afc..d4781e4d7cc 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -1,167 +1,52 @@ -import { - isSensitiveKey, - REDACTED_MARKER, - redactExactSensitiveValues, - redactKnownSensitiveValues, -} from '@/lib/core/security/redaction' - -const MAX_OCI_ERROR_FIELD_LENGTH = 1024 -const MAX_OCI_ERROR_INPUT_LENGTH = 65_536 -const MAX_NESTED_JSON_DEPTH = 3 -const OCI_SENSITIVE_JSON_FIELDS = new Set(['signature', 'signingstring']) - -function normalizeJsonDiagnosticKey(key: string): string | undefined { - let normalized = key - for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { - if (!normalized.includes('%')) return normalized - if (!/%[0-9a-f]{2}/i.test(normalized)) return undefined - try { - normalized = decodeURIComponent(normalized) - } catch { - return undefined - } - } - return normalized.includes('%') ? undefined : normalized -} - -function looksLikeStructuredJson(value: string): boolean { - const first = value.trimStart()[0] - return first === '{' || first === '[' || first === '"' -} - -function isSensitiveOciJsonKey(key: string): boolean { - const compactKey = key.replace(/[^a-z]/gi, '').toLowerCase() - return OCI_SENSITIVE_JSON_FIELDS.has(compactKey) || isSensitiveKey(compactKey) -} - -function containsEmbeddedStructuredText(value: string): boolean { - return ( - !looksLikeStructuredJson(value) && - (/[[{]\s*\\*(?:["{[\]}]|-?\d|true\b|false\b|null\b)/.test(value) || - /\\*"[^"\\\r\n]{1,128}\\*"\s*:\s*/.test(value)) - ) -} - -function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined { - if (depth > MAX_NESTED_JSON_DEPTH) return undefined - if (value === null) return 'null' - if (typeof value === 'string') { - if (!looksLikeStructuredJson(value)) { - return containsEmbeddedStructuredText(value) ? undefined : value - } - if (depth === MAX_NESTED_JSON_DEPTH) return undefined - try { - return flattenJsonDiagnostic(JSON.parse(value), depth + 1) - } catch { - return undefined - } - } - if (typeof value === 'number' || typeof value === 'boolean') return String(value) - if (Array.isArray(value)) { - if (depth === MAX_NESTED_JSON_DEPTH) return undefined - const flattened = value.map((entry) => flattenJsonDiagnostic(entry, depth + 1)) - if (flattened.some((entry) => entry === undefined)) return undefined - return flattened.join(' ') - } - if (typeof value !== 'object') return undefined - if (depth === MAX_NESTED_JSON_DEPTH) return undefined - const flattened = Object.entries(value).map(([key, entry]) => { - const normalizedKey = normalizeJsonDiagnosticKey(key) - if (normalizedKey === undefined) return undefined - if (isSensitiveOciJsonKey(normalizedKey)) return `${key}: ${REDACTED_MARKER}` - const nested = flattenJsonDiagnostic(entry, depth + 1) - return nested === undefined ? undefined : `${key}: ${nested}` - }) - if (flattened.some((entry) => entry === undefined)) return undefined - return flattened.join(' ') -} - -function decodeNestedJsonDiagnostic(value: string): string | undefined { - if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined - if (containsEmbeddedStructuredText(value)) return undefined - if (!looksLikeStructuredJson(value)) return value - try { - return flattenJsonDiagnostic(JSON.parse(value)) - } catch { +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 } -function sanitizeOciErrorField( - value: unknown, - sensitiveValues: readonly string[] = [] -): string | undefined { - if (typeof value !== 'string') return undefined - if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined - const exactValues = sensitiveValues.flatMap((sensitiveValue) => { - const jsonEncoded = JSON.stringify(sensitiveValue).slice(1, -1) - return jsonEncoded === sensitiveValue ? [sensitiveValue] : [sensitiveValue, jsonEncoded] - }) - let knownRedacted: string - try { - knownRedacted = redactKnownSensitiveValues(value, exactValues) - } catch { - return undefined - } - if (/%[0-9a-f]{2}/i.test(knownRedacted)) return undefined - if (/\\(?:u[0-9a-f]{4}|x[0-9a-f]{2})/i.test(knownRedacted)) return undefined - if (/\(request-target\)|x-content-sha256/i.test(knownRedacted)) return undefined - if (/\bsignature\s*(?:version\s*)?=/i.test(knownRedacted)) return undefined - const decoded = decodeNestedJsonDiagnostic(knownRedacted) - if (decoded === undefined) return undefined - let exactRedacted: string - try { - exactRedacted = redactExactSensitiveValues(decoded, exactValues) - } catch { - return undefined - } - const sanitized = exactRedacted - .replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]') - .replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]') - .replace(/Signature\s+version\s*=\s*\\*"1\\*"\s*,[^\r\n]*/gi, '[redacted-authorization]') - .replace(/[\u0000-\u001f\u007f]/g, ' ') - .trim() - return sanitized ? sanitized.slice(0, MAX_OCI_ERROR_FIELD_LENGTH) : undefined -} - -/** A bounded, credential-safe projection of an OCI service error. */ -export class OciRequestError extends Error { - readonly status: number - readonly code?: string +/** 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(params: { - status: number - code?: unknown - message?: unknown - opcRequestId?: unknown - sensitiveValues?: readonly string[] - }) { - const code = sanitizeOciErrorField(params.code, params.sensitiveValues) - const message = sanitizeOciErrorField(params.message, params.sensitiveValues) - super( - message ? `OCI request failed: ${message}` : `OCI request failed with status ${params.status}` - ) - this.name = 'OciRequestError' - this.status = params.status + constructor(code: OciClientErrorCode, options: { status?: number; opcRequestId?: unknown } = {}) { + super(ERROR_MESSAGES[code]) + this.name = 'OciClientError' this.code = code - this.opcRequestId = sanitizeOciErrorField(params.opcRequestId, params.sensitiveValues) - } -} - -export function parseOciErrorBody( - body: string, - sensitiveValues: readonly string[] = [] -): { code?: string; message?: string } { - try { - const parsed: unknown = JSON.parse(body) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} - const record = parsed as Record - return { - code: sanitizeOciErrorField(record.code, sensitiveValues), - message: sanitizeOciErrorField(record.message, sensitiveValues), + if ( + options.status !== undefined && + Number.isInteger(options.status) && + options.status >= 100 && + options.status <= 599 + ) { + this.status = options.status } - } catch { - return {} + this.opcRequestId = safeRequestId(options.opcRequestId) } } diff --git a/apps/sim/lib/internal/oci/signing.server.test.ts b/apps/sim/lib/internal/oci/signing.server.test.ts deleted file mode 100644 index b8abd179775..00000000000 --- a/apps/sim/lib/internal/oci/signing.server.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * @vitest-environment node - */ -import { createHash, createPublicKey, createVerify, generateKeyPairSync } from 'node:crypto' -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { - type OciRequestMethod, - type OciSigningCredentials, - signOciRequest, -} from '@/lib/internal/oci/signing.server' - -/** Oracle's public request-signing fixture from the OCI Request Signatures documentation. */ -const ORACLE_FIXTURE_PRIVATE_KEY = `${['-----BEGIN', 'RSA PRIVATE KEY-----'].join(' ')} -MIICXgIBAAKBgQDCFENGw33yGihy92pDjZQhl0C36rPJj+CvfSC8+q28hxA161QF -NUd13wuCTUcq0Qd2qsBe/2hFyc2DCJJg0h1L78+6Z4UMR7EOcpfdUE9Hf3m/hs+F -UR45uBJeDK1HSFHD8bHKD6kv8FPGfJTotc+2xjJwoYi+1hqp1fIekaxsyQIDAQAB -AoGBAJR8ZkCUvx5kzv+utdl7T5MnordT1TvoXXJGXK7ZZ+UuvMNUCdN2QPc4sBiA -QWvLw1cSKt5DsKZ8UETpYPy8pPYnnDEz2dDYiaew9+xEpubyeW2oH4Zx71wqBtOK -kqwrXa/pzdpiucRRjk6vE6YY7EBBs/g7uanVpGibOVAEsqH1AkEA7DkjVH28WDUg -f1nqvfn2Kj6CT7nIcE3jGJsZZ7zlZmBmHFDONMLUrXR/Zm3pR5m0tCmBqa5RK95u -412jt1dPIwJBANJT3v8pnkth48bQo/fKel6uEYyboRtA5/uHuHkZ6FQF7OUkGogc -mSJluOdc5t6hI1VsLn0QZEjQZMEOWr+wKSMCQQCC4kXJEsHAve77oP6HtG/IiEn7 -kpyUXRNvFsDE0czpJJBvL/aRFUJxuRK91jhjC68sA7NsKMGg5OXb5I5Jj36xAkEA -gIT7aFOYBFwGgQAQkWNKLvySgKbAZRTeLBacpHMuQdl1DfdntvAyqpAZ0lY0RKmW -G6aFKaqQfOXKCyWoUiVknQJAXrlgySFci/2ueKlIE1QqIiLSZ8V8OlpFLRnb1pzI -7U1yQXnTAEFYM560yJlzUpOb1V4cScGd365tiSMvxLOvTA== -${['-----END', 'RSA PRIVATE KEY-----'].join(' ')}` - -const BASE_CREDENTIALS: OciSigningCredentials = { - tenancyId: 'ocid1.tenancy.oc1..oraclefixture', - userId: 'ocid1.user.oc1..oraclefixture', - fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', - privateKey: ORACLE_FIXTURE_PRIVATE_KEY, -} - -function authorizationParameter(authorization: string, name: string): string { - const match = new RegExp(`${name}="([^"]+)"`).exec(authorization) - if (!match?.[1]) throw new Error(`Missing ${name} authorization parameter`) - return match[1] -} - -function expectValidSignature(params: { - request: Awaited> - publicKey: ReturnType -}): void { - const authorization = params.request.headers.authorization - expect(authorization).toBeDefined() - const headerNames = authorizationParameter(authorization!, 'headers').split(' ') - const url = new URL(params.request.url) - const signingString = headerNames - .map((name) => { - if (name === '(request-target)') { - return `(request-target): ${params.request.method.toLowerCase()} ${url.pathname}${url.search}` - } - const value = params.request.headers[name.toLowerCase()] - if (value === undefined) throw new Error(`Signed header ${name} is absent`) - return `${name.toLowerCase()}: ${value}` - }) - .join('\n') - const signature = authorizationParameter(authorization!, 'signature') - const verifier = createVerify('RSA-SHA256').update(signingString).end() - expect(verifier.verify(params.publicKey, signature, 'base64')).toBe(true) -} - -describe('signOciRequest', () => { - let generatedCredentials: OciSigningCredentials - let encryptedCredentials: OciSigningCredentials - let generatedPublicKey: ReturnType - - beforeAll(() => { - const pair = generateKeyPairSync('rsa', { modulusLength: 2048 }) - const privateKey = pair.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString() - generatedPublicKey = createPublicKey(pair.privateKey) - generatedCredentials = { ...BASE_CREDENTIALS, privateKey } - encryptedCredentials = { - ...BASE_CREDENTIALS, - privateKey: pair.privateKey - .export({ - format: 'pem', - type: 'pkcs8', - cipher: 'aes-256-cbc', - passphrase: 'signing-test-passphrase', - }) - .toString(), - passphrase: 'signing-test-passphrase', - } - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('signs Oracle’s published RSA fixture entirely in memory', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) - const request = await signOciRequest({ - credentials: BASE_CREDENTIALS, - method: 'GET', - url: 'https://iaas.us-phoenix-1.oraclecloud.com/20160918/instances?displayName=Team%20X', - }) - expectValidSignature({ request, publicKey: createPublicKey(ORACLE_FIXTURE_PRIVATE_KEY) }) - expect(request.headers.authorization).toContain( - `keyId="${BASE_CREDENTIALS.tenancyId}/${BASE_CREDENTIALS.userId}/${BASE_CREDENTIALS.fingerprint}"` - ) - }) - - it('signs with an independently generated encrypted PKCS#8 key', async () => { - const request = await signOciRequest({ - credentials: encryptedCredentials, - method: 'GET', - url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users', - }) - expectValidSignature({ request, publicKey: generatedPublicKey }) - }) - - it.each(['GET', 'HEAD', 'DELETE'] as const)('signs %s without body headers', async (method) => { - const request = await signOciRequest({ - credentials: generatedCredentials, - method, - url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users?a=1&a=&name=%E2%98%83', - serviceHeaders: { accept: 'application/json' }, - }) - expect(request.body).toBeUndefined() - expect(request.headers['content-length']).toBeUndefined() - expect(request.headers['x-content-sha256']).toBeUndefined() - expect(request.headers.date).toBeUndefined() - expectValidSignature({ request, publicKey: generatedPublicKey }) - }) - - it.each(['POST', 'PUT', 'PATCH'] as const)( - 'signs empty and Unicode %s bodies with byte-correct headers', - async (method) => { - for (const body of ['', '{"message":"héllo ☃"}']) { - const request = await signOciRequest({ - credentials: generatedCredentials, - method, - url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users', - body, - }) - expect(request.body).toBe(body) - expect(request.headers['content-length']).toBe(String(Buffer.byteLength(body, 'utf8'))) - expect(request.headers['x-content-sha256']).toBe( - createHash('sha256').update(body, 'utf8').digest('base64') - ) - expect(request.headers['content-type']).toBe('application/json') - expect(request.headers.date).toBeUndefined() - expectValidSignature({ request, publicKey: generatedPublicKey }) - } - } - ) - - it('preserves finalized URL/query bytes in the signed request target', async () => { - const url = - 'https://identity.us-ashburn-1.oraclecloud.com/resource?z=last&a=one&a=&unicode=%E2%98%83' - const request = await signOciRequest({ credentials: generatedCredentials, method: 'GET', url }) - expect(request.url).toBe(url) - expectValidSignature({ request, publicKey: generatedPublicKey }) - }) - - it('creates a fresh x-date and removes the signer’s unsigned date header', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) - const first = await signOciRequest({ - credentials: generatedCredentials, - method: 'GET', - url: 'https://identity.us-ashburn-1.oraclecloud.com/a', - }) - vi.setSystemTime(new Date('2026-09-03T19:00:01.000Z')) - const second = await signOciRequest({ - credentials: generatedCredentials, - method: 'GET', - url: 'https://identity.us-ashburn-1.oraclecloud.com/a', - }) - expect(first.headers['x-date']).not.toBe(second.headers['x-date']) - expect(first.headers.date).toBeUndefined() - expect(second.headers.date).toBeUndefined() - }) - - it.each(['GET', 'HEAD', 'DELETE'] as OciRequestMethod[])( - 'rejects a body on %s', - async (method) => { - await expect( - signOciRequest({ - credentials: generatedCredentials, - method, - url: 'https://identity.us-ashburn-1.oraclecloud.com/a', - body: '', - }) - ).rejects.toThrow('must not include a body') - } - ) - - it.each([Buffer.from('body'), new Uint8Array([1, 2, 3])])( - 'rejects non-string request bodies', - async (body) => { - await expect( - signOciRequest({ - credentials: generatedCredentials, - method: 'POST', - url: 'https://identity.us-ashburn-1.oraclecloud.com/a', - body: body as unknown as string, - }) - ).rejects.toThrow('finalized strings') - } - ) - - it.each([ - 'Authorization', - 'HOST', - 'date', - 'x-date', - 'content-length', - 'content-type', - 'x-content-sha256', - ])('blocks callers from overriding %s', async (header) => { - await expect( - signOciRequest({ - credentials: generatedCredentials, - method: 'GET', - url: 'https://identity.us-ashburn-1.oraclecloud.com/a', - serviceHeaders: { [header]: 'attacker-controlled' }, - }) - ).rejects.toThrow('signing-controlled') - }) -}) diff --git a/apps/sim/lib/internal/oci/signing.server.ts b/apps/sim/lib/internal/oci/signing.server.ts deleted file mode 100644 index 7382c87ed36..00000000000 --- a/apps/sim/lib/internal/oci/signing.server.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { DefaultRequestSigner, SimpleAuthenticationDetailsProvider } from 'oci-common' - -export type OciRequestMethod = 'GET' | 'HEAD' | 'DELETE' | 'POST' | 'PUT' | 'PATCH' - -export interface OciSigningCredentials { - readonly tenancyId: string - readonly userId: string - readonly fingerprint: string - readonly privateKey: string - readonly passphrase?: string -} - -export interface SignedOciRequest { - readonly method: OciRequestMethod - readonly url: string - readonly headers: Readonly> - readonly body?: string -} - -const BODY_METHODS: ReadonlySet = new Set(['POST', 'PUT', 'PATCH']) - -export const OCI_SIGNING_CONTROLLED_HEADERS: ReadonlySet = new Set([ - 'authorization', - 'host', - 'date', - 'x-date', - 'content-length', - 'content-type', - 'x-content-sha256', -]) - -function assertServiceHeaders(headers: Readonly>): void { - for (const [name, value] of Object.entries(headers)) { - if (OCI_SIGNING_CONTROLLED_HEADERS.has(name.toLowerCase())) { - throw new Error(`OCI service header is signing-controlled: ${name}`) - } - if ( - typeof value !== 'string' || - !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || - /[\u0000-\u001f\u007f]/.test(value) - ) { - throw new Error('OCI service headers must not contain control characters') - } - } -} - -/** Signs one finalized OCI request without consulting local OCI configuration. */ -export async function signOciRequest(params: { - credentials: OciSigningCredentials - method: OciRequestMethod - url: string - serviceHeaders?: Readonly> - body?: string - contentType?: string -}): Promise { - const serviceHeaders = params.serviceHeaders ?? {} - assertServiceHeaders(serviceHeaders) - const hasBodyMethod = BODY_METHODS.has(params.method) - if (!hasBodyMethod && params.body !== undefined) { - throw new Error(`${params.method} requests must not include a body`) - } - if (params.body !== undefined && typeof params.body !== 'string') { - throw new Error('OCI request bodies must be finalized strings') - } - if (params.contentType !== undefined && !hasBodyMethod) { - throw new Error('OCI content type is only valid for requests with signed bodies') - } - if ( - params.contentType !== undefined && - (params.contentType.length === 0 || - params.contentType.length > 256 || - /[\u0000-\u001f\u007f]/.test(params.contentType)) - ) { - throw new Error('OCI content type must not contain control characters') - } - - const body = hasBodyMethod ? (params.body ?? '') : undefined - const headers = new Headers(serviceHeaders) - headers.set('x-date', new Date().toUTCString()) - if (hasBodyMethod) headers.set('content-type', params.contentType ?? 'application/json') - - const provider = new SimpleAuthenticationDetailsProvider( - params.credentials.tenancyId, - params.credentials.userId, - params.credentials.fingerprint, - params.credentials.privateKey, - params.credentials.passphrase ?? null - ) - const signer = new DefaultRequestSigner(provider) - await signer.signHttpRequest({ - method: params.method, - uri: params.url, - headers, - ...(body !== undefined ? { body } : {}), - }) - headers.delete('date') - - return { - method: params.method, - url: params.url, - headers: Object.fromEntries(headers.entries()), - ...(body !== undefined ? { body } : {}), - } -} 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/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 b5b55dff5ad..3aba8b37536 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -20,6 +20,9 @@ export const OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID = 'oci-api-key-service-acco /** 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. */ @@ -99,6 +102,7 @@ export type OAuthProvider = | 'zoho-desk' export type OAuthService = + | 'oci' | 'google' | 'google-email' | 'google-drive' 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/package.json b/apps/sim/package.json index 269347f1122..b7d20522da1 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -208,7 +208,6 @@ "next-themes": "^0.4.6", "nodemailer": "9.0.1", "nuqs": "2.8.9", - "oci-common": "2.140.0", "officeparser": "5.2.2", "openai": "7.0.0", "opentype.js": "1.3.4", diff --git a/bun.lock b/bun.lock index 08f4701d31d..a80c11c21c1 100644 --- a/bun.lock +++ b/bun.lock @@ -318,7 +318,6 @@ "next-themes": "^0.4.6", "nodemailer": "9.0.1", "nuqs": "2.8.9", - "oci-common": "2.140.0", "officeparser": "5.2.2", "openai": "7.0.0", "opentype.js": "1.3.4", @@ -2223,16 +2222,12 @@ "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], - "@types/isomorphic-fetch": ["@types/isomorphic-fetch@0.0.35", "", {}, "sha512-DaZNUvLDCAnCTjgwxgiL1eQdxIKEpNLOlTNtAgnZc50bG2copGhRrFN9/PxPBuJe+tZVLCbQ7ls0xveXVRPkvw=="], - "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], "@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-b0jGiOgHtZ2jqdPgPnP6WLCXZk1T8p06A/vPGzUvxpFGgKMbjXJDjC5m52ErqBnIuWZFgGoIJyRdeG5AyreJjA=="], - "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], @@ -2255,8 +2250,6 @@ "@types/opentype.js": ["@types/opentype.js@1.3.10", "", {}, "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ=="], - "@types/opossum": ["@types/opossum@4.1.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-9TMnd8AWRVtnZMqBbbzceQoJdafErgUViogFaQ3eetsbeLtiFFZ695mepNaLtlfJi4uRP3GmHfe3CJ2DZKaxYA=="], - "@types/pako": ["@types/pako@1.0.7", "", {}, "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A=="], "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], @@ -2277,8 +2270,6 @@ "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], - "@types/sshpk": ["@types/sshpk@1.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-cru1waDhHZnZuB18E6Dgf2UXf8U93mdOEDcKYe5jTri+fpucidSs7DLmGICpLxN+95aYkwtgeyny9fBFzQVdmA=="], - "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -2287,8 +2278,6 @@ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], - "@types/uuid": ["@types/uuid@8.3.4", "", {}, "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw=="], - "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], "@types/whatwg-url": ["@types/whatwg-url@11.0.5", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ=="], @@ -2451,8 +2440,6 @@ "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], - "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="], @@ -2813,8 +2800,6 @@ "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], - "dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="], - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], @@ -2939,8 +2924,6 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="], - "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="], @@ -3007,8 +2990,6 @@ "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - "es6-promise": ["es6-promise@4.2.6", "", {}, "sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q=="], - "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], @@ -3077,8 +3058,6 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], - "extsprintf": ["extsprintf@1.3.0", "", {}, "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g=="], - "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], @@ -3193,8 +3172,6 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="], - "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], @@ -3307,8 +3284,6 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - "http-signature": ["http-signature@1.3.1", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.14.1" } }, "sha512-Y29YKEc8MQsjch/VzkUVJ+2MXd9WcR42fK5u36CZf4G8bXw2DXMTWuESiB0R6m59JAWxlPPw5/Fri/t/AyyueA=="], - "http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="], "https": ["https@1.0.0", "", {}, "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="], @@ -3409,8 +3384,6 @@ "isolated-vm": ["isolated-vm@6.2.0", "", { "dependencies": { "node-gyp-build": "^4.8.4" } }, "sha512-UuSlxSHWt2QuJ5WvBhzlIJx2VVZN/a44SqBbEZFKNdvuSyhOvhmyDo8SQ+njVbhnh/njoL/aW0bUTiFYlpweGQ=="], - "isomorphic-fetch": ["isomorphic-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA=="], - "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], @@ -3445,8 +3418,6 @@ "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], - "jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="], - "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], @@ -3477,10 +3448,6 @@ "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], - "jsprim": ["jsprim@1.4.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw=="], - - "jssha": ["jssha@3.3.1", "", {}, "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ=="], - "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -3881,8 +3848,6 @@ "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], - "oci-common": ["oci-common@2.140.0", "", { "dependencies": { "@types/isomorphic-fetch": "0.0.35", "@types/jsonwebtoken": "9.0.3", "@types/opossum": "4.1.1", "@types/sshpk": "1.10.3", "@types/uuid": "8.3.4", "es6-promise": "4.2.6", "http-signature": "1.3.1", "isomorphic-fetch": "3.0.0", "jsonwebtoken": "9.0.3", "jssha": "3.3.1", "opossum": "5.0.1", "sshpk": "1.18.0", "uuid": "11.1.1" } }, "sha512-yHdfmB0gIx0QYC7sNvgll4HEw+w+fVo/eldhZfN2VxLJK+oqVHDlr6rT/ytwK8Fc8bS5XPkofIpCStPNR10MdQ=="], - "officeparser": ["officeparser@5.2.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.10", "concat-stream": "^2.0.0", "file-type": "^16.5.4", "node-ensure": "^0.0.0", "pdfjs-dist": "^5.3.31", "yauzl": "^3.1.3" }, "bin": { "officeparser": "officeParser.js" } }, "sha512-5JrV1CZFqTv/27fXy2bcf+3g6BpDZiJ3XoSRW3fb2i2EFex0DduqjTxiU2RsJ08WBsk4Hp0nZoGi9ZtHMZFaPA=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], @@ -3913,8 +3878,6 @@ "opentype.js": ["opentype.js@1.3.4", "", { "dependencies": { "string.prototype.codepointat": "^0.2.1", "tiny-inflate": "^1.0.3" }, "bin": { "ot": "bin/ot" } }, "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw=="], - "opossum": ["opossum@5.0.1", "", {}, "sha512-iUDUQmFl3RanaBVLMDTZ6WtXj/Hk84pwJ5JWoJaQd1lXGifdApHhszI3biZvdBDdpTERCmB6x+7+uNvzhzVZIg=="], - "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], "ora": ["ora@4.1.1", "", { "dependencies": { "chalk": "^3.0.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.2.0", "is-interactive": "^1.0.0", "log-symbols": "^3.0.0", "mute-stream": "0.0.8", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A=="], @@ -4383,8 +4346,6 @@ "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], - "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], @@ -4637,8 +4598,6 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="], - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], @@ -4679,8 +4638,6 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], - "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], @@ -5083,8 +5040,6 @@ "@types/fs-extra/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/jsonwebtoken/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/keyv/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/mssql/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5093,8 +5048,6 @@ "@types/nodemailer/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/opossum/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/readable-stream/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/readdir-glob/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5107,8 +5060,6 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@types/sshpk/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -5509,8 +5460,6 @@ "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], - "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], - "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "widest-line/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -5725,8 +5674,6 @@ "@types/fs-extra/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/jsonwebtoken/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/keyv/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/mssql/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5735,8 +5682,6 @@ "@types/nodemailer/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/opossum/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/readable-stream/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/readdir-glob/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5749,8 +5694,6 @@ "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@types/sshpk/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/ws/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], From 9329a073068e601d33f84d828c8b1f4df64fc1bf Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 19:06:22 -0700 Subject: [PATCH 08/11] feat(credentials): complete OCI API key setup --- apps/docs/components/icons.tsx | 4 +- apps/docs/content/docs/cli/credentials.mdx | 5 + apps/docs/content/docs/cli/reference.mdx | 5 + apps/docs/openapi-v2-resources.json | 32 +- .../connect-service-account-modal.tsx | 223 +++++++++++- apps/sim/components/icons.tsx | 4 +- apps/sim/lib/api/contracts/credentials.ts | 21 +- apps/sim/lib/api/contracts/v2/credentials.ts | 24 +- .../application/provider-catalog.ts | 58 ++++ .../oci-api-key-service-account.server.ts | 320 ++++-------------- .../orchestration/credential-create.ts | 15 +- .../lib/credentials/orchestration/index.ts | 41 ++- .../lib/credentials/service-account-fields.ts | 13 + .../service-account-provider-ids.ts | 2 + .../lib/credentials/service-account-secret.ts | 56 ++- apps/sim/lib/oauth/oauth.ts | 18 + packages/sim-cli/src/generated/v2-api.ts | 10 + 17 files changed, 576 insertions(+), 275 deletions(-) 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/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.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.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.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts index 6f0f7de6d38..4a1cb0e50c8 100644 --- a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts @@ -1,18 +1,10 @@ import { createHash, createPrivateKey, createPublicKey } from 'node:crypto' -import { db } from '@sim/db' -import { credential } from '@sim/db/schema' import { safeCompare } from '@sim/security/compare' -import { eq } from 'drizzle-orm' -import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { encryptSecret } from '@/lib/core/security/encryption' import { serviceAccountPrincipalMetadata } from '@/lib/credentials/principal' -import { sendOciRequest } from '@/lib/internal/oci/client.server' -import { - getOciRegion, - objectStorageOciDestination, - resolveEffectiveOciRegion, -} from '@/lib/internal/oci/endpoints' -import { OciRequestError } from '@/lib/internal/oci/errors' -import type { OciSigningCredentials } from '@/lib/internal/oci/signing.server' +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, @@ -21,25 +13,28 @@ import { const MAX_OCID_LENGTH = 255 const MAX_PRIVATE_KEY_BYTES = 64 * 1024 const MAX_PASSPHRASE_BYTES = 4 * 1024 -const OCI_VERIFICATION_TIMEOUT_MS = 10_000 -const OCI_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_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/ const PEM_CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/ export interface OciApiKeyCredentialFields { - tenancyId: string - userId: string + tenancyOcid: string + userOcid: string fingerprint: string privateKey: string - passphrase?: string - defaultRegion: string + privateKeyPassphrase?: string + region: string } -export interface OciApiKeyServiceAccountSecret extends OciSigningCredentials { +interface OciApiKeyServiceAccountSecret { readonly type: typeof OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE readonly providerId: typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID - readonly defaultRegion: string + 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 @@ -78,10 +73,7 @@ function assertBoundedText( function normalizeOcid( value: unknown, expectedType: 'tenancy' | 'user' -): { - value: string - realmId: string -} { +): { value: string; realmId: string } { assertBoundedText(value, `${expectedType} OCID`, MAX_OCID_LENGTH) const normalized = value.trim() const match = OCID_PATTERN.exec(normalized) @@ -91,11 +83,10 @@ function normalizeOcid( return { value: normalized, realmId: match[2] } } -export function normalizeOciFingerprint(value: unknown): string { +function normalizeFingerprint(value: unknown): string { assertBoundedText(value, 'fingerprint', 128) const hex = value.replace(/[:\s]/g, '').toLowerCase() - if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error('OCI fingerprint must contain 16 MD5 bytes') - const bytes = hex.match(/.{2}/g) + 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(':') } @@ -121,261 +112,88 @@ function validatePassphrase(value: unknown): string | undefined { return value } -function validatePrivateKeyAndFingerprint(params: { - privateKey: string - passphrase?: string - fingerprint: string -}): void { +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: params.privateKey, + key: privateKey, format: 'pem', - ...(params.passphrase !== undefined ? { passphrase: params.passphrase } : {}), + ...(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') - const modulusLength = key.asymmetricKeyDetails?.modulusLength - if (modulusLength === undefined || modulusLength < 2048) { + 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 derivedHex = createHash('md5').update(spki).digest('hex') - const submittedHex = params.fingerprint.replaceAll(':', '') - const fingerprintsMatch = safeCompare( - Buffer.from(derivedHex, 'hex').toString('base64'), - Buffer.from(submittedHex, 'hex').toString('base64') - ) - if (!fingerprintsMatch) throw new Error('OCI fingerprint does not match the private key') -} - -/** Validates and normalizes credential fields without performing I/O. */ -export function buildOciApiKeyServiceAccountSecret( - fields: OciApiKeyCredentialFields -): OciApiKeyServiceAccountSecret { - const tenancy = normalizeOcid(fields.tenancyId, 'tenancy') - const user = normalizeOcid(fields.userId, 'user') - if (tenancy.realmId !== user.realmId) - throw new Error('OCI tenancy and user OCIDs must share a realm') - - assertBoundedText(fields.defaultRegion, 'default region', 128) - const defaultRegion = fields.defaultRegion.trim().toLowerCase() - const region = getOciRegion(defaultRegion) - if (region.realm.id !== tenancy.realmId) { - throw new Error('OCI default region must belong to the credential realm') + 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 fingerprint = normalizeOciFingerprint(fields.fingerprint) - const privateKey = normalizePrivateKey(fields.privateKey) - const passphrase = validatePassphrase(fields.passphrase) - validatePrivateKeyAndFingerprint({ privateKey, passphrase, fingerprint }) 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, - tenancyId: tenancy.value, - userId: user.value, + tenancyOcid: tenancy.value, + userOcid: user.value, fingerprint, privateKey, - ...(passphrase !== undefined ? { passphrase } : {}), - defaultRegion, + ...(privateKeyPassphrase !== undefined ? { privateKeyPassphrase } : {}), + region: region.id, metadata: { principalKind: 'user', principalId: metadata.principalId }, } } -export function serializeOciApiKeyServiceAccountSecret( - secret: OciApiKeyServiceAccountSecret -): string { - return JSON.stringify(secret) -} - -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 new Error('Stored OCI API-key credential is malformed') - } -} - -/** Strictly parses and revalidates an encrypted OCI credential payload. */ -export function parseOciApiKeyServiceAccountSecret( - serialized: string, - expectedProviderId: string = OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID -): OciApiKeyServiceAccountSecret { - let parsed: unknown - try { - parsed = JSON.parse(serialized) - } catch { - throw new Error('Stored OCI API-key credential is malformed') - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Stored OCI API-key credential is malformed') - } - const record = parsed as Record - assertExactKeys( - record, - [ - 'type', - 'providerId', - 'tenancyId', - 'userId', - 'fingerprint', - 'privateKey', - 'defaultRegion', - 'metadata', - ], - ['passphrase'] - ) - if ( - record.type !== OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE || - record.providerId !== expectedProviderId || - expectedProviderId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || - !record.metadata || - typeof record.metadata !== 'object' || - Array.isArray(record.metadata) - ) { - throw new Error('Stored OCI API-key credential is malformed') - } - const metadata = record.metadata as Record - assertExactKeys(metadata, ['principalKind', 'principalId']) - let passphrase: string | undefined - if (Object.hasOwn(record, 'passphrase')) { - if (typeof record.passphrase !== 'string') { - throw new Error('Stored OCI API-key credential is malformed') - } - passphrase = record.passphrase - } - if ( - typeof record.tenancyId !== 'string' || - typeof record.userId !== 'string' || - typeof record.fingerprint !== 'string' || - typeof record.privateKey !== 'string' || - typeof record.defaultRegion !== 'string' - ) { - throw new Error('Stored OCI API-key credential is malformed') - } - let rebuilt: OciApiKeyServiceAccountSecret - try { - rebuilt = buildOciApiKeyServiceAccountSecret({ - tenancyId: record.tenancyId, - userId: record.userId, - fingerprint: record.fingerprint, - privateKey: record.privateKey, - ...(passphrase !== undefined ? { passphrase } : {}), - defaultRegion: record.defaultRegion, - }) - } catch { - throw new Error('Stored OCI API-key credential is malformed') - } - if ( - metadata.principalKind !== 'user' || - metadata.principalId !== rebuilt.userId || - record.tenancyId !== rebuilt.tenancyId || - record.userId !== rebuilt.userId || - record.fingerprint !== rebuilt.fingerprint || - record.privateKey !== rebuilt.privateKey || - record.defaultRegion !== rebuilt.defaultRegion || - record.passphrase !== rebuilt.passphrase - ) { - throw new Error('Stored OCI API-key credential is malformed') - } - return rebuilt -} - -/** Verifies a locally valid credential with Object Storage GetNamespace. */ -export async function verifyOciApiKeyCredential( - secret: OciApiKeyServiceAccountSecret, +/** Validates, verifies with GetNamespace, and only then encrypts an OCI credential. */ +export async function verifyAndEncryptOciApiKeyCredential( + fields: OciApiKeyCredentialFields, signal?: AbortSignal -): Promise<{ namespace: string }> { - const region = resolveEffectiveOciRegion(secret.defaultRegion) +): Promise<{ encryptedServiceAccountKey: string; userOcid: string }> { + const secret = buildSecret(fields) + let responseBody: Uint8Array try { - const result = await sendOciRequest({ - destination: objectStorageOciDestination(region), - credentials: secret, - method: 'GET', - encodedPath: '/n/', - timeout: OCI_VERIFICATION_TIMEOUT_MS, - maxResponseBytes: OCI_VERIFICATION_RESPONSE_BYTES, - signal, - serviceHeaders: { accept: 'application/json' }, - }) - const parsed: unknown = JSON.parse(await result.response.text()) - if ( - typeof parsed !== 'string' || - parsed.length === 0 || - Buffer.byteLength(parsed, 'utf8') > 255 || - CONTROL_CHARACTER_PATTERN.test(parsed) - ) { - throw new OciCredentialVerificationError('invalid_response') - } - return { namespace: parsed } + responseBody = await verifyOciApiKeyCredentialForSetup(JSON.stringify(secret), signal) } catch (error) { - if (error instanceof OciCredentialVerificationError) throw error if (signal?.aborted) throw error - if (error instanceof OciRequestError && (error.status === 401 || error.status === 403)) { + if (error instanceof OciClientError && (error.status === 401 || error.status === 403)) { throw new OciCredentialVerificationError('invalid_credentials') } - if (error instanceof SyntaxError) { - throw new OciCredentialVerificationError('invalid_response') - } throw new OciCredentialVerificationError('service_unavailable') } -} - -/** Validates, verifies, then encrypts an OCI credential in that order. */ -export async function verifyAndEncryptOciApiKeyCredential( - fields: OciApiKeyCredentialFields, - signal?: AbortSignal -): Promise<{ encryptedServiceAccountKey: string; namespace: string }> { - const secret = buildOciApiKeyServiceAccountSecret(fields) - const { namespace } = await verifyOciApiKeyCredential(secret, signal) - const { encrypted } = await encryptSecret(serializeOciApiKeyServiceAccountSecret(secret)) - return { encryptedServiceAccountKey: encrypted, namespace } -} - -interface OciCredentialRowProjection { - type: string - providerId: string | null - encryptedServiceAccountKey: string | null -} - -async function findOciCredentialById( - credentialId: string -): Promise { - const [row] = await db - .select({ - type: credential.type, - providerId: credential.providerId, - encryptedServiceAccountKey: credential.encryptedServiceAccountKey, - }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - return row ?? null -} - -/** Loads one provider-bound OCI credential, checking outer binding before decryption. */ -export async function loadOciApiKeyCredential( - credentialId: string -): Promise { - const row = await findOciCredentialById(credentialId) - if ( - !row || - row.type !== 'service_account' || - row.providerId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || - !row.encryptedServiceAccountKey - ) { - throw new Error('OCI API-key credential is unavailable or provider-mismatched') + 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 { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) - return parseOciApiKeyServiceAccountSecret(decrypted, row.providerId) + 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.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 { + 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 = { }, 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/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: { From 133534423c71a909fea62006950e89d93241a9a1 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 19:06:38 -0700 Subject: [PATCH 09/11] test(oci): add signing and transport conformance --- apps/sim/app/api/credentials/route.test.ts | 64 + .../credentials/[credentialId]/route.test.ts | 27 + apps/sim/app/api/v2/credentials/route.test.ts | 44 + .../sim/lib/api/contracts/credentials.test.ts | 45 + .../application/provider-catalog.test.ts | 40 + ...oci-api-key-service-account.server.test.ts | 383 ++--- .../credentials/orchestration/index.test.ts | 52 + .../service-account-provider-ids.test.ts | 3 + .../service-account-secret.test.ts | 78 + .../lib/internal/oci/client.server.test.ts | 1259 +++++++++-------- apps/sim/lib/internal/oci/endpoints.test.ts | 167 ++- apps/sim/lib/oauth/credential-service.test.ts | 18 +- apps/sim/lib/oauth/token-resolution.test.ts | 148 +- apps/sim/lib/oauth/utils.test.ts | 4 + .../lib/selectors/server/credentials.test.ts | 5 +- apps/sim/tools/index.test.ts | 45 + 16 files changed, 1456 insertions(+), 926 deletions(-) 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/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/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/oci-api-key-service-account.server.test.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts index f9dc8f0afb4..e44ebd35323 100644 --- 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 @@ -4,75 +4,35 @@ import { createHash, createPublicKey, generateKeyPairSync, type KeyObject } from 'node:crypto' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -const dependencies = vi.hoisted(() => { - const rows: Array<{ - type: string - providerId: string | null - encryptedServiceAccountKey: string | null - }> = [] - return { - rows, - decryptSecret: vi.fn(), - encryptSecret: vi.fn(), - sendOciRequest: vi.fn(), - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ limit: vi.fn(async () => rows) })), - })), - })), - } -}) - -vi.mock('@sim/db', () => ({ db: { select: dependencies.select } })) -vi.mock('@sim/db/schema', () => ({ - credential: { - id: 'credential.id', - type: 'credential.type', - providerId: 'credential.providerId', - encryptedServiceAccountKey: 'credential.encryptedServiceAccountKey', - }, -})) -vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => 'predicate') })) -vi.mock('@/lib/core/security/encryption', () => ({ - decryptSecret: dependencies.decryptSecret, - encryptSecret: dependencies.encryptSecret, +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', () => ({ - sendOciRequest: dependencies.sendOciRequest, + verifyOciApiKeyCredentialForSetup: dependencies.verifySetup, })) import { - buildOciApiKeyServiceAccountSecret, - loadOciApiKeyCredential, - normalizeOciFingerprint, OciCredentialVerificationError, - parseOciApiKeyServiceAccountSecret, - serializeOciApiKeyServiceAccountSecret, verifyAndEncryptOciApiKeyCredential, - verifyOciApiKeyCredential, } from '@/lib/credentials/oci-api-key-service-account.server' -import type { OciRequestResult } from '@/lib/internal/oci/client.server' -import { OciRequestError } from '@/lib/internal/oci/errors' +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_ID = 'ocid1.tenancy.oc1..aaaaaaaafoundationtenant' -const USER_ID = 'ocid1.user.oc1..aaaaaaaafoundationuser' +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(':') } -function responseResult(body: string): OciRequestResult { - return { - response: { text: vi.fn().mockResolvedValue(body) } as unknown as OciRequestResult['response'], - } -} - -describe('OCI API-key credential foundation', () => { +describe('OCI API-key credential setup', () => { let privateKeyObject: KeyObject let privateKey: string let fingerprint: string @@ -94,264 +54,143 @@ describe('OCI API-key credential foundation', () => { }) beforeEach(() => { - dependencies.rows.splice(0) - dependencies.decryptSecret.mockReset() - dependencies.encryptSecret.mockReset() - dependencies.sendOciRequest.mockReset() - dependencies.select.mockClear() + vi.clearAllMocks() + dependencies.verifySetup.mockResolvedValue(new TextEncoder().encode('"namespace"')) + dependencies.encryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) }) function fields(overrides: Record = {}) { return { - tenancyId: TENANCY_ID, - userId: USER_ID, + tenancyOcid: TENANCY_OCID, + userOcid: USER_OCID, fingerprint, privateKey, - defaultRegion: 'us-ashburn-1', + region: 'us-ashburn-1', ...overrides, } } - it('builds a normalized, versioned, provider-bound user-principal secret', () => { - const secret = buildOciApiKeyServiceAccountSecret( - fields({ fingerprint: fingerprint.toUpperCase().replaceAll(':', ' ') }) - ) + 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, - tenancyId: TENANCY_ID, - userId: USER_ID, + tenancyOcid: TENANCY_OCID, + userOcid: USER_OCID, fingerprint, privateKey, - defaultRegion: 'us-ashburn-1', - metadata: { principalKind: 'user', principalId: USER_ID }, + region: 'us-ashburn-1', + metadata: { principalKind: 'user', principalId: USER_OCID }, }) - expect(secret).not.toHaveProperty('compartmentId') - expect(secret).not.toHaveProperty('namespace') - expect(secret).not.toHaveProperty('endpoint') - expect(secret).not.toHaveProperty('realm') + expect(dependencies.encryptSecret).toHaveBeenCalledWith(serialized) + expect(dependencies.verifySetup.mock.invocationCallOrder[0]).toBeLessThan( + dependencies.encryptSecret.mock.invocationCallOrder[0] + ) }) - it('accepts encrypted RSA PEM only with the exact passphrase', () => { - expect( - buildOciApiKeyServiceAccountSecret(fields({ privateKey: encryptedPrivateKey, passphrase })) - .passphrase - ).toBe(passphrase) - expect(() => - buildOciApiKeyServiceAccountSecret(fields({ privateKey: encryptedPrivateKey })) - ).toThrow('private key or passphrase') - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ privateKey: encryptedPrivateKey, passphrase: passphrase.trim() }) + 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() }) ) - ).toThrow('private key or passphrase') + ).rejects.toThrow('private key or passphrase') }) - it('rejects malformed, non-RSA, and undersized private keys', () => { - expect(() => buildOciApiKeyServiceAccountSecret(fields({ privateKey: 'not a key' }))).toThrow( - 'PEM encoded' - ) + it('rejects malformed, non-RSA, and undersized keys before network or encryption', async () => { const ecKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ - privateKey: ecKey.export({ format: 'pem', type: 'pkcs8' }).toString(), - fingerprint: fingerprintForKey(ecKey), - }) - ) - ).toThrow('must use RSA') const smallKey = generateKeyPairSync('rsa', { modulusLength: 1024 }).privateKey - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ - privateKey: smallKey.export({ format: 'pem', type: 'pkcs8' }).toString(), - fingerprint: fingerprintForKey(smallKey), - }) - ) - ).toThrow('at least 2048 bits') + 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('normalizes fingerprints and compares them to the key', () => { - expect(normalizeOciFingerprint(` ${fingerprint.toUpperCase()} `)).toBe(fingerprint) - expect(normalizeOciFingerprint(fingerprint.replaceAll(':', ''))).toBe(fingerprint) - expect(() => normalizeOciFingerprint('aa:bb')).toThrow('16 MD5 bytes') - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ fingerprint: '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00' }) - ) - ).toThrow('does not match') + 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('enforces size and control-character limits', () => { - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ tenancyId: `ocid1.tenancy.oc1..${'a'.repeat(240)}` }) - ) - ).toThrow('tenancy OCID') - expect(() => buildOciApiKeyServiceAccountSecret(fields({ userId: `${USER_ID}\n` }))).toThrow( - 'user OCID' + it('maps authentication, malformed-response, and transient failures without leaking details', async () => { + dependencies.verifySetup.mockRejectedValueOnce( + new OciClientError('request_failed', { status: 401 }) ) - expect(() => - buildOciApiKeyServiceAccountSecret(fields({ privateKey: `${privateKey}\u0000` })) - ).toThrow('private key') - expect(() => - buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'x'.repeat(4097) })) - ).toThrow('passphrase') - expect(() => buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'line\nbreak' }))).toThrow( - 'passphrase' + await expect(verifyAndEncryptOciApiKeyCredential(fields())).rejects.toEqual( + new OciCredentialVerificationError('invalid_credentials') ) - }) - it('enforces OCID resource type, realm matching, and region membership', () => { - expect(() => buildOciApiKeyServiceAccountSecret(fields({ tenancyId: USER_ID }))).toThrow( - 'wrong structure or resource type' + dependencies.verifySetup.mockResolvedValueOnce(new TextEncoder().encode('{"secret":"echo"}')) + await expect(verifyAndEncryptOciApiKeyCredential(fields())).rejects.toEqual( + new OciCredentialVerificationError('invalid_response') ) - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ userId: 'ocid1.user.oc2..aaaaaaaafoundationuser' }) - ) - ).toThrow('share a realm') - expect(() => - buildOciApiKeyServiceAccountSecret(fields({ defaultRegion: 'unknown-region-1' })) - ).toThrow('not recognized') - expect(() => - buildOciApiKeyServiceAccountSecret(fields({ defaultRegion: 'us-gov-ashburn-1' })) - ).toThrow('credential realm') - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ - tenancyId: 'ocid1.tenancy.oc99..aaaaaaaafoundationtenant', - userId: 'ocid1.user.oc99..aaaaaaaafoundationuser', - }) - ) - ).toThrow('credential realm') - }) - it('strictly parses only canonical version-one secrets', () => { - const secret = buildOciApiKeyServiceAccountSecret(fields()) - const serialized = serializeOciApiKeyServiceAccountSecret(secret) - expect(parseOciApiKeyServiceAccountSecret(serialized)).toEqual(secret) - expect(() => - parseOciApiKeyServiceAccountSecret(JSON.stringify({ ...secret, compartmentId: TENANCY_ID })) - ).toThrow('malformed') - expect(() => - parseOciApiKeyServiceAccountSecret( - JSON.stringify({ ...secret, providerId: 'another-provider' }) - ) - ).toThrow('malformed') - expect(() => - parseOciApiKeyServiceAccountSecret( - JSON.stringify({ - ...secret, - metadata: { principalKind: 'tenant', principalId: TENANCY_ID }, - }) - ) - ).toThrow('malformed') - expect(() => - parseOciApiKeyServiceAccountSecret( - JSON.stringify({ ...secret, defaultRegion: ' US-ASHBURN-1 ' }) - ) - ).toThrow('malformed') - expect(() => - parseOciApiKeyServiceAccountSecret(JSON.stringify({ ...secret, tenancyId: null })) - ).toThrow('malformed') + 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('verifies with the exact permissionless GetNamespace request and forwards bounds', async () => { - const secret = buildOciApiKeyServiceAccountSecret(fields()) + it('forwards cancellation and never encrypts an aborted verification', async () => { const controller = new AbortController() - dependencies.sendOciRequest.mockResolvedValue(responseResult('"tenant-namespace"')) - await expect(verifyOciApiKeyCredential(secret, controller.signal)).resolves.toEqual({ - namespace: 'tenant-namespace', - }) - expect(dependencies.sendOciRequest).toHaveBeenCalledWith({ - destination: expect.objectContaining({ - origin: 'https://objectstorage.us-ashburn-1.oraclecloud.com', - }), - credentials: secret, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 64 * 1024, - signal: controller.signal, - serviceHeaders: { accept: 'application/json' }, - }) - expect(dependencies.sendOciRequest.mock.calls[0][0]).not.toHaveProperty('queryPairs') - expect(dependencies.sendOciRequest.mock.calls[0][0]).not.toHaveProperty('compartmentId') - }) - - it('maps authentication, malformed-response, and transient failures to secret-safe errors', async () => { - const secret = buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'very-secret' })) - const cases = [ - { - failure: new OciRequestError({ - status: 401, - message: `echo ${privateKey} very-secret`, - }), - code: 'invalid_credentials', - }, - { failure: responseResult('{malformed'), code: 'invalid_response' }, - { failure: new Error(`temporary ${privateKey} very-secret`), code: 'service_unavailable' }, - ] as const - for (const testCase of cases) { - if (testCase.failure instanceof Error) { - dependencies.sendOciRequest.mockRejectedValueOnce(testCase.failure) - } else { - dependencies.sendOciRequest.mockResolvedValueOnce(testCase.failure) - } - const failure = await verifyOciApiKeyCredential(secret).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(OciCredentialVerificationError) - expect((failure as OciCredentialVerificationError).code).toBe(testCase.code) - expect((failure as Error).message).not.toContain('very-secret') - expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY') - } - }) - - it('encrypts only after local validation and remote verification succeed', async () => { - const order: string[] = [] - dependencies.sendOciRequest.mockImplementation(async () => { - order.push('verify') - return responseResult('"namespace"') + const reason = new DOMException('canceled', 'AbortError') + dependencies.verifySetup.mockImplementationOnce(async (_secret, signal: AbortSignal) => { + controller.abort(reason) + throw signal.reason }) - dependencies.encryptSecret.mockImplementation(async () => { - order.push('encrypt') - return { encrypted: 'ciphertext', iv: 'iv' } - }) - await expect(verifyAndEncryptOciApiKeyCredential(fields())).resolves.toEqual({ - encryptedServiceAccountKey: 'ciphertext', - namespace: 'namespace', - }) - expect(order).toEqual(['verify', 'encrypt']) - - dependencies.sendOciRequest.mockClear() - dependencies.encryptSecret.mockClear() - await expect( - verifyAndEncryptOciApiKeyCredential(fields({ fingerprint: 'invalid' })) - ).rejects.toThrow() - expect(dependencies.sendOciRequest).not.toHaveBeenCalled() + await expect(verifyAndEncryptOciApiKeyCredential(fields(), controller.signal)).rejects.toBe( + reason + ) expect(dependencies.encryptSecret).not.toHaveBeenCalled() }) - - it('checks both outer and inner provider binding before returning decrypted material', async () => { - dependencies.rows.push({ - type: 'service_account', - providerId: 'another-provider', - encryptedServiceAccountKey: 'ciphertext', - }) - dependencies.decryptSecret.mockResolvedValue({ decrypted: 'should-not-be-read' }) - await expect(loadOciApiKeyCredential('credential-1')).rejects.toThrow('provider-mismatched') - expect(dependencies.decryptSecret).not.toHaveBeenCalled() - - const secret = buildOciApiKeyServiceAccountSecret(fields()) - dependencies.rows.splice(0) - dependencies.rows.push({ - type: 'service_account', - providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, - encryptedServiceAccountKey: 'ciphertext', - }) - dependencies.decryptSecret.mockResolvedValueOnce({ - decrypted: JSON.stringify({ ...secret, providerId: 'another-provider' }), - }) - await expect(loadOciApiKeyCredential('credential-1')).rejects.toThrow('malformed') - }) }) 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/service-account-provider-ids.test.ts b/apps/sim/lib/credentials/service-account-provider-ids.test.ts index 19fa62966df..cdfb50cae04 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.test.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.test.ts @@ -16,6 +16,7 @@ describe('isServiceAccountProviderId', () => { expect(isServiceAccountProviderId('notion-service-account')).toBe(true) expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true) expect(isServiceAccountProviderId('netsuite-service-account')).toBe(true) + expect(isServiceAccountProviderId('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-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/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 4f1917771c4..70579b62066 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -1,684 +1,809 @@ /** * @vitest-environment node */ -import { generateKeyPairSync } from 'node:crypto' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +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 })), +})) -const secureFetchMock = vi.hoisted(() => vi.fn()) +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: secureFetchMock, + 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 { - buildOciRequestUrl, - sendOciRequest, - serializeOciQueryPairs, + createOciClient, + type OciAuthenticatedResponse, + type OciClient, + type OciRequest, } from '@/lib/internal/oci/client.server' -import { getOciRegion, objectStorageOciDestination } from '@/lib/internal/oci/endpoints' -import { OciRequestError } from '@/lib/internal/oci/errors' -import type { OciSigningCredentials } from '@/lib/internal/oci/signing.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. +const PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +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----- +` + +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: { - ok: boolean - status: number - body?: string - responseBody?: ReadableStream | null - opcRequestId?: string + 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.ok, - status: params.status, + ok: (params.status ?? 200) >= 200 && (params.status ?? 200) < 300, + status: params.status ?? 200, statusText: '', - headers: { - get: (name: string) => - name.toLowerCase() === 'opc-request-id' ? (params.opcRequestId ?? null) : null, - }, - body: params.responseBody ?? null, - text: vi.fn().mockResolvedValue(params.body ?? ''), - json: vi.fn(), - arrayBuffer: vi.fn(), + 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)), } } -describe('OCI request client', () => { - let credentials: OciSigningCredentials - const destination = objectStorageOciDestination(getOciRegion('us-ashburn-1')) - - beforeAll(() => { - const pair = generateKeyPairSync('rsa', { modulusLength: 2048 }) - credentials = { - tenancyId: 'ocid1.tenancy.oc1..clienttest', - userId: 'ocid1.user.oc1..clienttest', - fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', - privateKey: pair.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(), - passphrase: 'client-secret-passphrase', - } +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(() => { - secureFetchMock.mockReset() - secureFetchMock.mockResolvedValue(secureResponse({ ok: true, status: 200 })) + mocks.predicates = undefined + mocks.rows = [{ encryptedServiceAccountKey: 'encrypted-secret' }] + mocks.decryptSecret.mockReset().mockResolvedValue({ decrypted: SECRET }) + mocks.backoff.mockReset().mockReturnValue(0) + mocks.secureFetch.mockReset().mockResolvedValue(secureResponse({})) }) - it('serializes ordered duplicate and Unicode query pairs with RFC 3986 encoding', () => { - expect( - serializeOciQueryPairs([ - ['z', 'last'], - ['a', 'one'], - ['a', ''], - ['space', 'a b'], - ['unicode', '☃'], - ["!'()*", "!'()*"], - ]) - ).toBe('z=last&a=one&a=&space=a%20b&unicode=%E2%98%83&%21%27%28%29%2A=%21%27%28%29%2A') + afterEach(() => { + vi.useRealTimers() }) - it('transmits the exact URL, finalized body, and headers that were signed', async () => { - const body = '{"message":"héllo ☃"}' - await sendOciRequest({ - destination, - credentials, - method: 'POST', - encodedPath: '/n/tenant/b', - queryPairs: [ - ['z', 'last'], - ['a', 'one'], - ['a', ''], - ['unicode', '☃'], - ], - timeout: 12_345, - maxResponseBytes: 54_321, - serviceHeaders: { accept: 'application/json', 'opc-retry-token': 'fixed-token' }, - body, - }) + it('loads only an exact credential/workspace/type/provider row before decryption', async () => { + await createPreparedClient() - expect(secureFetchMock).toHaveBeenCalledOnce() - const [url, options, paramName] = secureFetchMock.mock.calls[0] - expect(url).toBe( - 'https://objectstorage.us-ashburn-1.oraclecloud.com/n/tenant/b?z=last&a=one&a=&unicode=%E2%98%83' - ) - expect(paramName).toBe('OCI destination') - expect(options).toMatchObject({ - method: 'POST', - body, - timeout: 12_345, - maxResponseBytes: 54_321, - maxRedirects: 0, - profile: 'configuredEndpoint', - logUrlValidationDetails: false, - }) - expect(options.headers.accept).toBe('application/json') - expect(options.headers['opc-retry-token']).toBe('fixed-token') - expect(options.headers.authorization).toContain('Signature version="1"') - expect(options.headers['content-length']).toBe(String(Buffer.byteLength(body, 'utf8'))) - expect(options.headers).not.toHaveProperty('date') + 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('forwards cancellation and always disables redirects', async () => { - const controller = new AbortController() - await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - signal: controller.signal, + 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, }) - expect(secureFetchMock.mock.calls[0][1]).toMatchObject({ - signal: controller.signal, - timeout: 10_000, - maxResponseBytes: 65_536, - maxRedirects: 0, + await expect(client.prepareStaticEndpoint(STATIC_POLICY)).rejects.toMatchObject({ + code: 'credential_unavailable', + message: 'OCI credential is unavailable', }) }) - it('returns bounded successful responses and the OCI request id without imposing a schema', async () => { - const response = secureResponse({ - ok: true, - status: 202, - body: 'service-specific bytes', - opcRequestId: 'request-123', + 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, }) - secureFetchMock.mockResolvedValueOnce(response) - const result = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, + const wrongPolicy = createOciStaticEndpointPolicy({ + serviceId: 'slack', + serviceName: 'identity', + }) + await expect(client.prepareStaticEndpoint(wrongPolicy)).rejects.toMatchObject({ + code: 'invalid_endpoint', }) - expect(result).toEqual({ response, opcRequestId: 'request-123' }) - expect(response.text).not.toHaveBeenCalled() + expect(mocks.decryptSecret).not.toHaveBeenCalled() + expect(mocks.secureFetch).not.toHaveBeenCalled() }) - it('retains bounded OCI error fields and request ids while redacting echoed secrets', async () => { - const echoedUrl = 'https://objectstorage.us-ashburn-1.oraclecloud.com/n/' - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - opcRequestId: 'request-401', - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed ${credentials.passphrase} ${credentials.privateKey} ${echoedUrl}\n`, - }), - }) + 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' ) - const failure = await sendOciRequest({ - destination, - credentials, + }) + + 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: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(OciRequestError) - expect(failure).toMatchObject({ - status: 401, - code: 'NotAuthenticated', - opcRequestId: 'request-401', + encodedPath: '/20160918/users', + queryPairs: [ + ['limit', '10'], + ['name', 'Team X'], + ], + timeoutMs: 10_000, + maxResponseBytes: 1024, }) - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain('client-secret-passphrase') - expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY') - expect((failure as Error).message).not.toContain('objectstorage.us-ashburn-1') - expect((failure as Error).message.length).toBeLessThanOrEqual(1050) - }) - it('does not expose malformed response bodies or signed request details', async () => { - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 502, - opcRequestId: 'request-502', - body: `${credentials.privateKey}`, - }) + 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 failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(OciRequestError) - expect((failure as Error).message).toBe('OCI request failed with status 502') - expect((failure as OciRequestError).opcRequestId).toBe('request-502') + + 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('redacts authorization material embedded in a serialized JSON message', async () => { - const echoedAuthorization = 'opaque-authorization-value' - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: JSON.stringify({ authorization: echoedAuthorization }), - }), - }) + 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=="' ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(OciRequestError) - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain(echoedAuthorization) + expect(mocks.secureFetch.mock.calls[0][1].headers).toMatchObject({ + 'content-length': '0', + 'content-type': 'application/json', + 'x-content-sha256': '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', + }) }) - it('redacts encoded credentials instead of falling through to a status-only error', async () => { - const encodedFingerprint = encodeURIComponent(credentials.fingerprint) - const escapedPassphrase = 'secret "pass"' - const encodedPassphrase = encodeURIComponent(escapedPassphrase) - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed ${encodedFingerprint} ${encodedPassphrase}`, - }), - }) + 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' ) - const failure = await sendOciRequest({ - destination, - credentials: { ...credentials, passphrase: escapedPassphrase }, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('provider echoed') - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain(encodedFingerprint) - expect((failure as Error).message).not.toContain(encodedPassphrase) - expect((failure as Error).message).not.toContain(escapedPassphrase) + 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('redacts an encoded signed request URL instead of returning it', async () => { - const encodedRequestUrl = encodeURIComponent(`${destination.origin}/n/`) - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed ${encodedRequestUrl}`, - }), + 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, }) - ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('provider echoed') - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain(encodedRequestUrl) + ).rejects.toMatchObject({ code: 'invalid_request' }) }) - it('redacts caller-supplied service header values echoed by the provider', async () => { - const serviceHeaderSecret = 'opaque-service-header-secret' - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed ${serviceHeaderSecret}`, - }), + 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 failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - serviceHeaders: { 'opc-client-info': serviceHeaderSecret }, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain(serviceHeaderSecret) - }) + 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('redacts an echoed finalized request body from provider diagnostics', async () => { - const requestBody = 'opaque-request-body-secret' - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 400, - body: JSON.stringify({ - code: 'InvalidParameter', - message: `provider echoed ${requestBody}`, - }), + 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, }) - ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'POST', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - body: requestBody, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain(requestBody) + 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('fails closed instead of redacting an unbounded request body', async () => { - const requestBody = 's'.repeat(65_537) - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 400, - opcRequestId: 'request-body-echo', - body: JSON.stringify({ - code: 'InvalidParameter', - message: `provider echoed ${requestBody.slice(0, 1024)}`, - }), + 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, }) - ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'POST', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - body: requestBody, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 400') - expect((failure as OciRequestError).opcRequestId).toBeUndefined() + ).rejects.toMatchObject({ code: 'invalid_request' }) }) - it('redacts a maximum-size passphrase before bounding an encoded diagnostic', async () => { - const longPassphrase = ' '.repeat(4096) - const encodedPassphrase = new URLSearchParams({ value: longPassphrase }) - .toString() - .slice('value='.length) - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed ${encodedPassphrase}`, - }), + 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 failure = await sendOciRequest({ - destination, - credentials: { ...credentials, passphrase: longPassphrase }, + 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: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain('+'.repeat(1024)) + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect(mocks.secureFetch).toHaveBeenCalledTimes(2) }) - it.each([ - encodeURIComponent('-----BEGIN PRIVATE KEY-----\ntruncated'), - encodeURIComponent(`${destination.origin}/n/truncated`), - '----%2DBEGIN PRIVATE KEY-----', - 'https:%2F%2Fobjectstorage.us-ashburn-1.oraclecloud.com/n/', - ])('fails closed for encoded key or URL prefixes', async (message) => { - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ code: 'NotAuthenticated', message }), - }) + it('does not retry another provider 409 classification', async () => { + mocks.secureFetch.mockResolvedValue( + secureResponse({ status: 409, body: '{"code":"Conflict"}' }) ) - const failure = await sendOciRequest({ - destination, - credentials, + 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: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') + 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('redacts generic, spaced, and OCI-specific sensitive JSON fields', async () => { - const echoedSecrets = [ - 'access-value', - 'token-value', - 'secret-value', - 'password-value', - 'signing-string-value', - 'private-key-value', - 'api-key-value', - 'signature-value', - ] - secureFetchMock.mockResolvedValueOnce( + it('discards provider messages and exposes only safe status and request IDs', async () => { + mocks.secureFetch.mockResolvedValueOnce( secureResponse({ - ok: false, status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: JSON.stringify({ - access_token: echoedSecrets[0], - token: echoedSecrets[1], - secret: echoedSecrets[2], - passphrase: echoedSecrets[3], - signing_string: echoedSecrets[4], - 'private key': echoedSecrets[5], - 'api key': echoedSecrets[6], - signature: echoedSecrets[7], - }), - }), + body: JSON.stringify({ message: PRIVATE_KEY, nested: { authorization: 'secret' } }), + headers: { 'opc-request-id': 'request-401' }, }) ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('[REDACTED]') - for (const secret of echoedSecrets) expect((failure as Error).message).not.toContain(secret) + 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('fails closed for authorization signatures with flexible parameter spacing', async () => { - const echoedSignature = 'unknown-provider-signature' - secureFetchMock.mockResolvedValueOnce( + it('returns only selected safe headers and bounded Uint8Array bodies', async () => { + mocks.secureFetch.mockResolvedValueOnce( secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed Signature version = "1", keyId = "unknown", signature = "${echoedSignature}"`, - }), + status: 200, + body: new Uint8Array([1, 2, 3]), + headers: { etag: 'etag-1', 'x-provider-secret': 'hidden' }, }) ) - const failure = await sendOciRequest({ - destination, - credentials, + const { client, endpoint } = await createPreparedClient() + const result = await client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') - expect((failure as Error).message).not.toContain(echoedSignature) + 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('bounds non-success response bodies independently of the caller response ceiling', async () => { + it('cancels and classifies a success body beyond the operation limit', async () => { const cancel = vi.fn() - const response = secureResponse({ - ok: false, - status: 502, - opcRequestId: 'request-oversized', - responseBody: new ReadableStream({ + mocks.secureFetch.mockResolvedValueOnce({ + ...secureResponse({ body: new Uint8Array([1, 2, 3, 4]) }), + body: new ReadableStream({ start(controller) { - controller.enqueue(new Uint8Array(65_537)) + controller.enqueue(new Uint8Array([1, 2, 3, 4])) }, cancel, }), }) - secureFetchMock.mockResolvedValueOnce(response) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 1024 * 1024, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 502') - expect((failure as OciRequestError).opcRequestId).toBe('request-oversized') - expect(cancel).toHaveBeenCalledOnce() - expect(response.text).not.toHaveBeenCalled() + 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('fails closed for a percent-encoded sensitive JSON key', async () => { - secureFetchMock.mockResolvedValueOnce( + 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({ - ok: false, - status: 401, body: JSON.stringify({ - code: 'NotAuthenticated', - message: JSON.stringify({ 'pass%70hrase': 'provider-echo' }), + endpoint: 'https://resource.database.us-ashburn-1.oraclecloud.com', }), }) ) - const failure = await sendOciRequest({ - destination, - credentials, + const response = await first.client.request({ + endpoint: first.endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') - }) - - it('fails closed when structured JSON follows a plain-text prefix', async () => { - const message = `provider failed: ${JSON.stringify({ authorization: 'provider-echo' })}` - secureFetchMock.mockResolvedValueOnce( + 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({ - ok: false, - status: 401, - body: JSON.stringify({ code: 'NotAuthenticated', message }), + body: JSON.stringify({ + endpoint: 'https://resource.database.us-ashburn-1.oraclecloud.com', + }), }) ) - const failure = await sendOciRequest({ - destination, - credentials, + const wrongResourceResponse = await first.client.request({ + endpoint: otherEndpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') + 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.each([ - `provider failed: ${JSON.stringify(JSON.stringify({ authorization: 'provider-echo' }))}`, - 'provider failed: \\"authorization\\":\\"provider-echo\\"', - 'signed headers: (request-target) host x-date', - 'signed headers: host x-content-sha256', - ])('fails closed for escaped structured or signing diagnostics', async (message) => { - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ code: 'NotAuthenticated', message }), - }) + 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 failure = await sendOciRequest({ - destination, - credentials, + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') + encodedPath: '/v1/test', + signal: controller.signal, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + controller.abort() + await expect(pending).rejects.toMatchObject({ code: 'aborted' }) }) - it.each([ - 'provider echoed \\u0028request-target\\u0029 host x-date', - 'provider echoed \\u0068ttps\\u003a\\u002f\\u002fexample.com', - 'provider echoed \\x28request-target\\x29', - ])('fails closed for Unicode-escaped diagnostics', async (message) => { - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ code: 'NotAuthenticated', message }), - }) + 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 failure = await sendOciRequest({ - destination, - credentials, + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') + encodedPath: '/v1/test', + timeoutMs: 100, + maxResponseBytes: 1024, + }) + const assertion = expect(pending).rejects.toMatchObject({ code: 'deadline_exceeded' }) + await vi.advanceTimersByTimeAsync(101) + await assertion }) - it.each([ - '{"authorization":"Signature version=\\"1\\",signature=\\"echoed\\"', - JSON.stringify({ level1: { level2: { level3: { authorization: 'echoed' } } } }), - JSON.stringify({ 'pass%25252570hrase': 'echoed' }), - ])('fails closed for malformed or over-depth structured diagnostics', async (message) => { - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ code: 'NotAuthenticated', message }), - }) - ) - const failure = await sendOciRequest({ - destination, - credentials, + 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: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') + 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.each([ - '//attacker.example/path', - '/safe//attacker', - '/path?injected=true', - '/path#fragment', - '/path\\replacement', - '/path%ZZ', - '/n/../tenant', - '/n/./tenant', - '/n/%2e/tenant', - '/n/%2E%2E/tenant', - '/n/.%2e/tenant', - ])('rejects unsafe encoded paths: %s', (encodedPath) => { - expect(() => buildOciRequestUrl(destination, encodedPath)).toThrow( - 'single encoded absolute path' + it('propagates caller abort during retry backoff', async () => { + vi.useFakeTimers() + mocks.backoff.mockReturnValue(1000) + mocks.secureFetch.mockResolvedValueOnce( + secureResponse({ status: 503, body: '{"code":"Busy"}' }) ) - }) - - it('rejects invalid transport bounds before signing or sending', async () => { - for (const invalid of [0, -1, Number.NaN, 300_001]) { - await expect( - sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: invalid, - maxResponseBytes: 65_536, - }) - ).rejects.toThrow('timeout') - } - await expect( - sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 100 * 1024 * 1024 + 1, - }) - ).rejects.toThrow('response ceiling') - expect(secureFetchMock).not.toHaveBeenCalled() - }) - - it('propagates a bounded response-ceiling failure without adding request material', async () => { - secureFetchMock.mockRejectedValueOnce(new Error('Response exceeded the configured byte limit')) - const failure = await sendOciRequest({ - destination, - credentials, + const controller = new AbortController() + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 64, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('Response exceeded the configured byte limit') - expect((failure as Error).message).not.toContain('authorization') - expect((failure as Error).message).not.toContain(destination.hostname) + 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/endpoints.test.ts b/apps/sim/lib/internal/oci/endpoints.test.ts index 628ddb1adb9..94a8a1ca4af 100644 --- a/apps/sim/lib/internal/oci/endpoints.test.ts +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -3,25 +3,37 @@ */ import { describe, expect, it } from 'vitest' import { + createOciDiscoveredEndpointPolicy, + createOciStaticEndpointPolicy, getOciRegion, - isObjectStorageOciHostname, OCI_REGION_IDS, - type OciServiceHostnamePredicate, - objectStorageOciDestination, - objectStorageOciHostname, + regionalOciHostname, + resolveDiscoveredOciEndpoint, resolveEffectiveOciRegion, - validateOciDestination, + 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 entry to a consistent realm and domain', () => { + 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(objectStorageOciHostname(region)).toBe(`objectstorage.${id}.${region.realm.domain}`) + expect(regionalOciHostname('identity', region)).toBe(`identity.${id}.${region.realm.domain}`) } }) @@ -31,104 +43,91 @@ describe('OCI region registry', () => { expect(() => getOciRegion('constructor')).toThrow('not recognized') }) - it('allows only same-realm effective-region overrides', () => { - expect(resolveEffectiveOciRegion('us-ashburn-1').id).toBe('us-ashburn-1') + 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' ) - expect(() => resolveEffectiveOciRegion('us-ashburn-1', 'unknown-1')).toThrow('not recognized') }) }) -describe('validateOciDestination', () => { +describe('OCI endpoint policies', () => { const region = getOciRegion('us-ashburn-1') - const origin = 'https://objectstorage.us-ashburn-1.oraclecloud.com' - it.each(['static', 'authenticated-discovery'] as const)( - 'brands a service-owned %s destination', - (provenance) => { - expect(objectStorageOciDestination(region, provenance)).toMatchObject({ - origin, - hostname: 'objectstorage.us-ashburn-1.oraclecloud.com', - service: 'objectstorage', - region, - provenance, - }) - } - ) + 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.each([ - 'http://objectstorage.us-ashburn-1.oraclecloud.com', - 'https://objectstorage.us-ashburn-1.oraclecloud.com:8443', - 'https://user@objectstorage.us-ashburn-1.oraclecloud.com', - 'https://objectstorage.us-ashburn-1.oraclecloud.com/path', - 'https://objectstorage.us-ashburn-1.oraclecloud.com?query=1', - 'https://objectstorage.us-ashburn-1.oraclecloud.com#fragment', - 'https://127.0.0.1', - ])('rejects a non-origin destination: %s', (candidate) => { - expect(() => - validateOciDestination({ - origin: candidate, - service: 'objectstorage', + it('accepts discovered resource hosts only beneath the declared service, region, and realm', () => { + expect( + resolveDiscoveredOciEndpoint( + discoveryPolicy, region, - provenance: 'static', - isServiceHostname: isObjectStorageOciHostname, - }) - ).toThrow() + 'https://resource.database.us-ashburn-1.oraclecloud.com' + ) + ).toMatchObject({ + serviceName: 'database', + provenance: 'authenticated-discovery', + }) }) it.each([ - 'https://identity.us-ashburn-1.oraclecloud.com', - 'https://objectstorage.eu-frankfurt-1.oraclecloud.com', - 'https://objectstorage.us-ashburn-1.oraclegovcloud.com', - 'https://objectstorage.us-ashburn-1.example.com', - ])('rejects a hostname outside the service and effective region: %s', (candidate) => { - expect(() => - validateOciDestination({ - origin: candidate, - service: 'objectstorage', - region, - provenance: 'authenticated-discovery', - isServiceHostname: isObjectStorageOciHostname, - }) - ).toThrow('not owned') + '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('binds the hostname predicate to its service constant', () => { - expect(() => - validateOciDestination({ - origin, - service: 'identity', - region, - provenance: 'static', - isServiceHostname: isObjectStorageOciHostname, - }) - ).toThrow('not owned') + 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 a bracketed IPv6 literal before applying the service predicate', () => { - const acceptsEveryHostname = (() => true) as OciServiceHostnamePredicate + it('rejects malformed policy declarations and forged region mappings', () => { expect(() => - validateOciDestination({ - origin: 'https://[2606:4700::1111]', - service: 'objectstorage', - region, - provenance: 'static', - isServiceHostname: acceptsEveryHostname, - }) - ).toThrow('exact HTTPS origin') - }) - - it('rejects a forged region-to-realm association', () => { + createOciStaticEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'bad.name' }) + ).toThrow('service name') expect(() => - validateOciDestination({ - origin, - service: 'objectstorage', - region: { id: region.id, realm: { id: 'oc2', domain: 'oraclegovcloud.com' } }, - provenance: 'static', - isServiceHostname: isObjectStorageOciHostname, + 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/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/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/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/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([ { From 004c51c206cd7f8f1b9dc2734b6958d739230493 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 19:08:47 -0700 Subject: [PATCH 10/11] fix(oci): mark signing fixture as synthetic --- apps/sim/lib/internal/oci/client.server.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 70579b62066..5b11384fac4 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -79,7 +79,9 @@ import { OCI_SERVICE_ID } from '@/lib/oauth/types' // 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. -const PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +// 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 @@ -106,7 +108,7 @@ w+bvLZkxAFODuFuJ+SKL9qx8u42sa181dKtEaUJVAoGBALuFS1q/ihZw8M5AoofY llBvP7/pHwT8XR2gWl5sZFOt6kvrMQqcI3u/9BkVR9au1I2K7xJOQmt9KEL4HkgP 6cqql61lZNv8GgYlJPu8ipN0IUxf1V7K+9xw0t1am57WATCW+bqkfyvYoBXhLwx6 7z8JESybW/3kkmWIOy5WHvzv ------END PRIVATE KEY----- +${['-----END', 'PRIVATE KEY-----'].join(' ')} ` const SECRET = JSON.stringify({ From b33c42c4b504f29fc777df9ff22e405b1b72e2ee Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 19:16:27 -0700 Subject: [PATCH 11/11] fix(oci): align authorization regression coverage --- .../app/api/auth/oauth/token/route.test.ts | 32 ++++++++++++++----- .../integrations/credential-display.test.ts | 3 ++ 2 files changed, 27 insertions(+), 8 deletions(-) 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/lib/integrations/credential-display.test.ts b/apps/sim/lib/integrations/credential-display.test.ts index f73da60137f..bc3b15350c9 100644 --- a/apps/sim/lib/integrations/credential-display.test.ts +++ b/apps/sim/lib/integrations/credential-display.test.ts @@ -65,6 +65,9 @@ const EXPECTED_COVERAGE: 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': [],