Skip to content

Commit 17daed1

Browse files
pvaneckscottaddie
andauthored
[Identity] Add AzurePipelinesCredential (#35397)
Add `AzurePipelinesCredential` to support workload identity federation in Azure Pipelines with service connections. Signed-off-by: Paul Van Eck <[email protected]> Co-authored-by: Scott Addie <[email protected]>
1 parent 78b00c5 commit 17daed1

21 files changed

+695
-6
lines changed

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features Added
66

77
- Added environment variable `AZURE_CLIENT_SEND_CERTIFICATE_CHAIN` support for `EnvironmentCredential`.
8+
- Introduced a new credential, `AzurePipelinesCredential`, for supporting workload identity federation in Azure Pipelines with service connections ([#35397](https://github.com/Azure/azure-sdk-for-python/pull/35397)).
89

910
### Breaking Changes
1011

sdk/identity/azure-identity/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ Not all credentials require this configuration. Credentials that authenticate th
250250

251251
|Credential|Usage|Reference
252252
|-|-|-
253+
| `AzurePipelinesCredential` | Supports [Microsoft Entra Workload ID](https://learn.microsoft.com/azure/devops/pipelines/release/configure-workload-identity?view=azure-devops) on Azure Pipelines. |
253254
|[`CertificateCredential`][cert_cred_ref]| Authenticates a service principal using a certificate. | [Service principal authentication](https://learn.microsoft.com/entra/identity-platform/app-objects-and-service-principals)
254255
|[`ClientAssertionCredential`][client_assertion_cred_ref]| Authenticates a service principal using a signed client assertion. |
255256
|[`ClientSecretCredential`][client_secret_cred_ref]| Authenticates a service principal using a secret. | [Service principal authentication](https://learn.microsoft.com/entra/identity-platform/app-objects-and-service-principals)

sdk/identity/azure-identity/azure/identity/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
UsernamePasswordCredential,
2727
VisualStudioCodeCredential,
2828
WorkloadIdentityCredential,
29+
AzurePipelinesCredential,
2930
)
3031
from ._persistent_cache import TokenCachePersistenceOptions
3132
from ._bearer_token_provider import get_bearer_token_provider
@@ -38,6 +39,7 @@
3839
"AzureAuthorityHosts",
3940
"AzureCliCredential",
4041
"AzureDeveloperCliCredential",
42+
"AzurePipelinesCredential",
4143
"AzurePowerShellCredential",
4244
"CertificateCredential",
4345
"ChainedTokenCredential",

sdk/identity/azure-identity/azure/identity/_constants.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Copyright (c) Microsoft Corporation.
33
# Licensed under the MIT License.
44
# ------------------------------------
5-
5+
# cspell:ignore teamprojectid, planid, jobid, oidctoken
66

77
DEVELOPER_SIGN_ON_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
88
AZURE_VSCODE_CLIENT_ID = "aebc6443-996d-45c2-90f0-388ff96faa56"
@@ -54,3 +54,11 @@ class EnvironmentVariables:
5454

5555
AZURE_FEDERATED_TOKEN_FILE = "AZURE_FEDERATED_TOKEN_FILE"
5656
WORKLOAD_IDENTITY_VARS = (AZURE_AUTHORITY_HOST, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE)
57+
58+
# Azure Pipelines specific environment variables
59+
SYSTEM_TEAMFOUNDATIONCOLLECTIONURI = "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI"
60+
SYSTEM_TEAMPROJECTID = "SYSTEM_TEAMPROJECTID"
61+
SYSTEM_PLANID = "SYSTEM_PLANID"
62+
SYSTEM_JOBID = "SYSTEM_JOBID"
63+
SYSTEM_ACCESSTOKEN = "SYSTEM_ACCESSTOKEN"
64+
SYSTEM_HOSTTYPE = "SYSTEM_HOSTTYPE"

sdk/identity/azure-identity/azure/identity/_credentials/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@
2020
from .vscode import VisualStudioCodeCredential
2121
from .client_assertion import ClientAssertionCredential
2222
from .workload_identity import WorkloadIdentityCredential
23+
from .azure_pipelines import AzurePipelinesCredential
2324

2425

2526
__all__ = [
2627
"AuthorizationCodeCredential",
2728
"AzureCliCredential",
2829
"AzureDeveloperCliCredential",
30+
"AzurePipelinesCredential",
2931
"AzurePowerShellCredential",
3032
"CertificateCredential",
3133
"ChainedTokenCredential",
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# ------------------------------------
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT License.
4+
# ------------------------------------
5+
# cspell:ignore teamprojectid, planid, jobid, oidctoken
6+
import os
7+
from typing import Any, Optional
8+
9+
from azure.core.exceptions import ClientAuthenticationError
10+
from azure.core.credentials import AccessToken
11+
from azure.core.rest import HttpRequest, HttpResponse
12+
13+
from .client_assertion import ClientAssertionCredential
14+
from .. import CredentialUnavailableError
15+
from .._internal import validate_tenant_id
16+
from .._internal.pipeline import build_pipeline
17+
from .._constants import EnvironmentVariables as ev
18+
19+
20+
AZURE_PIPELINES_VARS = (
21+
ev.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI,
22+
ev.SYSTEM_TEAMPROJECTID,
23+
ev.SYSTEM_PLANID,
24+
ev.SYSTEM_JOBID,
25+
ev.SYSTEM_ACCESSTOKEN,
26+
ev.SYSTEM_HOSTTYPE,
27+
)
28+
OIDC_API_VERSION = "7.1-preview.1"
29+
30+
31+
def build_oidc_request(service_connection_id: str) -> HttpRequest:
32+
base_uri = os.environ[ev.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI].rstrip("/")
33+
url = (
34+
f"{base_uri}/{os.environ[ev.SYSTEM_TEAMPROJECTID]}/_apis/distributedtask/hubs/"
35+
f"{os.environ[ev.SYSTEM_HOSTTYPE]}/plans/{os.environ[ev.SYSTEM_PLANID]}/jobs/{os.environ[ev.SYSTEM_JOBID]}/"
36+
f"oidctoken?api-version={OIDC_API_VERSION}&serviceConnectionId={service_connection_id}"
37+
)
38+
access_token = os.environ[ev.SYSTEM_ACCESSTOKEN]
39+
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}
40+
return HttpRequest("POST", url, headers=headers)
41+
42+
43+
def validate_env_vars():
44+
missing_vars = []
45+
for var in AZURE_PIPELINES_VARS:
46+
if var not in os.environ or not os.environ[var]:
47+
missing_vars.append(var)
48+
if missing_vars:
49+
raise CredentialUnavailableError(
50+
message=f"Missing values for environment variables: {', '.join(missing_vars)}. "
51+
f"AzurePipelinesCredential is intended for use in Azure Pipelines where the following environment "
52+
f"variables are set: {AZURE_PIPELINES_VARS}."
53+
)
54+
55+
56+
class AzurePipelinesCredential:
57+
"""Authenticates using Microsoft Entra Workload ID in Azure Pipelines.
58+
59+
This credential enables authentication in Azure Pipelines using workload identity federation for Azure service
60+
connections.
61+
62+
:keyword str service_connection_id: The service connection ID, as found in the querystring's resourceId key.
63+
Required.
64+
:keyword str tenant_id: ID of the application's Microsoft Entra tenant. Also called its "directory" ID.
65+
:keyword str client_id: The client ID of a Microsoft Entra app registration.
66+
:keyword List[str] additionally_allowed_tenants: Specifies tenants in addition to the specified "tenant_id"
67+
for which the credential may acquire tokens. Add the wildcard value "*" to allow the credential to
68+
acquire tokens for any tenant the application can access.
69+
70+
.. admonition:: Example:
71+
72+
.. literalinclude:: ../samples/credential_creation_code_snippets.py
73+
:start-after: [START create_azure_pipelines_credential]
74+
:end-before: [END create_azure_pipelines_credential]
75+
:language: python
76+
:dedent: 4
77+
:caption: Create an AzurePipelinesCredential.
78+
"""
79+
80+
def __init__(
81+
self,
82+
*,
83+
tenant_id: str,
84+
client_id: str,
85+
service_connection_id: str,
86+
**kwargs: Any,
87+
) -> None:
88+
89+
if not tenant_id or not client_id or not service_connection_id:
90+
raise ValueError("tenant_id, client_id, and service_connection_id are required.")
91+
validate_tenant_id(tenant_id)
92+
93+
self._service_connection_id = service_connection_id
94+
self._client_assertion_credential = ClientAssertionCredential(
95+
tenant_id=tenant_id, client_id=client_id, func=self._get_oidc_token, **kwargs
96+
)
97+
self._pipeline = build_pipeline(**kwargs)
98+
self._env_validated = False
99+
100+
def get_token(
101+
self,
102+
*scopes: str,
103+
claims: Optional[str] = None,
104+
tenant_id: Optional[str] = None,
105+
enable_cae: bool = False,
106+
**kwargs: Any,
107+
) -> AccessToken:
108+
"""Request an access token for `scopes`.
109+
110+
This method is called automatically by Azure SDK clients.
111+
112+
:param str scopes: desired scopes for the access token. This method requires at least one scope.
113+
For more information about scopes, see
114+
https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
115+
:keyword str claims: additional claims required in the token, such as those returned in a resource provider's
116+
claims challenge following an authorization failure.
117+
:keyword str tenant_id: optional tenant to include in the token request.
118+
:keyword bool enable_cae: indicates whether to enable Continuous Access Evaluation (CAE) for the requested
119+
token. Defaults to False.
120+
121+
:return: An access token with the desired scopes.
122+
:rtype: ~azure.core.credentials.AccessToken
123+
:raises CredentialUnavailableError: the credential is unable to attempt authentication because it lacks
124+
required data, state, or platform support
125+
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message``
126+
attribute gives a reason.
127+
"""
128+
if not self._env_validated:
129+
validate_env_vars()
130+
self._env_validated = True
131+
return self._client_assertion_credential.get_token(
132+
*scopes, claims=claims, tenant_id=tenant_id, enable_cae=enable_cae, **kwargs
133+
)
134+
135+
def _get_oidc_token(self) -> str:
136+
request = build_oidc_request(self._service_connection_id)
137+
response = self._pipeline.run(request, retry_on_methods=[request.method])
138+
http_response: HttpResponse = response.http_response
139+
if http_response.status_code not in [200]:
140+
raise ClientAuthenticationError(
141+
message="Unexpected response from OIDC token endpoint.", response=http_response
142+
)
143+
json_response = http_response.json()
144+
if "oidcToken" not in json_response:
145+
raise ClientAuthenticationError(message="OIDC token not found in response.")
146+
return json_response["oidcToken"]
147+
148+
def __enter__(self):
149+
self._client_assertion_credential.__enter__()
150+
self._pipeline.__enter__()
151+
return self
152+
153+
def __exit__(self, *args):
154+
self._client_assertion_credential.__exit__(*args)
155+
self._pipeline.__exit__(*args)
156+
157+
def close(self) -> None:
158+
"""Close the credential's transport session."""
159+
self.__exit__()

sdk/identity/azure-identity/azure/identity/_credentials/client_assertion.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def __init__(self, tenant_id: str, client_id: str, func: Callable[[], str], **kw
5353
additionally_allowed_tenants=additionally_allowed_tenants,
5454
**kwargs
5555
)
56-
super(ClientAssertionCredential, self).__init__(**kwargs)
56+
super().__init__()
5757

5858
def __enter__(self) -> "ClientAssertionCredential":
5959
self._client.__enter__()

sdk/identity/azure-identity/azure/identity/_credentials/workload_identity.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212

1313

1414
class TokenFileMixin:
15-
def __init__(self, token_file_path: str, **_: Any) -> None:
15+
16+
_token_file_path: str
17+
18+
def __init__(self, **_: Any) -> None:
1619
super(TokenFileMixin, self).__init__()
1720
self._jwt = ""
1821
self._last_read_time = 0
19-
self._token_file_path = token_file_path
2022

2123
def _get_service_account_token(self) -> str:
2224
now = int(time.time())
@@ -85,6 +87,7 @@ def __init__(
8587
"'token_file_path' is required. Please pass it in or set the "
8688
f"{EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE} environment variable"
8789
)
90+
self._token_file_path = token_file_path
8891
super(WorkloadIdentityCredential, self).__init__(
8992
tenant_id=tenant_id,
9093
client_id=client_id,

sdk/identity/azure-identity/azure/identity/aio/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
VisualStudioCodeCredential,
2121
ClientAssertionCredential,
2222
WorkloadIdentityCredential,
23+
AzurePipelinesCredential,
2324
)
2425
from ._bearer_token_provider import get_bearer_token_provider
2526

@@ -28,6 +29,7 @@
2829
"AuthorizationCodeCredential",
2930
"AzureDeveloperCliCredential",
3031
"AzureCliCredential",
32+
"AzurePipelinesCredential",
3133
"AzurePowerShellCredential",
3234
"CertificateCredential",
3335
"ClientSecretCredential",

sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
from .vscode import VisualStudioCodeCredential
1818
from .client_assertion import ClientAssertionCredential
1919
from .workload_identity import WorkloadIdentityCredential
20+
from .azure_pipelines import AzurePipelinesCredential
2021

2122

2223
__all__ = [
2324
"AuthorizationCodeCredential",
2425
"AzureCliCredential",
2526
"AzureDeveloperCliCredential",
27+
"AzurePipelinesCredential",
2628
"AzurePowerShellCredential",
2729
"CertificateCredential",
2830
"ChainedTokenCredential",

0 commit comments

Comments
 (0)