mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(Embeddings Azure OpenAI Node): Add Entra ID authentication (#39390)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
15d597c4ca
commit
830486a851
|
|
@ -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<SupplyData> {
|
||||
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<string>) | undefined;
|
||||
|
||||
if (authentication === ENTRA_AUTH) {
|
||||
const credential =
|
||||
await this.getCredentials<AzureEntraCognitiveServicesOAuth2ApiCredential>(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<AzureApiKeyCredential>(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<string>` 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 <resource>.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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {};
|
||||
|
||||
constructor(...args: unknown[]) {
|
||||
tokenCredentialSpy(...args);
|
||||
this.credential = (args[1] ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<ISupplyDataFunctions>) => {
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user