feat(core): Make Azure Key Vault provider endpoints configurable (#35214)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marc Littlemore
2026-08-05 10:31:54 +01:00
committed by GitHub
parent 5d813f7ed6
commit 82e22c954c
4 changed files with 255 additions and 65 deletions
@@ -1,4 +1,4 @@
import { AuthenticationError } from '@azure/identity';
import { AuthenticationError, ClientSecretCredential } from '@azure/identity';
import { SecretClient } from '@azure/keyvault-secrets';
import type { KeyVaultSecret } from '@azure/keyvault-secrets';
import type { Logger } from '@n8n/backend-common';
@@ -12,6 +12,17 @@ import type { AzureKeyVaultContext } from '../azure-key-vault/types';
vi.mock('@azure/identity');
vi.mock('@azure/keyvault-secrets');
const baseSettings: AzureKeyVaultContext['settings'] = {
vaultName: 'my-vault',
tenantId: 'my-tenant-id',
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
};
function createSettingsContext(settings: AzureKeyVaultContext['settings']): AzureKeyVaultContext {
return { connected: false, connectedAt: null, settings };
}
function createRestErrorLike(
message: string,
{ statusCode, code }: { statusCode?: number; code?: string },
@@ -86,17 +97,124 @@ describe('AzureKeyVault', () => {
});
});
describe('endpoint resolution', () => {
const ClientSecretCredentialMock = ClientSecretCredential as unknown as Mock;
const SecretClientMock = SecretClient as unknown as Mock;
it('should use commercial endpoints when no environment is set', async () => {
await azureKeyVault.init(createSettingsContext(baseSettings));
await azureKeyVault.connect();
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
'my-tenant-id',
'my-client-id',
'my-client-secret',
{ authorityHost: 'https://login.microsoftonline.com' },
);
expect(SecretClientMock).toHaveBeenCalledWith(
'https://my-vault.vault.azure.net/',
expect.anything(),
);
});
it('should use commercial endpoints for the public environment', async () => {
await azureKeyVault.init(createSettingsContext({ ...baseSettings, environment: 'public' }));
await azureKeyVault.connect();
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
'my-tenant-id',
'my-client-id',
'my-client-secret',
{ authorityHost: 'https://login.microsoftonline.com' },
);
expect(SecretClientMock).toHaveBeenCalledWith(
'https://my-vault.vault.azure.net/',
expect.anything(),
);
});
it('should use US Government endpoints for the usGovernment environment', async () => {
await azureKeyVault.init(
createSettingsContext({ ...baseSettings, environment: 'usGovernment' }),
);
await azureKeyVault.connect();
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
'my-tenant-id',
'my-client-id',
'my-client-secret',
{ authorityHost: 'https://login.microsoftonline.us' },
);
expect(SecretClientMock).toHaveBeenCalledWith(
'https://my-vault.vault.usgovcloudapi.net/',
expect.anything(),
);
});
it('should use China endpoints for the china environment', async () => {
await azureKeyVault.init(createSettingsContext({ ...baseSettings, environment: 'china' }));
await azureKeyVault.connect();
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
'my-tenant-id',
'my-client-id',
'my-client-secret',
{ authorityHost: 'https://login.partner.microsoftonline.cn' },
);
expect(SecretClientMock).toHaveBeenCalledWith(
'https://my-vault.vault.azure.cn/',
expect.anything(),
);
});
it('should use the provided vault URL and authority host for the custom environment', async () => {
await azureKeyVault.init(
createSettingsContext({
...baseSettings,
environment: 'custom',
vaultUrl: 'https://my-vault.keyvault.internal.example.com ',
authorityHost: ' https://login.internal.example.com',
}),
);
await azureKeyVault.connect();
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
'my-tenant-id',
'my-client-id',
'my-client-secret',
{ authorityHost: 'https://login.internal.example.com' },
);
expect(SecretClientMock).toHaveBeenCalledWith(
'https://my-vault.keyvault.internal.example.com',
expect.anything(),
);
});
it('should fall back to the commercial authority host when the custom environment has none', async () => {
await azureKeyVault.init(
createSettingsContext({
...baseSettings,
environment: 'custom',
vaultUrl: 'https://my-vault.keyvault.internal.example.com',
}),
);
await azureKeyVault.connect();
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
'my-tenant-id',
'my-client-id',
'my-client-secret',
{ authorityHost: 'https://login.microsoftonline.com' },
);
expect(SecretClientMock).toHaveBeenCalledWith(
'https://my-vault.keyvault.internal.example.com',
expect.anything(),
);
});
});
it('should log failed client setup while preserving error state', async () => {
await azureKeyVault.init(
mock<AzureKeyVaultContext>({
settings: {
vaultName: 'my-vault',
tenantId: 'my-tenant-id',
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
},
}),
);
await azureKeyVault.init(createSettingsContext(baseSettings));
const setupError = new Error('Invalid configuration');
const SecretClientMock = SecretClient as unknown as Mock;
@@ -121,16 +239,7 @@ describe('AzureKeyVault', () => {
});
it('should log test failures with Azure error context', async () => {
await azureKeyVault.init(
mock<AzureKeyVaultContext>({
settings: {
vaultName: 'my-vault',
tenantId: 'my-tenant-id',
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
},
}),
);
await azureKeyVault.init(createSettingsContext(baseSettings));
await azureKeyVault.connect();
@@ -165,16 +274,7 @@ describe('AzureKeyVault', () => {
/**
* Arrange
*/
await azureKeyVault.init(
mock<AzureKeyVaultContext>({
settings: {
vaultName: 'my-vault',
tenantId: 'my-tenant-id',
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
},
}),
);
await azureKeyVault.init(createSettingsContext(baseSettings));
const listSpy = vi.spyOn(SecretClient.prototype, 'listPropertiesOfSecrets').mockImplementation(
() =>
@@ -213,16 +313,7 @@ describe('AzureKeyVault', () => {
});
it('should skip disabled secrets without calling getSecret', async () => {
await azureKeyVault.init(
mock<AzureKeyVaultContext>({
settings: {
vaultName: 'my-vault',
tenantId: 'my-tenant-id',
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
},
}),
);
await azureKeyVault.init(createSettingsContext(baseSettings));
const listSpy = vi.spyOn(SecretClient.prototype, 'listPropertiesOfSecrets').mockImplementation(
() =>
@@ -249,16 +340,7 @@ describe('AzureKeyVault', () => {
});
it('should still load other secrets when one getSecret fails', async () => {
await azureKeyVault.init(
mock<AzureKeyVaultContext>({
settings: {
vaultName: 'my-vault',
tenantId: 'my-tenant-id',
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
},
}),
);
await azureKeyVault.init(createSettingsContext(baseSettings));
vi.spyOn(SecretClient.prototype, 'listPropertiesOfSecrets').mockImplementation(
() =>
@@ -304,16 +386,7 @@ describe('AzureKeyVault', () => {
});
it('should throw when every getSecret fails and leave the previous cache unchanged', async () => {
await azureKeyVault.init(
mock<AzureKeyVaultContext>({
settings: {
vaultName: 'my-vault',
tenantId: 'my-tenant-id',
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
},
}),
);
await azureKeyVault.init(createSettingsContext(baseSettings));
vi.spyOn(SecretClient.prototype, 'listPropertiesOfSecrets').mockImplementation(
() =>
@@ -5,7 +5,7 @@ import { Container } from '@n8n/di';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import { type INodeProperties, UnexpectedError } from 'n8n-workflow';
import type { AzureKeyVaultContext } from './types';
import type { AzureKeyVaultContext, AzureKeyVaultEnvironment } from './types';
import { DOCS_HELP_NOTICE } from '../../constants';
import {
buildFailureSummaryLogContext,
@@ -22,6 +22,26 @@ type AzureHttpLikeError = Error & {
code?: string;
};
const DEFAULT_AUTHORITY_HOST = 'https://login.microsoftonline.com';
const AZURE_CLOUD_ENDPOINTS: Record<
Exclude<AzureKeyVaultEnvironment, 'custom'>,
{ vaultSuffix: string; authorityHost: string }
> = {
public: {
vaultSuffix: 'vault.azure.net',
authorityHost: DEFAULT_AUTHORITY_HOST,
},
usGovernment: {
vaultSuffix: 'vault.usgovcloudapi.net',
authorityHost: 'https://login.microsoftonline.us',
},
china: {
vaultSuffix: 'vault.azure.cn',
authorityHost: 'https://login.partner.microsoftonline.cn',
},
};
export class AzureKeyVault extends SecretsProvider {
name = 'azureKeyVault';
@@ -29,6 +49,40 @@ export class AzureKeyVault extends SecretsProvider {
properties: INodeProperties[] = [
DOCS_HELP_NOTICE,
{
displayName: 'Azure Cloud',
name: 'environment',
hint: 'The Azure cloud environment your Key Vault is hosted in.',
type: 'options',
options: [
{
name: 'Azure Public Cloud',
value: 'public',
description:
'Uses <code>vault.azure.net</code> and <code>login.microsoftonline.com</code>',
},
{
name: 'Azure US Government',
value: 'usGovernment',
description:
'Uses <code>vault.usgovcloudapi.net</code> and <code>login.microsoftonline.us</code>',
},
{
name: 'Azure China',
value: 'china',
description:
'Uses <code>vault.azure.cn</code> and <code>login.partner.microsoftonline.cn</code>',
},
{
name: 'Custom',
value: 'custom',
description:
'Provide the vault URL and authority host directly, for setups such as Azure Stack or proxied environments',
},
],
default: 'public',
noDataExpression: true,
},
{
displayName: 'Vault Name',
hint: 'The name of your existing Azure Key Vault.',
@@ -38,6 +92,26 @@ export class AzureKeyVault extends SecretsProvider {
required: true,
placeholder: 'e.g. my-vault',
noDataExpression: true,
displayOptions: {
hide: {
environment: ['custom'],
},
},
},
{
displayName: 'Vault URL',
hint: 'The full URL of your existing Azure Key Vault.',
name: 'vaultUrl',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. https://my-vault.vault.usgovcloudapi.net',
noDataExpression: true,
displayOptions: {
show: {
environment: ['custom'],
},
},
},
{
displayName: 'Tenant ID',
@@ -70,6 +144,20 @@ export class AzureKeyVault extends SecretsProvider {
typeOptions: { password: true },
noDataExpression: true,
},
{
displayName: 'Authority Host',
hint: 'The Microsoft Entra authority to authenticate against. Leave empty to use the default (https://login.microsoftonline.com).',
name: 'authorityHost',
type: 'string',
default: '',
placeholder: 'e.g. https://login.microsoftonline.us',
noDataExpression: true,
displayOptions: {
show: {
environment: ['custom'],
},
},
},
];
private cachedSecrets: Record<string, string> = {};
@@ -91,14 +179,17 @@ export class AzureKeyVault extends SecretsProvider {
protected async doConnect(): Promise<void> {
try {
const { vaultName, tenantId, clientId, clientSecret } = this.settings;
const { tenantId, clientId, clientSecret } = this.settings;
const { vaultUrl, authorityHost } = this.resolveEndpoints();
const { ClientSecretCredential } = await import('@azure/identity');
const { SecretClient } = await import('@azure/keyvault-secrets');
// TODO: Not routed through OutboundHttp for now. It would require `@azure/core-rest-pipeline`, which is not worth it just to share agents.
const credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
this.client = new SecretClient(`https://${vaultName}.vault.azure.net/`, credential);
const credential = new ClientSecretCredential(tenantId, clientId, clientSecret, {
authorityHost,
});
this.client = new SecretClient(vaultUrl, credential);
this.logger.debug('Azure Key Vault provider connected');
} catch (error) {
@@ -111,6 +202,24 @@ export class AzureKeyVault extends SecretsProvider {
}
}
private resolveEndpoints(): { vaultUrl: string; authorityHost: string } {
const { environment = 'public', vaultName, vaultUrl, authorityHost } = this.settings;
if (environment === 'custom') {
const trimmedAuthorityHost = authorityHost?.trim();
return {
vaultUrl: (vaultUrl ?? '').trim(),
authorityHost: trimmedAuthorityHost ? trimmedAuthorityHost : DEFAULT_AUTHORITY_HOST,
};
}
const cloudEndpoints = AZURE_CLOUD_ENDPOINTS[environment];
return {
vaultUrl: `https://${vaultName}.${cloudEndpoints.vaultSuffix}/`,
authorityHost: cloudEndpoints.authorityHost,
};
}
async test(): Promise<[boolean] | [boolean, string]> {
if (!this.client) return [false, 'Failed to connect to Azure Key Vault'];
@@ -1,8 +1,13 @@
import type { SecretsProviderSettings } from '../../types';
export type AzureKeyVaultEnvironment = 'public' | 'usGovernment' | 'china' | 'custom';
export type AzureKeyVaultContext = SecretsProviderSettings<{
vaultName: string;
tenantId: string;
clientId: string;
clientSecret: string;
environment?: AzureKeyVaultEnvironment;
vaultUrl?: string;
authorityHost?: string;
}>;
@@ -29,6 +29,9 @@ const defaultProviderData: Record<string, Partial<ExternalSecretsProviderData>>
infisical: {
siteURL: 'https://app.infisical.com',
},
azureKeyVault: {
environment: 'public',
},
};
const externalSecretsStore = useExternalSecretsStore();