Skip to content

Commit 8d6c57d

Browse files
committed
[Identity] Add AzurePipelinesCredential
Signed-off-by: Paul Van Eck <paulvaneck@microsoft.com>
1 parent 6704033 commit 8d6c57d

21 files changed

Lines changed: 678 additions & 6 deletions

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
@@ -245,6 +245,7 @@ Not all credentials require this configuration. Credentials that authenticate th
245245
|[`EnvironmentCredential`][environment_cred_ref]| Authenticates a service principal or user via credential information specified in environment variables.
246246
|[`ManagedIdentityCredential`][managed_id_cred_ref]| Authenticates the managed identity of an Azure resource.
247247
|[`WorkloadIdentityCredential`][workload_id_cred_ref]| Supports [Microsoft Entra Workload ID](https://learn.microsoft.com/azure/aks/workload-identity-overview) on Kubernetes.
248+
| `AzurePipelinesCredential` | Supports [Microsoft Entra Workload ID](https://learn.microsoft.com/azure/devops/pipelines/release/configure-workload-identity?view=azure-devops) on Azure Pipelines.
248249

249250
### Authenticate service principals
250251

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: 15 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,17 @@ 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+
AZURE_PIPELINES_VARS = (
65+
SYSTEM_TEAMFOUNDATIONCOLLECTIONURI,
66+
SYSTEM_TEAMPROJECTID,
67+
SYSTEM_PLANID,
68+
SYSTEM_JOBID,
69+
SYSTEM_ACCESSTOKEN,
70+
)

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