Skip to content

Commit d405737

Browse files
authored
Python: Support DefaultAzureCredential Auth for Azure Resources (microsoft#8711)
### Motivation and Context For security best practices, we're moving away from api key based auth for Azure resources like Azure OpenAI. Previously, all tests and samples run with an api_key only, handled via the env var, the .env file, or directly passed into the AzureChatCompletion constructor. If the api_key is not found once the Azure Settings are loaded, we will attempt to retrieve a token via the DefaultAzureCredential. If it succeeds, we use the `ad_token`; otherwise, if the token is empty (meaning we failed to retrieve it), Pydantic validation will fail as part of service construction, just as it does today. The user may configure a `TOKEN_ENDPOINT` (for example, `AZURE_OPENAI_TOKEN_ENDPOINT`) to override the default token endpoint used for AzureOpenAI, if required. This is also possible for the Python Sessions Container Apps plugin via `ACA_TOKEN_ENDPOINT`, although the default value is specified and works well if the user has the proper role on the resource. <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> ### Description This PR: - allows our GitHub integration tests to run off of the DefaultAzureCredential. - allows a user to retrieve a token via DefaultAzureCredential so they don't need to manage api keys for Azure Resources. - closes: microsoft#6512 <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [X] I didn't break anyone 😄
1 parent 9d28474 commit d405737

19 files changed

Lines changed: 207 additions & 111 deletions

File tree

‎.github/workflows/python-integration-tests.yml‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ on:
1515

1616
permissions:
1717
contents: read
18+
id-token: "write"
1819

1920
env:
2021
# Configure a constant location for the uv cache
@@ -46,7 +47,6 @@ jobs:
4647
name: Python Pre-Merge Integration Tests
4748
needs: paths-filter
4849
if: github.event_name != 'pull_request' && github.event_name != 'schedule' && needs.paths-filter.outputs.pythonChanges == 'true'
49-
runs-on: ${{ matrix.os }}
5050
strategy:
5151
max-parallel: 1
5252
fail-fast: false
@@ -56,6 +56,8 @@ jobs:
5656
defaults:
5757
run:
5858
working-directory: python
59+
runs-on: ${{ matrix.os }}
60+
environment: "integration"
5961
steps:
6062
- uses: actions/checkout@v4
6163
- name: Set up uv
@@ -109,6 +111,13 @@ jobs:
109111
- name: Setup Redis Stack Server
110112
if: matrix.os == 'ubuntu-latest'
111113
run: docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest
114+
- name: Azure CLI Login
115+
if: github.event_name != 'pull_request'
116+
uses: azure/login@v2
117+
with:
118+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
119+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
120+
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
112121
- name: Run Integration Tests
113122
id: run_tests
114123
shell: bash
@@ -121,6 +130,7 @@ jobs:
121130
AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION }}
122131
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
123132
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
133+
AZURE_OPENAI_TOKEN_ENDPOINT: ${{ vars.AZURE_OPENAI_TOKEN_ENDPOINT }}
124134
BING_API_KEY: ${{ secrets.BING_API_KEY }}
125135
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_ID }}
126136
OPENAI_TEXT_MODEL_ID: ${{ vars.OPENAI_TEXT_MODEL_ID }}
@@ -149,7 +159,7 @@ jobs:
149159
VERTEX_AI_GEMINI_MODEL_ID: ${{ vars.VERTEX_AI_GEMINI_MODEL_ID }}
150160
VERTEX_AI_EMBEDDING_MODEL_ID: ${{ vars.VERTEX_AI_EMBEDDING_MODEL_ID }}
151161
REDIS_CONNECTION_STRING: ${{ vars.REDIS_CONNECTION_STRING }}
152-
run: |
162+
run: |
153163
uv run pytest -n logical --dist loadfile --dist worksteal ./tests/integration ./tests/samples -v --junitxml=pytest.xml
154164
- name: Surface failing tests
155165
if: always()
@@ -166,7 +176,6 @@ jobs:
166176
python-integration-tests:
167177
needs: paths-filter
168178
if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && needs.paths-filter.outputs.pythonChanges == 'true'
169-
runs-on: ${{ matrix.os }}
170179
strategy:
171180
max-parallel: 1
172181
fail-fast: false
@@ -176,6 +185,8 @@ jobs:
176185
defaults:
177186
run:
178187
working-directory: python
188+
runs-on: ${{ matrix.os }}
189+
environment: "integration"
179190
steps:
180191
- uses: actions/checkout@v4
181192
- name: Set up uv
@@ -225,14 +236,21 @@ jobs:
225236
project_id: ${{ vars.VERTEX_AI_PROJECT_ID }}
226237
credentials_json: ${{ secrets.VERTEX_AI_SERVICE_ACCOUNT_KEY }}
227238
- name: Set up gcloud
228-
uses: google-github-actions/setup-gcloud@v2
239+
uses: google-github-actions/setup-gcloud@v2
229240
- name: Setup Redis Stack Server
230241
if: matrix.os == 'ubuntu-latest'
231242
run: docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest
243+
- name: Azure CLI Login
244+
if: github.event_name != 'pull_request' && matrix.integration-tests
245+
uses: azure/login@v2
246+
with:
247+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
248+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
249+
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
232250
- name: Run Integration Tests
233251
id: run_tests
234252
shell: bash
235-
env:
253+
env:
236254
HNSWLIB_NO_NATIVE: 1
237255
Python_Integration_Tests: Python_Integration_Tests
238256
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} # azure-text-embedding-ada-002
@@ -241,6 +259,7 @@ jobs:
241259
AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION }}
242260
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
243261
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
262+
AZURE_OPENAI_TOKEN_ENDPOINT: ${{ vars.AZURE_OPENAI_TOKEN_ENDPOINT }}
244263
BING_API_KEY: ${{ secrets.BING_API_KEY }}
245264
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_ID }}
246265
OPENAI_TEXT_MODEL_ID: ${{ vars.OPENAI_TEXT_MODEL_ID }}

‎python/samples/concepts/auto_function_calling/azure_python_code_interpreter_function_calling.py‎

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33
import asyncio
4-
import datetime
5-
6-
from azure.core.credentials import AccessToken
7-
from azure.core.exceptions import ClientAuthenticationError
8-
from azure.identity import DefaultAzureCredential
94

105
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
116
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.azure_chat_prompt_execution_settings import (
@@ -15,37 +10,9 @@
1510
from semantic_kernel.contents.chat_history import ChatHistory
1611
from semantic_kernel.core_plugins.sessions_python_tool.sessions_python_plugin import SessionsPythonTool
1712
from semantic_kernel.core_plugins.time_plugin import TimePlugin
18-
from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException
1913
from semantic_kernel.functions.kernel_arguments import KernelArguments
2014
from semantic_kernel.kernel import Kernel
2115

22-
auth_token: AccessToken | None = None
23-
24-
ACA_TOKEN_ENDPOINT: str = "https://acasessions.io/.default" # nosec
25-
26-
27-
async def auth_callback() -> str:
28-
"""Auth callback for the SessionsPythonTool.
29-
This is a sample auth callback that shows how to use Azure's DefaultAzureCredential
30-
to get an access token.
31-
"""
32-
global auth_token
33-
current_utc_timestamp = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
34-
35-
if not auth_token or auth_token.expires_on < current_utc_timestamp:
36-
credential = DefaultAzureCredential()
37-
38-
try:
39-
auth_token = credential.get_token(ACA_TOKEN_ENDPOINT)
40-
except ClientAuthenticationError as cae:
41-
err_messages = getattr(cae, "messages", [])
42-
raise FunctionExecutionException(
43-
f"Failed to retrieve the client auth token with messages: {' '.join(err_messages)}"
44-
) from cae
45-
46-
return auth_token.token
47-
48-
4916
kernel = Kernel()
5017

5118
service_id = "sessions-tool"
@@ -54,9 +21,7 @@ async def auth_callback() -> str:
5421
)
5522
kernel.add_service(chat_service)
5623

57-
sessions_tool = SessionsPythonTool(
58-
auth_callback=auth_callback,
59-
)
24+
sessions_tool = SessionsPythonTool()
6025

6126
kernel.add_plugin(sessions_tool, "SessionsTool")
6227
kernel.add_plugin(TimePlugin(), "Time")

‎python/semantic_kernel/connectors/ai/open_ai/const.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
from typing import Final
44

5-
DEFAULT_AZURE_API_VERSION: Final[str] = "2024-02-01"
5+
DEFAULT_AZURE_API_VERSION: Final[str] = "2024-06-01"

‎python/semantic_kernel/connectors/ai/open_ai/services/azure_chat_completion.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def __init__(
4848
api_version: str | None = None,
4949
ad_token: str | None = None,
5050
ad_token_provider: AsyncAzureADTokenProvider | None = None,
51+
token_endpoint: str | None = None,
5152
default_headers: Mapping[str, str] | None = None,
5253
async_client: AsyncAzureOpenAI | None = None,
5354
env_file_path: str | None = None,
@@ -69,6 +70,7 @@ def __init__(
6970
in the env vars or .env file.
7071
ad_token (str | None): The Azure Active Directory token. (Optional)
7172
ad_token_provider (AsyncAzureADTokenProvider): The Azure Active Directory token provider. (Optional)
73+
token_endpoint (str | None): The token endpoint to request an Azure token. (Optional)
7274
default_headers (Mapping[str, str]): The default headers mapping of string keys to
7375
string values for HTTP requests. (Optional)
7476
async_client (AsyncAzureOpenAI | None): An existing client to use. (Optional)
@@ -84,13 +86,21 @@ def __init__(
8486
api_version=api_version,
8587
env_file_path=env_file_path,
8688
env_file_encoding=env_file_encoding,
89+
token_endpoint=token_endpoint,
8790
)
8891
except ValidationError as exc:
8992
raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc
9093

9194
if not azure_openai_settings.chat_deployment_name:
9295
raise ServiceInitializationError("chat_deployment_name is required.")
9396

97+
# If the api_key is none, and the ad_token is none, and the ad_token_provider is none,
98+
# then we will attempt to get the ad_token using the default endpoint specified in the Azure OpenAI settings.
99+
if api_key is None and ad_token_provider is None and azure_openai_settings.token_endpoint and ad_token is None:
100+
ad_token = azure_openai_settings.get_azure_openai_auth_token(
101+
token_endpoint=azure_openai_settings.token_endpoint
102+
)
103+
94104
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider:
95105
raise ServiceInitializationError("Please provide either api_key, ad_token or ad_token_provider")
96106

‎python/semantic_kernel/connectors/ai/open_ai/services/azure_text_completion.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ def __init__(
7070
if not azure_openai_settings.text_deployment_name:
7171
raise ServiceInitializationError("The Azure Text deployment name is required.")
7272

73+
# If the api_key is none, and the ad_token is none, and the ad_token_provider is none,
74+
# then we will attempt to get the ad_token using the default endpoint specified in the Azure OpenAI settings.
75+
if api_key is None and ad_token_provider is None and azure_openai_settings.token_endpoint and ad_token is None:
76+
ad_token = azure_openai_settings.get_azure_openai_auth_token(
77+
token_endpoint=azure_openai_settings.token_endpoint
78+
)
79+
80+
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider:
81+
raise ServiceInitializationError("Please provide either api_key, ad_token or ad_token_provider")
82+
7383
super().__init__(
7484
deployment_name=azure_openai_settings.text_deployment_name,
7585
endpoint=azure_openai_settings.endpoint,

‎python/semantic_kernel/connectors/ai/open_ai/services/azure_text_embedding.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ def __init__(
7272
if not azure_openai_settings.embedding_deployment_name:
7373
raise ServiceInitializationError("The Azure OpenAI embedding deployment name is required.")
7474

75+
# If the api_key is none, and the ad_token is none, and the ad_token_provider is none,
76+
# then we will attempt to get the ad_token using the default endpoint specified in the Azure OpenAI settings.
77+
if api_key is None and ad_token_provider is None and azure_openai_settings.token_endpoint and ad_token is None:
78+
ad_token = azure_openai_settings.get_azure_openai_auth_token(
79+
token_endpoint=azure_openai_settings.token_endpoint
80+
)
81+
7582
super().__init__(
7683
deployment_name=azure_openai_settings.embedding_deployment_name,
7784
endpoint=azure_openai_settings.endpoint,

‎python/semantic_kernel/connectors/ai/open_ai/settings/azure_open_ai_settings.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3-
43
from typing import ClassVar
54

65
from pydantic import SecretStr
76

87
from semantic_kernel.connectors.ai.open_ai.const import DEFAULT_AZURE_API_VERSION
8+
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
99
from semantic_kernel.kernel_pydantic import HttpsUrl, KernelBaseSettings
10+
from semantic_kernel.utils.authentication.entra_id_authentication import get_entra_auth_token
1011

1112

1213
class AzureOpenAISettings(KernelBaseSettings):
@@ -59,7 +60,9 @@ class AzureOpenAISettings(KernelBaseSettings):
5960
(Env var AZURE_OPENAI_ENDPOINT)
6061
- api_version: str | None - The API version to use. The default value is "2024-02-01".
6162
(Env var AZURE_OPENAI_API_VERSION)
62-
- env_file_path: str | None - if provided, the .env settings are read from this file path location
63+
- token_endpoint: str - The token endpoint to use to retrieve the authentication token.
64+
The default value is "https://cognitiveservices.azure.com".
65+
(Env var AZURE_OPENAI_TOKEN_ENDPOINT)
6366
"""
6467

6568
env_prefix: ClassVar[str] = "AZURE_OPENAI_"
@@ -72,3 +75,26 @@ class AzureOpenAISettings(KernelBaseSettings):
7275
base_url: HttpsUrl | None = None
7376
api_key: SecretStr | None = None
7477
api_version: str = DEFAULT_AZURE_API_VERSION
78+
token_endpoint: str = "https://cognitiveservices.azure.com"
79+
80+
def get_azure_openai_auth_token(self, token_endpoint: str | None = None) -> str | None:
81+
"""Retrieve a Microsoft Entra Auth Token for a given token endpoint for the use with Azure OpenAI.
82+
83+
The required role for the token is `Cognitive Services OpenAI Contributor`.
84+
The token endpoint may be specified as an environment variable, via the .env
85+
file or as an argument. If the token endpoint is not provided, the default is None.
86+
The `token_endpoint` argument takes precedence over the `token_endpoint` attribute.
87+
88+
Args:
89+
token_endpoint: The token endpoint to use. Defaults to `https://cognitiveservices.azure.com`.
90+
91+
Returns:
92+
The Azure token or None if the token could not be retrieved.
93+
94+
Raises:
95+
ServiceInitializationError: If the token endpoint is not provided.
96+
"""
97+
endpoint_to_use = token_endpoint or self.token_endpoint
98+
if endpoint_to_use is None:
99+
raise ServiceInitializationError("Please provide a token endpoint to retrieve the authentication token.")
100+
return get_entra_auth_token(endpoint_to_use)

‎python/semantic_kernel/core_plugins/sessions_python_tool/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ Next, as an environment variable or in the .env file, add the `poolManagementEnd
1414
https://eastus.acasessions.io/subscriptions/{{subscriptionId}}/resourceGroups/{{resourceGroup}}/sessionPools/{{sessionPool}}/python/execute
1515
```
1616

17-
It is possible to add the code interpreter plugin as follows:
17+
You can also provide the the `ACA_TOKEN_ENDPOINT` if you want to override the default value of `https://acasessions.io/.default`. If this token endpoint doesn't need to be overridden, then it is
18+
not necessary to include this as an environment variable, in the .env file, or via the plugin's constructor. Please follow the [Azure Container Apps Documentation](https://learn.microsoft.com/en-us/azure/container-apps/sessions-code-interpreter) to review the proper role required to authenticate with the `DefaultAzureCredential`.
19+
20+
Next, let's move on to implementing the plugin in code. It is possible to add the code interpreter plugin as follows:
1821

1922
```python
2023
kernel = Kernel()

0 commit comments

Comments
 (0)