mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
fix(core): Resolve managed OAuth endpoint fields from the credential type (no-changelog) (#36922)
This commit is contained in:
parent
293e1cc3ad
commit
9ef845a3f3
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user