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..90f0df8260e 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" @@ -33,6 +34,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") @@ -257,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. @@ -294,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 = {} @@ -311,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} @@ -347,7 +358,19 @@ 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} + + # 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 @@ -456,3 +479,148 @@ 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() + 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 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(): + """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') + # 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', + '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 + + +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 + + +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 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: + 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())) + 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 993039faca3..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 @@ -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,74 @@ 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_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 --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' + # 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): + 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") @@ -291,6 +360,93 @@ 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'.""" + + @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_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' + }, 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_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): + # 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() + + @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): 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..ac5fd69ace1 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,17 @@ 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 ' + '(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', '--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, ' + '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 164a362a054..db1bfedf66f 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, federated_token_callback=None, # Managed identity identity=False, client_id=None, object_id=None, resource_id=None, # Subscription discovery and default subscription selection control @@ -156,7 +157,19 @@ 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: + 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: @@ -188,7 +201,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 or federated_token_callback): try: password = prompt_pass('Password: ') except NoTTYException: @@ -197,11 +210,12 @@ 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, + 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