From f1bac74455fad9629fd7970f075b2137dbef8595 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:30:49 -0700 Subject: [PATCH 1/4] test(managed-identity): add real MI E2E tests + self-hosted CI stages Mirror the MSAL Go MI E2E setup (#641). Add tests/test_mi_e2e.py, which acquires ARM tokens for the system-assigned identity and each user-assigned binding (client id / resource id / object id) over IMDS, plus the system-assigned identity over Azure Arc, asserting the first call reaches the identity provider and the second is served from the cache (token_source). Uses the same lab identities as the Go / .NET MI E2E tests. Wire two OneBranch stages that run the test on the self-hosted lab pools (MISEManagedIdentity VM/IMDS and MISEAZUREARC), gated so IMDS cases run only on the IMDS pool (MSAL_TEST_MI_IMDS) and the Arc case only on an Arc machine; the test self-skips everywhere else. Exclude the E2E file from the unit stage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ad57850-7d1f-4b9a-bf9f-723d69a9687c --- .Pipelines/template-pipeline-stages.yml | 119 ++++++++++++++++++++++++ tests/test_mi_e2e.py | 110 ++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 tests/test_mi_e2e.py diff --git a/.Pipelines/template-pipeline-stages.yml b/.Pipelines/template-pipeline-stages.yml index b763f01a..813cfd97 100644 --- a/.Pipelines/template-pipeline-stages.yml +++ b/.Pipelines/template-pipeline-stages.yml @@ -124,6 +124,7 @@ stages: --ignore=tests/test_e2e.py \ --ignore=tests/test_e2e_manual.py \ --ignore=tests/test_fmi_e2e.py \ + --ignore=tests/test_mi_e2e.py \ --deselect tests/test_cryptography.py::CryptographyTestCase::test_ceiling_should_be_latest_cryptography_version_plus_three \ --deselect tests/test_cryptography.py::CryptographyTestCase::test_should_be_run_with_latest_version_of_cryptography \ 2>&1 | tee test-results/pytest-unit.log @@ -276,3 +277,121 @@ stages: - bash: rm -f "$(Agent.TempDirectory)/lab-auth.pfx" displayName: 'Remove lab certificate from agent' condition: always() + +# ══════════════════════════════════════════════════════════════════════════════ +# Stage 4 - MI E2E (IMDS) - REAL managed identity token acquisition on an Azure VM. +# Runs on the self-hosted "MISEManagedIdentity" pool, a Windows Azure VM +# that has the lab system-assigned + user-assigned identities assigned. +# Mirrors the MSAL Go "MI E2E - IMDS" stage. Only the IMDS cases in +# tests/test_mi_e2e.py run here (gated on MSAL_TEST_MI_IMDS); the Arc case +# self-skips. No lab certificate is needed - the VM's own managed identity +# is used. +# +# Pool assumptions (self-hosted): Python 3.x on PATH and outbound access to +# PyPI for "pip install". Adjust if the pool differs. +# ══════════════════════════════════════════════════════════════════════════════ +- stage: MIE2EImds + displayName: 'MI E2E - IMDS' + dependsOn: UnitTests + condition: eq(dependencies.UnitTests.result, 'Succeeded') + jobs: + - job: Pytest + displayName: 'Managed Identity E2E - VM / IMDS' + pool: + type: windows + isCustom: true + name: MISEManagedIdentity + timeoutInMinutes: 30 + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + steps: + - task: PowerShell@2 + displayName: 'Install Python dependencies' + inputs: + targetType: 'inline' + workingDirectory: '$(System.DefaultWorkingDirectory)' + script: | + python --version + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install pytest pytest-azurepipelines pytest-timeout + + - task: PowerShell@2 + displayName: 'Run pytest (MI E2E - IMDS)' + inputs: + targetType: 'inline' + workingDirectory: '$(System.DefaultWorkingDirectory)' + script: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Force -Path test-results | Out-Null + python -m pytest -vv --timeout=300 --junitxml=test-results/junit-mi-e2e-imds.xml tests/test_mi_e2e.py + env: + PYTHONUNBUFFERED: '1' + MSAL_TEST_MI_IMDS: '1' + + - task: PublishTestResults@2 + displayName: 'Publish MI E2E (IMDS) test results' + condition: succeededOrFailed() + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: 'test-results/junit-mi-e2e-imds.xml' + failTaskOnFailedTests: true + testRunTitle: 'MI E2E - IMDS' + +# ══════════════════════════════════════════════════════════════════════════════ +# Stage 5 - MI E2E (Azure Arc) - REAL managed identity token acquisition on an +# Azure Arc-enabled machine. Runs on the self-hosted "MISEAZUREARC" pool. +# Azure Arc supports the system-assigned identity only. Mirrors the MSAL Go +# "MI E2E - Azure Arc" stage. tests/test_mi_e2e.py's Arc case runs here; the +# IMDS cases self-skip (MSAL_TEST_MI_IMDS unset). +# +# Pool note: as observed for MSAL Go, the Arc machine's egress inspection can +# reset TLS for package downloads. If "pip install" cannot reach PyPI, pre-provision +# the dependencies on the pool (or use an offline wheel cache) and drop the install step. +# ══════════════════════════════════════════════════════════════════════════════ +- stage: MIE2EAzureArc + displayName: 'MI E2E - Azure Arc' + dependsOn: UnitTests + condition: eq(dependencies.UnitTests.result, 'Succeeded') + jobs: + - job: Pytest + displayName: 'Managed Identity E2E - Azure Arc' + pool: + type: windows + isCustom: true + name: MISEAZUREARC + timeoutInMinutes: 30 + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + steps: + - task: PowerShell@2 + displayName: 'Install Python dependencies' + inputs: + targetType: 'inline' + workingDirectory: '$(System.DefaultWorkingDirectory)' + script: | + python --version + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install pytest pytest-azurepipelines pytest-timeout + + - task: PowerShell@2 + displayName: 'Run pytest (MI E2E - Azure Arc)' + inputs: + targetType: 'inline' + workingDirectory: '$(System.DefaultWorkingDirectory)' + script: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Force -Path test-results | Out-Null + python -m pytest -vv --timeout=300 --junitxml=test-results/junit-mi-e2e-arc.xml tests/test_mi_e2e.py + env: + PYTHONUNBUFFERED: '1' + + - task: PublishTestResults@2 + displayName: 'Publish MI E2E (Azure Arc) test results' + condition: succeededOrFailed() + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: 'test-results/junit-mi-e2e-arc.xml' + failTaskOnFailedTests: true + testRunTitle: 'MI E2E - Azure Arc' diff --git a/tests/test_mi_e2e.py b/tests/test_mi_e2e.py new file mode 100644 index 00000000..1fe15e36 --- /dev/null +++ b/tests/test_mi_e2e.py @@ -0,0 +1,110 @@ +"""End-to-end Managed Identity tests (real token acquisition). + +These tests perform REAL token acquisition and therefore only run on the +self-hosted Azure DevOps pools that are actual Azure VM / Azure Arc machines with +the lab managed identities assigned: + + * IMDS tests -> the "MISEManagedIdentity" pool (an Azure VM). Gated on the + MSAL_TEST_MI_IMDS environment variable, which that pipeline + stage sets. (DEFAULT_TO_VM is also the fallback source on hosted + agents, so an explicit flag is used instead of source detection.) + * Azure Arc -> the "MISEAZUREARC" pool (an Azure Arc-enabled machine). Gated on + the Azure Arc source being detected on the machine. + +They mirror the MSAL Go E2E tests +(apps/tests/e2e/managedidentity_e2e_test.go and managedidentity_arc_e2e_test.go) +and use the SAME lab identities and ARM resource, so both SDKs exercise the same +lab configuration on the same machines. + +Everywhere else (hosted agents, local dev) the tests self-skip. +""" +import os +import unittest + +import requests + +from msal import ( + ManagedIdentityClient, + SystemAssignedManagedIdentity, + UserAssignedManagedIdentity, +) +from msal.managed_identity import get_managed_identity_source, AZURE_ARC + + +# Azure Resource Manager resource. Matches the ARM scope used by the MSAL .NET and +# Go managed identity E2E tests. +_ARM_RESOURCE = "https://management.azure.com" + +# User-assigned managed identities assigned to the MISEManagedIdentity VM. These are +# the SAME values used by the MSAL Go / .NET IMDS E2E tests, so all SDKs exercise the +# same lab configuration on the same VM. +_UAMI_CLIENT_ID = "6325cd32-9911-41f3-819c-416cdf9104e7" +_UAMI_OBJECT_ID = "ecb2ad92-3e30-4505-b79f-ac640d069f24" +_UAMI_RESOURCE_ID = ( + "/subscriptions/c1686c51-b717-4fe0-9af3-24a20a41fb0c/resourcegroups/" + "MSIV2-Testing-MSALNET/providers/Microsoft.ManagedIdentity/userAssignedIdentities/msiv2uami" +) + + +def _acquire_token_twice_assert_caching(test, managed_identity): + """Acquire an ARM token twice for the given managed identity and assert the first + call reaches the identity provider while the second is served from the token cache. + + Shared by the IMDS and Azure Arc E2E tests, mirroring the Go helper of the same name. + """ + client = ManagedIdentityClient(managed_identity, http_client=requests.Session()) + + first = client.acquire_token_for_client(resource=_ARM_RESOURCE) + test.assertNotIn("error", first, "first acquisition failed: {}".format(first)) + test.assertIn("access_token", first) + test.assertEqual( + "identity_provider", first.get("token_source"), + "first call should reach the identity provider") + + second = client.acquire_token_for_client(resource=_ARM_RESOURCE) + test.assertNotIn("error", second, "second acquisition failed: {}".format(second)) + test.assertEqual( + "cache", second.get("token_source"), + "second call should be served from the token cache") + test.assertEqual( + first["access_token"], second["access_token"], + "cached token should match the original token") + + +@unittest.skipUnless( + os.getenv("MSAL_TEST_MI_IMDS"), + "Set MSAL_TEST_MI_IMDS to run on the MISEManagedIdentity Azure VM (IMDS) pool") +class ManagedIdentityImdsE2ETestCase(unittest.TestCase): + """Acquires ARM tokens over IMDS v1 for the system-assigned identity and each + user-assigned identity binding (client id / resource id / object id). Each test + asserts the first call reaches the identity provider and the second is cached.""" + + def test_system_assigned(self): + _acquire_token_twice_assert_caching(self, SystemAssignedManagedIdentity()) + + def test_user_assigned_client_id(self): + _acquire_token_twice_assert_caching( + self, UserAssignedManagedIdentity(client_id=_UAMI_CLIENT_ID)) + + def test_user_assigned_resource_id(self): + _acquire_token_twice_assert_caching( + self, UserAssignedManagedIdentity(resource_id=_UAMI_RESOURCE_ID)) + + def test_user_assigned_object_id(self): + _acquire_token_twice_assert_caching( + self, UserAssignedManagedIdentity(object_id=_UAMI_OBJECT_ID)) + + +@unittest.skipUnless( + get_managed_identity_source() == AZURE_ARC, + "Runs only on an Azure Arc-enabled machine (the MISEAZUREARC pool)") +class ManagedIdentityAzureArcE2ETestCase(unittest.TestCase): + """Azure Arc supports the system-assigned identity only, so unlike the IMDS tests + there are no user-assigned variants.""" + + def test_system_assigned(self): + _acquire_token_twice_assert_caching(self, SystemAssignedManagedIdentity()) + + +if __name__ == "__main__": + unittest.main() From c1b20476049f29d0bab1cbf8e4d2083a3ff4e70b Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:13:33 -0700 Subject: [PATCH 2/4] ci(mi-e2e): run pytest directly on self-hosted pools; drop runtime pip install The self-hosted MI E2E pools carry the toolchain (like Go/.NET); pre-provision msal's deps + pytest on them instead of installing at run time. This removes the runtime "pip install", which the Azure Arc pool cannot do anyway - its egress TLS inspection blocks files.pythonhosted.org (the same restriction MSAL Go documented). Also drop --timeout so pytest-timeout is not required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ad57850-7d1f-4b9a-bf9f-723d69a9687c --- .Pipelines/template-pipeline-stages.yml | 34 ++++++++----------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/.Pipelines/template-pipeline-stages.yml b/.Pipelines/template-pipeline-stages.yml index 813cfd97..4b8ce93c 100644 --- a/.Pipelines/template-pipeline-stages.yml +++ b/.Pipelines/template-pipeline-stages.yml @@ -305,17 +305,9 @@ stages: variables: ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' steps: - - task: PowerShell@2 - displayName: 'Install Python dependencies' - inputs: - targetType: 'inline' - workingDirectory: '$(System.DefaultWorkingDirectory)' - script: | - python --version - python -m pip install --upgrade pip - python -m pip install -r requirements.txt - python -m pip install pytest pytest-azurepipelines pytest-timeout - + # msal + its dependencies (requests, cryptography, PyJWT) and pytest are PRE-PROVISIONED on the + # self-hosted pool (like the Go/.NET toolchains already are), so there is no runtime pip install. + # This also avoids the Arc pool's egress TLS inspection blocking files.pythonhosted.org. - task: PowerShell@2 displayName: 'Run pytest (MI E2E - IMDS)' inputs: @@ -323,8 +315,9 @@ stages: workingDirectory: '$(System.DefaultWorkingDirectory)' script: | $ErrorActionPreference = 'Stop' + python --version New-Item -ItemType Directory -Force -Path test-results | Out-Null - python -m pytest -vv --timeout=300 --junitxml=test-results/junit-mi-e2e-imds.xml tests/test_mi_e2e.py + python -m pytest -vv --junitxml=test-results/junit-mi-e2e-imds.xml tests/test_mi_e2e.py env: PYTHONUNBUFFERED: '1' MSAL_TEST_MI_IMDS: '1' @@ -364,17 +357,9 @@ stages: variables: ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' steps: - - task: PowerShell@2 - displayName: 'Install Python dependencies' - inputs: - targetType: 'inline' - workingDirectory: '$(System.DefaultWorkingDirectory)' - script: | - python --version - python -m pip install --upgrade pip - python -m pip install -r requirements.txt - python -m pip install pytest pytest-azurepipelines pytest-timeout - + # msal + its dependencies (requests, cryptography, PyJWT) and pytest are PRE-PROVISIONED on the + # self-hosted pool (like the Go/.NET toolchains already are), so there is no runtime pip install. + # This is required here because the Arc machine's egress TLS inspection blocks files.pythonhosted.org. - task: PowerShell@2 displayName: 'Run pytest (MI E2E - Azure Arc)' inputs: @@ -382,8 +367,9 @@ stages: workingDirectory: '$(System.DefaultWorkingDirectory)' script: | $ErrorActionPreference = 'Stop' + python --version New-Item -ItemType Directory -Force -Path test-results | Out-Null - python -m pytest -vv --timeout=300 --junitxml=test-results/junit-mi-e2e-arc.xml tests/test_mi_e2e.py + python -m pytest -vv --junitxml=test-results/junit-mi-e2e-arc.xml tests/test_mi_e2e.py env: PYTHONUNBUFFERED: '1' From 6f210dbf8a59e2e7e2b73e13d71b6cb7e70dcbb4 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:22:38 -0700 Subject: [PATCH 3/4] ci(mi-e2e): resolve python.exe by full path so the step doesn't depend on the agent PATH Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ad57850-7d1f-4b9a-bf9f-723d69a9687c --- .Pipelines/template-pipeline-stages.yml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.Pipelines/template-pipeline-stages.yml b/.Pipelines/template-pipeline-stages.yml index 4b8ce93c..4687e74c 100644 --- a/.Pipelines/template-pipeline-stages.yml +++ b/.Pipelines/template-pipeline-stages.yml @@ -315,9 +315,16 @@ stages: workingDirectory: '$(System.DefaultWorkingDirectory)' script: | $ErrorActionPreference = 'Stop' - python --version + $py = (Get-Command python.exe -ErrorAction SilentlyContinue).Source + if (-not $py) { + $py = Get-ChildItem 'C:\Program Files\Python3*\python.exe','C:\Program Files (x86)\Python3*\python.exe','C:\Python3*\python.exe' -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + } + if (-not $py) { throw 'Python not found on this agent. Install Python 3.x on the pool machine.' } + Write-Host "Using Python: $py" + & $py --version New-Item -ItemType Directory -Force -Path test-results | Out-Null - python -m pytest -vv --junitxml=test-results/junit-mi-e2e-imds.xml tests/test_mi_e2e.py + & $py -m pytest -vv --junitxml=test-results/junit-mi-e2e-imds.xml tests/test_mi_e2e.py env: PYTHONUNBUFFERED: '1' MSAL_TEST_MI_IMDS: '1' @@ -367,9 +374,16 @@ stages: workingDirectory: '$(System.DefaultWorkingDirectory)' script: | $ErrorActionPreference = 'Stop' - python --version + $py = (Get-Command python.exe -ErrorAction SilentlyContinue).Source + if (-not $py) { + $py = Get-ChildItem 'C:\Program Files\Python3*\python.exe','C:\Program Files (x86)\Python3*\python.exe','C:\Python3*\python.exe' -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + } + if (-not $py) { throw 'Python not found on this agent. Install Python 3.x on the pool machine.' } + Write-Host "Using Python: $py" + & $py --version New-Item -ItemType Directory -Force -Path test-results | Out-Null - python -m pytest -vv --junitxml=test-results/junit-mi-e2e-arc.xml tests/test_mi_e2e.py + & $py -m pytest -vv --junitxml=test-results/junit-mi-e2e-arc.xml tests/test_mi_e2e.py env: PYTHONUNBUFFERED: '1' From ce3f0b1f2dc520ff5e9684c7a688ece560e6ab51 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:55:44 -0700 Subject: [PATCH 4/4] test(mi-e2e): redact tokens in logs, close session, fork-guard self-hosted stages Address PR review feedback: - Assert access_token present on the cached (second) acquisition before indexing. - Never log token material: summarize error results to safe fields only, and compare the cached token to the original via SHA-256 digest instead of raw values. - Close the requests.Session in a finally block. - Add a stage-level fork guard so forked-PR code never runs on the self-hosted MISEManagedIdentity / MISEAZUREARC pools. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ad57850-7d1f-4b9a-bf9f-723d69a9687c --- .Pipelines/template-pipeline-stages.yml | 6 ++- tests/test_mi_e2e.py | 57 +++++++++++++++++-------- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/.Pipelines/template-pipeline-stages.yml b/.Pipelines/template-pipeline-stages.yml index 4687e74c..ec22ca1b 100644 --- a/.Pipelines/template-pipeline-stages.yml +++ b/.Pipelines/template-pipeline-stages.yml @@ -293,7 +293,8 @@ stages: - stage: MIE2EImds displayName: 'MI E2E - IMDS' dependsOn: UnitTests - condition: eq(dependencies.UnitTests.result, 'Succeeded') + # Fork guard: never run untrusted forked-PR code on the self-hosted pool. + condition: and(eq(dependencies.UnitTests.result, 'Succeeded'), ne(variables['System.PullRequest.IsFork'], 'True')) jobs: - job: Pytest displayName: 'Managed Identity E2E - VM / IMDS' @@ -352,7 +353,8 @@ stages: - stage: MIE2EAzureArc displayName: 'MI E2E - Azure Arc' dependsOn: UnitTests - condition: eq(dependencies.UnitTests.result, 'Succeeded') + # Fork guard: never run untrusted forked-PR code on the self-hosted pool. + condition: and(eq(dependencies.UnitTests.result, 'Succeeded'), ne(variables['System.PullRequest.IsFork'], 'True')) jobs: - job: Pytest displayName: 'Managed Identity E2E - Azure Arc' diff --git a/tests/test_mi_e2e.py b/tests/test_mi_e2e.py index 1fe15e36..5509fc53 100644 --- a/tests/test_mi_e2e.py +++ b/tests/test_mi_e2e.py @@ -18,6 +18,7 @@ Everywhere else (hosted agents, local dev) the tests self-skip. """ +import hashlib import os import unittest @@ -46,29 +47,51 @@ ) +def _safe_error(result): + """Return a log-safe summary of a failed result. + + Only the non-sensitive error fields are surfaced, so an assertion failure can + never spill an access token (or the whole result dict) into the CI logs. + """ + return { + key: result[key] + for key in ("error", "error_description", "correlation_id") + if key in result + } + + def _acquire_token_twice_assert_caching(test, managed_identity): """Acquire an ARM token twice for the given managed identity and assert the first call reaches the identity provider while the second is served from the token cache. Shared by the IMDS and Azure Arc E2E tests, mirroring the Go helper of the same name. """ - client = ManagedIdentityClient(managed_identity, http_client=requests.Session()) - - first = client.acquire_token_for_client(resource=_ARM_RESOURCE) - test.assertNotIn("error", first, "first acquisition failed: {}".format(first)) - test.assertIn("access_token", first) - test.assertEqual( - "identity_provider", first.get("token_source"), - "first call should reach the identity provider") - - second = client.acquire_token_for_client(resource=_ARM_RESOURCE) - test.assertNotIn("error", second, "second acquisition failed: {}".format(second)) - test.assertEqual( - "cache", second.get("token_source"), - "second call should be served from the token cache") - test.assertEqual( - first["access_token"], second["access_token"], - "cached token should match the original token") + http_client = requests.Session() + client = ManagedIdentityClient(managed_identity, http_client=http_client) + try: + first = client.acquire_token_for_client(resource=_ARM_RESOURCE) + test.assertNotIn( + "error", first, "first acquisition failed: {}".format(_safe_error(first))) + test.assertIn("access_token", first) + test.assertEqual( + "identity_provider", first.get("token_source"), + "first call should reach the identity provider") + + second = client.acquire_token_for_client(resource=_ARM_RESOURCE) + test.assertNotIn( + "error", second, "second acquisition failed: {}".format(_safe_error(second))) + test.assertIn("access_token", second) + test.assertEqual( + "cache", second.get("token_source"), + "second call should be served from the token cache") + # Compare tokens by SHA-256 digest so a mismatch never prints the actual + # token material into CI logs. + test.assertEqual( + hashlib.sha256(first["access_token"].encode("utf-8")).hexdigest(), + hashlib.sha256(second["access_token"].encode("utf-8")).hexdigest(), + "cached token should match the original token") + finally: + http_client.close() @unittest.skipUnless(