diff --git a/packages/cli/src/__tests__/credentials-helper.test.ts b/packages/cli/src/__tests__/credentials-helper.test.ts index 8a432cf285c..4cd2f9cd07d 100644 --- a/packages/cli/src/__tests__/credentials-helper.test.ts +++ b/packages/cli/src/__tests__/credentials-helper.test.ts @@ -444,6 +444,221 @@ describe('CredentialsHelper', () => { }); }); + describe('applyDefaultsAndOverwrites — managed OAuth endpoint fields', () => { + const buildHelper = (credentialsOverwrites: CredentialsOverwrites) => + new CredentialsHelper( + new CredentialTypes(mockNodesAndCredentials), + credentialsOverwrites, + credentialsRepository, + dynamicCredentialProxy, + secretsProviderRepository, + licenseState, + externalSecretsConfig, + mock(), + ); + + const registerType = (credentialType: ICredentialType) => + mockNodesAndCredentials.getCredential + .calledWith(credentialType.name) + .mockReturnValue({ type: credentialType, sourcePath: '' }); + + const managedOAuth2Type: ICredentialType = { + name: 'slackOAuth2Api', + displayName: 'Slack OAuth2 API', + properties: [ + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://slack.com/oauth/v2/authorize', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://slack.com/api/oauth.v2.access', + }, + { displayName: 'Authentication', name: 'authentication', type: 'hidden', default: 'body' }, + { displayName: 'Client ID', name: 'clientId', type: 'string', default: '' }, + { displayName: 'Client Secret', name: 'clientSecret', type: 'string', default: '' }, + ], + }; + + const managedOAuth1Type: ICredentialType = { + name: 'twitterOAuth1Api', + displayName: 'Twitter OAuth1 API', + properties: [ + { + displayName: 'Request Token URL', + name: 'requestTokenUrl', + type: 'hidden', + default: 'https://api.twitter.com/oauth/request_token', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://api.twitter.com/oauth/authorize', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://api.twitter.com/oauth/access_token', + }, + { + displayName: 'Signature Method', + name: 'signatureMethod', + type: 'hidden', + default: 'HMAC-SHA1', + }, + { displayName: 'Consumer Key', name: 'consumerKey', type: 'string', default: '' }, + { displayName: 'Consumer Secret', name: 'consumerSecret', type: 'string', default: '' }, + ], + }; + + test('resolves hidden OAuth2 endpoint fields from the credential type for managed credentials', async () => { + registerType(managedOAuth2Type); + const credentialsOverwrites = mock(); + credentialsOverwrites.applyOverwrite.mockImplementation((_type, data) => data); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + + const result = await buildHelper(credentialsOverwrites).applyDefaultsAndOverwrites( + mock({ variables: {} }), + { + clientId: 'shared-client-id', + clientSecret: 'shared-client-secret', + grantType: 'clientCredentials', + authUrl: 'https://custom.example.com/authorize', + accessTokenUrl: 'https://custom.example.com/token', + authentication: 'header', + }, + managedOAuth2Type.name, + 'internal', + ); + + expect(result).toMatchObject({ + grantType: 'authorizationCode', + authUrl: 'https://slack.com/oauth/v2/authorize', + accessTokenUrl: 'https://slack.com/api/oauth.v2.access', + authentication: 'body', + // The instance-provided client is preserved; only the endpoints are pinned. + clientId: 'shared-client-id', + clientSecret: 'shared-client-secret', + }); + }); + + test('honors an admin overwrite for a pinned endpoint field, falling back to the type default for the rest', async () => { + registerType(managedOAuth2Type); + const credentialsOverwrites = mock(); + credentialsOverwrites.applyOverwrite.mockImplementation((_type, data) => data); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + credentialsOverwrites.getOverwrites.mockReturnValue({ + accessTokenUrl: 'https://proxy.internal.example.com/token', + }); + + const result = await buildHelper(credentialsOverwrites).applyDefaultsAndOverwrites( + mock({ variables: {} }), + { + clientId: 'shared-client-id', + clientSecret: 'shared-client-secret', + authUrl: 'https://custom.example.com/authorize', + accessTokenUrl: 'https://custom.example.com/token', + }, + managedOAuth2Type.name, + 'internal', + ); + + expect(result).toMatchObject({ + // The admin overwrite wins over both the user value and the type default. + accessTokenUrl: 'https://proxy.internal.example.com/token', + // Fields without an overwrite still pin to the type default. + authUrl: 'https://slack.com/oauth/v2/authorize', + }); + }); + + test('resolves hidden OAuth1 endpoint fields from the credential type for managed credentials', async () => { + registerType(managedOAuth1Type); + const credentialsOverwrites = mock(); + credentialsOverwrites.applyOverwrite.mockImplementation((_type, data) => data); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + + const result = await buildHelper(credentialsOverwrites).applyDefaultsAndOverwrites( + mock({ variables: {} }), + { + consumerKey: 'shared-consumer-key', + consumerSecret: 'shared-consumer-secret', + requestTokenUrl: 'https://custom.example.com/request_token', + accessTokenUrl: 'https://custom.example.com/access_token', + }, + managedOAuth1Type.name, + 'internal', + ); + + expect(result).toMatchObject({ + requestTokenUrl: 'https://api.twitter.com/oauth/request_token', + accessTokenUrl: 'https://api.twitter.com/oauth/access_token', + consumerKey: 'shared-consumer-key', + consumerSecret: 'shared-consumer-secret', + }); + }); + + test('keeps user-provided endpoint fields for non-managed credentials', async () => { + registerType(managedOAuth2Type); + const credentialsOverwrites = mock(); + credentialsOverwrites.applyOverwrite.mockImplementation((_type, data) => data); + credentialsOverwrites.usesManagedAuth.mockReturnValue(false); + + const result = await buildHelper(credentialsOverwrites).applyDefaultsAndOverwrites( + mock({ variables: {} }), + { + clientId: 'user-client-id', + clientSecret: 'user-client-secret', + accessTokenUrl: 'https://custom.example.com/token', + }, + managedOAuth2Type.name, + 'internal', + ); + + expect(result).toMatchObject({ accessTokenUrl: 'https://custom.example.com/token' }); + }); + + test('leaves user-editable endpoint fields untouched for managed credentials', async () => { + const genericOAuth2Type: ICredentialType = { + name: 'oAuth2Api', + displayName: 'OAuth2 API', + properties: [ + { displayName: 'Access Token URL', name: 'accessTokenUrl', type: 'string', default: '' }, + { displayName: 'Client ID', name: 'clientId', type: 'string', default: '' }, + { displayName: 'Client Secret', name: 'clientSecret', type: 'string', default: '' }, + ], + }; + registerType(genericOAuth2Type); + const credentialsOverwrites = mock(); + credentialsOverwrites.applyOverwrite.mockImplementation((_type, data) => data); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + + const result = await buildHelper(credentialsOverwrites).applyDefaultsAndOverwrites( + mock({ variables: {} }), + { + clientId: 'shared-client-id', + clientSecret: 'shared-client-secret', + accessTokenUrl: 'https://custom.example.com/token', + }, + genericOAuth2Type.name, + 'internal', + ); + + expect(result).toMatchObject({ accessTokenUrl: 'https://custom.example.com/token' }); + }); + }); + describe('authenticate', () => { const tests: Array<{ description: string; diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index 86ba1db64e0..a6de29a0041 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -41,7 +41,10 @@ import { import { CredentialTypes } from '@/credential-types'; import { CredentialsOverwrites } from '@/credentials-overwrites'; -import { DCR_MANAGED_CREDENTIAL_FIELDS } from '@/oauth/dcr-managed-fields'; +import { + DCR_MANAGED_CREDENTIAL_FIELDS, + MANAGED_OAUTH_PINNED_FIELDS, +} from '@/oauth/dcr-managed-fields'; import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; import { AiGatewayService } from '@/services/ai-gateway.service'; @@ -654,6 +657,19 @@ export class CredentialsHelper extends ICredentialsHelper { for (const field of DCR_MANAGED_CREDENTIAL_FIELDS) { decryptedData[field] = decryptedDataOriginal[field]; } + } else if (this.credentialsOverwrites.usesManagedAuth(type, decryptedDataOriginal)) { + // For managed credentials the instance owns the OAuth endpoints. Honor an + // admin-configured overwrite for the field, otherwise pin the credential + // type default. The user's stored value is never used. + const overwrites = this.credentialsOverwrites.getOverwrites(type) ?? {}; + for (const field of MANAGED_OAUTH_PINNED_FIELDS) { + const property = credentialsProperties.find((p) => p.name === field && p.type === 'hidden'); + // Pinned endpoint/flow fields always default to a string; anything else is skipped. + if (typeof property?.default !== 'string') continue; + const overwritten = overwrites[field]; + decryptedData[field] = + typeof overwritten === 'string' && overwritten !== '' ? overwritten : property.default; + } } const parsedJsonLeafExpressionFields = this.parseJsonLeafExpressionFields( diff --git a/packages/cli/src/oauth/dcr-managed-fields.ts b/packages/cli/src/oauth/dcr-managed-fields.ts index b93e28b521d..72c401a3ced 100644 --- a/packages/cli/src/oauth/dcr-managed-fields.ts +++ b/packages/cli/src/oauth/dcr-managed-fields.ts @@ -16,6 +16,16 @@ export const DCR_MANAGED_CREDENTIAL_FIELDS = [ export type DcrManagedCredentialField = (typeof DCR_MANAGED_CREDENTIAL_FIELDS)[number]; +/** OAuth endpoint/flow fields the instance owns for a managed credential. */ +export const MANAGED_OAUTH_PINNED_FIELDS = [ + 'authUrl', + 'accessTokenUrl', + 'grantType', + 'authentication', // OAuth2 + 'requestTokenUrl', + 'signatureMethod', // OAuth1 +] as const; + /** Values negotiated for {@link DCR_MANAGED_CREDENTIAL_FIELDS}; `undefined` clears the field. */ export type DcrManagedCredentialValues = { [Field in DcrManagedCredentialField]: OAuth2CredentialData[Field] | undefined;