diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts index d36b0e722df..2267867469a 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts @@ -1,3 +1,4 @@ +import { getBearerTokenProvider } from '@azure/identity'; import { AzureOpenAIEmbeddings, OpenAIEmbeddings } from '@langchain/openai'; import { getProxyAgent, @@ -6,14 +7,63 @@ import { getConnectionHintNoticeField, } from '@n8n/ai-utilities'; import { + assertUrlAllowed, + getCredentialAllowedDomains, NodeConnectionTypes, NodeOperationError, + type ICredentialDataDecryptedObject, + type INode, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; +import { N8nOAuth2TokenCredential } from '../../llms/LmChatAzureOpenAi/credentials/N8nOAuth2TokenCredential'; +import { + AuthenticationType, + AZURE_OPENAI_INFERENCE_SCOPE, +} from '../../llms/LmChatAzureOpenAi/types'; +import type { AzureEntraCognitiveServicesOAuth2ApiCredential } from '../../llms/LmChatAzureOpenAi/types'; + +const API_KEY_AUTH = AuthenticationType.ApiKey; +const ENTRA_AUTH = AuthenticationType.EntraOAuth2; + +type AzureApiKeyCredential = { + apiKey: string; + resourceName?: string; + apiVersion?: string; + endpoint?: string; + endpointType?: 'classic' | 'foundry'; + foundryEndpoint?: string; +}; + +/** The endpoint and deployment details both credential types have to supply. */ +type AzureTarget = Pick< + AzureApiKeyCredential, + 'resourceName' | 'apiVersion' | 'endpoint' | 'endpointType' | 'foundryEndpoint' +>; + +/** + * Applies the credential's domain restriction to a host the credential itself supplies. + * + * `'none'` is a no-op here. It means "do not use this credential in the HTTP Request node", + * and the editor writes it into every credential made through its OAuth flow, so honouring it + * would stop the node the credential belongs to. An explicit `'domains'` list still applies. + */ +function assertCredentialAllowsHost( + node: INode, + credentialData: ICredentialDataDecryptedObject, + url: string, +): void { + const allowedDomains = getCredentialAllowedDomains({ + node, + credentialData, + credentialOwnedSurface: true, + }); + assertUrlAllowed({ url, allowedDomains, node }); +} + export class EmbeddingsAzureOpenAi implements INodeType { description: INodeTypeDescription = { displayName: 'Microsoft Foundry Embeddings', @@ -21,8 +71,22 @@ export class EmbeddingsAzureOpenAi implements INodeType { icon: 'file:azure.svg', credentials: [ { - name: 'azureOpenAiApi', + name: API_KEY_AUTH, required: true, + displayOptions: { + show: { + authentication: [API_KEY_AUTH], + }, + }, + }, + { + name: ENTRA_AUTH, + required: true, + displayOptions: { + show: { + authentication: [ENTRA_AUTH], + }, + }, }, ], group: ['transform'], @@ -60,6 +124,22 @@ export class EmbeddingsAzureOpenAi implements INodeType { outputs: [NodeConnectionTypes.AiEmbedding], outputNames: ['Embeddings'], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + default: API_KEY_AUTH, + options: [ + { + name: 'API Key', + value: API_KEY_AUTH, + }, + { + name: 'Azure Entra ID (OAuth2)', + value: ENTRA_AUTH, + }, + ], + }, getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]), { displayName: 'Model (Deployment) Name', @@ -136,14 +216,52 @@ export class EmbeddingsAzureOpenAi implements INodeType { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { this.logger.debug('Supply data for embeddings'); - const credentials = await this.getCredentials<{ - apiKey: string; - resourceName?: string; - apiVersion?: string; - endpoint?: string; - endpointType?: 'classic' | 'foundry'; - foundryEndpoint?: string; - }>('azureOpenAiApi'); + const authentication = this.getNodeParameter('authentication', itemIndex, API_KEY_AUTH) as + | typeof API_KEY_AUTH + | typeof ENTRA_AUTH; + + let target: AzureTarget; + // Typed credential shapes have no index signature, so they need widening + // to reach the restriction check. Same form as core's oauth.ts. + let credentialData: ICredentialDataDecryptedObject; + // Exactly one of these is set. The API key is a string the client keeps; the token provider + // is a function the client calls before each request, so a long run never reuses a stale + // token. + let apiKey: string | undefined; + let tokenProvider: (() => Promise) | undefined; + + if (authentication === ENTRA_AUTH) { + const credential = + await this.getCredentials(ENTRA_AUTH); + const entraCredential = new N8nOAuth2TokenCredential( + this.getNode(), + credential, + undefined, + this.helpers.getSecureEgressFilter(), + ); + const deployment = await entraCredential.getDeploymentDetails(); + + target = { + resourceName: deployment.resourceName, + apiVersion: deployment.apiVersion, + endpoint: deployment.endpoint, + endpointType: credential.endpointType, + foundryEndpoint: credential.foundryEndpoint, + }; + tokenProvider = getBearerTokenProvider(entraCredential, AZURE_OPENAI_INFERENCE_SCOPE); + credentialData = credential as unknown as ICredentialDataDecryptedObject; + } else { + const credential = await this.getCredentials(API_KEY_AUTH); + target = credential; + apiKey = credential.apiKey; + credentialData = credential as unknown as ICredentialDataDecryptedObject; + } + + const credentialLabel = + authentication === ENTRA_AUTH + ? 'Microsoft Foundry (Entra ID)' + : 'Microsoft Foundry (API Key)'; + const modelName = this.getNodeParameter('model', itemIndex) as string; const options = this.getNodeParameter('options', itemIndex, {}) as { @@ -157,16 +275,19 @@ export class EmbeddingsAzureOpenAi implements INodeType { options.timeout = undefined; } - if (credentials.endpointType === 'foundry') { - const foundryURL = credentials.foundryEndpoint?.trim(); + if (target.endpointType === 'foundry') { + const foundryURL = target.foundryEndpoint?.trim(); if (!foundryURL) { throw new NodeOperationError( this.getNode(), - 'Foundry endpoint is missing in the selected Microsoft Foundry (API Key) credential.', + `Foundry endpoint is missing in the selected ${credentialLabel} credential.`, ); } + assertCredentialAllowsHost(this.getNode(), credentialData, foundryURL); + const embeddings = new OpenAIEmbeddings({ - apiKey: credentials.apiKey, + // The openai client accepts a `() => Promise` here and calls it per request + apiKey: tokenProvider ?? apiKey, model: modelName, configuration: { baseURL: foundryURL, @@ -183,25 +304,40 @@ export class EmbeddingsAzureOpenAi implements INodeType { }; } + // A classic deployment addresses .openai.azure.com/...?api-version=. An empty + // value for either reaches Azure as a malformed URL, so say which field is missing. + if (!target.endpoint?.trim() && !target.resourceName?.trim()) { + throw new NodeOperationError( + this.getNode(), + `Resource Name is missing in the selected ${credentialLabel} credential.`, + ); + } + if (!target.apiVersion?.trim()) { + throw new NodeOperationError( + this.getNode(), + `API Version is missing in the selected ${credentialLabel} credential.`, + ); + } + + // `||` rather than `??`, so an endpoint that is set but empty also falls back. + const dialledHost = target.endpoint || `https://${target.resourceName}.openai.azure.com`; + assertCredentialAllowsHost(this.getNode(), credentialData, dialledHost); + const embeddings = new AzureOpenAIEmbeddings({ azureOpenAIApiDeploymentName: modelName, // instance name only needed to set base url - azureOpenAIApiInstanceName: !credentials.endpoint ? credentials.resourceName : undefined, - azureOpenAIApiKey: credentials.apiKey, - azureOpenAIApiVersion: credentials.apiVersion, + azureOpenAIApiInstanceName: !target.endpoint ? target.resourceName : undefined, + azureOpenAIApiKey: apiKey, + azureADTokenProvider: tokenProvider, + azureOpenAIApiVersion: target.apiVersion, // azureOpenAIEndpoint and configuration.baseURL are both ignored here // only setting azureOpenAIBasePath worked - azureOpenAIBasePath: credentials.endpoint - ? `${credentials.endpoint}/openai/deployments` - : undefined, + azureOpenAIBasePath: target.endpoint ? `${target.endpoint}/openai/deployments` : undefined, configuration: { fetch: aiClientFetch, fetchOptions: { - dispatcher: getProxyAgent( - credentials.endpoint ?? `https://${credentials.resourceName}.openai.azure.com`, - {}, - this.helpers.getSecureEgressFilter(), - ), + // Resolve the proxy against the host LangChain dials so NO_PROXY applies to it. + dispatcher: getProxyAgent(dialledHost, {}, this.helpers.getSecureEgressFilter()), }, }, ...options, diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/test/EmbeddingsAzureOpenAi.test.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/test/EmbeddingsAzureOpenAi.test.ts index 4d6ecada721..7a60f970047 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/test/EmbeddingsAzureOpenAi.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/test/EmbeddingsAzureOpenAi.test.ts @@ -1,11 +1,44 @@ import { AzureOpenAIEmbeddings, OpenAIEmbeddings } from '@langchain/openai'; -import { aiClientFetch } from '@n8n/ai-utilities'; +import { aiClientFetch, getProxyAgent } from '@n8n/ai-utilities'; import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; import type { Mocked } from 'vitest'; import { EmbeddingsAzureOpenAi } from '../EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node'; +const tokenCredentialSpy = vi.fn(); + +// Records the constructor arguments but still derives the deployment from the credential it was +// given, so the other tests keep asserting real mapping rather than a fixed stub. +vi.mock('../../llms/LmChatAzureOpenAi/credentials/N8nOAuth2TokenCredential', () => ({ + N8nOAuth2TokenCredential: class N8nOAuth2TokenCredentialMock { + private credential: Record = {}; + + constructor(...args: unknown[]) { + tokenCredentialSpy(...args); + this.credential = (args[1] ?? {}) as Record; + } + + getDeploymentDetails = async () => { + const c = this.credential; + if (c.endpointType === 'foundry') { + return { + apiVersion: c.apiVersion ?? '', + endpoint: c.foundryEndpoint, + resourceName: c.resourceName ?? '', + endpointType: 'foundry' as const, + foundryEndpoint: c.foundryEndpoint, + }; + } + return { + apiVersion: c.apiVersion ?? '', + endpoint: c.endpoint, + resourceName: c.resourceName ?? '', + }; + }; + }, +})); + vi.mock('@langchain/openai'); class MockProxyAgent {} @@ -126,6 +159,86 @@ describe('AzureOpenAIEmbeddings', () => { ); }); + describe('credential domain restrictions', () => { + const restricted = { + allowedHttpRequestDomains: 'domains', + allowedDomains: 'allowed.example.com', + }; + + it.each([ + [ + 'a Foundry endpoint', + { endpointType: 'foundry', foundryEndpoint: 'https://evil.example.com/openai/v1' }, + ], + ['a classic endpoint', { endpoint: 'https://evil.example.com', apiVersion: 'v1' }], + ['a resource-derived classic host', { resourceName: 'evil', apiVersion: 'v1' }], + ])('refuses to send the token to %s outside the allowed domains', async (_name, target) => { + const mockContext = setupMockContext(); + mockContext.getCredentials.mockResolvedValue({ + apiKey: 'test-api-key', + ...restricted, + ...target, + }); + mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'text-embedding-3-large'; + if (paramName === 'options') return {}; + return undefined; + }); + + await expect(embeddingsAzureOpenAi.supplyData.call(mockContext, 0)).rejects.toThrow( + 'Domain not allowed', + ); + }); + + // 'none' means "do not use this credential in the HTTP Request node". The editor + // writes it into every credential made through its OAuth flow, so blocking on it + // here would stop the node the credential belongs to. + it.each([ + [ + 'a Foundry endpoint', + { + endpointType: 'foundry', + foundryEndpoint: 'https://test.services.ai.azure.com/openai/v1', + }, + ], + [ + 'a classic endpoint', + { endpoint: 'https://test-resource-name.openai.azure.com', apiVersion: 'v1' }, + ], + ])('still reaches %s when the restriction is None', async (_name, target) => { + const mockContext = setupMockContext(); + mockContext.getCredentials.mockResolvedValue({ + apiKey: 'test-api-key', + allowedHttpRequestDomains: 'none', + ...target, + }); + mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'text-embedding-3-large'; + if (paramName === 'options') return {}; + return undefined; + }); + + await expect(embeddingsAzureOpenAi.supplyData.call(mockContext, 0)).resolves.toBeDefined(); + }); + + it('allows a host the credential permits', async () => { + const mockContext = setupMockContext(); + mockContext.getCredentials.mockResolvedValue({ + apiKey: 'test-api-key', + ...restricted, + endpointType: 'foundry', + foundryEndpoint: 'https://allowed.example.com/openai/v1', + }); + mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'text-embedding-3-large'; + if (paramName === 'options') return {}; + return undefined; + }); + + await expect(embeddingsAzureOpenAi.supplyData.call(mockContext, 0)).resolves.toBeDefined(); + }); + }); + it('should use OpenAIEmbeddings against the Foundry base URL', async () => { const mockContext = setupMockContext(); const MockedOpenAIEmbeddings = vi.mocked(OpenAIEmbeddings); @@ -157,6 +270,248 @@ describe('AzureOpenAIEmbeddings', () => { expect(MockedAzureOpenAIEmbeddings).not.toHaveBeenCalled(); }); + describe('with Entra ID', () => { + const entraCredential = { + clientId: 'client-id', + clientSecret: 'client-secret', + accessTokenUrl: 'https://login.microsoftonline.com/tenant/oauth2/token', + authentication: 'body', + scope: '', + tenantId: 'tenant', + }; + + const selectEntra = (context: Mocked) => { + context.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'authentication') return 'azureEntraCognitiveServicesOAuth2Api'; + if (paramName === 'model') return 'text-embedding-3-large'; + if (paramName === 'options') return {}; + return undefined; + }); + }; + + // The Entra branch is the one this PR adds the egress check to, so it needs its own + // cases. The API-key copies cannot cover it: they dial a different code path. + describe('credential domain restrictions', () => { + const restricted = { + allowedHttpRequestDomains: 'domains', + allowedDomains: 'allowed.example.com', + }; + + it.each([ + [ + 'a Foundry endpoint', + { endpointType: 'foundry', foundryEndpoint: 'https://evil.example.com/openai/v1' }, + ], + ['a classic endpoint', { endpoint: 'https://evil.example.com', apiVersion: 'v1' }], + ['a resource-derived classic host', { resourceName: 'evil', apiVersion: 'v1' }], + ])('refuses to send the token to %s outside the allowed domains', async (_name, target) => { + const mockContext = setupMockContext(); + mockContext.getCredentials.mockResolvedValue({ + ...entraCredential, + ...restricted, + ...target, + }); + selectEntra(mockContext); + + await expect(embeddingsAzureOpenAi.supplyData.call(mockContext, 0)).rejects.toThrow( + 'Domain not allowed', + ); + }); + + // 'none' means "do not use this credential in the HTTP Request node". The editor + // writes it into every credential made through its OAuth flow, so blocking on it + // here would stop the node the credential belongs to. + it.each([ + [ + 'a Foundry endpoint', + { + endpointType: 'foundry', + foundryEndpoint: 'https://test.services.ai.azure.com/openai/v1', + }, + ], + [ + 'a classic endpoint', + { endpoint: 'https://test-resource-name.openai.azure.com', apiVersion: 'v1' }, + ], + ])('still reaches %s when the restriction is None', async (_name, target) => { + const mockContext = setupMockContext(); + mockContext.getCredentials.mockResolvedValue({ + ...entraCredential, + allowedHttpRequestDomains: 'none', + ...target, + }); + selectEntra(mockContext); + + await expect( + embeddingsAzureOpenAi.supplyData.call(mockContext, 0), + ).resolves.toBeDefined(); + }); + + it('allows a host the credential permits', async () => { + const mockContext = setupMockContext(); + mockContext.getCredentials.mockResolvedValue({ + ...entraCredential, + ...restricted, + endpointType: 'foundry', + foundryEndpoint: 'https://allowed.example.com/openai/v1', + }); + selectEntra(mockContext); + + await expect( + embeddingsAzureOpenAi.supplyData.call(mockContext, 0), + ).resolves.toBeDefined(); + }); + }); + + it('should pass a token provider to a classic deployment, and no API key', async () => { + const mockContext = setupMockContext(); + mockContext.getCredentials.mockResolvedValue({ + ...entraCredential, + endpoint: 'https://test-resource-name.openai.azure.com', + apiVersion: 'v1', + }); + selectEntra(mockContext); + + await embeddingsAzureOpenAi.supplyData.call(mockContext, 0); + + expect(MockedAzureOpenAIEmbeddings).toHaveBeenCalledWith( + expect.objectContaining({ + azureADTokenProvider: expect.any(Function), + azureOpenAIApiKey: undefined, + azureOpenAIBasePath: 'https://test-resource-name.openai.azure.com/openai/deployments', + }), + ); + }); + + // A function, not an awaited string: the client calls it per request, so a long run never + // sends a token that has since expired. + it('should pass a token provider as the Foundry apiKey, not a resolved string', async () => { + const mockContext = setupMockContext(); + const MockedOpenAIEmbeddings = vi.mocked(OpenAIEmbeddings); + mockContext.getCredentials.mockResolvedValue({ + ...entraCredential, + endpointType: 'foundry', + foundryEndpoint: 'https://test.services.ai.azure.com/openai/v1', + }); + selectEntra(mockContext); + + await embeddingsAzureOpenAi.supplyData.call(mockContext, 0); + + const config = MockedOpenAIEmbeddings.mock.calls[0][0]; + expect(typeof config?.apiKey).toBe('function'); + expect(config?.model).toBe('text-embedding-3-large'); + expect(config?.configuration).toEqual( + expect.objectContaining({ baseURL: 'https://test.services.ai.azure.com/openai/v1' }), + ); + expect(MockedAzureOpenAIEmbeddings).not.toHaveBeenCalled(); + }); + + // The host has to come from the resource name whether the endpoint is absent or blank. + // The blank case is the one that needs `||`: an empty string is falsy but not nullish, + // so `??` would pass it straight through to the proxy. + it.each([ + ['absent', {}], + ['blank', { endpoint: '' }], + ])( + 'should resolve the proxy against the resource host when the endpoint is %s', + async (_, endpoint) => { + const mockContext = setupMockContext(); + mockContext.getCredentials.mockResolvedValue({ + ...entraCredential, + ...endpoint, + resourceName: 'my-resource', + apiVersion: 'v1', + }); + selectEntra(mockContext); + + await embeddingsAzureOpenAi.supplyData.call(mockContext, 0); + + expect(vi.mocked(getProxyAgent)).toHaveBeenCalledWith( + 'https://my-resource.openai.azure.com', + expect.any(Object), + expect.any(Object), + ); + }, + ); + + // The mint posts the client secret to a stored URL, so it runs inside the egress policy, + // as the chat model node's does. + it('should hand the egress filter to the token credential', async () => { + const mockContext = setupMockContext(); + mockContext.getCredentials.mockResolvedValue({ + ...entraCredential, + resourceName: 'my-resource', + apiVersion: 'v1', + }); + selectEntra(mockContext); + + await embeddingsAzureOpenAi.supplyData.call(mockContext, 0); + + expect(tokenCredentialSpy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + mockContext.helpers.getSecureEgressFilter(), + ); + }); + + it('should read the Entra credential, not the API key one', async () => { + const mockContext = setupMockContext(); + mockContext.getCredentials.mockResolvedValue({ + ...entraCredential, + endpoint: 'https://test-resource-name.openai.azure.com', + apiVersion: 'v1', + }); + selectEntra(mockContext); + + await embeddingsAzureOpenAi.supplyData.call(mockContext, 0); + + expect(mockContext.getCredentials).toHaveBeenCalledWith( + 'azureEntraCognitiveServicesOAuth2Api', + ); + expect(mockContext.getCredentials).not.toHaveBeenCalledWith('azureOpenAiApi'); + }); + }); + + // Existing nodes have no `authentication` parameter stored. The Workflow constructor fills + // this default into node.parameters, and getCredentials gates the API key credential on it, + // so changing it would point every saved node at a credential it does not have. + it('should default authentication to the API key, for nodes saved before the selector', () => { + const authentication = new EmbeddingsAzureOpenAi().description.properties.find( + (p) => p.name === 'authentication', + ); + + expect(authentication?.default).toBe('azureOpenAiApi'); + }); + + it.each([ + [ + 'no resource name or endpoint', + { apiKey: 'test-api-key', apiVersion: 'v1' }, + 'Resource Name is missing in the selected Microsoft Foundry (API Key) credential.', + ], + [ + 'no API version', + { apiKey: 'test-api-key', resourceName: 'my-resource' }, + 'API Version is missing in the selected Microsoft Foundry (API Key) credential.', + ], + ])( + 'should say which field is missing when a classic credential has %s', + async (_, credential, message) => { + const mockContext = setupMockContext(); + mockContext.getCredentials.mockResolvedValue(credential); + mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'text-embedding-3-large'; + if (paramName === 'options') return {}; + return undefined; + }); + + await expect(embeddingsAzureOpenAi.supplyData.call(mockContext, 0)).rejects.toThrow( + message, + ); + }, + ); + it('should reject a Foundry credential that has no endpoint', async () => { const mockContext = setupMockContext(); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/credentials/oauth2.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/credentials/oauth2.ts index a8ea1db0c89..16c1a0931bc 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/credentials/oauth2.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/credentials/oauth2.ts @@ -8,9 +8,8 @@ import type { AzureEntraCognitiveServicesOAuth2ApiCredential, AzureOpenAIOAuth2ModelConfig, } from '../types'; -import { AZURE_OPENAI_INFERENCE_AUDIENCE } from '../types'; +import { AZURE_OPENAI_INFERENCE_SCOPE } from '../types'; -const AZURE_OPENAI_SCOPE = `${AZURE_OPENAI_INFERENCE_AUDIENCE}/.default`; /** * Creates Entra ID (OAuth2) authentication for Azure OpenAI */ @@ -32,7 +31,10 @@ export async function setupOAuth2Authentication( // getBearerTokenProvider caches the token across calls. It requires a scope, but the // audience comes from the credential above; the v1.0 endpoint reads `resource`, not `scope`. - const azureADTokenProvider = getBearerTokenProvider(entraTokenCredential, AZURE_OPENAI_SCOPE); + const azureADTokenProvider = getBearerTokenProvider( + entraTokenCredential, + AZURE_OPENAI_INFERENCE_SCOPE, + ); this.logger.debug('Successfully created Azure AD Token Provider.'); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/types.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/types.ts index a7ef1e0f5d7..e572e1cd11f 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/types.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/types.ts @@ -68,6 +68,13 @@ export interface AzureOpenAIOAuth2ModelConfig extends AzureOpenAIBaseModelConfig /** Audience for the node's inference requests. */ export const AZURE_OPENAI_INFERENCE_AUDIENCE = 'https://cognitiveservices.azure.com'; +/** + * The audience in the form `getBearerTokenProvider` takes. It is only required by that API: + * `N8nOAuth2TokenCredential` ignores the scopes it is handed and asks for the audience as the + * `resource` body parameter, which is what the v1.0 token endpoint reads. + */ +export const AZURE_OPENAI_INFERENCE_SCOPE = `${AZURE_OPENAI_INFERENCE_AUDIENCE}/.default`; + /** * Audience for the Foundry deployments-list call. Each tenant's Entra ID app * registration grants access per audience, so this is requested only for the