From 127ba143db47202965cfb089174600bb0b8a2c34 Mon Sep 17 00:00:00 2001 From: Maddison Das Date: Tue, 25 Aug 2026 07:42:45 +0100 Subject: [PATCH 1/5] {Profile} `az login`: Add `--federated-identity` to auto-refresh OIDC federated tokens Static `--federated-token` values expire in ~10 minutes and cannot be refreshed, so long-running CI/CD tasks fail with `AADSTS700024: Client assertion is not within its valid time range`. This registers a callable client_assertion with MSAL (the documented, recommended interface) so an expired OIDC ID token is transparently re-fetched on every token acquisition, including in later `az` processes. Wiring: - New `FEDERATED_IDENTITY` sentinel persisted as the SP entry's client_assertion. - `ServicePrincipalAuth.get_msal_client_credential()` resolves that sentinel to a provider dispatcher, `get_federated_id_token()`, instead of a static string. - Dispatcher implements GitHub Actions; other environments raise a clear error pointing at `--federated-token`. Azure DevOps is a planned follow-up (#28708). - New `az login --federated-identity` flag, mutually exclusive with `--federated-token` and only valid with `--service-principal`. Adds unit tests covering the sentinel-to-callable resolution, the GitHub fetch (success and HTTP error), and the unsupported/no-provider branches. Partially addresses https://github.com/Azure/azure-cli/issues/28708 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/cli/core/auth/identity.py | 60 +++++++++++++++++- .../cli/core/auth/tests/test_identity.py | 63 +++++++++++++++++++ .../cli/command_modules/profile/__init__.py | 6 +- .../cli/command_modules/profile/custom.py | 11 +++- 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 91629e89441..5c5fede9614 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -33,6 +33,10 @@ AZURE_CLIENT_ID = "AZURE_CLIENT_ID" AZURE_CLIENT_SECRET = "AZURE_CLIENT_SECRET" +# Sentinel value persisted as the client_assertion of a service principal entry to indicate that the OIDC +# ID token should be fetched (and refreshed) on demand from the CI/CD provider instead of being a static token. +FEDERATED_IDENTITY = "FEDERATED_IDENTITY" + WAM_PROMPT = ( "Select the account you want to log in with. " "For more information on login with Azure CLI, see https://go.microsoft.com/fwlink/?linkid=2271136") @@ -347,7 +351,9 @@ def get_msal_client_credential(self): # "client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..." # } if self.client_assertion: - client_credential = {'client_assertion': self.client_assertion} + client_credential = { + 'client_assertion': get_federated_id_token if self.client_assertion == FEDERATED_IDENTITY + else self.client_assertion} return client_credential @@ -456,3 +462,55 @@ def get_environment_credential(): getenv(AZURE_TENANT_ID)) credentials = ServicePrincipalCredential(sp_auth, authority=authority) return credentials + + +def get_federated_id_token(): + """Acquire a fresh OIDC ID token from the current CI/CD provider. + + This is the dispatcher registered as MSAL's ``client_assertion`` callback. MSAL invokes it lazily every + time it needs a client assertion, so an expired ID token is transparently replaced with a fresh one for + as long as the underlying provider request token is valid. The provider is auto-detected from the + environment so that ``--federated-identity`` stays a single, provider-agnostic flag. + """ + if 'ACTIONS_ID_TOKEN_REQUEST_URL' in os.environ: + return _get_id_token_github() + raise CLIError( + "--federated-identity: no supported CI/CD OIDC provider was detected in the environment. " + "Only GitHub Actions is currently supported. Provide a token with --federated-token instead. " + "See https://github.com/Azure/azure-cli/issues/28708 for provider support progress.") + + +def _get_id_token_github(): + """Fetch a fresh OIDC ID token from the GitHub Actions token service. + + Valid for the lifetime of the GitHub Actions request token (currently ~6 hours after the job starts). + https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers + """ + from urllib.parse import quote + import requests + + try: + request_token = os.environ['ACTIONS_ID_TOKEN_REQUEST_TOKEN'] + request_url = os.environ['ACTIONS_ID_TOKEN_REQUEST_URL'] + except KeyError as ex: + raise CLIError( + 'Environment variable {} is not set. --federated-identity requires GitHub Actions with ' + '"id-token: write" permission granted to the workflow.'.format(ex)) + + audience = quote('api://AzureADTokenExchange') + url = '{}&audience={}'.format(request_url, audience) + headers = { + 'Authorization': 'bearer {}'.format(request_token), + 'Accept': 'application/json; api-version=2.0', + 'Content-Type': 'application/json' + } + response = requests.get(url, headers=headers) + if not response.ok: + raise CLIError('Failed to retrieve an ID token from GitHub Actions: {} {}'.format( + response.status_code, response.reason)) + id_token = response.json().get('value') + if not id_token: + raise CLIError('GitHub Actions OIDC endpoint did not return an ID token.') + # Never log the token value itself. + logger.debug('Retrieved a fresh ID token from the GitHub Actions OIDC endpoint.') + return id_token diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index 993039faca3..d40fbce9e6d 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -9,6 +9,7 @@ from unittest import mock from azure.cli.core.auth.identity import (Identity, ServicePrincipalAuth, ServicePrincipalStore, + FEDERATED_IDENTITY, get_federated_id_token, _get_authority_url) from knack.util import CLIError @@ -263,6 +264,26 @@ def test_service_principal_auth_client_assertion(self): client_credential = sp_auth.get_msal_client_credential() assert client_credential == {'client_assertion': 'test_jwt'} + def test_service_principal_auth_federated_identity(self): + # The FEDERATED_IDENTITY sentinel is persisted like a normal client_assertion, ... + sp_auth = ServicePrincipalAuth.build_from_credential('tenant1', 'sp_id1', + {'client_assertion': FEDERATED_IDENTITY}) + assert sp_auth.client_assertion == FEDERATED_IDENTITY + + # Verify persist entry keeps the sentinel so later processes can rebuild the callback + entry = sp_auth.get_entry_to_persist() + assert entry == { + 'client_id': 'sp_id1', + 'tenant': 'tenant1', + 'client_assertion': FEDERATED_IDENTITY + } + + # ... but get_msal_client_credential resolves the sentinel to the refreshing callback (not a string), + # so MSAL fetches a fresh ID token on every acquisition. + client_credential = sp_auth.get_msal_client_credential() + assert client_credential == {'client_assertion': get_federated_id_token} + assert callable(client_credential['client_assertion']) + def test_build_credential(self): # client_secret cred = ServicePrincipalAuth.build_credential(client_secret="test_secret") @@ -292,6 +313,48 @@ def test_build_credential(self): assert cred == {"client_assertion": "test_jwt"} +class TestFederatedIdentity(unittest.TestCase): + """Tests for the OIDC federated-token dispatcher used by 'az login --federated-identity'.""" + + @mock.patch.dict(os.environ, { + 'ACTIONS_ID_TOKEN_REQUEST_URL': 'https://github.example/token?foo=bar', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN': 'request_token' + }, clear=True) + @mock.patch('requests.get') + def test_get_federated_id_token_github(self, get_mock): + get_mock.return_value = mock.MagicMock(ok=True, json=lambda: {'value': 'fresh_id_token'}) + + token = get_federated_id_token() + + assert token == 'fresh_id_token' + # The audience is appended to the GitHub-provided request URL and the request token is used as bearer. + called_url = get_mock.call_args.args[0] + assert called_url.startswith('https://github.example/token?foo=bar&audience=') + assert 'api%3A//AzureADTokenExchange' in called_url + assert get_mock.call_args.kwargs['headers']['Authorization'] == 'bearer request_token' + + @mock.patch.dict(os.environ, { + 'ACTIONS_ID_TOKEN_REQUEST_URL': 'https://github.example/token', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN': 'request_token' + }, clear=True) + @mock.patch('requests.get') + def test_get_federated_id_token_github_http_error(self, get_mock): + get_mock.return_value = mock.MagicMock(ok=False, status_code=403, reason='Forbidden') + with self.assertRaisesRegex(CLIError, 'Failed to retrieve an ID token'): + get_federated_id_token() + + @mock.patch.dict(os.environ, {'SYSTEM_TEAMFOUNDATIONCOLLECTIONURI': 'https://dev.azure.com/org'}, clear=True) + def test_get_federated_id_token_unsupported_provider(self): + # Only GitHub Actions is supported for now; any other environment reports the same clear error. + with self.assertRaisesRegex(CLIError, 'no supported CI/CD OIDC provider'): + get_federated_id_token() + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_federated_id_token_no_provider(self): + with self.assertRaisesRegex(CLIError, 'no supported CI/CD OIDC provider'): + get_federated_id_token() + + class TestServicePrincipalStore(unittest.TestCase): test_sp = { diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index af25643541d..979459edb59 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -5,7 +5,7 @@ from azure.cli.core import AzCommandsLoader from azure.cli.core.commands import CliCommandType -from azure.cli.core.commands.parameters import get_enum_type +from azure.cli.core.commands.parameters import get_enum_type, get_three_state_flag from azure.cli.command_modules.profile._format import transform_account_list import azure.cli.command_modules.profile._help # pylint: disable=unused-import @@ -86,6 +86,10 @@ def load_arguments(self, command): 'certificate rolls.') c.argument('client_assertion', options_list=['--federated-token'], help='Federated token that can be used for OIDC token exchange.') + c.argument('federated_identity', options_list=['--federated-identity'], arg_type=get_three_state_flag(), + help='Acquire and automatically refresh the OIDC federated token from the CI/CD provider ' + '(currently GitHub Actions). Avoids the AADSTS700024 error on long-running tasks. ' + 'Cannot be combined with --federated-token.') # Managed identity c.argument('identity', options_list=('-i', '--identity'), action='store_true', diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 164a362a054..012d8ea5e5d 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -139,6 +139,7 @@ def login(cmd, username=None, password=None, tenant=None, scopes=None, allow_no_ use_device_code=False, # Service principal service_principal=None, certificate=None, use_cert_sn_issuer=None, client_assertion=None, + federated_identity=None, # Managed identity identity=False, client_id=None, object_id=None, resource_id=None, # Subscription discovery and default subscription selection control @@ -157,6 +158,10 @@ def login(cmd, username=None, password=None, tenant=None, scopes=None, allow_no_ raise CLIError("usage error: '--use-sn-issuer' is only applicable with a service principal") if service_principal and not username: raise CLIError('usage error: --service-principal --username NAME --password SECRET --tenant TENANT') + if client_assertion and federated_identity: + raise CLIError('usage error: Only one of --federated-token and --federated-identity can be specified') + if federated_identity and not service_principal: + raise CLIError("usage error: '--federated-identity' is only applicable with a service principal") if skip_subscription_discovery and not tenant: raise CLIError("usage error: '--skip-subscription-discovery' requires '--tenant'") if skip_subscription_discovery and subscription: @@ -188,7 +193,7 @@ def login(cmd, username=None, password=None, tenant=None, scopes=None, allow_no_ logger.warning(_CLOUD_CONSOLE_LOGIN_WARNING) if username: - if not (password or client_assertion or certificate): + if not (password or client_assertion or certificate or federated_identity): try: password = prompt_pass('Password: ') except NoTTYException: @@ -197,11 +202,11 @@ def login(cmd, username=None, password=None, tenant=None, scopes=None, allow_no_ interactive = True if service_principal: - from azure.cli.core.auth.identity import ServicePrincipalAuth + from azure.cli.core.auth.identity import ServicePrincipalAuth, FEDERATED_IDENTITY password = ServicePrincipalAuth.build_credential( client_secret=password, certificate=certificate, use_cert_sn_issuer=use_cert_sn_issuer, - client_assertion=client_assertion) + client_assertion=FEDERATED_IDENTITY if federated_identity else client_assertion) login_experience_v2 = cmd.cli_ctx.config.getboolean('core', 'login_experience_v2', fallback=True) # Send login_experience_v2 config to telemetry From dd6e57e80b332d639bd076bf3bcf2cb0b2025c42 Mon Sep 17 00:00:00 2001 From: Maddison Das Date: Tue, 25 Aug 2026 08:20:37 +0100 Subject: [PATCH 2/5] {Profile} `az login --federated-identity`: Support Azure DevOps OIDC token refresh Extends `--federated-identity` (GitHub Actions only) to also refresh OIDC federated tokens on Azure DevOps Pipelines, so long-running pipeline tasks no longer fail with `AADSTS700024`. The provider dispatcher now detects Azure DevOps and POSTs to the pipeline oidctoken API, following the documented Azure DevOps / azure-identity `AzurePipelinesCredential` contract. Because the refresh runs in a later `az` process, the three required inputs are read from the environment: - request URL: ARM_OIDC_REQUEST_URL, else SYSTEM_OIDCREQUESTURI - access token: ARM_OIDC_REQUEST_TOKEN, else SYSTEM_ACCESSTOKEN (System.AccessToken) - service conn: ARM_OIDC_AZURE_SERVICE_CONNECTION_ID Missing variables produce a clear, actionable error. Adds unit tests for the Azure DevOps success path, missing-environment handling, and updates the help text. Partially addresses https://github.com/Azure/azure-cli/issues/28708 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/cli/core/auth/identity.py | 58 ++++++++++++++++++- .../cli/core/auth/tests/test_identity.py | 28 ++++++++- .../cli/command_modules/profile/__init__.py | 4 +- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 5c5fede9614..411ac95fc7b 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -474,10 +474,12 @@ def get_federated_id_token(): """ if 'ACTIONS_ID_TOKEN_REQUEST_URL' in os.environ: return _get_id_token_github() + if 'SYSTEM_OIDCREQUESTURI' in os.environ or 'ARM_OIDC_REQUEST_URL' in os.environ: + return _get_id_token_azure_devops() raise CLIError( "--federated-identity: no supported CI/CD OIDC provider was detected in the environment. " - "Only GitHub Actions is currently supported. Provide a token with --federated-token instead. " - "See https://github.com/Azure/azure-cli/issues/28708 for provider support progress.") + "Only GitHub Actions and Azure DevOps are currently supported. Provide a token with " + "--federated-token instead. See https://github.com/Azure/azure-cli/issues/28708 for details.") def _get_id_token_github(): @@ -514,3 +516,55 @@ def _get_id_token_github(): # Never log the token value itself. logger.debug('Retrieved a fresh ID token from the GitHub Actions OIDC endpoint.') return id_token + + +def _get_id_token_azure_devops(): + """Fetch a fresh OIDC ID token from Azure DevOps Pipelines. + + Azure DevOps issues short-lived ID tokens (~5 min) and, unlike GitHub Actions, requires a POST to the + oidctoken API authenticated with the pipeline's System.AccessToken and targeting a specific service + connection. Because the token refresh runs in a later `az` process, all three inputs are read from the + environment following the documented Azure DevOps convention: + + - request URL: ARM_OIDC_REQUEST_URL, else SYSTEM_OIDCREQUESTURI + - access token: ARM_OIDC_REQUEST_TOKEN, else SYSTEM_ACCESSTOKEN (the pipeline's System.AccessToken) + - service conn: ARM_OIDC_AZURE_SERVICE_CONNECTION_ID + + https://devblogs.microsoft.com/devops/introducing-azure-devops-id-token-refresh-and-terraform-task-version-5/ + """ + from urllib.parse import quote + import requests + + request_url = os.environ.get('ARM_OIDC_REQUEST_URL') or os.environ.get('SYSTEM_OIDCREQUESTURI') + request_token = os.environ.get('ARM_OIDC_REQUEST_TOKEN') or os.environ.get('SYSTEM_ACCESSTOKEN') + service_connection_id = os.environ.get('ARM_OIDC_AZURE_SERVICE_CONNECTION_ID') + + missing = [name for name, value in ( + ('ARM_OIDC_REQUEST_URL (or SYSTEM_OIDCREQUESTURI)', request_url), + ('ARM_OIDC_REQUEST_TOKEN (or SYSTEM_ACCESSTOKEN)', request_token), + ('ARM_OIDC_AZURE_SERVICE_CONNECTION_ID', service_connection_id)) if not value] + if missing: + raise CLIError( + '--federated-identity on Azure DevOps requires the environment variable(s): {}. ' + 'System.AccessToken is not exposed to scripts by default, so map it explicitly and set the ' + 'service connection ID. See https://github.com/Azure/azure-cli/issues/28708 for guidance.' + .format(', '.join(missing))) + + url = '{}?api-version=7.1&serviceConnectionId={}'.format( + request_url.rstrip('/'), quote(service_connection_id)) + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'bearer {}'.format(request_token), + # Prevents the service from responding with a redirect HTTP status code. + 'X-TFS-FedAuthRedirect': 'Suppress', + } + response = requests.post(url, headers=headers) + if not response.ok: + raise CLIError('Failed to retrieve an ID token from Azure DevOps: {} {}'.format( + response.status_code, response.reason)) + id_token = response.json().get('oidcToken') + if not id_token: + raise CLIError('Azure DevOps OIDC endpoint did not return an ID token.') + # Never log the token value itself. + logger.debug('Retrieved a fresh ID token from the Azure DevOps OIDC endpoint.') + return id_token diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index d40fbce9e6d..f11fee7427c 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -343,9 +343,35 @@ def test_get_federated_id_token_github_http_error(self, get_mock): with self.assertRaisesRegex(CLIError, 'Failed to retrieve an ID token'): get_federated_id_token() + @mock.patch.dict(os.environ, { + 'SYSTEM_OIDCREQUESTURI': 'https://vstoken.dev.azure.com/org/', + 'SYSTEM_ACCESSTOKEN': 'system_access_token', + 'ARM_OIDC_AZURE_SERVICE_CONNECTION_ID': 'sc-guid' + }, clear=True) + @mock.patch('requests.post') + def test_get_federated_id_token_azure_devops(self, post_mock): + post_mock.return_value = mock.MagicMock(ok=True, json=lambda: {'oidcToken': 'ado_id_token'}) + + token = get_federated_id_token() + + assert token == 'ado_id_token' + called_url = post_mock.call_args.args[0] + # Trailing slash on the request URI is trimmed; api-version and service connection are appended. + assert called_url == ('https://vstoken.dev.azure.com/org' + '?api-version=7.1&serviceConnectionId=sc-guid') + headers = post_mock.call_args.kwargs['headers'] + assert headers['Authorization'] == 'bearer system_access_token' + assert headers['X-TFS-FedAuthRedirect'] == 'Suppress' + + @mock.patch.dict(os.environ, {'SYSTEM_OIDCREQUESTURI': 'https://vstoken.dev.azure.com/org/'}, clear=True) + def test_get_federated_id_token_azure_devops_missing_env(self): + # Detected as Azure DevOps, but the access token and service connection ID are missing. + with self.assertRaisesRegex(CLIError, 'ARM_OIDC_AZURE_SERVICE_CONNECTION_ID'): + get_federated_id_token() + @mock.patch.dict(os.environ, {'SYSTEM_TEAMFOUNDATIONCOLLECTIONURI': 'https://dev.azure.com/org'}, clear=True) def test_get_federated_id_token_unsupported_provider(self): - # Only GitHub Actions is supported for now; any other environment reports the same clear error. + # A DevOps collection URI without the OIDC request URI is not enough to attempt a refresh. with self.assertRaisesRegex(CLIError, 'no supported CI/CD OIDC provider'): get_federated_id_token() diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 979459edb59..9fb79458f76 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -88,8 +88,8 @@ def load_arguments(self, command): help='Federated token that can be used for OIDC token exchange.') c.argument('federated_identity', options_list=['--federated-identity'], arg_type=get_three_state_flag(), help='Acquire and automatically refresh the OIDC federated token from the CI/CD provider ' - '(currently GitHub Actions). Avoids the AADSTS700024 error on long-running tasks. ' - 'Cannot be combined with --federated-token.') + '(GitHub Actions or Azure DevOps). Avoids the AADSTS700024 error on long-running ' + 'tasks. Cannot be combined with --federated-token.') # Managed identity c.argument('identity', options_list=('-i', '--identity'), action='store_true', From 2f63c2be978e1e539b06040612c9fd711474dca0 Mon Sep 17 00:00:00 2001 From: Maddison Das Date: Wed, 26 Aug 2026 02:23:28 +0100 Subject: [PATCH 3/5] {Profile} `az login --federated-token-callback`: Add universal OIDC token refresh Adds a provider-agnostic escape hatch alongside the built-in `--federated-identity` providers. `--federated-token-callback ` takes any command that prints a fresh OIDC token to stdout; Azure CLI wraps it as the MSAL client_assertion callable and re-runs it on demand, so token refresh works with any CI/CD system (not just the built-in GitHub Actions / Azure DevOps providers) without platform-specific logic in the CLI. - The command is persisted (client_assertion_callback) so later `az` processes rebuild the callable and refresh independently. - Mutually exclusive with --federated-token and --federated-identity; service principal only. Clear errors on non-zero exit or empty output. The token value is never logged. The three flags now cover the full spectrum: static token (--federated-token), built-in providers (--federated-identity), and universal callback (--federated-token-callback). Partially addresses https://github.com/Azure/azure-cli/issues/28708 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/cli/core/auth/identity.py | 48 +++++++++++++++++-- .../cli/core/auth/tests/test_identity.py | 41 ++++++++++++++++ .../cli/command_modules/profile/__init__.py | 4 ++ .../cli/command_modules/profile/custom.py | 14 ++++-- 4 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 411ac95fc7b..c259eaaec15 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -26,6 +26,7 @@ _CERTIFICATE = 'certificate' _USE_CERT_SN_ISSUER = 'use_cert_sn_issuer' _CLIENT_ASSERTION = 'client_assertion' +_CLIENT_ASSERTION_CALLBACK = 'client_assertion_callback' # For environment credential AZURE_AUTHORITY_HOST = "AZURE_AUTHORITY_HOST" @@ -261,6 +262,8 @@ def __init__(self, entry): self.use_cert_sn_issuer = None # federated identity credential self.client_assertion = None + # command that prints a fresh federated token to stdout (provider-agnostic callback) + self.client_assertion_callback = None # Internal attributes for certificate # They are computed at runtime and not persisted in the service principal entry. @@ -298,12 +301,13 @@ def build_from_credential(cls, tenant_id, client_id, credential): @classmethod def build_credential(cls, client_secret=None, certificate=None, use_cert_sn_issuer=None, - client_assertion=None): + client_assertion=None, client_assertion_callback=None): """Build credential from user input. The credential looks like below, but only one key can exist. { 'client_secret': 'my_secret', 'certificate': '/path/to/cert.pem', - 'client_assertion': 'my_federated_token' + 'client_assertion': 'my_federated_token', + 'client_assertion_callback': 'my-get-token-command' } """ entry = {} @@ -315,11 +319,14 @@ def build_credential(cls, client_secret=None, entry[_USE_CERT_SN_ISSUER] = use_cert_sn_issuer elif client_assertion: entry[_CLIENT_ASSERTION] = client_assertion + elif client_assertion_callback: + entry[_CLIENT_ASSERTION_CALLBACK] = client_assertion_callback return entry def get_entry_to_persist(self): """Get a service principal entry that can be persisted by ServicePrincipalStore.""" - persisted_keys = [_CLIENT_ID, _TENANT, _CLIENT_SECRET, _CERTIFICATE, _USE_CERT_SN_ISSUER, _CLIENT_ASSERTION] + persisted_keys = [_CLIENT_ID, _TENANT, _CLIENT_SECRET, _CERTIFICATE, _USE_CERT_SN_ISSUER, + _CLIENT_ASSERTION, _CLIENT_ASSERTION_CALLBACK] # Only persist certain attributes whose values are not None return {k: v for k, v in self.__dict__.items() if k in persisted_keys and v} @@ -355,6 +362,16 @@ def get_msal_client_credential(self): 'client_assertion': get_federated_id_token if self.client_assertion == FEDERATED_IDENTITY else self.client_assertion} + # client_assertion_callback + # A user-provided command that prints a fresh federated token to stdout. Wrapped as a callable so + # MSAL re-runs it whenever a fresh assertion is needed (provider-agnostic refresh). + # { + # "client_assertion": + # } + if self.client_assertion_callback: + client_credential = { + 'client_assertion': _build_command_assertion_callback(self.client_assertion_callback)} + return client_credential @@ -568,3 +585,28 @@ def _get_id_token_azure_devops(): # Never log the token value itself. logger.debug('Retrieved a fresh ID token from the Azure DevOps OIDC endpoint.') return id_token + + +def _build_command_assertion_callback(command): + """Wrap a user-provided command as a callable that returns a fresh federated token. + + This is the provider-agnostic escape hatch behind `az login --federated-token-callback`. The command + is expected to print a single OIDC token to stdout; MSAL invokes the returned callable whenever it + needs a fresh assertion, so refresh works with any CI/CD provider. The command is supplied directly by + the user on the command line (it is their own command, not untrusted input). + """ + def get_id_token(): + import subprocess + try: + # shell=True is required so users can pipe (e.g. `curl ... | jq -r .value`). + result = subprocess.run(command, shell=True, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as ex: + raise CLIError('--federated-token-callback command exited with code {}: {}'.format( + ex.returncode, (ex.stderr or '').strip())) + id_token = result.stdout.strip() + if not id_token: + raise CLIError('--federated-token-callback command produced no output on stdout.') + # Never log the token value itself. + logger.debug('Retrieved a fresh ID token from the --federated-token-callback command.') + return id_token + return get_id_token diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index f11fee7427c..70bee3c0dd0 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -284,6 +284,43 @@ def test_service_principal_auth_federated_identity(self): assert client_credential == {'client_assertion': get_federated_id_token} assert callable(client_credential['client_assertion']) + def test_service_principal_auth_federated_token_callback(self): + # The callback command is persisted so later `az` processes can rebuild the callable. + sp_auth = ServicePrincipalAuth.build_from_credential( + 'tenant1', 'sp_id1', {'client_assertion_callback': 'my-get-token-command'}) + assert sp_auth.client_assertion_callback == 'my-get-token-command' + + entry = sp_auth.get_entry_to_persist() + assert entry == { + 'client_id': 'sp_id1', + 'tenant': 'tenant1', + 'client_assertion_callback': 'my-get-token-command' + } + + # get_msal_client_credential wraps the command as a refreshing callable (not a static string). + client_credential = sp_auth.get_msal_client_credential() + assert callable(client_credential['client_assertion']) + + @mock.patch('subprocess.run') + def test_federated_token_callback_invokes_command(self, run_mock): + run_mock.return_value = mock.MagicMock(stdout='fresh_token\n', stderr='') + sp_auth = ServicePrincipalAuth.build_from_credential( + 'tenant1', 'sp_id1', {'client_assertion_callback': 'get-token'}) + callback = sp_auth.get_msal_client_credential()['client_assertion'] + + # The callable runs the user command and returns its trimmed stdout each time MSAL calls it. + assert callback() == 'fresh_token' + assert run_mock.call_args.args[0] == 'get-token' + + @mock.patch('subprocess.run') + def test_federated_token_callback_empty_output(self, run_mock): + run_mock.return_value = mock.MagicMock(stdout=' \n', stderr='') + sp_auth = ServicePrincipalAuth.build_from_credential( + 'tenant1', 'sp_id1', {'client_assertion_callback': 'get-token'}) + callback = sp_auth.get_msal_client_credential()['client_assertion'] + with self.assertRaisesRegex(CLIError, 'produced no output'): + callback() + def test_build_credential(self): # client_secret cred = ServicePrincipalAuth.build_credential(client_secret="test_secret") @@ -312,6 +349,10 @@ def test_build_credential(self): cred = ServicePrincipalAuth.build_credential(client_assertion="test_jwt") assert cred == {"client_assertion": "test_jwt"} + # client_assertion_callback + cred = ServicePrincipalAuth.build_credential(client_assertion_callback="get-token") + assert cred == {"client_assertion_callback": "get-token"} + class TestFederatedIdentity(unittest.TestCase): """Tests for the OIDC federated-token dispatcher used by 'az login --federated-identity'.""" diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 9fb79458f76..12f538f879c 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -90,6 +90,10 @@ def load_arguments(self, command): help='Acquire and automatically refresh the OIDC federated token from the CI/CD provider ' '(GitHub Actions or Azure DevOps). Avoids the AADSTS700024 error on long-running ' 'tasks. Cannot be combined with --federated-token.') + c.argument('federated_token_callback', options_list=['--federated-token-callback'], + help='A command that prints a fresh OIDC federated token to stdout. Azure CLI runs it on ' + 'demand to refresh the token, so it works with any CI/CD provider. Cannot be combined ' + 'with --federated-token or --federated-identity.') # Managed identity c.argument('identity', options_list=('-i', '--identity'), action='store_true', diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 012d8ea5e5d..62e3783d614 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -139,7 +139,7 @@ def login(cmd, username=None, password=None, tenant=None, scopes=None, allow_no_ use_device_code=False, # Service principal service_principal=None, certificate=None, use_cert_sn_issuer=None, client_assertion=None, - federated_identity=None, + federated_identity=None, federated_token_callback=None, # Managed identity identity=False, client_id=None, object_id=None, resource_id=None, # Subscription discovery and default subscription selection control @@ -158,10 +158,13 @@ def login(cmd, username=None, password=None, tenant=None, scopes=None, allow_no_ raise CLIError("usage error: '--use-sn-issuer' is only applicable with a service principal") if service_principal and not username: raise CLIError('usage error: --service-principal --username NAME --password SECRET --tenant TENANT') - if client_assertion and federated_identity: - raise CLIError('usage error: Only one of --federated-token and --federated-identity can be specified') + if sum(map(bool, [client_assertion, federated_identity, federated_token_callback])) > 1: + raise CLIError('usage error: Only one of --federated-token, --federated-identity and ' + '--federated-token-callback can be specified') if federated_identity and not service_principal: raise CLIError("usage error: '--federated-identity' is only applicable with a service principal") + if federated_token_callback and not service_principal: + raise CLIError("usage error: '--federated-token-callback' is only applicable with a service principal") if skip_subscription_discovery and not tenant: raise CLIError("usage error: '--skip-subscription-discovery' requires '--tenant'") if skip_subscription_discovery and subscription: @@ -193,7 +196,7 @@ def login(cmd, username=None, password=None, tenant=None, scopes=None, allow_no_ logger.warning(_CLOUD_CONSOLE_LOGIN_WARNING) if username: - if not (password or client_assertion or certificate or federated_identity): + if not (password or client_assertion or certificate or federated_identity or federated_token_callback): try: password = prompt_pass('Password: ') except NoTTYException: @@ -206,7 +209,8 @@ def login(cmd, username=None, password=None, tenant=None, scopes=None, allow_no_ password = ServicePrincipalAuth.build_credential( client_secret=password, certificate=certificate, use_cert_sn_issuer=use_cert_sn_issuer, - client_assertion=FEDERATED_IDENTITY if federated_identity else client_assertion) + client_assertion=FEDERATED_IDENTITY if federated_identity else client_assertion, + client_assertion_callback=federated_token_callback) login_experience_v2 = cmd.cli_ctx.config.getboolean('core', 'login_experience_v2', fallback=True) # Send login_experience_v2 config to telemetry From 59ee6b322b2dd685f850984890e412ab9a268692 Mon Sep 17 00:00:00 2001 From: Maddison Das Date: Wed, 26 Aug 2026 04:52:52 +0100 Subject: [PATCH 4/5] {Profile} `az login`: Address code review feedback on federated identity - Harden --federated-token-callback: parse the command into an argv list and run it without a shell (shell=False), so a tampered token cache cannot become arbitrary code execution. Users needing pipes/redirection use a script or wrap in bash -c. - Reject combining --federated-token/--federated-identity/--federated-token-callback with --password or --certificate (previously the secret silently won and refresh was silently disabled). - Fix the GitHub OIDC request URL to append the audience with the correct separator when the URL has no existing query string. - Correct the misleading --service-principal usage error to list all credential modes. - Align --federated-identity help text with the actual mutual-exclusion validation. - Add tests for the no-shell argv behavior and the query-less GitHub URL case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/cli/core/auth/identity.py | 24 +++++++++++---- .../cli/core/auth/tests/test_identity.py | 30 +++++++++++++++++-- .../cli/command_modules/profile/__init__.py | 8 +++-- .../cli/command_modules/profile/custom.py | 7 ++++- 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index c259eaaec15..90f0df8260e 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -517,7 +517,9 @@ def _get_id_token_github(): '"id-token: write" permission granted to the workflow.'.format(ex)) audience = quote('api://AzureADTokenExchange') - url = '{}&audience={}'.format(request_url, audience) + # Append the audience with the correct separator in case the request URL already has a query string. + separator = '&' if '?' in request_url else '?' + url = '{}{}audience={}'.format(request_url, separator, audience) headers = { 'Authorization': 'bearer {}'.format(request_token), 'Accept': 'application/json; api-version=2.0', @@ -592,14 +594,26 @@ def _build_command_assertion_callback(command): This is the provider-agnostic escape hatch behind `az login --federated-token-callback`. The command is expected to print a single OIDC token to stdout; MSAL invokes the returned callable whenever it - needs a fresh assertion, so refresh works with any CI/CD provider. The command is supplied directly by - the user on the command line (it is their own command, not untrusted input). + needs a fresh assertion, so refresh works with any CI/CD provider. + + The command is parsed into an argument vector and run WITHOUT a shell (shell=False). Because the command + is persisted in the service principal entry and re-executed on every refresh, avoiding a shell prevents a + tampered token cache from turning into arbitrary code execution. Users who need shell features such as + pipes or redirection should either point to a script file or wrap the pipeline explicitly, e.g. + --federated-token-callback "bash -c 'curl ... | jq -r .value'". """ + import shlex + # posix=False keeps Windows paths (backslashes) intact; args are still executed without a shell. + args = shlex.split(command, posix=not sys.platform.startswith('win')) + if not args: + raise CLIError('--federated-token-callback: the command is empty.') + def get_id_token(): import subprocess try: - # shell=True is required so users can pipe (e.g. `curl ... | jq -r .value`). - result = subprocess.run(command, shell=True, capture_output=True, text=True, check=True) + result = subprocess.run(args, capture_output=True, text=True, check=True) + except FileNotFoundError: + raise CLIError("--federated-token-callback: command not found: '{}'".format(args[0])) except subprocess.CalledProcessError as ex: raise CLIError('--federated-token-callback command exited with code {}: {}'.format( ex.returncode, (ex.stderr or '').strip())) diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index 70bee3c0dd0..6de0536d5f7 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -305,12 +305,23 @@ def test_service_principal_auth_federated_token_callback(self): def test_federated_token_callback_invokes_command(self, run_mock): run_mock.return_value = mock.MagicMock(stdout='fresh_token\n', stderr='') sp_auth = ServicePrincipalAuth.build_from_credential( - 'tenant1', 'sp_id1', {'client_assertion_callback': 'get-token'}) + 'tenant1', 'sp_id1', {'client_assertion_callback': 'get-token --audience x'}) callback = sp_auth.get_msal_client_credential()['client_assertion'] # The callable runs the user command and returns its trimmed stdout each time MSAL calls it. assert callback() == 'fresh_token' - assert run_mock.call_args.args[0] == 'get-token' + # The command is split into an argv list and run WITHOUT a shell (no shell=True kwarg). + assert run_mock.call_args.args[0] == ['get-token', '--audience', 'x'] + assert 'shell' not in run_mock.call_args.kwargs + + def test_federated_token_callback_no_shell_interpretation(self): + # Shell metacharacters must be treated as literal argv, never interpreted by a shell. + sp_auth = ServicePrincipalAuth.build_from_credential( + 'tenant1', 'sp_id1', {'client_assertion_callback': "get-token ; rm -rf /"}) + with mock.patch('subprocess.run') as run_mock: + run_mock.return_value = mock.MagicMock(stdout='tok\n', stderr='') + sp_auth.get_msal_client_credential()['client_assertion']() + assert run_mock.call_args.args[0] == ['get-token', ';', 'rm', '-rf', '/'] @mock.patch('subprocess.run') def test_federated_token_callback_empty_output(self, run_mock): @@ -374,6 +385,21 @@ def test_get_federated_id_token_github(self, get_mock): assert 'api%3A//AzureADTokenExchange' in called_url assert get_mock.call_args.kwargs['headers']['Authorization'] == 'bearer request_token' + @mock.patch.dict(os.environ, { + 'ACTIONS_ID_TOKEN_REQUEST_URL': 'https://github.example/token', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN': 'request_token' + }, clear=True) + @mock.patch('requests.get') + def test_get_federated_id_token_github_url_without_query(self, get_mock): + # When the request URL has no existing query string, the audience must be appended with '?', not '&'. + get_mock.return_value = mock.MagicMock(ok=True, json=lambda: {'value': 'fresh_id_token'}) + + get_federated_id_token() + + called_url = get_mock.call_args.args[0] + assert called_url.startswith('https://github.example/token?audience=') + assert '&audience=' not in called_url + @mock.patch.dict(os.environ, { 'ACTIONS_ID_TOKEN_REQUEST_URL': 'https://github.example/token', 'ACTIONS_ID_TOKEN_REQUEST_TOKEN': 'request_token' diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 12f538f879c..47f34d16392 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -89,11 +89,13 @@ def load_arguments(self, command): c.argument('federated_identity', options_list=['--federated-identity'], arg_type=get_three_state_flag(), help='Acquire and automatically refresh the OIDC federated token from the CI/CD provider ' '(GitHub Actions or Azure DevOps). Avoids the AADSTS700024 error on long-running ' - 'tasks. Cannot be combined with --federated-token.') + 'tasks. Cannot be combined with --federated-token or --federated-token-callback.') c.argument('federated_token_callback', options_list=['--federated-token-callback'], help='A command that prints a fresh OIDC federated token to stdout. Azure CLI runs it on ' - 'demand to refresh the token, so it works with any CI/CD provider. Cannot be combined ' - 'with --federated-token or --federated-identity.') + 'demand to refresh the token, so it works with any CI/CD provider. The command runs ' + 'without a shell; to use pipes or redirection, point to a script file or wrap it, ' + 'e.g. "bash -c \'...\'". Cannot be combined with --federated-token or ' + '--federated-identity.') # Managed identity c.argument('identity', options_list=('-i', '--identity'), action='store_true', diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 62e3783d614..db1bfedf66f 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -157,10 +157,15 @@ def login(cmd, username=None, password=None, tenant=None, scopes=None, allow_no_ if use_cert_sn_issuer and not service_principal: raise CLIError("usage error: '--use-sn-issuer' is only applicable with a service principal") if service_principal and not username: - raise CLIError('usage error: --service-principal --username NAME --password SECRET --tenant TENANT') + raise CLIError('usage error: --service-principal --username NAME --tenant TENANT with one credential ' + '(--password, --certificate, --federated-token, --federated-identity or ' + '--federated-token-callback)') if sum(map(bool, [client_assertion, federated_identity, federated_token_callback])) > 1: raise CLIError('usage error: Only one of --federated-token, --federated-identity and ' '--federated-token-callback can be specified') + if (client_assertion or federated_identity or federated_token_callback) and (password or certificate): + raise CLIError('usage error: --federated-token, --federated-identity and --federated-token-callback ' + 'cannot be combined with --password or --certificate') if federated_identity and not service_principal: raise CLIError("usage error: '--federated-identity' is only applicable with a service principal") if federated_token_callback and not service_principal: From f1a525acc9d3f29a6db7f91e2f969e6b6bf11e0a Mon Sep 17 00:00:00 2001 From: Maddison Das Date: Wed, 26 Aug 2026 05:22:26 +0100 Subject: [PATCH 5/5] {Profile} `az login`: Add --federated-token-cmd alias to satisfy option-length linter The --federated-token-callback option (26 chars) exceeds azure-cli's 22-char option-length threshold, so add the shorter --federated-token-cmd alias. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/azure-cli/azure/cli/command_modules/profile/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 47f34d16392..ac5fd69ace1 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -90,7 +90,8 @@ def load_arguments(self, command): help='Acquire and automatically refresh the OIDC federated token from the CI/CD provider ' '(GitHub Actions or Azure DevOps). Avoids the AADSTS700024 error on long-running ' 'tasks. Cannot be combined with --federated-token or --federated-token-callback.') - c.argument('federated_token_callback', options_list=['--federated-token-callback'], + c.argument('federated_token_callback', + options_list=['--federated-token-callback', '--federated-token-cmd'], help='A command that prints a fresh OIDC federated token to stdout. Azure CLI runs it on ' 'demand to refresh the token, so it works with any CI/CD provider. The command runs ' 'without a shell; to use pipes or redirection, point to a script file or wrap it, '