fix(core): Resolve managed OAuth endpoint fields from the credential type (no-changelog) (#36922)

This commit is contained in:
Andreas Fitzek 2026-08-24 14:52:56 +00:00 committed by GitHub
parent 293e1cc3ad
commit 9ef845a3f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 242 additions and 1 deletions

View File

@ -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<AiGatewayService>(),
);
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>();
credentialsOverwrites.applyOverwrite.mockImplementation((_type, data) => data);
credentialsOverwrites.usesManagedAuth.mockReturnValue(true);
const result = await buildHelper(credentialsOverwrites).applyDefaultsAndOverwrites(
mock<IWorkflowExecuteAdditionalData>({ 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>();
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<IWorkflowExecuteAdditionalData>({ 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>();
credentialsOverwrites.applyOverwrite.mockImplementation((_type, data) => data);
credentialsOverwrites.usesManagedAuth.mockReturnValue(true);
const result = await buildHelper(credentialsOverwrites).applyDefaultsAndOverwrites(
mock<IWorkflowExecuteAdditionalData>({ 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>();
credentialsOverwrites.applyOverwrite.mockImplementation((_type, data) => data);
credentialsOverwrites.usesManagedAuth.mockReturnValue(false);
const result = await buildHelper(credentialsOverwrites).applyDefaultsAndOverwrites(
mock<IWorkflowExecuteAdditionalData>({ 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>();
credentialsOverwrites.applyOverwrite.mockImplementation((_type, data) => data);
credentialsOverwrites.usesManagedAuth.mockReturnValue(true);
const result = await buildHelper(credentialsOverwrites).applyDefaultsAndOverwrites(
mock<IWorkflowExecuteAdditionalData>({ 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;

View File

@ -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(

View File

@ -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;