Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sdk/identity/azure-identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Features Added

- Added environment variable `AZURE_CLIENT_SEND_CERTIFICATE_CHAIN` support for `EnvironmentCredential`.
- 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)).

### Breaking Changes

Expand Down
1 change: 1 addition & 0 deletions sdk/identity/azure-identity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ Not all credentials require this configuration. Credentials that authenticate th

|Credential|Usage|Reference
|-|-|-
| `AzurePipelinesCredential` | Supports [Microsoft Entra Workload ID](https://learn.microsoft.com/azure/devops/pipelines/release/configure-workload-identity?view=azure-devops) on Azure Pipelines. |
|[`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)
|[`ClientAssertionCredential`][client_assertion_cred_ref]| Authenticates a service principal using a signed client assertion. |
|[`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)
Expand Down
2 changes: 2 additions & 0 deletions sdk/identity/azure-identity/azure/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
UsernamePasswordCredential,
VisualStudioCodeCredential,
WorkloadIdentityCredential,
AzurePipelinesCredential,
)
from ._persistent_cache import TokenCachePersistenceOptions
from ._bearer_token_provider import get_bearer_token_provider
Expand All @@ -38,6 +39,7 @@
"AzureAuthorityHosts",
"AzureCliCredential",
"AzureDeveloperCliCredential",
"AzurePipelinesCredential",
"AzurePowerShellCredential",
"CertificateCredential",
"ChainedTokenCredential",
Expand Down
10 changes: 9 additions & 1 deletion sdk/identity/azure-identity/azure/identity/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------

# cspell:ignore teamprojectid, planid, jobid, oidctoken

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

AZURE_FEDERATED_TOKEN_FILE = "AZURE_FEDERATED_TOKEN_FILE"
WORKLOAD_IDENTITY_VARS = (AZURE_AUTHORITY_HOST, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE)

# Azure Pipelines specific environment variables
SYSTEM_TEAMFOUNDATIONCOLLECTIONURI = "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI"
SYSTEM_TEAMPROJECTID = "SYSTEM_TEAMPROJECTID"
SYSTEM_PLANID = "SYSTEM_PLANID"
SYSTEM_JOBID = "SYSTEM_JOBID"
SYSTEM_ACCESSTOKEN = "SYSTEM_ACCESSTOKEN"
SYSTEM_HOSTTYPE = "SYSTEM_HOSTTYPE"
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@
from .vscode import VisualStudioCodeCredential
from .client_assertion import ClientAssertionCredential
from .workload_identity import WorkloadIdentityCredential
from .azure_pipelines import AzurePipelinesCredential


__all__ = [
"AuthorizationCodeCredential",
"AzureCliCredential",
"AzureDeveloperCliCredential",
"AzurePipelinesCredential",
"AzurePowerShellCredential",
"CertificateCredential",
"ChainedTokenCredential",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
# cspell:ignore teamprojectid, planid, jobid, oidctoken
import os
from typing import Any, Optional

from azure.core.exceptions import ClientAuthenticationError
from azure.core.credentials import AccessToken
from azure.core.rest import HttpRequest, HttpResponse

from .client_assertion import ClientAssertionCredential
from .. import CredentialUnavailableError
from .._internal import validate_tenant_id
from .._internal.pipeline import build_pipeline
from .._constants import EnvironmentVariables as ev


AZURE_PIPELINES_VARS = (
ev.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI,
ev.SYSTEM_TEAMPROJECTID,
ev.SYSTEM_PLANID,
ev.SYSTEM_JOBID,
ev.SYSTEM_ACCESSTOKEN,
ev.SYSTEM_HOSTTYPE,
)
OIDC_API_VERSION = "7.1-preview.1"


def build_oidc_request(service_connection_id: str) -> HttpRequest:
base_uri = os.environ[ev.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI].rstrip("/")
url = (
f"{base_uri}/{os.environ[ev.SYSTEM_TEAMPROJECTID]}/_apis/distributedtask/hubs/"
f"{os.environ[ev.SYSTEM_HOSTTYPE]}/plans/{os.environ[ev.SYSTEM_PLANID]}/jobs/{os.environ[ev.SYSTEM_JOBID]}/"
f"oidctoken?api-version={OIDC_API_VERSION}&serviceConnectionId={service_connection_id}"
)
access_token = os.environ[ev.SYSTEM_ACCESSTOKEN]
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}
return HttpRequest("POST", url, headers=headers)


def validate_env_vars():
missing_vars = []
for var in AZURE_PIPELINES_VARS:
if var not in os.environ or not os.environ[var]:
missing_vars.append(var)
if missing_vars:
raise CredentialUnavailableError(
message=f"Missing values for environment variables: {', '.join(missing_vars)}. "
f"AzurePipelinesCredential is intended for use in Azure Pipelines where the following environment "
f"variables are set: {AZURE_PIPELINES_VARS}."
)


class AzurePipelinesCredential:
"""Authenticates using Microsoft Entra Workload ID in Azure Pipelines.

This credential enable authentication in Azure Pipelines using workload identity federation for Azure service
Comment thread
pvaneck marked this conversation as resolved.
Outdated
connections.

:keyword str service_connection_id: The service connection ID, as found in the querystring's resourceId key.
Required.
:keyword str tenant_id: ID of the application's Microsoft Entra tenant. Also called its "directory" ID.
:keyword str client_id: The client ID of a Microsoft Entra app registration.
:keyword List[str] additionally_allowed_tenants: Specifies tenants in addition to the specified "tenant_id"
for which the credential may acquire tokens. Add the wildcard value "*" to allow the credential to
acquire tokens for any tenant the application can access.

.. admonition:: Example:

.. literalinclude:: ../samples/credential_creation_code_snippets.py
:start-after: [START create_azure_pipelines_credential]
:end-before: [END create_azure_pipelines_credential]
:language: python
:dedent: 4
:caption: Create an AzurePipelinesCredential.
"""

def __init__(
self,
*,
tenant_id: str,
client_id: str,
service_connection_id: str,
**kwargs: Any,
) -> None:

if not tenant_id or not client_id or not service_connection_id:
raise ValueError("tenant_id, client_id, and service_connection_id are required.")
validate_tenant_id(tenant_id)

self._service_connection_id = service_connection_id
self._client_assertion_credential = ClientAssertionCredential(
tenant_id=tenant_id, client_id=client_id, func=self._get_oidc_token, **kwargs
)
self._pipeline = build_pipeline(**kwargs)
self._env_validated = False

def get_token(
self,
*scopes: str,
claims: Optional[str] = None,
tenant_id: Optional[str] = None,
enable_cae: bool = False,
**kwargs: Any,
) -> AccessToken:
"""Request an access token for `scopes`.

This method is called automatically by Azure SDK clients.

:param str scopes: desired scopes for the access token. This method requires at least one scope.
For more information about scopes, see
https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
:keyword str claims: additional claims required in the token, such as those returned in a resource provider's
claims challenge following an authorization failure.
:keyword str tenant_id: optional tenant to include in the token request.
:keyword bool enable_cae: indicates whether to enable Continuous Access Evaluation (CAE) for the requested
token. Defaults to False.

:return: An access token with the desired scopes.
:rtype: ~azure.core.credentials.AccessToken
:raises CredentialUnavailableError: the credential is unable to attempt authentication because it lacks
required data, state, or platform support
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message``
attribute gives a reason.
"""
if not self._env_validated:
validate_env_vars()
self._env_validated = True
return self._client_assertion_credential.get_token(
*scopes, claims=claims, tenant_id=tenant_id, enable_cae=enable_cae, **kwargs
)

def _get_oidc_token(self) -> str:
request = build_oidc_request(self._service_connection_id)
response = self._pipeline.run(request, retry_on_methods=[request.method])
http_response: HttpResponse = response.http_response
if http_response.status_code not in [200]:
raise ClientAuthenticationError(
message="Unexpected response from OIDC token endpoint.", response=http_response
)
json_response = http_response.json()
if "oidcToken" not in json_response:
raise ClientAuthenticationError(message="OIDC token not found in response.")
return json_response["oidcToken"]

def __enter__(self):
self._client_assertion_credential.__enter__()
self._pipeline.__enter__()
return self

def __exit__(self, *args):
self._client_assertion_credential.__exit__(*args)
self._pipeline.__exit__(*args)

def close(self) -> None:
"""Close the credential's transport session."""
self.__exit__()
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def __init__(self, tenant_id: str, client_id: str, func: Callable[[], str], **kw
additionally_allowed_tenants=additionally_allowed_tenants,
**kwargs
)
super(ClientAssertionCredential, self).__init__(**kwargs)
super().__init__()

def __enter__(self) -> "ClientAssertionCredential":
self._client.__enter__()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@


class TokenFileMixin:
def __init__(self, token_file_path: str, **_: Any) -> None:

_token_file_path: str

def __init__(self, **_: Any) -> None:
super(TokenFileMixin, self).__init__()
self._jwt = ""
self._last_read_time = 0
self._token_file_path = token_file_path

def _get_service_account_token(self) -> str:
now = int(time.time())
Expand Down Expand Up @@ -85,6 +87,7 @@ def __init__(
"'token_file_path' is required. Please pass it in or set the "
f"{EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE} environment variable"
)
self._token_file_path = token_file_path
super(WorkloadIdentityCredential, self).__init__(
tenant_id=tenant_id,
client_id=client_id,
Expand Down
2 changes: 2 additions & 0 deletions sdk/identity/azure-identity/azure/identity/aio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
VisualStudioCodeCredential,
ClientAssertionCredential,
WorkloadIdentityCredential,
AzurePipelinesCredential,
)
from ._bearer_token_provider import get_bearer_token_provider

Expand All @@ -28,6 +29,7 @@
"AuthorizationCodeCredential",
"AzureDeveloperCliCredential",
"AzureCliCredential",
"AzurePipelinesCredential",
"AzurePowerShellCredential",
"CertificateCredential",
"ClientSecretCredential",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
from .vscode import VisualStudioCodeCredential
from .client_assertion import ClientAssertionCredential
from .workload_identity import WorkloadIdentityCredential
from .azure_pipelines import AzurePipelinesCredential


__all__ = [
"AuthorizationCodeCredential",
"AzureCliCredential",
"AzureDeveloperCliCredential",
"AzurePipelinesCredential",
"AzurePowerShellCredential",
"CertificateCredential",
"ChainedTokenCredential",
Expand Down
Loading