diff --git a/.github/workflows/deploy-managed-identity-webapi.yml b/.github/workflows/deploy-managed-identity-webapi.yml deleted file mode 100644 index 05da3c87d0..0000000000 --- a/.github/workflows/deploy-managed-identity-webapi.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Build and Deploy Managed Identity WebAPI - -on: - push: - branches: - - main - pull_request: - paths: - - '.github/workflows/deploy-managed-identity-webapi.yml' - - 'src/client/Microsoft.Identity.Client/ManagedIdentity/**' - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '8.0.x' - - - name: Restore dependencies - working-directory: ./tests/devapps/Managed Identity apps/ManagedIdentityWebApi - run: dotnet restore - - - name: Build - working-directory: ./tests/devapps/Managed Identity apps/ManagedIdentityWebApi - run: dotnet build --configuration Release --no-restore - - - name: Publish - working-directory: ./tests/devapps/Managed Identity apps/ManagedIdentityWebApi - run: dotnet publish -c Release -o ${{github.workspace}}/publish - - - name: Deploy to Azure Web App - uses: azure/webapps-deploy@v2 - with: - app-name: 'msalmsi' - publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} - package: ${{github.workspace}}/publish - - - name: Test deployed endpoint - Get Token - run: | - echo "Testing token endpoint..." - RESPONSE=$(curl -s -X GET "https://msalmsi-buaxcubfa5a9drf9.westus3-01.azurewebsites.net/AppService?resourceuri=https://vault.azure.net") - echo "Response: $RESPONSE" - if echo "$RESPONSE" | grep -q "Access token received"; then - echo "✅ Successfully retrieved access token" - exit 0 - else - echo "⚠️ Response received but may need review: $RESPONSE" - exit 0 - fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 4297ce313c..d7a29abda7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +4.87.0 +====== + +### New Features +- Exposed `MsalServiceException.ErrorCodesForLogging` (as a public `IReadOnlyList`), surfacing the raw STS-specific error codes (for example the numeric `AADSTS` codes) for diagnostics and logging. [#6138](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6138) + +4.86.1 +====== + +### Bug Fixes +- Fixed the mTLS Proof-of-Possession token cache to key on the certificate's full DER (`x5t#S256`) instead of only the public key, preventing a stale token (and `AADSTS500181`) after a same-key certificate renewal. [#6123](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6123) +- Fell back to RS256 when a certificate's PSS signing operation is rejected by `RSACryptoServiceProvider`, rebuilding the client assertion so authentication can proceed. [#6126](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6126) +- Detect and reject symbolic links in the Unix cache-file write path (lstat pre-check plus `O_NOFOLLOW`), closing a TOCTOU window. [#6115](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6115) +- Corrected misleading "region required" error messages and doc comments in the mTLS PoP flow. [#6127](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6127) + 4.86.0 ====== @@ -25,6 +40,7 @@ - Exposed canonical OpenTelemetry tag names per metric via `MsalMetricsCatalog.CanonicalTagsByMetric` for discoverability and validation. [#6076](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6076) ### Changes +- **Breaking change:** Removed the **experimental** managed identity support for `WithClaimsFromClient(claimsJson)`; the API is now confidential-client only. The `AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string)` overload (introduced experimentally in 4.84.2, gated behind `WithExperimentalFeatures`) has been removed, and managed identity no longer forwards client-originated claims to IMDS. Use `WithClaimsFromClient` on the confidential client flows (`AcquireTokenForClient`) instead. [#6113](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6113) (feature originally introduced in [#5999](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/5999)) - Managed identity error messages and request-failure logs now include the detected `ManagedIdentitySource` (e.g., `AppService`, `Imds`, `ServiceFabric`) so the host-issued `Managed Identity Correlation ID` can be traced to the correct host's telemetry. [#6101](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6101) 4.85.0 diff --git a/build/scripts/Deploy-WebAppZipDeploy.ps1 b/build/scripts/Deploy-WebAppZipDeploy.ps1 new file mode 100644 index 0000000000..e49747dd47 --- /dev/null +++ b/build/scripts/Deploy-WebAppZipDeploy.ps1 @@ -0,0 +1,58 @@ +<# +.SYNOPSIS + Deploys a published .NET app to an Azure App Service using the publish profile (Kudu Zip Deploy). + +.DESCRIPTION + Reads the web app publish profile XML from the PUBLISH_PROFILE_XML environment variable + (sourced from Key Vault), extracts the SCM basic-auth credentials from the MSDeploy entry, + zips the published output, and POSTs it to the Kudu '/api/zipdeploy' endpoint. No ARM service + connection is required because the publish profile carries its own SCM credentials. + +.PARAMETER PublishDirectory + Directory containing the published app output to deploy. + +.PARAMETER ZipPath + Full path of the zip archive to create and upload. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $PublishDirectory, + + [Parameter(Mandatory = $true)] + [string] $ZipPath +) + +$ErrorActionPreference = 'Stop' + +$profileXml = $env:PUBLISH_PROFILE_XML +if ([string]::IsNullOrWhiteSpace($profileXml)) { + throw "The 'PUBLISH_PROFILE_XML' environment variable is empty. Map the publish-profile Key Vault secret to it via the task 'env:' block." +} + +[xml] $publishData = $profileXml +$msDeployProfile = $publishData.publishData.publishProfile | + Where-Object { $_.publishMethod -eq 'MSDeploy' } | + Select-Object -First 1 + +if ($null -eq $msDeployProfile) { + throw 'No MSDeploy entry found in the publish profile.' +} + +$scmHost = ($msDeployProfile.publishUrl -split ':')[0] +$userName = $msDeployProfile.userName +$password = $msDeployProfile.userPWD + +if (Test-Path -Path $ZipPath) { + Remove-Item -Path $ZipPath -Force +} +Compress-Archive -Path (Join-Path $PublishDirectory '*') -DestinationPath $ZipPath -Force + +$authHeader = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("$($userName):$($password)")) +$zipDeployUri = "https://$scmHost/api/zipdeploy" +$zipLength = (Get-Item -Path $ZipPath).Length + +Write-Host "Deploying $zipLength bytes to $zipDeployUri" +Invoke-RestMethod -Uri $zipDeployUri -Method Post -InFile $ZipPath -ContentType 'application/zip' ` + -Headers @{ Authorization = "Basic $authHeader" } -TimeoutSec 300 | Out-Null +Write-Host 'Deployment complete.' diff --git a/build/scripts/Invoke-ManagedIdentityWebAppTest.ps1 b/build/scripts/Invoke-ManagedIdentityWebAppTest.ps1 new file mode 100644 index 0000000000..cdbeefcb09 --- /dev/null +++ b/build/scripts/Invoke-ManagedIdentityWebAppTest.ps1 @@ -0,0 +1,159 @@ +<# +.SYNOPSIS + Verifies the Easy Auth-protected ManagedIdentityWebApi endpoint for system- and user-assigned MI. + +.DESCRIPTION + Acquires an app-only Entra token as the Easy Auth app registration using the LabAuth client + certificate (certificate-based client credentials), then calls the protected endpoint with a + bearer token for both system-assigned and (optionally) user-assigned managed identity, asserting + the web app successfully acquired a managed-identity token. Easy Auth rejects unauthenticated + callers with HTTP 401. + + Token version note: the resource app registration uses the default requestedAccessTokenVersion + (v1), so requesting 'api:///.default' from the v2.0 token endpoint still yields a + v1-form access token (iss = https://sts.windows.net//, aud = api://), which + matches the Easy Auth issuer/audience configuration. + +.PARAMETER ClientId + App (client) ID of the Easy Auth app registration. + +.PARAMETER TenantId + Tenant (directory) ID that hosts the app registration and web app. + +.PARAMETER WebAppName + Name of the App Service (without the .azurewebsites.net suffix). + +.PARAMETER ResourceUri + Resource URI the web app should acquire a managed-identity token for (for example + https://vault.azure.net). + +.PARAMETER UserAssignedClientId + Optional client ID of a user-assigned managed identity to additionally verify. When empty, only + the system-assigned identity is checked. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $ClientId, + + [Parameter(Mandatory = $true)] + [string] $TenantId, + + [Parameter(Mandatory = $true)] + [string] $WebAppName, + + [Parameter(Mandatory = $true)] + [string] $ResourceUri, + + [string] $UserAssignedClientId +) + +$ErrorActionPreference = 'Stop' + +$pfxPath = $env:LABAUTH_PFX_PATH +if ([string]::IsNullOrWhiteSpace($pfxPath)) { + throw "The 'LABAUTH_PFX_PATH' environment variable is empty. It should be published by the 'Install LabAuth client certificate' step." +} + +function ConvertTo-Base64Url { + param([byte[]] $Bytes) + return [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') +} + +# --- Acquire an app-only token as the Easy Auth app using the LabAuth certificate. --- +# Load the certificate for signing only, keeping the private key in memory (EphemeralKeySet) +# so key material is not persisted to the agent user profile on disk. +$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $pfxPath, '', [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet) + +$tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" +$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + +$header = @{ alg = 'RS256'; typ = 'JWT'; x5t = (ConvertTo-Base64Url $cert.GetCertHash()) } | ConvertTo-Json -Compress +$payload = @{ + aud = $tokenEndpoint + iss = $ClientId + sub = $ClientId + jti = [guid]::NewGuid().ToString() + nbf = $now + exp = $now + 600 +} | ConvertTo-Json -Compress + +$unsigned = (ConvertTo-Base64Url ([System.Text.Encoding]::UTF8.GetBytes($header))) + '.' + + (ConvertTo-Base64Url ([System.Text.Encoding]::UTF8.GetBytes($payload))) + +$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert) +if ($null -eq $rsa) { + throw 'Unable to load the RSA private key from the LabAuth certificate. Ensure the PFX contains an accessible private key.' +} +$signature = $rsa.SignData( + [System.Text.Encoding]::UTF8.GetBytes($unsigned), + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) +$clientAssertion = $unsigned + '.' + (ConvertTo-Base64Url $signature) + +$tokenRequestBody = @{ + client_id = $ClientId + scope = "api://$ClientId/.default" + grant_type = 'client_credentials' + client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + client_assertion = $clientAssertion +} + +$tokenResponse = Invoke-RestMethod -Uri $tokenEndpoint -Method Post -Body $tokenRequestBody -ContentType 'application/x-www-form-urlencoded' +$accessToken = $tokenResponse.access_token +if ([string]::IsNullOrEmpty($accessToken)) { + throw 'Failed to acquire an access token for the Easy Auth app registration.' +} + +# --- Call the protected endpoint for each managed identity type. --- +$baseUrl = "https://$WebAppName.azurewebsites.net/AppService?resourceuri=$ResourceUri" +$authHeaders = @{ Authorization = "Bearer $accessToken" } + +function Invoke-MiEndpoint { + param( + [string] $Label, + [string] $Url + ) + + Write-Host "[$Label] Calling: $Url" + $response = $null + $lastError = $null + + # Allow the freshly deployed app to warm up; retry a few times. + for ($attempt = 1; $attempt -le 5; $attempt++) { + try { + $response = Invoke-RestMethod -Uri $Url -Method Get -Headers $authHeaders -TimeoutSec 120 + break + } + catch { + $lastError = $_.Exception.Message + Write-Host "[$Label] attempt $attempt failed: $lastError" + Start-Sleep -Seconds 15 + } + } + + if ($null -eq $response) { + throw "[$Label] All attempts to call the protected endpoint failed. Last error: $lastError" + } + + Write-Host "[$Label] Response: $response" + if ("$response" -notmatch 'Access token received') { + throw "[$Label] Unexpected response from protected endpoint: $response" + } + + Write-Host "[$Label] OK" +} + +# System-assigned managed identity (no userAssignedId). +Invoke-MiEndpoint -Label 'SAMI' -Url $baseUrl + +# User-assigned managed identity (when a client ID is provided). +if (-not [string]::IsNullOrWhiteSpace($UserAssignedClientId)) { + Invoke-MiEndpoint -Label 'UAMI' -Url ($baseUrl + '&userAssignedId=' + $UserAssignedClientId) +} +else { + Write-Host 'UserAssignedClientId not provided; skipping user-assigned managed identity check.' +} + +Write-Host 'All managed identity checks passed.' diff --git a/build/template-build-and-run-all-tests.yaml b/build/template-build-and-run-all-tests.yaml index c93a872dbf..48f2895de4 100644 --- a/build/template-build-and-run-all-tests.yaml +++ b/build/template-build-and-run-all-tests.yaml @@ -339,3 +339,29 @@ stages: parameters: BuildConfiguration: 'Release' TargetFramework: 'net8.0' + +# ────────────────────────────────────────────── +# Stage 12 — Deploy & Test Managed Identity WebApp (Easy Auth) +# ────────────────────────────────────────────── +- stage: DeployAndTestManagedIdentityWebApp + displayName: 'Deploy & Test MI WebApp (Easy Auth)' + dependsOn: [] # Builds from source — no dependency on Build stage + jobs: + + - job: 'DeployAndTestManagedIdentityWebApp' + displayName: 'Deploy ManagedIdentityWebApi and verify Easy Auth-protected endpoint' + pool: + vmImage: 'windows-2022' + demands: + - msbuild + - visualstudio + variables: + runCodesignValidationInjection: false + Codeql.SkipTaskAutoInjection: true + condition: and(succeeded(), eq(variables['RunManagedIdentityWebAppDeploy'], 'true')) + + steps: + - template: template-deploy-managed-identity-webapp-and-test.yaml + parameters: + BuildConfiguration: 'Release' + TargetFramework: 'net8.0' diff --git a/build/template-deploy-managed-identity-webapp-and-test.yaml b/build/template-deploy-managed-identity-webapp-and-test.yaml new file mode 100644 index 0000000000..61627b204a --- /dev/null +++ b/build/template-deploy-managed-identity-webapp-and-test.yaml @@ -0,0 +1,120 @@ +# template-deploy-managed-identity-webapp-and-test.yaml +# +# Builds and deploys the ManagedIdentityWebApi dev app to an Azure App Service protected by +# App Service Authentication (Easy Auth / Microsoft Entra ID), then verifies the protected +# endpoint by acquiring a bearer token with the LabAuth certificate for both system-assigned +# and user-assigned managed identity. +# +# Auth model: +# * The LabAuth client certificate is fetched from Key Vault and installed by the shared +# 'template-install-keyvault-secrets.yaml' template (exposes $(generateLabCert.certDir)). +# * Deploy uses the web app publish profile stored in Key Vault (Kudu Zip Deploy) - no ARM +# service connection is required for the deployment itself. +# * The test step acquires an app-only token as the Easy Auth app registration using the +# LabAuth certificate, then calls the endpoint with 'Authorization: Bearer'. Easy Auth +# rejects unauthenticated callers with HTTP 401. +# +# Prerequisites (provisioned outside this pipeline): +# * App Service '' with Easy Auth configured to accept 'api://'. +# * App registration '' carrying the LabAuth certificate. +# * Key Vault 'msidlabs' secrets: 'LabAuth' and ''. + +parameters: +- name: BuildConfiguration + type: string + default: 'Release' +- name: TargetFramework + type: string + default: 'net8.0' +- name: WebAppName + type: string + default: 'msal-webapp-ci-auth-913a8e' +- name: EasyAuthClientId + type: string + default: '1060f219-d935-462d-a624-97cbdd648f9e' +- name: TenantId + type: string + default: '10c419d4-4a50-45b2-aa4e-919fb84df24f' +- name: ProjectDirectory + type: string + default: 'tests/devapps/Managed Identity apps/ManagedIdentityWebApi' +- name: PublishProfileSecretName + type: string + default: 'MSAL-WebApp-CI-PublishProfile' +- name: KeyVaultName + type: string + default: 'msidlabs' +- name: AzureServiceConnection + type: string + default: 'AuthSdkResourceManager' +- name: TestResourceUri + type: string + default: 'https://vault.azure.net' +- name: UserAssignedClientId + type: string + default: 'e9207e64-f339-4a63-b074-90dc105a1954' + +steps: +- task: UseDotNet@2 + displayName: 'Install .NET 8 SDK' + inputs: + packageType: 'sdk' + version: '8.0.x' + +# Reuse the shared template to fetch LabAuth from Key Vault and install the client certificate. +# It publishes the PFX path as the output variable $(generateLabCert.certDir). +- template: template-install-keyvault-secrets.yaml + +# Fetch the web app publish profile (separate Key Vault secret used only by this stage). +- task: AzureKeyVault@2 + displayName: 'Fetch web app publish profile' + inputs: + azureSubscription: '${{ parameters.AzureServiceConnection }}' + KeyVaultName: '${{ parameters.KeyVaultName }}' + SecretsFilter: '${{ parameters.PublishProfileSecretName }}' + +# NetCoreOnly restricts the referenced Microsoft.Identity.Client project to netstandard2.0 + net8.0, +# avoiding the mobile (net8.0-ios / net8.0-android) target frameworks that require MAUI workloads +# which are not installed on the hosted Windows agent. +- powershell: dotnet restore -p:NetCoreOnly=true + displayName: 'Restore ManagedIdentityWebApi' + workingDirectory: '$(Build.SourcesDirectory)/${{ parameters.ProjectDirectory }}' + +- powershell: dotnet build --configuration ${{ parameters.BuildConfiguration }} --framework ${{ parameters.TargetFramework }} --no-restore -p:NetCoreOnly=true + displayName: 'Build ManagedIdentityWebApi' + workingDirectory: '$(Build.SourcesDirectory)/${{ parameters.ProjectDirectory }}' + +- powershell: dotnet publish --configuration ${{ parameters.BuildConfiguration }} --framework ${{ parameters.TargetFramework }} --no-build --output '$(Build.ArtifactStagingDirectory)/webapp' -p:NetCoreOnly=true + displayName: 'Publish ManagedIdentityWebApi' + workingDirectory: '$(Build.SourcesDirectory)/${{ parameters.ProjectDirectory }}' + +# Deploy using the publish profile (Kudu Zip Deploy). The publish profile carries its own SCM +# basic-auth credentials, so no ARM service connection is needed for the deployment. +- task: PowerShell@2 + displayName: 'Deploy to Azure Web App (Zip Deploy via publish profile)' + inputs: + filePath: '$(Build.SourcesDirectory)/build/scripts/Deploy-WebAppZipDeploy.ps1' + arguments: '-PublishDirectory "$(Build.ArtifactStagingDirectory)/webapp" -ZipPath "$(Build.ArtifactStagingDirectory)/webapp.zip"' + env: + PUBLISH_PROFILE_XML: $(${{ parameters.PublishProfileSecretName }}) + +# Acquire an app-only token as the Easy Auth app (LabAuth cert, installed by the shared template) +# and verify the protected endpoint for system-assigned and user-assigned managed identity. +# +# Token version: the resource app registration uses the default requestedAccessTokenVersion (v1), +# so requesting 'api:///.default' from the v2.0 token endpoint still yields a v1-form +# access token (iss = https://sts.windows.net//, aud = api://), which matches +# the Easy Auth issuer/audience. If the app is switched to requestedAccessTokenVersion=2, update +# the Easy Auth issuer to the v2 endpoint (.../v2.0) to keep them aligned. +- task: PowerShell@2 + displayName: 'Test protected endpoint (SAMI + UAMI via LabAuth)' + inputs: + filePath: '$(Build.SourcesDirectory)/build/scripts/Invoke-ManagedIdentityWebAppTest.ps1' + arguments: >- + -ClientId "${{ parameters.EasyAuthClientId }}" + -TenantId "${{ parameters.TenantId }}" + -WebAppName "${{ parameters.WebAppName }}" + -ResourceUri "${{ parameters.TestResourceUri }}" + -UserAssignedClientId "${{ parameters.UserAssignedClientId }}" + env: + LABAUTH_PFX_PATH: $(generateLabCert.certDir) diff --git a/docs/nsp_claims_design.md b/docs/nsp_claims_design.md index 84bad81183..17ff621653 100644 --- a/docs/nsp_claims_design.md +++ b/docs/nsp_claims_design.md @@ -1,5 +1,12 @@ # WithClaimsFromClient API Design +> **Implementation status (updated):** `WithClaimsFromClient` ships for **confidential client only** +> (`AcquireTokenForClientParameterBuilder`, via +> `AbstractConfidentialClientAcquireTokenParameterBuilderExtension`). **Managed Identity support has been +> removed:** there is no `WithClaimsFromClient` overload on `AcquireTokenForManagedIdentityParameterBuilder`, +> and MSAL does not forward client-originated claims to IMDS (MSIv1/MSIv2). The Managed Identity / IMDS +> sections below are retained for historical design context only and do **not** reflect shipped behavior. + ## Background Azure Redis Cache operates in a Backing resource VM/VMSS and uses MSAL with Managed Identity credentials to acquire tokens from ESTS. The Redis team has requested that MSAL support sending NSP (Network Security Perimeter) claims to IMDS, so that the resulting tokens contain the NSP claim required to access NSP-protected resources. diff --git a/docs/sni_mtls_pop_token_design.md b/docs/sni_mtls_pop_token_design.md index 04b79bb50d..e951684e1e 100644 --- a/docs/sni_mtls_pop_token_design.md +++ b/docs/sni_mtls_pop_token_design.md @@ -88,7 +88,6 @@ IConfidentialClientApplication app = ConfidentialClientApplicationBuilder.Create .WithAuthority(authority) .WithAzureRegion("westus") .WithCertificate(certificate, true) - .WithExperimentalFeatures(true) .Build(); AuthenticationResult result = await app.AcquireTokenForClient(scopes).WithMtlsProofOfPossession() diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs index dfe1bcf820..a6f9c5b44e 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs @@ -83,31 +83,6 @@ public AcquireTokenForManagedIdentityParameterBuilder WithClaims(string claims) return this; } - /// - /// Specifies client-originated claims to include in the token request. - /// Unlike (for server-issued claims challenges), tokens acquired - /// with client claims are cached and keyed on the claims value. Different claim values produce - /// separate cache entries. Use stable, non-dynamic claim values to avoid cache fragmentation. - /// - /// A JSON string containing the client claims. Must be valid JSON. - /// The builder to chain .With methods. - public AcquireTokenForManagedIdentityParameterBuilder WithClaimsFromClient(string claimsJson) - { - if (string.IsNullOrWhiteSpace(claimsJson)) - { - return this; - } - - ValidateUseOfExperimentalFeature(); - - CommonParameters.ClientClaims = claimsJson; - - CommonParameters.CacheKeyComponents ??= new SortedList>>(); - CommonParameters.CacheKeyComponents["client_claims"] = _ => Task.FromResult(claimsJson); - - return this; - } - /// internal override Task ExecuteInternalAsync(CancellationToken cancellationToken) { diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs index 55630525f4..c2bf16fb34 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs @@ -23,12 +23,6 @@ internal class AcquireTokenForManagedIdentityParameters : IAcquireTokenParameter public string Claims { get; set; } - /// - /// Client-originated claims to be sent to the identity endpoint. - /// Unlike (server-issued), these are cached and keyed on the claims value. - /// - public string ClientClaims { get; set; } - public string RevokedTokenHash { get; set; } public bool IsMtlsPopRequested { get; set; } @@ -58,7 +52,6 @@ public void LogParameters(ILoggerAdapter logger) ForceRefresh: {ForceRefresh} Resource: {Resource} Claims: {!string.IsNullOrEmpty(Claims)} - ClientClaims: {!string.IsNullOrEmpty(ClientClaims)} RevokedTokenHash: {!string.IsNullOrEmpty(RevokedTokenHash)} IsMtlsPopRequested: {IsMtlsPopRequested} MtlsPopMinStrength: {MtlsPopMinStrength} diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs index 08515853bd..8e8aca90c9 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs @@ -208,7 +208,8 @@ private static CredentialContext BuildPreflightContext( authority: canonicalAuthority, tenantId: tenantId, correlationId: p.CorrelationId, - logger: serviceBundle.ApplicationLogger); + logger: serviceBundle.ApplicationLogger, + otelTagsEnricher: p.OtelTagsEnricher); } private static AssertionRequestOptions CreateAssertionRequestOptions( @@ -224,6 +225,7 @@ private static AssertionRequestOptions CreateAssertionRequestOptions( CancellationToken = ct, ClientAssertionFmiPath = p.ClientAssertionFmiPath, CorrelationId = p.CorrelationId, + OtelTagsEnricher = p.OtelTagsEnricher, // Best-effort context. IMPORTANT: use AbsoluteUri, not Uri.Authority (host only). TokenEndpoint = serviceBundle.Config.Authority.AuthorityInfo.CanonicalAuthority.AbsoluteUri diff --git a/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs b/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs index b1d3f916d2..24838f6082 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Threading; +using Microsoft.Identity.Client.Extensibility; namespace Microsoft.Identity.Client { @@ -85,5 +86,14 @@ internal AssertionRequestOptions(ApplicationConfiguration appConfig, string toke /// to downstream token requests (e.g., Managed Identity) for coherent end-to-end tracing. /// public Guid CorrelationId { get; set; } + + /// + /// The OpenTelemetry tags enricher configured on the outer request via WithOtelTagsEnricher, if any. + /// When the client-assertion callback acquires the assertion by issuing another token request + /// (e.g. via ITokenAcquirer.GetTokenForAppAsync for a Federated Identity Credential), forward this + /// delegate to that inner request so the inner acquisition's metrics carry the same enrichment tags as the + /// outer request. Null when no enricher was configured. + /// + public Action>> OtelTagsEnricher { get; set; } } } diff --git a/src/client/Microsoft.Identity.Client/AuthenticationResultMetadata.cs b/src/client/Microsoft.Identity.Client/AuthenticationResultMetadata.cs index ec07ecb171..d56e304807 100644 --- a/src/client/Microsoft.Identity.Client/AuthenticationResultMetadata.cs +++ b/src/client/Microsoft.Identity.Client/AuthenticationResultMetadata.cs @@ -9,6 +9,13 @@ namespace Microsoft.Identity.Client /// /// Contains metadata of the authentication result. for additional MSAL-wide metrics. /// +#if NETFRAMEWORK || NETSTANDARD + // Marked [Serializable] only on the frameworks whose Exception.Data (ListDictionaryInternal) rejects + // non-serializable values, so downstream providers can read this object off the raw exception's Data bag + // (Bug 3696194). .NET (Core) removed that check, so the attribute is unnecessary there. The whole member + // graph is serializable (enums, strings, numeric types, and the RegionDetails DTO), so this is honest. + [Serializable] +#endif public class AuthenticationResultMetadata { diff --git a/src/client/Microsoft.Identity.Client/Extensibility/AbstractManagedIdentityAcquireTokenParameterBuilderExtension.cs b/src/client/Microsoft.Identity.Client/Extensibility/AbstractManagedIdentityAcquireTokenParameterBuilderExtension.cs new file mode 100644 index 0000000000..d4a38175e7 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Extensibility/AbstractManagedIdentityAcquireTokenParameterBuilderExtension.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Identity.Client.Extensibility +{ + /// + /// Extension methods for managed identity acquire-token requests + /// (). + /// + public static class AbstractManagedIdentityAcquireTokenParameterBuilderExtension + { + /// + /// Registers a delegate that adds additional tags (dimensions) to the OpenTelemetry metrics MSAL emits + /// for this managed identity token acquisition. The delegate is invoked while MSAL records its metrics and + /// receives the of the acquisition (indicating success or failure, with the + /// result or exception) together with a mutable list of tags. Tags appended to that list are attached to + /// every metric recorded for the request, including metrics emitted during proactive background refresh. + /// + /// The concrete managed identity builder type. + /// The builder to chain options to. + /// + /// A delegate that receives the and a mutable list of tags to enrich. + /// The delegate runs on MSAL's metric-recording path, so it should be fast, non-blocking and must not throw. + /// The supplied tag list must be populated synchronously; do not retain or mutate it after the delegate returns. + /// + /// The builder to chain the .With methods. + /// Thrown when is null. + /// + /// Keep both the number of added tags and — more importantly — their value cardinality low. High-cardinality + /// tag values (such as correlation ids, timestamps, or user identifiers) can cause an unbounded number of + /// metric time series in the downstream telemetry backend. The tags are applied to every metric MSAL records + /// for the request, so a large number of tags also adds overhead on the metric-recording path. + /// + public static AbstractManagedIdentityAcquireTokenParameterBuilder WithOtelTagsEnricher( + this AbstractManagedIdentityAcquireTokenParameterBuilder builder, + Action>> tagsEnricher) + where T : AbstractManagedIdentityAcquireTokenParameterBuilder + { + if (tagsEnricher == null) + { + throw new ArgumentNullException(nameof(tagsEnricher)); + } + + builder.CommonParameters.OtelTagsEnricher = tagsEnricher; + + return builder; + } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs index d59b2c0c48..aff65b081f 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Threading; using Microsoft.Identity.Client.Core; +using Microsoft.Identity.Client.Extensibility; using Microsoft.Identity.Client.PlatformsCommon.Interfaces; namespace Microsoft.Identity.Client.Internal.ClientCredential @@ -58,6 +59,13 @@ internal sealed class CredentialContext /// Correlation ID for end-to-end request tracing. public Guid CorrelationId { get; init; } + /// + /// OpenTelemetry tags enricher from the outer request (set via WithOtelTagsEnricher), forwarded to + /// the client-assertion callback so a callback that acquires the assertion via an inner token request + /// (e.g. a Federated Identity Credential) can enrich the inner acquisition's metrics identically. May be null. + /// + public Action>> OtelTagsEnricher { get; init; } + /// Logger for credential resolution diagnostics. public ILoggerAdapter Logger { get; init; } @@ -83,7 +91,8 @@ internal static CredentialContext Create( string authority, string tenantId, Guid correlationId, - ILoggerAdapter logger) + ILoggerAdapter logger, + Action>> otelTagsEnricher = null) { return new CredentialContext { @@ -101,6 +110,7 @@ internal static CredentialContext Create( TenantId = tenantId, CorrelationId = correlationId, Logger = logger, + OtelTagsEnricher = otelTagsEnricher, }; } @@ -120,7 +130,8 @@ internal AssertionRequestOptions ToAssertionRequestOptions(CancellationToken can TenantId = TenantId, CorrelationId = CorrelationId, ClientAssertionFmiPath = ClientAssertionFmiPath, - CancellationToken = cancellationToken + CancellationToken = cancellationToken, + OtelTagsEnricher = OtelTagsEnricher }; } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs index e9a40090fe..b0d7b8e01c 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs @@ -112,7 +112,8 @@ private static CredentialContext BuildContext( authority: requestParams.AuthorityManager.Authority.AuthorityInfo.CanonicalAuthority?.ToString(), tenantId: requestParams.AuthorityManager.Authority.TenantId, correlationId: requestParams.RequestContext.CorrelationId, - logger: requestParams.RequestContext.Logger); + logger: requestParams.RequestContext.Logger, + otelTagsEnricher: requestParams.OtelTagsEnricher); } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/Logger/LoggerHelper.cs b/src/client/Microsoft.Identity.Client/Internal/Logger/LoggerHelper.cs index 10546833a8..df8fd81dfa 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Logger/LoggerHelper.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Logger/LoggerHelper.cs @@ -76,7 +76,7 @@ public static ILoggerAdapter CreateLogger( public static string FormatLogMessage(string message, bool piiEnabled, string correlationId, string clientInformation) { - return string.Format( + string formattedMessage = string.Format( CultureInfo.InvariantCulture, "{0} MSAL {1} {2} {3} {4} [{5}{6}]{7} {8}", piiEnabled, @@ -88,6 +88,8 @@ public static string FormatLogMessage(string message, bool piiEnabled, string co correlationId, clientInformation, message); + + return formattedMessage; } internal static string GetPiiScrubbedExceptionDetails(Exception ex) @@ -109,9 +111,9 @@ internal static string GetPiiScrubbedExceptionDetails(Exception ex) { sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "HTTP StatusCode {0}", msalServiceException.StatusCode)); sb.AppendLine($"CorrelationId {msalServiceException.CorrelationId}"); - if (msalServiceException.ErrorCodes is {Length: > 0}) + if (msalServiceException.ErrorCodesForLogging is {Count: > 0}) { - sb.AppendLine($"Microsoft Entra ID Error Code AADSTS{string.Join(" ", msalServiceException.ErrorCodes)}"); + sb.AppendLine($"Microsoft Entra ID Error Code AADSTS{string.Join(" ", msalServiceException.ErrorCodesForLogging)}"); } } diff --git a/src/client/Microsoft.Identity.Client/Internal/Logger/MsalLoggerExtensions.cs b/src/client/Microsoft.Identity.Client/Internal/Logger/MsalLoggerExtensions.cs index d593137143..3b6cb89fc1 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Logger/MsalLoggerExtensions.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Logger/MsalLoggerExtensions.cs @@ -30,7 +30,12 @@ public static void Error(this ILoggerAdapter logger, string message) public static void ErrorPiiWithPrefix(this ILoggerAdapter logger, Exception exWithPii, string prefix) { - logger.Log(LogLevel.Error, prefix + exWithPii, prefix + LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii)); + if (!logger.IsLoggingEnabled(LogLevel.Error)) + { + return; + } + + logger.Log(LogLevel.Error, prefix + TokenScrubber.Scrub(exWithPii?.ToString()), prefix + LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii)); } public static void ErrorPii(this ILoggerAdapter logger, string messageWithPii, string messageScrubbed) @@ -40,7 +45,12 @@ public static void ErrorPii(this ILoggerAdapter logger, string messageWithPii, s public static void ErrorPii(this ILoggerAdapter logger, Exception exWithPii) { - logger.Log(LogLevel.Error, exWithPii.ToString(), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii)); + if (!logger.IsLoggingEnabled(LogLevel.Error)) + { + return; + } + + logger.Log(LogLevel.Error, TokenScrubber.Scrub(exWithPii?.ToString()), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii)); } public static void Warning(this ILoggerAdapter logger, string message) @@ -55,12 +65,22 @@ public static void WarningPii(this ILoggerAdapter logger, string messageWithPii, public static void WarningPii(this ILoggerAdapter logger, Exception exWithPii) { - logger.Log(LogLevel.Warning, exWithPii.ToString(), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii)); + if (!logger.IsLoggingEnabled(LogLevel.Warning)) + { + return; + } + + logger.Log(LogLevel.Warning, TokenScrubber.Scrub(exWithPii?.ToString()), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii)); } public static void WarningPiiWithPrefix(this ILoggerAdapter logger, Exception exWithPii, string prefix) { - logger.Log(LogLevel.Warning, prefix + exWithPii, prefix + LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii)); + if (!logger.IsLoggingEnabled(LogLevel.Warning)) + { + return; + } + + logger.Log(LogLevel.Warning, prefix + TokenScrubber.Scrub(exWithPii?.ToString()), prefix + LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii)); } public static void Info(this ILoggerAdapter logger, string message) @@ -97,7 +117,12 @@ public static void InfoPii(this ILoggerAdapter logger, Func messageWithP public static void InfoPii(this ILoggerAdapter logger, Exception exWithPii) { - logger.Log(LogLevel.Info, exWithPii?.ToString(), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii)); + if (!logger.IsLoggingEnabled(LogLevel.Info)) + { + return; + } + + logger.Log(LogLevel.Info, TokenScrubber.Scrub(exWithPii?.ToString()), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii)); } public static void Verbose(this ILoggerAdapter logger, Func messageProducer) diff --git a/src/client/Microsoft.Identity.Client/Internal/Logger/TokenScrubber.cs b/src/client/Microsoft.Identity.Client/Internal/Logger/TokenScrubber.cs new file mode 100644 index 0000000000..5a8fc184b5 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/Logger/TokenScrubber.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Text; + +namespace Microsoft.Identity.Client.Internal.Logger +{ + /// + /// Redacts opaque tokens that eSTS/CCS have tagged with a Highly Identifiable Token (HIT) tag + /// from MSAL's own log output before the text reaches any logging sink. + /// + /// + /// eSTS/CCS embed the "EvoStsArtifacts" marker (MSA uses the literal "MsaArtifacts") inside the + /// base64/base64url encoded token. Because the marker can appear at any of three byte-offset + /// alignments once base64 encoded, all three renderings are matched. The marker sits between the + /// token header and body, so a match is expanded both left and right over the token charset to + /// cover the full token run before replacing it with . + /// + internal static class TokenScrubber + { + internal const string Placeholder = "[Redacted opaque token]"; + + private const string DisableSwitchName = "Microsoft.Identity.Client.DisableOpaqueTokenScrubbing"; + + // Base64 renderings of the "EvoStsArtifacts" prefix at the three possible byte-offset + // alignments, plus the MSA literal "MsaArtifacts". Ordinal, case-sensitive. + private static readonly string[] s_patterns = new[] + { + "RXZvU3RzQXJ0aWZhY3Rz", // offset 0 + "V2b1N0c0FydGlmYWN0c", // offset 1 + "dm9TdHNBcnRpZmFjdH", // offset 2 + "MsaArtifacts", // MSA literal + }; + + private static readonly bool s_disabled = AppContext.TryGetSwitch(DisableSwitchName, out bool isDisabled) && isDisabled; + + /// + /// Redacts any HIT-tagged opaque tokens found in . + /// + /// The log message that may contain a tagged opaque token. + /// + /// The same string reference when nothing was redacted (fast path, no allocation); otherwise a new + /// string with each tagged token run replaced by . + /// + /// + /// + /// string scrubbed = TokenScrubber.Scrub("Set-Cookie: esctx-x=AQAB...RXZvU3RzQXJ0aWZhY3Rz...; path=/"); + /// // scrubbed == "Set-Cookie: [Redacted opaque token]; path=/" + /// + /// + public static string Scrub(string message) + { + if (s_disabled || string.IsNullOrEmpty(message)) + { + return message; + } + + // Single search for the first match. On the no-match fast path this returns the same + // reference with no allocation and without scanning the string twice. + int matchStart = FindEarliestMatch(message, 0, out int matchLength); + if (matchStart < 0) + { + return message; + } + + var sb = new StringBuilder(message.Length); + int index = 0; + + while (matchStart >= 0) + { + // Expand the match left and right over the token charset to cover the whole run. + int runStart = matchStart; + while (runStart > index && IsTokenChar(message[runStart - 1])) + { + runStart--; + } + + int runEnd = matchStart + matchLength; // exclusive + while (runEnd < message.Length && IsTokenChar(message[runEnd])) + { + runEnd++; + } + + // Append text before the run, then the placeholder. + sb.Append(message, index, runStart - index); + sb.Append(Placeholder); + + index = runEnd; + matchStart = index < message.Length ? FindEarliestMatch(message, index, out matchLength) : -1; + } + + if (index < message.Length) + { + sb.Append(message, index, message.Length - index); + } + + return sb.ToString(); + } + + private static int FindEarliestMatch(string message, int startIndex, out int matchLength) + { + int earliest = -1; + matchLength = 0; + + for (int i = 0; i < s_patterns.Length; i++) + { + int found = message.IndexOf(s_patterns[i], startIndex, StringComparison.Ordinal); + if (found >= 0 && (earliest < 0 || found < earliest)) + { + earliest = found; + matchLength = s_patterns[i].Length; + } + } + + return earliest; + } + + private static bool IsTokenChar(char c) + { + // base64 + base64url + '=' padding: [A-Za-z0-9+/_-=] + return (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '+' || c == '/' || c == '_' || c == '-' || c == '='; + } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs index 8de9f86f98..bffae0470e 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs @@ -144,12 +144,6 @@ public string Claims } } - /// - /// Client-originated claims set via .WithClaimsFromClient(). These are cached (no bypass) and - /// keyed on the raw claims string as passed by the caller. - /// - public string ClientClaims => _commonParameters.ClientClaims; - private IAuthenticationOperation _requestOverrideScheme; /// diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs index 53aed918ea..920a3bb8be 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs @@ -222,18 +222,9 @@ private async Task SendTokenRequestForManagedIdentityAsync _managedIdentityParameters.IsMtlsPopRequested = AuthenticationRequestParameters.IsMtlsPopRequested; - // Propagate client-originated claims to the MI parameters for transport. - // Unlike server-issued Claims (which bypass the cache), ClientClaims participate in caching - // via CacheKeyComponents set on the builder — tokens are keyed per distinct claims value. - if (!string.IsNullOrEmpty(AuthenticationRequestParameters.ClientClaims)) - { - _managedIdentityParameters.ClientClaims = AuthenticationRequestParameters.ClientClaims; - } - // mTLS PoP is served exclusively by IMDSv2. Mint the binding certificate, then delegate the - // token leg to MSAL's internal TokenClient exchange (the same path CCA uses) so client-originated - // claims, client-capability (CP1) merge, claims-based cache keying, and ESTS error handling are - // inherited rather than re-implemented in a bespoke MI token POST. + // token leg to MSAL's internal TokenClient exchange (the same path CCA uses) so client-capability + // (CP1) merge and ESTS error handling are inherited rather than re-implemented in a bespoke MI token POST. if (AuthenticationRequestParameters.IsMtlsPopRequested) { return await SendDelegatedImdsV2TokenRequestAsync(logger, cancellationToken).ConfigureAwait(false); @@ -307,8 +298,8 @@ private async Task DelegateImdsV2TokenLegAsync( _managedIdentityClient.SetRuntimeMtlsBindingCertificate(binding.Certificate); // grant_type is not added by TokenClient; client_id overrides AppConfig.ClientId - // (the SAMI placeholder) with the canonical GUID from the binding. Client-originated claims - // are emitted automatically by TokenClient via ClaimsAndClientCapabilities. + // (the SAMI placeholder) with the canonical GUID from the binding. Server-issued claims and + // client capabilities are emitted automatically by TokenClient via ClaimsAndClientCapabilities. var bodyParameters = new Dictionary { [OAuth2Parameter.GrantType] = OAuth2GrantType.ClientCredentials, diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs index e291e861ee..222a63f48d 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs @@ -130,7 +130,7 @@ public async Task RunAsync(CancellationToken cancellationT httpStatusCode, totalDurationInMs, exception: ex, - rawStsErrorCode: serviceException?.ErrorCodes?.FirstOrDefault()); + rawStsErrorCode: serviceException?.ErrorCodesForLogging?.FirstOrDefault()); throw; } catch (Exception ex) @@ -138,7 +138,49 @@ public async Task RunAsync(CancellationToken cancellationT apiEvent.ApiErrorCode = ex.GetType().Name; AuthenticationRequestParameters.RequestContext.Logger.ErrorPii(ex); - LogFailureTelemetryToOtel(ex.GetType().Name, apiEvent, apiEvent.CacheInfo, httpStatusCode: 0, totalDurationInMs: requestStopwatch.ElapsedMilliseconds + measureTelemetryDurationResult.Milliseconds); + // Compute the total duration once so the value on the synthesized metadata matches the + // value logged to OpenTelemetry (the stopwatch keeps running, so re-reading it drifts). + long totalDurationInMs = requestStopwatch.ElapsedMilliseconds + measureTelemetryDurationResult.Milliseconds; + + AuthenticationResultMetadata failureMetadata = CreateFailureMetadata(apiEvent, totalDurationInMs); + + // Expose the failure metadata on the ORIGINAL exception via its Data bag so downstream + // header-creation providers - which catch the raw non-MSAL exception, not the enricher + // wrapper - can surface token-acquisition diagnostics (Bug 3696194). The value is the same + // strongly-typed object MSAL builds for the success path, so consumers reuse their mapper. + // Guarded because a derived exception may expose a null or read-only Data bag, and telemetry + // plumbing must never throw here and mask the caller's original exception. On .NET Framework / + // netstandard the Data bag also rejects non-serializable values, so AuthenticationResultMetadata + // is marked [Serializable] on those targets (see AuthenticationResultMetadata.cs). + if (ex.Data is { IsReadOnly: false }) + { + ex.Data[MsalException.AuthenticationResultMetadataKey] = failureMetadata; + } + + // The original exception is re-thrown below; MSAL never surfaces this wrapper. It exists only + // so the OpenTelemetry tag enricher observes a populated ExecutionResult.Exception (carrying + // failure metadata) for non-MSAL failures, mirroring the MsalException path above. The + // originating exception's type is captured as the ErrorCode and it is preserved as the + // InnerException so consumers retain full fidelity. Fall back to the type name when + // FullName is null (some generic/array types) or Message is empty/whitespace, because the + // MsalException ctor rejects a null/empty errorCode or errorMessage - without the fallback + // that ArgumentNullException would replace the original exception we re-throw below. + string enricherErrorCode = ex.GetType().FullName ?? ex.GetType().Name; + string enricherErrorMessage = string.IsNullOrWhiteSpace(ex.Message) ? ex.GetType().Name : ex.Message; + + MsalException enricherException = new MsalException(enricherErrorCode, enricherErrorMessage, ex) + { + AuthenticationResultMetadata = failureMetadata, + CorrelationId = AuthenticationRequestParameters.CorrelationId.ToString(), + }; + + LogFailureTelemetryToOtel( + ex.GetType().Name, + apiEvent, + apiEvent.CacheInfo, + httpStatusCode: 0, + totalDurationInMs: totalDurationInMs, + exception: enricherException); throw; } } diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/SilentRequestHelper.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/SilentRequestHelper.cs index 7a6002383a..199a530a5c 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/SilentRequestHelper.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/SilentRequestHelper.cs @@ -145,7 +145,7 @@ internal static void ProcessFetchInBackground( logger.ErrorPiiWithPrefix(ex, logMsg); LogBackgroundFailureTelemetry(serviceBundle, apiEvent, callerSdkId, callerSdkVersion, - ex.ErrorCode, ex.StatusCode, ex.ErrorCodes?.FirstOrDefault(), ex, tagsEnricher, logger); + ex.ErrorCode, ex.StatusCode, ex.ErrorCodesForLogging?.FirstOrDefault(), ex, tagsEnricher, logger); // Background refresh doesn't go through RunAsync, so the exception isn't carrying metadata yet. // Fill it in from apiEvent so the callback can see the HTTP duration and cache-refresh reason. diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs index fd91737bbd..0de514415c 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs @@ -15,8 +15,6 @@ using System.Security.Cryptography.X509Certificates; using System.Net.Security; using Microsoft.Identity.Client.Http.Retry; -using System.Collections.Generic; -using System.Linq; using System.Text.Json; namespace Microsoft.Identity.Client.ManagedIdentity @@ -38,13 +36,6 @@ protected AbstractManagedIdentity(RequestContext requestContext, ManagedIdentity _sourceType = sourceType; } - // True only for the IMDSv1 source. IMDSv1 and IMDSv2 both report - // publicly, so this flag preserves the - // v1-specific MSIv1 claims validation without relying on the (folded) source label. - protected virtual bool RequiresMsiV1ClaimsValidation => false; - - private const string XmsAzNwperimid = "xms_az_nwperimid"; - public virtual async Task AuthenticateAsync( AcquireTokenForManagedIdentityParameters parameters, CancellationToken cancellationToken) @@ -64,38 +55,6 @@ public virtual async Task AuthenticateAsync( ManagedIdentityRequest request = await CreateRequestAsync(resource).ConfigureAwait(false); - // Forward client-originated claims to the correct location for IMDS/MSIv2 only. - // Other MI sources (App Service, Azure Arc, Service Fabric, etc.) do not have a - // confirmed contract for the "claims" parameter; fail fast rather than silently - // ignoring the value and polluting the cache with keys the endpoint never saw. - if (!string.IsNullOrEmpty(parameters.ClientClaims)) - { - if (_sourceType != ManagedIdentitySource.Imds) - { - throw new MsalClientException( - MsalError.InvalidRequest, - $"WithClaimsFromClient is only supported for IMDS-based managed identity sources. " + - $"The detected source is {_sourceType}. " + - "Only ManagedIdentitySource.Imds supports the 'claims' parameter."); - } - - if (RequiresMsiV1ClaimsValidation) - { - ValidateMsiv1Claims(parameters.ClientClaims); - } - - if (request.Method == System.Net.Http.HttpMethod.Get) - { - request.QueryParameters["claims"] = Uri.EscapeDataString(parameters.ClientClaims); - _requestContext.Logger.Info("[Managed Identity] Adding client claims to IMDS request as query parameter."); - } - else - { - request.BodyParameters["claims"] = parameters.ClientClaims; - _requestContext.Logger.Info("[Managed Identity] Adding client claims to ESTS POST body."); - } - } - // When IMDSv2 mints a binding certificate during this request (via CSR), // it's exposed via request.MtlsCertificate. Bubble it up so the request // layer can set the mtls_pop scheme @@ -371,26 +330,5 @@ private static void CreateAndThrowException(string errorCode, throw exception; } - - /// - /// MSIv1 (IMDS v1) only supports a single custom claim: xms_az_nwperimid. - /// Any other top-level key in the claims JSON will cause IMDS to return HTTP 400 Bad Request - /// with no useful diagnostic. Validate early so the caller gets a clear MSAL error. - /// - private static void ValidateMsiv1Claims(string claimsJson) - { - var parsed = ClaimsHelper.ParseClaimsOrThrow(claimsJson); - foreach (var kvp in parsed) - { - if (!string.Equals(kvp.Key, XmsAzNwperimid, StringComparison.Ordinal)) - { - throw new MsalClientException( - MsalError.InvalidRequest, - $"MSIv1 (IMDS v1) only supports the `{XmsAzNwperimid}` custom claim. " + - $"The claims JSON contained the unsupported key `{kvp.Key}`. " + - $"Remove all keys other than `{XmsAzNwperimid}` when using WithClaimsFromClient with MSIv1."); - } - } - } } } diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/AzureArcManagedIdentitySource.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/AzureArcManagedIdentitySource.cs index ae3048e401..7f9e79c886 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/AzureArcManagedIdentitySource.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/AzureArcManagedIdentitySource.cs @@ -17,7 +17,7 @@ namespace Microsoft.Identity.Client.ManagedIdentity { internal class AzureArcManagedIdentitySource : AbstractManagedIdentity { - private const string ArcApiVersion = "2019-11-01"; + private const string ArcApiVersion = "2020-06-01"; private const string AzureArc = "Azure Arc"; private readonly Uri _endpoint; diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/ImdsManagedIdentitySource.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/ImdsManagedIdentitySource.cs index 71d1ca7c61..94b8eb833d 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/ImdsManagedIdentitySource.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/ImdsManagedIdentitySource.cs @@ -60,9 +60,6 @@ internal ImdsManagedIdentitySource(RequestContext requestContext) : requestContext.Logger.Verbose(() => "[Managed Identity] Creating IMDS managed identity source. Endpoint URI: " + _imdsEndpoint); } - // IMDSv1 enforces MSIv1-specific claims validation; IMDSv2 does not. - protected override bool RequiresMsiV1ClaimsValidation => true; - protected override Task CreateRequestAsync(string resource) { ManagedIdentityRequest request = new(HttpMethod.Get, _imdsEndpoint); diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs index 2763781382..9c58d1c698 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs @@ -365,19 +365,24 @@ public static bool IsKeyGuardProtected(CngKey key) /// on the cold-start path. Subsequent calls reuse the cached key in /// WindowsManagedIdentityKeyProvider, so the probe runs at most once per process. /// + /// + /// The probe uses PSS padding (the approved scheme, consistent with the rest of the + /// managed identity / PoP signing code). The produced signature is discarded; only the + /// success/failure of the operation matters for liveness detection. + /// /// private static bool CanSign(CngKey key, ILoggerAdapter logger) { try { - logger?.Verbose(() => "[MI][WinKeyProvider] Liveness probe: attempting RSA-SHA256 sign of 1-byte payload."); + logger?.Verbose(() => "[MI][WinKeyProvider] Liveness probe: attempting RSA-SHA256/PSS sign of 1-byte payload."); using (var rsa = new RSACng(key)) { _ = rsa.SignData( new byte[] { 0 }, HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); + RSASignaturePadding.Pss); } logger?.Verbose(() => "[MI][WinKeyProvider] Liveness probe: sign succeeded; key material is live."); diff --git a/src/client/Microsoft.Identity.Client/MsalException.cs b/src/client/Microsoft.Identity.Client/MsalException.cs index ccb8e63949..14e2f877d5 100644 --- a/src/client/Microsoft.Identity.Client/MsalException.cs +++ b/src/client/Microsoft.Identity.Client/MsalException.cs @@ -48,6 +48,14 @@ public class MsalException : Exception /// public const string ManagedIdentitySource = "ManagedIdentitySource"; + /// + /// A key on under which MSAL stores the + /// captured when a token acquisition fails with a + /// non-. Lets downstream consumers surface token-acquisition diagnostics + /// even though the originating exception is re-thrown unchanged. + /// + public const string AuthenticationResultMetadataKey = "MsalAuthenticationResultMetadata"; + private string _errorCode; /// diff --git a/src/client/Microsoft.Identity.Client/MsalServiceException.cs b/src/client/Microsoft.Identity.Client/MsalServiceException.cs index c4ec33fb7a..d695f5e612 100644 --- a/src/client/Microsoft.Identity.Client/MsalServiceException.cs +++ b/src/client/Microsoft.Identity.Client/MsalServiceException.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Globalization; using System.Net; using System.Net.Http.Headers; @@ -214,9 +215,13 @@ public HttpResponseHeaders Headers public string SubErrorForLogging { get; internal set; } /// - /// A list of STS-specific error codes that can help in diagnostics. + /// The list of STS-specific error codes returned by the token service (for example the + /// numeric AADSTS codes such as 50076, 50079) that refine + /// . Values are emitted by the service and may change + /// without notice; intended for diagnostics and logging — do not branch production behavior + /// on this value. Returns when the service did not supply error codes. /// - internal string[] ErrorCodes { get; set; } + public IReadOnlyList ErrorCodesForLogging { get; internal set; } /// /// As per discussion with Evo, AAD diff --git a/src/client/Microsoft.Identity.Client/MsalServiceExceptionFactory.cs b/src/client/Microsoft.Identity.Client/MsalServiceExceptionFactory.cs index e2039d971f..a2aca481a7 100644 --- a/src/client/Microsoft.Identity.Client/MsalServiceExceptionFactory.cs +++ b/src/client/Microsoft.Identity.Client/MsalServiceExceptionFactory.cs @@ -79,7 +79,7 @@ internal static MsalServiceException FromHttpResponse( ex.Claims = oAuth2Response?.Claims; ex.CorrelationId = oAuth2Response?.CorrelationId; ex.SubErrorForLogging = oAuth2Response?.SubError; - ex.ErrorCodes = oAuth2Response?.ErrorCodes; + ex.ErrorCodesForLogging = oAuth2Response?.ErrorCodes is { } errorCodes ? Array.AsReadOnly(errorCodes) : null; return ex; } diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Shipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Shipped.txt index e6a4ed1baa..31df49709f 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Shipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Shipped.txt @@ -265,7 +265,6 @@ Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithPreferredAzu Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaims(string claims) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder -Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder.WithAccount(Microsoft.Identity.Client.IAccount account) -> Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt index e69de29bb2..c11f47c939 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +const Microsoft.Identity.Client.MsalException.AuthenticationResultMetadataKey = "MsalAuthenticationResultMetadata" -> string +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.get -> System.Action>> +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.set -> void +Microsoft.Identity.Client.MsalServiceException.ErrorCodesForLogging.get -> System.Collections.Generic.IReadOnlyList +Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension +static Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension.WithOtelTagsEnricher(this Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder builder, System.Action>> tagsEnricher) -> Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Shipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Shipped.txt index e6a4ed1baa..31df49709f 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Shipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Shipped.txt @@ -265,7 +265,6 @@ Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithPreferredAzu Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaims(string claims) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder -Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder.WithAccount(Microsoft.Identity.Client.IAccount account) -> Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt index e69de29bb2..c11f47c939 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +const Microsoft.Identity.Client.MsalException.AuthenticationResultMetadataKey = "MsalAuthenticationResultMetadata" -> string +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.get -> System.Action>> +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.set -> void +Microsoft.Identity.Client.MsalServiceException.ErrorCodesForLogging.get -> System.Collections.Generic.IReadOnlyList +Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension +static Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension.WithOtelTagsEnricher(this Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder builder, System.Action>> tagsEnricher) -> Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Shipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Shipped.txt index d36873b697..69910a9d11 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Shipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Shipped.txt @@ -266,7 +266,6 @@ Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithPreferredAzu Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaims(string claims) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder -Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder.WithAccount(Microsoft.Identity.Client.IAccount account) -> Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt index e69de29bb2..c11f47c939 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +const Microsoft.Identity.Client.MsalException.AuthenticationResultMetadataKey = "MsalAuthenticationResultMetadata" -> string +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.get -> System.Action>> +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.set -> void +Microsoft.Identity.Client.MsalServiceException.ErrorCodesForLogging.get -> System.Collections.Generic.IReadOnlyList +Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension +static Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension.WithOtelTagsEnricher(this Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder builder, System.Action>> tagsEnricher) -> Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Shipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Shipped.txt index 284c3a09cf..df90a256af 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Shipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Shipped.txt @@ -270,7 +270,6 @@ Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithPreferredAzu Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaims(string claims) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder -Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder.WithAccount(Microsoft.Identity.Client.IAccount account) -> Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt index e69de29bb2..c11f47c939 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +const Microsoft.Identity.Client.MsalException.AuthenticationResultMetadataKey = "MsalAuthenticationResultMetadata" -> string +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.get -> System.Action>> +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.set -> void +Microsoft.Identity.Client.MsalServiceException.ErrorCodesForLogging.get -> System.Collections.Generic.IReadOnlyList +Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension +static Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension.WithOtelTagsEnricher(this Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder builder, System.Action>> tagsEnricher) -> Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Shipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Shipped.txt index a87f5e1f42..659cf467f8 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Shipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Shipped.txt @@ -263,7 +263,6 @@ Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithPreferredAzu Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaims(string claims) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder -Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder.WithAccount(Microsoft.Identity.Client.IAccount account) -> Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt index e69de29bb2..c11f47c939 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +const Microsoft.Identity.Client.MsalException.AuthenticationResultMetadataKey = "MsalAuthenticationResultMetadata" -> string +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.get -> System.Action>> +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.set -> void +Microsoft.Identity.Client.MsalServiceException.ErrorCodesForLogging.get -> System.Collections.Generic.IReadOnlyList +Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension +static Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension.WithOtelTagsEnricher(this Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder builder, System.Action>> tagsEnricher) -> Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Shipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Shipped.txt index 8c04d24022..04df9212c9 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Shipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Shipped.txt @@ -263,7 +263,6 @@ Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithPreferredAzu Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaims(string claims) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder -Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder.WithAccount(Microsoft.Identity.Client.IAccount account) -> Microsoft.Identity.Client.AcquireTokenInteractiveParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt index e69de29bb2..c11f47c939 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +const Microsoft.Identity.Client.MsalException.AuthenticationResultMetadataKey = "MsalAuthenticationResultMetadata" -> string +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.get -> System.Action>> +Microsoft.Identity.Client.AssertionRequestOptions.OtelTagsEnricher.set -> void +Microsoft.Identity.Client.MsalServiceException.ErrorCodesForLogging.get -> System.Collections.Generic.IReadOnlyList +Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension +static Microsoft.Identity.Client.Extensibility.AbstractManagedIdentityAcquireTokenParameterBuilderExtension.WithOtelTagsEnricher(this Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder builder, System.Action>> tagsEnricher) -> Microsoft.Identity.Client.AbstractManagedIdentityAcquireTokenParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/RegionDetails.cs b/src/client/Microsoft.Identity.Client/RegionDetails.cs index 9bd8c64572..ef8baf1575 100644 --- a/src/client/Microsoft.Identity.Client/RegionDetails.cs +++ b/src/client/Microsoft.Identity.Client/RegionDetails.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using Microsoft.Identity.Client.Region; namespace Microsoft.Identity.Client @@ -11,6 +12,11 @@ namespace Microsoft.Identity.Client /// for additional metadata /// information of the authentication result. /// +#if NETFRAMEWORK || NETSTANDARD + // Serializable alongside AuthenticationResultMetadata (its container) so the graph stored in + // Exception.Data is fully serializable on .NET Framework / netstandard (Bug 3696194). + [Serializable] +#endif public class RegionDetails { /// diff --git a/src/client/Microsoft.Identity.Lab.Api/Http/MockHttpManagerExtensions.cs b/src/client/Microsoft.Identity.Lab.Api/Http/MockHttpManagerExtensions.cs index 58f59c2421..8fbbe386a2 100644 --- a/src/client/Microsoft.Identity.Lab.Api/Http/MockHttpManagerExtensions.cs +++ b/src/client/Microsoft.Identity.Lab.Api/Http/MockHttpManagerExtensions.cs @@ -509,7 +509,7 @@ private static MockHttpMessageHandler BuildMockHandlerForManagedIdentitySource( break; case ManagedIdentitySource.AzureArc: httpMessageHandler.ExpectedMethod = HttpMethod.Get; - expectedQueryParams.Add("api-version", "2019-11-01"); + expectedQueryParams.Add("api-version", "2020-06-01"); expectedQueryParams.Add("resource", resource); expectedRequestHeaders.Add("Metadata", "true"); break; diff --git a/tests/Microsoft.Identity.Test.E2e/ManagedIdentityImdsV2FicTests.cs b/tests/Microsoft.Identity.Test.E2e/ManagedIdentityImdsV2FicTests.cs new file mode 100644 index 0000000000..b0232a7e4e --- /dev/null +++ b/tests/Microsoft.Identity.Test.E2e/ManagedIdentityImdsV2FicTests.cs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.AppConfig; +using Microsoft.Identity.Client.KeyAttestation; +using Microsoft.Identity.Test.Common.Core.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Identity.Test.E2E +{ + /// + /// E2E tests for FIC (Federated Identity Credential) two-leg token exchange using MSIv2. + /// + /// Flow: + /// Leg 1 — MSI acquires an mTLS PoP token for api://AzureADTokenExchange + /// Leg 2 — ConfApp uses the Leg 1 token as a ClientSignedAssertion to obtain either a bearer token + /// or an mTLS PoP token, toggled by .WithMtlsProofOfPossession() on the Leg 2 request + /// + /// These tests run on the MSALMSIV2 pool (IMDSv2 + Credential Guard). + /// + [TestClass] + [DoNotParallelize] + public class ManagedIdentityImdsV2FicTests + { + private const string TokenExchangeResource = "api://AzureADTokenExchange"; + private const string GraphScope = "https://graph.microsoft.com/.default"; + + // UAMI identifiers (same pool as ManagedIdentityImdsV2Tests) + private const string UamiClientId = "6325cd32-9911-41f3-819c-416cdf9104e7"; + + // ConfApp registered in the MSI team tenant with FIC trusting the MSALMSIV2 pool MSI + private const string FicConfAppClientId = "f62c5ae3-bf3a-4af5-afa8-a68b800396e9"; + private const string FicConfAppAuthority = "https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47"; + + private static IManagedIdentityApplication BuildMsi(string userAssignedClientId = null) + { + ManagedIdentityId miId = userAssignedClientId is null + ? ManagedIdentityId.SystemAssigned + : ManagedIdentityId.WithUserAssignedClientId(userAssignedClientId); + + var builder = ManagedIdentityApplicationBuilder.Create(miId); + builder.Config.AccessorOptions = null; + return builder.Build(); + } + + private static IConfidentialClientApplication BuildConfApp(AuthenticationResult leg1Result) + { + // Pass the Leg 1 binding certificate on the assertion so Leg 2 can prove possession of the + // Leg 1 mTLS PoP token. The Leg 2 token type is chosen on the request, not here: + // - without .WithMtlsProofOfPossession() on AcquireTokenForClient -> Bearer + // - with .WithMtlsProofOfPossession() on AcquireTokenForClient -> mtls_pop + return ConfidentialClientApplicationBuilder + .Create(FicConfAppClientId) + .WithAuthority(FicConfAppAuthority) + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithClientAssertion((_, ct) => Task.FromResult(new ClientSignedAssertion + { + Assertion = leg1Result.AccessToken, + TokenBindingCertificate = leg1Result.BindingCertificate + })) + .Build(); + } + + /// + /// Leg 1 — MSI acquires an mTLS PoP token for api://AzureADTokenExchange using Credential Guard attestation. + /// Shared by the Bearer and mTLS PoP Leg 2 tests. The returned BindingCertificate is what Leg 2 uses to + /// prove possession. Marks the test inconclusive when Credential Guard is unavailable. + /// + private static async Task AcquireLeg1MtlsPopTokenAsync(string uamiClientId) + { + if (!OperatingSystem.IsWindows()) + { + Assert.Inconclusive("Credential Guard attestation is only available on Windows."); + } + + var msiApp = BuildMsi(uamiClientId); + + AuthenticationResult leg1Result; + try + { + leg1Result = await msiApp + .AcquireTokenForManagedIdentity(TokenExchangeResource) + .WithMtlsProofOfPossession() + .WithAttestationSupport() + .ExecuteAsync() + .ConfigureAwait(false); + } + catch (MsalClientException ex) when (ex.ErrorCode == "credential_guard_not_available") + { + Assert.Inconclusive("Credential Guard is not available on this machine."); + throw; // unreachable: Assert.Inconclusive always throws + } + catch (System.Security.Cryptography.CryptographicException ex) + { + Assert.Inconclusive($"Cryptographic operation failed. Credential Guard may not be properly configured: {ex.Message}"); + throw; // unreachable: Assert.Inconclusive always throws + } + + Assert.IsFalse(string.IsNullOrEmpty(leg1Result.AccessToken), + "Leg 1: AccessToken should not be empty."); + Assert.AreEqual("mtls_pop", leg1Result.TokenType, + "Leg 1: TokenType must be 'mtls_pop'."); + Assert.IsNotNull(leg1Result.BindingCertificate, + "Leg 1: BindingCertificate must not be null — required for FIC Leg 2."); + Assert.AreEqual(TokenSource.IdentityProvider, leg1Result.AuthenticationResultMetadata.TokenSource, + "Leg 1: First call must hit the MSI endpoint."); + + return leg1Result; + } + + /// + /// MSI Leg 1 → ConfApp Leg 2 bearer token. + /// Verifies the full FIC two-leg exchange produces a valid bearer token. + /// + [RunOnAzureDevOps] + [TestCategory("MI_E2E_ImdsV2")] + [TestMethod] + [DataRow(null, DisplayName = "FicTwoLeg_Bearer_SAMI")] //SAMI Object ID ("11a5d2ba-f08b-4e99-9361-2a07b4bf7af9") + [DataRow(UamiClientId, DisplayName = "FicTwoLeg_Bearer_UAMI-ClientId")] + public async Task AcquireToken_OnImdsV2_FicTwoLeg_BearerToken_Succeeds(string uamiClientId) + { + // --- Leg 1: MSI acquires an mTLS PoP token for api://AzureADTokenExchange --- + + AuthenticationResult leg1Result = await AcquireLeg1MtlsPopTokenAsync(uamiClientId).ConfigureAwait(false); + + // --- Leg 2: ConfApp exchanges Leg 1 token for a bearer token --- + + var confApp = BuildConfApp(leg1Result); + + var leg2Result = await confApp + .AcquireTokenForClient(new[] { GraphScope }) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsFalse(string.IsNullOrEmpty(leg2Result.AccessToken), + "Leg 2: AccessToken should not be empty."); + Assert.IsTrue( + string.Equals(leg2Result.TokenType, "Bearer", StringComparison.OrdinalIgnoreCase), + $"Leg 2: Expected Bearer token type, got '{leg2Result.TokenType}'."); + + Assert.AreEqual(TokenSource.IdentityProvider, leg2Result.AuthenticationResultMetadata.TokenSource, + "Leg 2: First call must hit the identity provider so the cache check below is meaningful."); + + // --- Cache hit verification --- + + var leg2Cached = await confApp + .AcquireTokenForClient(new[] { GraphScope }) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, leg2Cached.AuthenticationResultMetadata.TokenSource, + "Leg 2: Second call should be served from cache."); + Assert.AreEqual(leg2Result.AccessToken, leg2Cached.AccessToken, + "Leg 2: Cached token should match original."); + } + + /// + /// MSI Leg 1 → ConfApp Leg 2 mTLS PoP token. + /// Same two-leg exchange as the bearer test, but Leg 2 calls .WithMtlsProofOfPossession(), + /// so the final token is an mTLS PoP token bound to a certificate instead of a bearer token. + /// + [RunOnAzureDevOps] + [TestCategory("MI_E2E_ImdsV2")] + [TestMethod] + [DataRow(null, DisplayName = "FicTwoLeg_MtlsPop_SAMI")] //SAMI Object ID ("11a5d2ba-f08b-4e99-9361-2a07b4bf7af9") + [DataRow(UamiClientId, DisplayName = "FicTwoLeg_MtlsPop_UAMI-ClientId")] + public async Task AcquireToken_OnImdsV2_FicTwoLeg_MtlsPopToken_Succeeds(string uamiClientId) + { + // --- Leg 1: MSI acquires an mTLS PoP token for api://AzureADTokenExchange --- + + AuthenticationResult leg1Result = await AcquireLeg1MtlsPopTokenAsync(uamiClientId).ConfigureAwait(false); + + // --- Leg 2: ConfApp exchanges the Leg 1 token for an mTLS PoP token --- + // The only difference from the bearer flow is .WithMtlsProofOfPossession() on the Leg 2 request. + + var confApp = BuildConfApp(leg1Result); + + var leg2Result = await confApp + .AcquireTokenForClient(new[] { GraphScope }) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsFalse(string.IsNullOrEmpty(leg2Result.AccessToken), + "Leg 2: AccessToken should not be empty."); + Assert.AreEqual("mtls_pop", leg2Result.TokenType, + "Leg 2: TokenType must be 'mtls_pop' when .WithMtlsProofOfPossession() is used."); + Assert.IsNotNull(leg2Result.BindingCertificate, + "Leg 2: BindingCertificate must not be null for an mTLS PoP token."); + + Assert.AreEqual(TokenSource.IdentityProvider, leg2Result.AuthenticationResultMetadata.TokenSource, + "Leg 2: First call must hit the identity provider so the cache check below is meaningful."); + + // --- Cache hit verification (same request shape hits the same cache entry) --- + + var leg2Cached = await confApp + .AcquireTokenForClient(new[] { GraphScope }) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, leg2Cached.AuthenticationResultMetadata.TokenSource, + "Leg 2: Second call should be served from cache."); + Assert.AreEqual(leg2Result.AccessToken, leg2Cached.AccessToken, + "Leg 2: Cached token should match original."); + Assert.IsNotNull(leg2Cached.BindingCertificate, + "Leg 2: Cached mTLS PoP token must retain its BindingCertificate."); + } + } +} diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs index e99e9e8f2e..a3f10b337f 100644 --- a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs +++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs @@ -531,122 +531,6 @@ public async Task MinStrength_NullPoPOptions_ThrowsArgumentNullExceptionAsync() } } - // --------------------------------------------------------------------------------- - // Client-originated claims on the IMDSv2 mTLS-PoP path. - // Because the IMDSv2 token leg is delegated to MSAL's internal TokenClient exchange, - // client claims ride the shared ESTS-R POST body (merged with client capabilities), - // are keyed into the cache, and are NOT subject to the MSIv1 `xms_az_nwperimid` allowlist. - // --------------------------------------------------------------------------------- - - // A full NSP-style claim. Unlike MSIv1, the IMDSv2 PoP path accepts arbitrary claim keys - // because the token leg is served by ESTS-R (same contract as confidential client). - private const string ClientClaims = @"{""xms_az_nwperimid"":{""essential"":true}}"; - private const string OtherClientClaims = @"{""custom_claim"":{""values"":[""abc""]}}"; - - [TestMethod] - public async Task mTLSPop_WithClaimsFromClient_ForwardsClaimsInEstsBodyAsync() - { - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint); - - var managedIdentityApp = await CreateManagedIdentityAsync( - httpManager, - managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard, - withExperimentalFeatures: true).ConfigureAwait(false); - - // The token-leg mock asserts the delegated ESTS-R POST body carries claims=. - // If the claims are not forwarded, the handler will not match and the test fails. - AddMocksToGetEntraToken(httpManager, expectedClaims: ClientClaims); - - var result = await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithMtlsProofOfPossession() - .WithAttestationSupport() - .WithClaimsFromClient(ClientClaims) - .ExecuteAsync().ConfigureAwait(false); - - Assert.IsNotNull(result); - Assert.AreEqual(MTLSPoP, result.TokenType); - Assert.IsNotNull(result.BindingCertificate); - Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); - } - } - - [TestMethod] - public async Task mTLSPop_WithClaimsFromClient_SameClaims_SecondCallFromCacheAsync() - { - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint); - - var managedIdentityApp = await CreateManagedIdentityAsync( - httpManager, - managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard, - withExperimentalFeatures: true).ConfigureAwait(false); - - // Only one network mock — the second call with identical claims must be served from cache. - AddMocksToGetEntraToken(httpManager, expectedClaims: ClientClaims); - - var result1 = await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithMtlsProofOfPossession() - .WithAttestationSupport() - .WithClaimsFromClient(ClientClaims) - .ExecuteAsync().ConfigureAwait(false); - - var result2 = await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithMtlsProofOfPossession() - .WithAttestationSupport() - .WithClaimsFromClient(ClientClaims) - .ExecuteAsync().ConfigureAwait(false); - - Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource, - "First call should hit the network."); - Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource, - "Second call with identical client claims must be served from cache."); - } - } - - [TestMethod] - public async Task mTLSPop_WithClaimsFromClient_DifferentClaims_SeparateCacheEntriesAsync() - { - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint); - - var managedIdentityApp = await CreateManagedIdentityAsync( - httpManager, - managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard, - withExperimentalFeatures: true).ConfigureAwait(false); - - // Two distinct network mocks — each claims value must produce a separate cache entry. - // The second acquire reuses the cached binding certificate, so it re-runs only the - // CSR-metadata + token leg (no /issuecredential), mirroring the cached-cert refresh path. - AddMocksToGetEntraToken(httpManager, expectedClaims: ClientClaims); - httpManager.AddMockHandler(MockHelpers.MockCsrResponse()); - httpManager.AddMockHandler(MockHelpers.MockImdsV2EntraTokenRequestResponse(_identityLoggerAdapter, OtherClientClaims)); - - var result1 = await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithMtlsProofOfPossession() - .WithAttestationSupport() - .WithClaimsFromClient(ClientClaims) - .ExecuteAsync().ConfigureAwait(false); - - var result2 = await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithMtlsProofOfPossession() - .WithAttestationSupport() - .WithClaimsFromClient(OtherClientClaims) - .ExecuteAsync().ConfigureAwait(false); - - Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource, - "First claims value should hit the network."); - Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource, - "A different claims value must produce a separate cache entry and hit the network."); - } - } - [TestMethod] public async Task mTLSPop_TokenLeg_InvalidClient_ReMintsBindingAndRetriesOnceAsync() { diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ManagedIdentityTests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ManagedIdentityTests.cs index 669adc45bf..8c650f3b9e 100644 --- a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ManagedIdentityTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ManagedIdentityTests.cs @@ -1041,6 +1041,60 @@ public async Task ManagedIdentity_BackgroundRefresh_InvokesCallback_Async() } } + [TestMethod] + [Description("The OpenTelemetry tags enricher can be set on a managed identity request and is invoked with the result, including on the proactive background refresh.")] + public async Task ManagedIdentity_BackgroundRefresh_InvokesOtelTagsEnricher_Async() + { + using (new EnvVariableContext()) + using (var httpManager = new MockHttpManager()) + { + SetEnvironmentVariables(ManagedIdentitySource.AppService, AppServiceEndpoint); + + ExecutionResult capturedResult = null; + int enricherInvocations = 0; + bool backgroundRefreshCompleted = false; + Action>> enricher = (executionResult, tags) => + { + Interlocked.Increment(ref enricherInvocations); + capturedResult = executionResult; + tags.Add(new KeyValuePair("mi_custom_tag", "mi_value")); + }; + + // The completion callback is used purely as a reliable barrier: it fires only once the proactive + // background refresh has finished, guaranteeing the background enricher invocation has occurred. + var mi = ManagedIdentityApplicationBuilder.Create(ManagedIdentityId.SystemAssigned) + .WithHttpManager(httpManager) + .WithExperimentalFeatures() + .OnBackgroundTokenRefreshCompleted(r => { backgroundRefreshCompleted = true; return Task.CompletedTask; }) + .BuildConcrete(); + + // 1. Prime the cache with a token. + httpManager.AddManagedIdentityMockHandler( + AppServiceEndpoint, Resource, MockHelpers.GetMsiSuccessfulResponse(), ManagedIdentitySource.AppService); + await mi.AcquireTokenForManagedIdentity(Resource).ExecuteAsync().ConfigureAwait(false); + + // 2. Mark the cached token as needing a proactive refresh. + TestCommon.UpdateATWithRefreshOn(mi.AppTokenCacheInternal.Accessor); + + // 3. Response the background refresh will consume. + httpManager.AddManagedIdentityMockHandler( + AppServiceEndpoint, Resource, MockHelpers.GetMsiSuccessfulResponse(), ManagedIdentitySource.AppService); + + // 4. Foreground returns the cached token and kicks off the background refresh; the enricher supplied + // here is propagated onto the managed identity background-refresh metrics. + await mi.AcquireTokenForManagedIdentity(Resource) + .WithOtelTagsEnricher(enricher) + .ExecuteAsync().ConfigureAwait(false); + + // Assert - the background refresh ran and the enricher was invoked with a successful outcome. + Assert.IsTrue(TestCommon.YieldTillSatisfied(() => backgroundRefreshCompleted), "Managed identity background refresh did not complete."); + Assert.AreNotEqual(0, enricherInvocations, "Managed identity OTel tags enricher was not invoked."); + Assert.IsNotNull(capturedResult); + Assert.IsTrue(capturedResult.Successful); + Assert.IsNotNull(capturedResult.Result); + } + } + [TestMethod] public async Task ProactiveRefresh_CancelsSuccessfully_Async() { diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/WithClientClaimsTests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/WithClientClaimsTests.cs deleted file mode 100644 index 257308e154..0000000000 --- a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/WithClientClaimsTests.cs +++ /dev/null @@ -1,819 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Identity.Client; -using Microsoft.Identity.Client.AppConfig; -using Microsoft.Identity.Client.Extensibility; -using Microsoft.Identity.Client.ManagedIdentity; -using Microsoft.Identity.Client.OAuth2; -using Microsoft.Identity.Test.Common.Core.Helpers; -using Microsoft.Identity.Test.Common.Core.Mocks; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using static Microsoft.Identity.Test.Common.Core.Helpers.ManagedIdentityTestUtil; - -namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests -{ - /// - /// Unit tests for WithClaimsFromClient() across all three auth flows: - /// 1. MSIv1 (IMDS GET — claims as query parameter) - /// 2. Confidential Client / AcquireTokenForClient (claims merged into ESTS POST body) - /// 3. Cache-key isolation — different claims values produce separate cache entries - /// - [TestClass] - public class WithClaimsFromClientTests : TestBase - { - // A simple NSP-style claims payload used across tests. MSIv1 only allows the `xms_az_nwperimid` key. - private const string NspClaims = @"{""xms_az_nwperimid"":{""essential"":true}}"; - - // A second, distinct claims value used to exercise separate-cache-entry behaviour. - private const string OtherClaims = @"{""xms_az_nwperimid"":{""values"":[""eastus""]}}"; - - // --------------------------------------------------------------------------------- - // Builder-level unit tests (no HTTP) - // --------------------------------------------------------------------------------- - - [TestMethod] - [DataRow(null)] - [DataRow("")] - [DataRow(" ")] - public void WithClaimsFromClient_NullOrWhitespace_IsNoOp(string emptyClaims) - { - // Arrange - using (new EnvVariableContext()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .Build(); - - // Act — should not throw - var builder = mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(emptyClaims); - - // Assert — ClientClaims must remain unset (no cache component added) - Assert.IsNull(builder.CommonParameters.ClientClaims, - "Empty/null claims should not set ClientClaims."); - Assert.IsNull(builder.CommonParameters.CacheKeyComponents, - "Empty/null claims should not add cache key components."); - } - } - - [TestMethod] - public void WithClaimsFromClient_SetsClientClaimsOnCommonParameters() - { - // Arrange - using (new EnvVariableContext()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithExperimentalFeatures(true) - .Build(); - - // Act - var builder = mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(NspClaims); - - // Assert — normalized claims are stored - Assert.IsNotNull(builder.CommonParameters.ClientClaims, - "ClientClaims must be set."); - Assert.IsNotNull(builder.CommonParameters.CacheKeyComponents, - "CacheKeyComponents must be populated."); - Assert.IsTrue(builder.CommonParameters.CacheKeyComponents.ContainsKey("client_claims"), - "client_claims cache key component must be present."); - } - } - - [TestMethod] - public void WithClaimsFromClient_DoesNotSetCommonParametersClaims() - { - // WithClaimsFromClient must NOT touch CommonParameters.Claims — doing so would - // incorrectly bypass the token cache (Claims is the server-issued bypass signal). - using (new EnvVariableContext()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithExperimentalFeatures(true) - .Build(); - - // Act - var builder = mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(NspClaims); - - // Assert — CommonParameters.Claims (the cache-bypass property) must be null - Assert.IsNull(builder.CommonParameters.Claims, - "WithClaimsFromClient must NOT set CommonParameters.Claims — that would bypass the cache."); - } - } - - // --------------------------------------------------------------------------------- - // MSIv1 (IMDS GET) — claims forwarded as a query parameter - // --------------------------------------------------------------------------------- - - [TestMethod] - public async Task WithClaimsFromClient_Imds_ForwardsClaimsAsQueryParameterAsync() - { - // Arrange - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithHttpManager(httpManager) - .WithExperimentalFeatures(true) - - .Build(); - - // The mock handler is set up to expect claims= in the query string. - // If the MSAL code does NOT send the parameter, the handler will not match and the - // test will throw InvalidOperationException (no handler matched). - string normalizedClaims = NspClaims; - httpManager.AddManagedIdentityMockHandler( - ManagedIdentityTests.ImdsEndpoint, - ManagedIdentityTests.Resource, - MockHelpers.GetMsiSuccessfulResponse(), - ManagedIdentitySource.Imds, - extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedClaims) } }); - - // Act - var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(NspClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert - Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_Imds_TokenIsCached_SecondCallDoesNotHitNetworkAsync() - { - // Arrange - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithHttpManager(httpManager) - .WithExperimentalFeatures(true) - - .Build(); - - // Only one network mock — second call must come from cache. - string normalizedClaims = NspClaims; - httpManager.AddManagedIdentityMockHandler( - ManagedIdentityTests.ImdsEndpoint, - ManagedIdentityTests.Resource, - MockHelpers.GetMsiSuccessfulResponse(), - ManagedIdentitySource.Imds, - extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedClaims) } }); - - // Act — first call - var result1 = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(NspClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - // Act — second call (no new mock handler added) - var result2 = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(NspClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert - Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource, - "First call should hit the network."); - Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource, - "Second call with identical claims must be served from cache."); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_Imds_DifferentClaims_ProduceSeparateCacheEntriesAsync() - { - // Two calls with distinct claims values must each produce a separate network call. - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithHttpManager(httpManager) - .WithExperimentalFeatures(true) - - .Build(); - - string normalizedNsp = NspClaims; - string normalizedOther = OtherClaims; - - // Two distinct network mocks — each must be consumed - httpManager.AddManagedIdentityMockHandler( - ManagedIdentityTests.ImdsEndpoint, - ManagedIdentityTests.Resource, - MockHelpers.GetMsiSuccessfulResponse(), - ManagedIdentitySource.Imds, - extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedNsp) } }); - - httpManager.AddManagedIdentityMockHandler( - ManagedIdentityTests.ImdsEndpoint, - ManagedIdentityTests.Resource, - MockHelpers.GetMsiSuccessfulResponse(), - ManagedIdentitySource.Imds, - extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedOther) } }); - - // Act - var result1 = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(NspClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - var result2 = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(OtherClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert — both calls must have hit the network (different cache partitions) - Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource, - "First claims value should hit the network."); - Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource, - "Different claims value should produce a separate cache entry and hit the network."); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_Imds_DoesNotBypassCache_UnlikeWithClaimsAsync() - { - // WithClaims() bypasses the cache on every call. - // WithClaimsFromClient() must NOT bypass the cache — second call should be a cache hit. - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithHttpManager(httpManager) - .WithExperimentalFeatures(true) - - .Build(); - - string normalizedClaims = NspClaims; - - // Only one mock handler — if the second call also hits the network it will throw - httpManager.AddManagedIdentityMockHandler( - ManagedIdentityTests.ImdsEndpoint, - ManagedIdentityTests.Resource, - MockHelpers.GetMsiSuccessfulResponse(), - ManagedIdentitySource.Imds, - extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedClaims) } }); - - // Act - await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(NspClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(NspClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert — second call must be a cache hit, not a network call - Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource, - "WithClaimsFromClient must use the cache (unlike WithClaims which always bypasses)."); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_Imds_CombinedWithWithClaims_ForwardsClientClaimsAndBypassesCacheAsync() - { - // When both .WithClaims (server-issued challenge) and .WithClaimsFromClient (client claims) - // are supplied on the same MSI request: - // - Only the client claims are forwarded to IMDS as the `claims` query parameter - // (server-issued challenges are not a recognised IMDS contract). - // - .WithClaims causes the request to bypass the cache on every call, so two back-to-back - // calls with identical inputs must each hit the network. - const string ServerClaims = @"{""access_token"":{""nbf"":{""essential"":true}}}"; - - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithHttpManager(httpManager) - .WithExperimentalFeatures(true) - .Build(); - - // Two mocks — both expect ONLY the client claims in the `claims` parameter, never - // a merged value that includes ServerClaims. If MSAL accidentally merges them or - // forwards the server-issued claims, neither handler will match and the test fails. - for (int i = 0; i < 2; i++) - { - httpManager.AddManagedIdentityMockHandler( - ManagedIdentityTests.ImdsEndpoint, - ManagedIdentityTests.Resource, - MockHelpers.GetMsiSuccessfulResponse(), - ManagedIdentitySource.Imds, - extraQueryParameters: new Dictionary - { - { "claims", Uri.EscapeDataString(NspClaims) } - }); - } - - // Act — first call - var result1 = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaims(ServerClaims) - .WithClaimsFromClient(NspClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - // Act — second call with identical inputs; .WithClaims must force a network round-trip - var result2 = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaims(ServerClaims) - .WithClaimsFromClient(NspClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert - Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource, - "First call should hit the network."); - Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource, - "WithClaims must bypass the cache even when WithClaimsFromClient is also set."); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_Imds_NoClaims_ClaimsParamAbsentFromRequestAsync() - { - // When no client claims are specified, the `claims` query parameter must be absent. - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithHttpManager(httpManager) - .WithExperimentalFeatures(true) - - .Build(); - - // Standard mock handler with no claims expectation - httpManager.AddManagedIdentityMockHandler( - ManagedIdentityTests.ImdsEndpoint, - ManagedIdentityTests.Resource, - MockHelpers.GetMsiSuccessfulResponse(), - ManagedIdentitySource.Imds); - - // Act — no WithClaimsFromClient call - var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert — should succeed normally - Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); - } - } - - // --------------------------------------------------------------------------------- - // Confidential Client / AcquireTokenForClient — claims merged into ESTS POST body - // --------------------------------------------------------------------------------- - - [TestMethod] - public async Task WithClaimsFromClient_ConfidentialClient_SendsClaimsInEstsBodyAsync() - { - // Arrange - using (var harness = CreateTestHarness()) - { - harness.HttpManager.AddInstanceDiscoveryMockHandler(); - - var app = ConfidentialClientApplicationBuilder - .Create(TestConstants.ClientId) - .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) - .WithClientSecret(TestConstants.ClientSecret) - .WithHttpManager(harness.HttpManager) - .WithExperimentalFeatures(true) - .BuildConcrete(); - - string normalizedClaims = NspClaims; - - // The POST body must contain claims= - harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( - TestConstants.AuthorityUtidTenant, - bodyParameters: new Dictionary - { - { OAuth2Parameter.Claims, normalizedClaims } - }, - responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); - - // Act - var result = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithClaimsFromClient(normalizedClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_ConfidentialClient_TokenIsCached_SecondCallFromCacheAsync() - { - // Arrange - using (var harness = CreateTestHarness()) - { - harness.HttpManager.AddInstanceDiscoveryMockHandler(); - - var app = ConfidentialClientApplicationBuilder - .Create(TestConstants.ClientId) - .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) - .WithClientSecret(TestConstants.ClientSecret) - .WithHttpManager(harness.HttpManager) - .WithExperimentalFeatures(true) - .BuildConcrete(); - - string normalizedClaims = NspClaims; - - // Only one mock — second call must come from cache - harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( - TestConstants.AuthorityUtidTenant, - responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); - - // Act - var result1 = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithClaimsFromClient(normalizedClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - var result2 = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithClaimsFromClient(normalizedClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert - Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource, - "First call should hit the network."); - Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource, - "Second call with identical claims must be served from cache."); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_ConfidentialClient_DifferentClaims_SeparateCacheEntriesAsync() - { - // Arrange - using (var harness = CreateTestHarness()) - { - harness.HttpManager.AddInstanceDiscoveryMockHandler(); - - var app = ConfidentialClientApplicationBuilder - .Create(TestConstants.ClientId) - .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) - .WithClientSecret(TestConstants.ClientSecret) - .WithHttpManager(harness.HttpManager) - .WithExperimentalFeatures(true) - .BuildConcrete(); - - string normalizedNsp = NspClaims; - string normalizedOther = OtherClaims; - - // Two distinct network mocks - harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( - TestConstants.AuthorityUtidTenant, - responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); - - harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( - TestConstants.AuthorityUtidTenant, - responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); - - // Act - var result1 = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithClaimsFromClient(normalizedNsp) - .ExecuteAsync() - .ConfigureAwait(false); - - var result2 = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithClaimsFromClient(normalizedOther) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert - Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource, - "First claims value should hit the network."); - Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource, - "Different claims value should produce a separate cache entry and hit the network."); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_ConfidentialClient_DoesNotBypassCacheAsync() - { - // Arrange - using (var harness = CreateTestHarness()) - { - harness.HttpManager.AddInstanceDiscoveryMockHandler(); - - var app = ConfidentialClientApplicationBuilder - .Create(TestConstants.ClientId) - .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) - .WithClientSecret(TestConstants.ClientSecret) - .WithHttpManager(harness.HttpManager) - .WithExperimentalFeatures(true) - .BuildConcrete(); - - string normalizedClaims = NspClaims; - - // Only one mock — if second call also hits the network it will throw - harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( - TestConstants.AuthorityUtidTenant, - responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); - - // Act - await app.AcquireTokenForClient(TestConstants.s_scope) - .WithClaimsFromClient(normalizedClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - var result = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithClaimsFromClient(normalizedClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert - Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource, - "WithClaimsFromClient must not bypass the cache on repeated calls."); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_ConfidentialClient_WithServerClaims_ServerClaimsBypassesCacheAsync() - { - // WithClaims (server-issued) always bypasses the cache. - // WithClaimsFromClient (client-originated) does not. - // When both are used together, the server claim should still bypass the cache. - using (var harness = CreateTestHarness()) - { - harness.HttpManager.AddInstanceDiscoveryMockHandler(); - - var app = ConfidentialClientApplicationBuilder - .Create(TestConstants.ClientId) - .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) - .WithClientSecret(TestConstants.ClientSecret) - .WithHttpManager(harness.HttpManager) - .WithExperimentalFeatures(true) - .BuildConcrete(); - - string normalizedClientClaims = NspClaims; - - // First call — populate cache with client claims - harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( - TestConstants.AuthorityUtidTenant, - responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); - - await app.AcquireTokenForClient(TestConstants.s_scope) - .WithClaimsFromClient(normalizedClientClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - // Second call — with WithClaims (server bypass) in addition to WithClaimsFromClient - harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( - TestConstants.AuthorityUtidTenant, - responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); - - var result = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithClaimsFromClient(normalizedClientClaims) - .WithClaims(TestConstants.Claims) // server-issued → bypasses cache - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert — server claims bypass forces a network call even though the token is cached - Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource, - "WithClaims (server-issued) must always bypass the cache."); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_ConfidentialClient_NoClaims_ClaimsParamAbsentFromBodyAsync() - { - // When no client claims are specified, the `claims` body parameter must not appear. - using (var harness = CreateTestHarness()) - { - harness.HttpManager.AddInstanceDiscoveryMockHandler(); - - var app = ConfidentialClientApplicationBuilder - .Create(TestConstants.ClientId) - .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) - .WithClientSecret(TestConstants.ClientSecret) - .WithHttpManager(harness.HttpManager) - .WithExperimentalFeatures(true) - .BuildConcrete(); - - // Standard success response — no body parameter expectation - harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( - TestConstants.AuthorityUtidTenant, - responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); - - // Act — no WithClaimsFromClient - var result = await app.AcquireTokenForClient(TestConstants.s_scope) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert — normal token acquisition succeeds - Assert.IsNotNull(result); - } - } - - // --------------------------------------------------------------------------------- - // Invalid JSON - // --------------------------------------------------------------------------------- - // - // Note: WithClaimsFromClient intentionally does NOT validate the JSON at builder time. - // Per reviewer feedback (Bogdan), MSAL stores the raw caller string verbatim and does no - // parsing on the hot path. Invalid JSON (e.g. "not-valid-json", "null") is forwarded as-is - // and will surface as an MsalServiceException from the wire when IMDS/ESTS rejects it, or - // as an MsalClientException from MergeClaimsObjects on cache miss when a server-issued - // claims challenge is also present. Builder-time fail-fast tests were removed when the - // NormalizeClaimsJson code path was deleted. - // --------------------------------------------------------------------------------- - - // --------------------------------------------------------------------------------- - // Non-IMDS sources — builder behavior - // --------------------------------------------------------------------------------- - - [TestMethod] - public void WithClaimsFromClient_NonImdsSource_SetsBuilderParameterButThrowsOnExecution() - { - // WithClaimsFromClient() sets the builder parameter for any MI source — the guard that - // rejects non-IMDS sources fires at request-execution time (in AbstractManagedIdentity), - // not at builder construction time. This test verifies the builder state; a full - // execution-level test requires mocking the App Service endpoint and is deferred. - using (new EnvVariableContext()) - { - SetEnvironmentVariables(ManagedIdentitySource.AppService, "http://127.0.0.1:41564/msi/token"); - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithExperimentalFeatures(true) - .Build(); - - // Act - var builder = mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(NspClaims); - - // Assert — parameter is stored on the builder regardless of source; - // MsalClientException is thrown later when the request is executed. - Assert.IsNotNull(builder.CommonParameters.ClientClaims, - "ClientClaims must be set on the builder even for non-IMDS sources."); - Assert.IsTrue(builder.CommonParameters.CacheKeyComponents.ContainsKey("client_claims"), - "Cache key component must be registered."); - } - } - - [TestMethod] - [DataRow(ManagedIdentitySource.AppService, "http://127.0.0.1:41564/msi/token")] - [DataRow(ManagedIdentitySource.AzureArc, "http://127.0.0.1:40342/metadata/identity/oauth2/token")] - [DataRow(ManagedIdentitySource.CloudShell, "http://localhost:50342/oauth2/token")] - [DataRow(ManagedIdentitySource.ServiceFabric, "https://127.0.0.1:2377/metadata/identity/oauth2/token")] - [DataRow(ManagedIdentitySource.MachineLearning, "http://localhost:7071/msi/token")] - public async Task WithClaimsFromClient_NonImdsSource_ExecuteThrowsMsalClientExceptionAsync( - ManagedIdentitySource source, - string endpoint) - { - // Only IMDS / IMDSv2 are wired to forward client claims today. Any other source must - // fail fast with MsalClientException at execute time so callers don't silently lose - // their claims (and so the cache doesn't pollute with keys the endpoint never saw). - using (new EnvVariableContext()) - { - SetEnvironmentVariables(source, endpoint); - - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithExperimentalFeatures(true) - .Build(); - - MsalClientException ex = await Assert.ThrowsExactlyAsync( - () => mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(NspClaims) - .ExecuteAsync()) - .ConfigureAwait(false); - - Assert.AreEqual(MsalError.InvalidRequest, ex.ErrorCode); - Assert.Contains(source.ToString(), ex.Message, - "Error message should name the detected source."); - Assert.Contains("IMDS", ex.Message, - "Error message should explain only IMDS sources are supported."); - } - } - - // --------------------------------------------------------------------------------- - // MSIv1 claim allowlist validation — only xms_az_nwperimid is permitted - // --------------------------------------------------------------------------------- - - private const string ValidNspClaim = @"{""xms_az_nwperimid"":{""values"":[""perimid-1234""]}}"; - private const string UnsupportedClaim = @"{""custom_claim"":{""essential"":true}}"; - private const string MixedClaims = @"{""xms_az_nwperimid"":{""values"":[""perimid-1234""]},""other_claim"":{""essential"":true}}"; - - [TestMethod] - public async Task WithClaimsFromClient_Imds_ValidXmsAzNwperimid_SucceedsAsync() - { - // xms_az_nwperimid is the only allowed claim for MSIv1; a request carrying it must succeed. - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithHttpManager(httpManager) - .WithExperimentalFeatures(true) - .Build(); - - string normalizedClaims = ValidNspClaim; - httpManager.AddManagedIdentityMockHandler( - ManagedIdentityTests.ImdsEndpoint, - ManagedIdentityTests.Resource, - MockHelpers.GetMsiSuccessfulResponse(), - ManagedIdentitySource.Imds, - extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedClaims) } }); - - // Act - var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(ValidNspClaim) - .ExecuteAsync() - .ConfigureAwait(false); - - // Assert - Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_Imds_UnsupportedClaim_ThrowsMsalClientExceptionAsync() - { - // Any claim key other than xms_az_nwperimid must be rejected before the network call, - // so the caller gets a clear error instead of an opaque HTTP 400 from IMDS. - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithHttpManager(httpManager) - .WithExperimentalFeatures(true) - .Build(); - - // Act & Assert — MsalClientException must be thrown before any HTTP request is made - MsalClientException ex = await Assert.ThrowsExactlyAsync( - () => mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(UnsupportedClaim) - .ExecuteAsync()) - .ConfigureAwait(false); - - Assert.AreEqual(MsalError.InvalidRequest, ex.ErrorCode); - Assert.Contains("xms_az_nwperimid", ex.Message, "Error message should name the only allowed claim."); - } - } - - [TestMethod] - public async Task WithClaimsFromClient_Imds_MixedClaims_ThrowsMsalClientExceptionAsync() - { - // Even if xms_az_nwperimid is present, any additional claims must be rejected. - using (new EnvVariableContext()) - using (var httpManager = new MockHttpManager()) - { - SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint); - - var mi = ManagedIdentityApplicationBuilder - .Create(ManagedIdentityId.SystemAssigned) - .WithHttpManager(httpManager) - .WithExperimentalFeatures(true) - .Build(); - - // Act & Assert - MsalClientException ex = await Assert.ThrowsExactlyAsync( - () => mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) - .WithClaimsFromClient(MixedClaims) - .ExecuteAsync()) - .ConfigureAwait(false); - - Assert.AreEqual(MsalError.InvalidRequest, ex.ErrorCode); - } - } - } -} - - diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs index 26cc495423..012076e5c6 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs @@ -64,6 +64,71 @@ public async Task SignedAssertionDelegateClientCredential_NoClaims() } } + [TestMethod] + public async Task SignedAssertionDelegateClientCredential_ForwardsOtelTagsEnricher() + { + using (var httpManager = new MockHttpManager()) + { + httpManager.AddInstanceDiscoveryMockHandler(); + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + + Action>> enricher = + (executionResult, tags) => tags.Add(new KeyValuePair("k", "v")); + + Action>> capturedEnricher = null; + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithHttpManager(httpManager) + .WithClientAssertion(async (AssertionRequestOptions options) => + { + // The enricher configured on the outer request must be forwarded to the + // assertion callback so a callback that acquires the assertion via an inner + // token request (e.g. a Federated Identity Credential) can enrich it identically. + capturedEnricher = options.OtelTagsEnricher; + return await Task.FromResult("dummy_assertion").ConfigureAwait(false); + }) + .BuildConcrete(); + + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithOtelTagsEnricher(enricher) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.AreSame(enricher, capturedEnricher, "OtelTagsEnricher should be forwarded to the assertion callback."); + } + } + + [TestMethod] + public async Task SignedAssertionDelegateClientCredential_NoEnricher_LeavesOtelTagsEnricherNull() + { + using (var httpManager = new MockHttpManager()) + { + httpManager.AddInstanceDiscoveryMockHandler(); + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + + bool enricherWasNull = false; + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithHttpManager(httpManager) + .WithClientAssertion(async (AssertionRequestOptions options) => + { + enricherWasNull = options.OtelTagsEnricher == null; + return await Task.FromResult("dummy_assertion").ConfigureAwait(false); + }) + .BuildConcrete(); + + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.IsTrue(enricherWasNull, "OtelTagsEnricher should be null when no enricher is configured."); + } + } + [TestMethod] public async Task SignedAssertionDelegateClientCredential_WithClaims() { diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/LoggerTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/LoggerTests.cs index 2303b189a7..295f3e9c6f 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/LoggerTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/LoggerTests.cs @@ -440,6 +440,86 @@ private void AfterCacheAccessWithLogging(TokenCacheNotificationArgs args) args.IdentityLogger.Log(entry); } + + private const string TaggedOpaqueToken = "AQABCQEAAAAdDD7nRXZvU3RzQXJ0aWZhY3RzDQAAAAAAwEvgQ_kqa0hyAA"; + + private static string GetPiiLoggedOutput(Action logAction) + { + var testLogger = new TestIdentityLogger(); + ILoggerAdapter logger = new IdentityLoggerAdapter(testLogger, Guid.Empty, null, null, enablePiiLogging: true); + logAction(logger); + return testLogger.StringBuilder.ToString(); + } + + [TestMethod] + [Description("Exception messages that carry an ESTS-tagged opaque token must be scrubbed before logging.")] + public void ErrorPii_Exception_WithTaggedToken_IsScrubbed() + { + // Arrange + var exception = new MsalServiceException("some_error", "server rejected the request") + { + ResponseBody = "{\"error\":\"invalid_grant\",\"opaque\":\"" + TaggedOpaqueToken + "\"}" + }; + + // Act + string output = GetPiiLoggedOutput(logger => logger.ErrorPii(exception)); + + // Assert + Assert.Contains(TokenScrubber.Placeholder, output); + Assert.DoesNotContain("RXZvU3RzQXJ0aWZhY3Rz", output); + Assert.DoesNotContain(TaggedOpaqueToken, output); + } + + [TestMethod] + [Description("Info-level exception logging must also scrub ESTS-tagged opaque tokens.")] + public void InfoPii_Exception_WithTaggedToken_IsScrubbed() + { + // Arrange + var exception = new MsalServiceException("some_error", "server rejected the request") + { + ResponseBody = "{\"opaque\":\"" + TaggedOpaqueToken + "\"}" + }; + + // Act + string output = GetPiiLoggedOutput(logger => logger.InfoPii(exception)); + + // Assert + Assert.Contains(TokenScrubber.Placeholder, output); + Assert.DoesNotContain("RXZvU3RzQXJ0aWZhY3Rz", output); + } + + [TestMethod] + [Description("Warning-level exception logging must also scrub ESTS-tagged opaque tokens.")] + public void WarningPii_Exception_WithTaggedToken_IsScrubbed() + { + // Arrange + var exception = new MsalServiceException("some_error", "server rejected the request") + { + ResponseBody = "{\"opaque\":\"" + TaggedOpaqueToken + "\"}" + }; + + // Act + string output = GetPiiLoggedOutput(logger => logger.WarningPii(exception)); + + // Assert + Assert.Contains(TokenScrubber.Placeholder, output); + Assert.DoesNotContain("RXZvU3RzQXJ0aWZhY3Rz", output); + } + + [TestMethod] + [Description("Tactical scope: ordinary (non-exception, non-ESTS) log messages are NOT scrubbed.")] + public void InfoPii_PlainMessage_WithTaggedToken_IsNotScrubbed() + { + // Arrange + string message = "diagnostic value " + TaggedOpaqueToken; + + // Act + string output = GetPiiLoggedOutput(logger => logger.InfoPii(message, string.Empty)); + + // Assert + Assert.Contains(TaggedOpaqueToken, output); + Assert.DoesNotContain(TokenScrubber.Placeholder, output); + } } } diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/TokenScrubberTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/TokenScrubberTests.cs new file mode 100644 index 0000000000..0ecc8e2ded --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/TokenScrubberTests.cs @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Identity.Client.Internal.Logger; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Identity.Test.Unit.PublicApiTests +{ + [TestClass] + public class TokenScrubberTests + { + private const string Offset0 = "RXZvU3RzQXJ0aWZhY3Rz"; + private const string Offset1 = "V2b1N0c0FydGlmYWN0c"; + private const string Offset2 = "dm9TdHNBcnRpZmFjdH"; + private const string MsaLiteral = "MsaArtifacts"; + + [TestMethod] + [DataRow(Offset0)] + [DataRow(Offset1)] + [DataRow(Offset2)] + public void Scrub_EstsOffsetPattern_RedactsToken(string pattern) + { + // Arrange + string message = $"Header AQABAAEAAAD{pattern}bodybodybodyTOKEN== trailing text"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(pattern, result); + Assert.Contains(TokenScrubber.Placeholder, result); + Assert.AreEqual("Header [Redacted opaque token] trailing text", result); + } + + [TestMethod] + public void Scrub_MsaLiteral_RedactsToken() + { + // Arrange + string message = $"prefix ABCdef{MsaLiteral}12345 suffix"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(MsaLiteral, result); + Assert.AreEqual("prefix [Redacted opaque token] suffix", result); + } + + [TestMethod] + public void Scrub_TokenInsideJson_RedactsToken() + { + // Arrange + string message = $"{{\"access_token\":\"eyJ0{Offset0}abc-DEF_123\",\"expires_in\":3600}}"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(Offset0, result); + Assert.AreEqual("{\"access_token\":\"[Redacted opaque token]\",\"expires_in\":3600}", result); + } + + [TestMethod] + public void Scrub_SetCookieEsctxLine_RedactsToken() + { + // Arrange + string message = "Set-Cookie: esctx-uYBOwwKr8Sg=AQABCQEAAAAdDD7nC9b5Q7JPd_okEQRFRXZvU3RzQXJ0aWZhY3RzDQAAAAAAwEvgY46VLqZk6xV4zJodl7RTZLwd1OotrpcUmu2Sk7nGwPKY--1D1oJptRZTR32ppc7bucqiQCsEY8qoYH56_5009mgGcrTkn4mQwWddzehxJCyoLjD2jNT322cq4JDFbyAXA7e7q_V1MtQ_kqa0hyAA; domain=.login.microsoftonline.com; path=/; secure; HttpOnly; SameSite=None"; + string expected = "Set-Cookie: [Redacted opaque token]; domain=.login.microsoftonline.com; path=/; secure; HttpOnly; SameSite=None"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.AreEqual(expected, result); + Assert.DoesNotContain(Offset0, result); + } + + [TestMethod] + public void Scrub_OrdinaryLogLine_ReturnsSameReference() + { + // Arrange + string message = "2024-01-01 MSAL 4.0.0 acquiring token for scope User.Read"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.AreSame(message, result); + } + + [TestMethod] + public void Scrub_LookAlikeString_NotRedacted() + { + // Arrange - contains the substring "Artifacts" and base64-ish text, but no tagged pattern. + string message = "Loading build Artifacts from RXZvU3Rz cache directory"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.AreSame(message, result); + Assert.DoesNotContain(TokenScrubber.Placeholder, result); + } + + [TestMethod] + public void Scrub_MultipleTokens_AllRedacted() + { + // Arrange + string message = $"first AAA{Offset0}BBB then second CCC{MsaLiteral}DDD end"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(Offset0, result); + Assert.DoesNotContain(MsaLiteral, result); + Assert.AreEqual("first [Redacted opaque token] then second [Redacted opaque token] end", result); + } + + [TestMethod] + public void Scrub_TokenAtStartOfString_RedactsToken() + { + // Arrange + string message = $"AAABBB{Offset1}CCCddd rest of line"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(Offset1, result); + Assert.AreEqual("[Redacted opaque token] rest of line", result); + } + + [TestMethod] + public void Scrub_TokenAtEndOfString_RedactsToken() + { + // Arrange + string message = $"log line ends with token AAABBB{Offset2}CCCddd=="; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(Offset2, result); + Assert.AreEqual("log line ends with token [Redacted opaque token]", result); + } + + [TestMethod] + public void Scrub_EntireStringIsToken_RedactsToken() + { + // Arrange + string message = $"AAABBB{Offset0}CCCddd=="; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(Offset0, result); + Assert.AreEqual(TokenScrubber.Placeholder, result); + } + + [TestMethod] + public void Scrub_Base64UrlToken_RedactsAcrossDashAndUnderscore() + { + // Arrange - base64url token using '-' and '_' on both sides of the tag. Left expansion + // crosses '=' (a token char), so the leading "token=" is folded into the redaction. + string message = $"token=ab-cd_ef{Offset0}gh-ij_kl-mn end"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(Offset0, result); + Assert.AreEqual("[Redacted opaque token] end", result); + } + + [TestMethod] + public void Scrub_BearerAuthorizationHeader_RedactsToken() + { + // Arrange + string message = $"Authorization: Bearer eyJhbGci{Offset0}payloadPart-signature_part after"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(Offset0, result); + Assert.AreEqual("Authorization: Bearer [Redacted opaque token] after", result); + } + + [TestMethod] + public void Scrub_MultipleSetCookieTokensOnOneLine_RedactsAll() + { + // Arrange - two tagged esctx cookies on one folded header line, an untagged fpc cookie in between. + string message = $"Set-Cookie: esctx-A=hdr{Offset0}body; path=/, fpc=Ag2n43XSImFA; expires=x, Set-Cookie: esctx-B=hdr{Offset1}body; path=/"; + string expected = "Set-Cookie: [Redacted opaque token]; path=/, fpc=Ag2n43XSImFA; expires=x, Set-Cookie: [Redacted opaque token]; path=/"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(Offset0, result); + Assert.DoesNotContain(Offset1, result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + public void Scrub_AdjacentTokensBackToBack_RedactsBoth() + { + // Arrange - two token runs separated only by a single space. + string message = $"AAA{Offset0}BBB {Offset1}CCC"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(Offset0, result); + Assert.DoesNotContain(Offset1, result); + Assert.AreEqual("[Redacted opaque token] [Redacted opaque token]", result); + } + + [TestMethod] + public void Scrub_MixedEstsAndMsaTokens_RedactsAll() + { + // Arrange - all four detection patterns in a single blob. + string message = $"a AAA{Offset0}xxx b BBB{Offset1}yyy c CCC{Offset2}zzz d DDD{MsaLiteral}www e"; + string expected = "a [Redacted opaque token] b [Redacted opaque token] c [Redacted opaque token] d [Redacted opaque token] e"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(Offset0, result); + Assert.DoesNotContain(Offset1, result); + Assert.DoesNotContain(Offset2, result); + Assert.DoesNotContain(MsaLiteral, result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + public void Scrub_TokenInsideHttpResponseErrorBlob_RedactsToken() + { + // Arrange - simulates a whole HttpResponseMessage embedded inside an error string. + string message = + "MsalServiceException: StatusCode=200, ResponseBody={StatusCode: 200, " + + $"Headers:{{ Set-Cookie: esctx-x=AQAB{Offset0}Q_kqa0hyAA; path=/ }}, " + + "Content: text/html }"; + string expected = + "MsalServiceException: StatusCode=200, ResponseBody={StatusCode: 200, " + + "Headers:{ Set-Cookie: [Redacted opaque token]; path=/ }, " + + "Content: text/html }"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(Offset0, result); + Assert.AreEqual(expected, result); + } + + [TestMethod] + public void Scrub_NullInput_ReturnsNull() + { + // Act + string result = TokenScrubber.Scrub(null); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void Scrub_EmptyInput_ReturnsSameReference() + { + // Arrange + string message = string.Empty; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.AreSame(message, result); + } + + [TestMethod] + public void Scrub_MsaLiteralLookAlikeWithoutTokenChars_StillRedactsLiteralOnly() + { + // Arrange - the MSA literal surrounded by non-token characters (spaces) redacts just the literal. + string message = $"value is {MsaLiteral} here"; + + // Act + string result = TokenScrubber.Scrub(message); + + // Assert + Assert.DoesNotContain(MsaLiteral, result); + Assert.AreEqual("value is [Redacted opaque token] here", result); + } + } +} diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/WithClaimsFromClientTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/WithClaimsFromClientTests.cs new file mode 100644 index 0000000000..e2b6613cc3 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/WithClaimsFromClientTests.cs @@ -0,0 +1,359 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Extensibility; +using Microsoft.Identity.Client.OAuth2; +using Microsoft.Identity.Test.Common.Core.Helpers; +using Microsoft.Identity.Test.Common.Core.Mocks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Identity.Test.Unit.PublicApiTests +{ + /// + /// Unit tests for WithClaimsFromClient() on confidential client + /// ( / AcquireTokenForClient). + /// Client-originated claims are merged into the ESTS POST body and participate in cache + /// keying (unlike server-issued WithClaims, which bypasses the cache). + /// + [TestClass] + public class WithClaimsFromClientTests : TestBase + { + // A simple NSP-style claims payload used across tests. + private const string NspClaims = @"{""xms_az_nwperimid"":{""essential"":true}}"; + + // A second, distinct claims value used to exercise separate-cache-entry behaviour. + private const string OtherClaims = @"{""xms_az_nwperimid"":{""values"":[""eastus""]}}"; + + // --------------------------------------------------------------------------------- + // Builder-level unit tests (no HTTP) + // --------------------------------------------------------------------------------- + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void WithClaimsFromClient_NullOrWhitespace_IsNoOp(string emptyClaims) + { + // Arrange — experimental features intentionally NOT enabled: the null/whitespace + // guard must return before the experimental-feature gate is evaluated. + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) + .WithClientSecret(TestConstants.ClientSecret) + .BuildConcrete(); + + // Act — should not throw + var builder = app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(emptyClaims); + + // Assert — ClientClaims must remain unset (no cache component added) + Assert.IsNull(builder.CommonParameters.ClientClaims, + "Empty/null claims should not set ClientClaims."); + Assert.IsNull(builder.CommonParameters.CacheKeyComponents, + "Empty/null claims should not add cache key components."); + } + + [TestMethod] + public void WithClaimsFromClient_SetsClientClaimsOnCommonParameters() + { + // Arrange + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) + .WithClientSecret(TestConstants.ClientSecret) + .WithExperimentalFeatures(true) + .BuildConcrete(); + + // Act + var builder = app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(NspClaims); + + // Assert — client claims are stored and keyed into the cache + Assert.IsNotNull(builder.CommonParameters.ClientClaims, + "ClientClaims must be set."); + Assert.IsNotNull(builder.CommonParameters.CacheKeyComponents, + "CacheKeyComponents must be populated."); + Assert.IsTrue(builder.CommonParameters.CacheKeyComponents.ContainsKey("client_claims"), + "client_claims cache key component must be present."); + } + + [TestMethod] + public void WithClaimsFromClient_DoesNotSetCommonParametersClaims() + { + // WithClaimsFromClient must NOT touch CommonParameters.Claims — doing so would + // incorrectly bypass the token cache (Claims is the server-issued bypass signal). + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) + .WithClientSecret(TestConstants.ClientSecret) + .WithExperimentalFeatures(true) + .BuildConcrete(); + + // Act + var builder = app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(NspClaims); + + // Assert — CommonParameters.Claims (the cache-bypass property) must be null + Assert.IsNull(builder.CommonParameters.Claims, + "WithClaimsFromClient must NOT set CommonParameters.Claims — that would bypass the cache."); + } + + // --------------------------------------------------------------------------------- + // Confidential Client / AcquireTokenForClient — claims merged into ESTS POST body + // --------------------------------------------------------------------------------- + + [TestMethod] + public async Task WithClaimsFromClient_ConfidentialClient_SendsClaimsInEstsBodyAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .WithExperimentalFeatures(true) + .BuildConcrete(); + + string normalizedClaims = NspClaims; + + // The POST body must contain claims= + harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant, + bodyParameters: new Dictionary + { + { OAuth2Parameter.Claims, normalizedClaims } + }, + responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); + + // Act + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(normalizedClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + } + } + + [TestMethod] + public async Task WithClaimsFromClient_ConfidentialClient_TokenIsCached_SecondCallFromCacheAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .WithExperimentalFeatures(true) + .BuildConcrete(); + + string normalizedClaims = NspClaims; + + // Only one mock — second call must come from cache + harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant, + responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); + + // Act + var result1 = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(normalizedClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + var result2 = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(normalizedClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource, + "First call should hit the network."); + Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource, + "Second call with identical claims must be served from cache."); + } + } + + [TestMethod] + public async Task WithClaimsFromClient_ConfidentialClient_DifferentClaims_SeparateCacheEntriesAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .WithExperimentalFeatures(true) + .BuildConcrete(); + + string normalizedNsp = NspClaims; + string normalizedOther = OtherClaims; + + // Two distinct network mocks + harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant, + responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); + + harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant, + responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); + + // Act + var result1 = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(normalizedNsp) + .ExecuteAsync() + .ConfigureAwait(false); + + var result2 = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(normalizedOther) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource, + "First claims value should hit the network."); + Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource, + "Different claims value should produce a separate cache entry and hit the network."); + } + } + + [TestMethod] + public async Task WithClaimsFromClient_ConfidentialClient_DoesNotBypassCacheAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .WithExperimentalFeatures(true) + .BuildConcrete(); + + string normalizedClaims = NspClaims; + + // Only one mock — if second call also hits the network it will throw + harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant, + responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); + + // Act + await app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(normalizedClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(normalizedClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource, + "WithClaimsFromClient must not bypass the cache on repeated calls."); + } + } + + [TestMethod] + public async Task WithClaimsFromClient_ConfidentialClient_WithServerClaims_ServerClaimsBypassesCacheAsync() + { + // WithClaims (server-issued) always bypasses the cache. + // WithClaimsFromClient (client-originated) does not. + // When both are used together, the server claim should still bypass the cache. + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .WithExperimentalFeatures(true) + .BuildConcrete(); + + string normalizedClientClaims = NspClaims; + + // First call — populate cache with client claims + harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant, + responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); + + await app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(normalizedClientClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + // Second call — with WithClaims (server bypass) in addition to WithClaimsFromClient + harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant, + responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); + + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithClaimsFromClient(normalizedClientClaims) + .WithClaims(TestConstants.Claims) // server-issued → bypasses cache + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert — server claims bypass forces a network call even though the token is cached + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource, + "WithClaims (server-issued) must always bypass the cache."); + } + } + + [TestMethod] + public async Task WithClaimsFromClient_ConfidentialClient_NoClaims_ClaimsParamAbsentFromBodyAsync() + { + // When no client claims are specified, the `claims` body parameter must not appear. + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .WithExperimentalFeatures(true) + .BuildConcrete(); + + // Standard success response — assert the `claims` body parameter is absent + var handler = harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant, + responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage()); + + handler.UnExpectedPostData = new Dictionary + { + { OAuth2Parameter.Claims, null } + }; + + // Act — no WithClaimsFromClient + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert — normal token acquisition succeeds + Assert.IsNotNull(result); + } + } + } +} diff --git a/tests/Microsoft.Identity.Test.Unit/TelemetryTests/OTelInstrumentationTests.cs b/tests/Microsoft.Identity.Test.Unit/TelemetryTests/OTelInstrumentationTests.cs index 5d733f8b0e..2b0f1568fd 100644 --- a/tests/Microsoft.Identity.Test.Unit/TelemetryTests/OTelInstrumentationTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/TelemetryTests/OTelInstrumentationTests.cs @@ -870,7 +870,7 @@ public async Task MsalFailure_ServiceException_RawStsErrorCodeTag_IncludedAsync( .WithTenantId(TestConstants.Utid) .ExecuteAsync(CancellationToken.None)).ConfigureAwait(false); - Assert.IsNotNull(ex.ErrorCodes, "ErrorCodes should be populated from IDP response."); + Assert.IsNotNull(ex.ErrorCodesForLogging, "ErrorCodesForLogging should be populated from IDP response."); s_meterProvider.ForceFlush(); @@ -880,7 +880,7 @@ public async Task MsalFailure_ServiceException_RawStsErrorCodeTag_IncludedAsync( var tags = GetTagDictionary(metricPoint.Tags); Assert.IsTrue(tags.ContainsKey(TelemetryConstants.RawStsErrorCode), "RawStsErrorCode tag should be present when the IDP response contains error_codes."); - Assert.AreEqual(ex.ErrorCodes.FirstOrDefault(), tags[TelemetryConstants.RawStsErrorCode]); + Assert.AreEqual(ex.ErrorCodesForLogging.FirstOrDefault(), tags[TelemetryConstants.RawStsErrorCode]); } } } @@ -1004,6 +1004,166 @@ await AssertException.TaskThrowsAsync( } } + [TestMethod] + [Description("For a non-MSAL failure the enricher still receives an ExecutionResult carrying a wrapper MsalException with failure metadata, while the original exception propagates unchanged to the caller.")] + public async Task WithOtelTagsEnricher_NonMsalFailure_ReceivesWrappedExceptionWithMetadataAsync() + { + using (_harness = CreateTestHarness()) + { + // A non-MSAL exception (e.g. a transport failure while a federated-credential callback fetches + // the client assertion) propagates out of MSAL unwrapped. This exercises RequestBase's generic + // catch, which must still hand the OTel enricher a populated ExecutionResult.Exception. + var transportException = new HttpRequestException("Simulated transport failure while fetching the federated credential assertion."); + + Func> throwingAssertion = async _ => + { + await Task.Yield(); + throw transportException; + }; + + var cca = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(TestConstants.AuthorityUtidTenant) + .WithClientAssertion(throwingAssertion) + .WithHttpManager(_harness.HttpManager) + .BuildConcrete(); + + _harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + bool? capturedSuccessful = null; + Exception capturedException = null; + + var thrown = await AssertException.TaskThrowsAsync( + () => cca.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraQueryParameters(extraQueryParams) + .WithOtelTagsEnricher((executionResult, tags) => + { + capturedSuccessful = executionResult.Successful; + capturedException = executionResult.Exception; + tags.Add(new KeyValuePair("CustomTag", "CustomValue")); + }) + .ExecuteAsync(CancellationToken.None)).ConfigureAwait(false); + + s_meterProvider.ForceFlush(); + + // The caller must observe the ORIGINAL exception, never the telemetry-only wrapper. + Assert.AreSame(transportException, thrown, "The original non-MSAL exception must propagate unchanged to the caller."); + + Assert.IsTrue(capturedSuccessful.HasValue, "Enricher should have been invoked."); + Assert.IsFalse(capturedSuccessful.Value, "ExecutionResult.Successful should be false for a failed acquisition."); + Assert.IsNotNull(capturedException, "ExecutionResult.Exception should be populated even for a non-MSAL failure."); + + var wrapper = capturedException as MsalException; + Assert.IsNotNull(wrapper, "ExecutionResult.Exception should be surfaced as an MsalException wrapper for the enricher."); + Assert.AreEqual(typeof(HttpRequestException).FullName, wrapper.ErrorCode, "The wrapper's ErrorCode should capture the originating exception type."); + Assert.AreSame(transportException, wrapper.InnerException, "The original exception should be preserved as the wrapper's InnerException."); + Assert.AreEqual(transportException.Message, wrapper.Message, "The wrapper should retain the original exception message."); + + Assert.IsNotNull(wrapper.AuthenticationResultMetadata, "The wrapper should carry failure metadata for the enricher."); + + var failureMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "MsalFailure"); + Assert.IsNotNull(failureMetric, "MsalFailure metric should be emitted."); + + bool foundCustomTag = false; + foreach (var metricPoint in failureMetric.GetMetricPoints()) + { + var tags = GetTagDictionary(metricPoint.Tags); + if (tags.TryGetValue("CustomTag", out var value) && (string)value == "CustomValue") + foundCustomTag = true; + } + Assert.IsTrue(foundCustomTag, "MsalFailure should include the custom tag added by the enricher."); + } + } + + [TestMethod] + [Description("A non-MSAL failure whose Message is empty must still propagate unchanged: the telemetry-only MsalException wrapper falls back to the type name so its ctor cannot throw and mask the original exception.")] + public async Task WithOtelTagsEnricher_NonMsalFailureWithEmptyMessage_PropagatesOriginalAndWrapsSafelyAsync() + { + using (_harness = CreateTestHarness()) + { + // An exception with an empty message is the edge case: MsalException's ctor rejects a + // null/whitespace errorMessage (and errorCode), so without a type-name fallback the wrapper + // construction would throw ArgumentNullException and replace the original exception. + var emptyMessageException = new InvalidOperationException(string.Empty); + + Func> throwingAssertion = async _ => + { + await Task.Yield(); + throw emptyMessageException; + }; + + var cca = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(TestConstants.AuthorityUtidTenant) + .WithClientAssertion(throwingAssertion) + .WithHttpManager(_harness.HttpManager) + .BuildConcrete(); + + _harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + Exception capturedException = null; + + var thrown = await AssertException.TaskThrowsAsync( + () => cca.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraQueryParameters(extraQueryParams) + .WithOtelTagsEnricher((executionResult, tags) => + { + capturedException = executionResult.Exception; + }) + .ExecuteAsync(CancellationToken.None)).ConfigureAwait(false); + + s_meterProvider.ForceFlush(); + + // The caller must still observe the ORIGINAL exception, not an ArgumentNullException from the wrapper. + Assert.AreSame(emptyMessageException, thrown, "The original exception must propagate unchanged even when its message is empty."); + + var wrapper = capturedException as MsalException; + Assert.IsNotNull(wrapper, "ExecutionResult.Exception should be surfaced as an MsalException wrapper for the enricher."); + Assert.AreEqual(typeof(InvalidOperationException).FullName, wrapper.ErrorCode, "The wrapper's ErrorCode should capture the originating exception type."); + Assert.AreEqual(typeof(InvalidOperationException).Name, wrapper.Message, "The wrapper should fall back to the type name when the original message is empty."); + Assert.AreSame(emptyMessageException, wrapper.InnerException, "The original exception should be preserved as the wrapper's InnerException."); + } + } + + [TestMethod] + [Description("For a non-MSAL failure MSAL stashes the AuthenticationResultMetadata on the original exception's Data bag so header-creation providers can surface token-acquisition diagnostics (Bug 3696194).")] + public async Task NonMsalFailure_ExposesAuthenticationResultMetadataOnExceptionDataAsync() + { + using (_harness = CreateTestHarness()) + { + var transportException = new HttpRequestException("Simulated transport failure while fetching the federated credential assertion."); + + Func> throwingAssertion = async _ => + { + await Task.Yield(); + throw transportException; + }; + + var cca = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(TestConstants.AuthorityUtidTenant) + .WithClientAssertion(throwingAssertion) + .WithHttpManager(_harness.HttpManager) + .BuildConcrete(); + + _harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + var thrown = await AssertException.TaskThrowsAsync( + () => cca.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync(CancellationToken.None)).ConfigureAwait(false); + + // The original exception propagates unchanged; the metadata rides along on its Data bag and + // casts cleanly for consumers (e.g. IdWeb, in a separate assembly) that read the same public + // getters their success-path mapper uses. + Assert.AreSame(transportException, thrown, "Original exception must propagate unchanged."); + + var metadata = thrown.Data[MsalException.AuthenticationResultMetadataKey] as AuthenticationResultMetadata; + Assert.IsNotNull(metadata, "AuthenticationResultMetadata should be exposed on the exception's Data bag for a non-MSAL failure."); + Assert.AreEqual(TestConstants.AuthorityUtidTenant + "oauth2/v2.0/token", metadata.TokenEndpoint, "Metadata should carry the MSAL-internal token endpoint."); + Assert.AreEqual(CacheRefreshReason.NoCachedAccessToken, metadata.CacheRefreshReason, "Metadata should carry the MSAL-internal cache-refresh reason."); + } + } + [TestMethod] [Description("A throwing OTel tags enricher must not break the token acquisition or telemetry recording, and a warning is logged.")] public async Task WithOtelTagsEnricher_ThrowingEnricher_DoesNotBreakAcquisitionAndLogsWarningAsync()