diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa6c8c9872..30d9c54819 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@
- Fixed `WithExtraQueryParameters` on `ManagedIdentityApplicationBuilder` bypassing token caching. [#6035](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6035)
- Guarded HTTP status codes on discovery endpoints in `KnownInstanceMetadataIsUpToDateAsync`. [#6048](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6048)
- Detect orphaned KeyGuard certificates via public-key modulus comparison. [#6020](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6020)
+- KeyGuard/Credential Guard attestation failures now surface as an `MsalServiceException("attestation_failed")` (carrying the MAA status/native error/reason) instead of silently sending a non-attested certificate request to IMDS, and native `AttestationClientLib` (MAA) logs are bridged into the MSAL `ILoggerAdapter`. **Behavior change:** when an attestation provider is configured via `WithAttestationSupport()` and returns no token for a KeyGuard key, the request now fails closed rather than falling back to a non-attested request. [#6081](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6081)
4.84.1
======
diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationClient.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationClient.cs
index be1ece3426..40635c715f 100644
--- a/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationClient.cs
+++ b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationClient.cs
@@ -3,6 +3,7 @@
using System;
using System.Runtime.InteropServices;
+using Microsoft.Identity.Client.Core;
using Microsoft.Win32.SafeHandles;
namespace Microsoft.Identity.Client.KeyAttestation.Attestation
@@ -15,19 +16,33 @@ internal sealed class AttestationClient : IDisposable
{
private bool _initialized;
+ // Strong reference to the native log callback for the lifetime of this client. The native library
+ // stores the function pointer during InitAttestationLib and invokes it until UninitAttestationLib;
+ // keeping the delegate referenced here prevents the GC from collecting it (which would crash the
+ // native callback).
+ private readonly AttestationClientLib.LogFunc _logBridge;
+
///
/// AttestationClient constructor. Relies on the default OS loader to locate the native DLL.
///
+ ///
+ /// Optional MSAL logger. When supplied, native AttestationClientLib.dll log lines are
+ /// forwarded into the MSAL logging pipeline so MAA diagnostics appear in MSAL logs. When null,
+ /// native logs fall back to .
+ ///
///
- public AttestationClient()
+ public AttestationClient(ILoggerAdapter logger = null)
{
string dllError = NativeDiagnostics.ProbeNativeDll();
// intentionally not throwing on dllError
+ // Bridge native logs into the MSAL logger when available; otherwise Trace.
+ _logBridge = AttestationLogger.CreateLoggerBridge(logger);
+
// Load & initialize (logger is required by native lib)
var info = new AttestationClientLib.AttestationLogInfo
{
- Log = AttestationLogger.ConsoleLogger,
+ Log = _logBridge,
Ctx = IntPtr.Zero
};
@@ -58,7 +73,13 @@ public AttestationResult Attest(string endpoint,
endpoint, null, null, keyHandle, out buf, clientId);
if (rc != 0)
- return new(AttestationStatus.NativeError, null, null, rc, null);
+ {
+ // Translate the native return code into a human-readable reason so the failure
+ // (e.g. MAA policy evaluation / dbx validation) propagates back to the caller
+ // instead of being silently dropped.
+ string reason = AttestationErrors.Describe((AttestationResultErrorCode)rc);
+ return new(AttestationStatus.NativeError, null, null, rc, reason);
+ }
if (buf == IntPtr.Zero)
return new(AttestationStatus.TokenEmpty, null, null, 0,
diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationLogger.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationLogger.cs
index c4a20a3e8b..8a171b4a8d 100644
--- a/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationLogger.cs
+++ b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationLogger.cs
@@ -4,6 +4,7 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
+using Microsoft.Identity.Client.Core;
namespace Microsoft.Identity.Client.KeyAttestation.Attestation
{
@@ -16,11 +17,7 @@ internal static class AttestationLogger
{
try
{
- string sTag = ToText(tag);
- string sFunc = ToText(func);
- string sMsg = ToText(msg);
-
- var lineText = $"[MtlsPop][{lvl}] {sTag} {sFunc}:{line} {sMsg}";
+ var lineText = FormatNativeLog(tag, lvl, func, line, msg);
// Default: Trace (respects listeners; safe for all app types)
Trace.WriteLine(lineText);
@@ -30,6 +27,86 @@ internal static class AttestationLogger
}
};
+ ///
+ /// Builds a native log callback () that forwards
+ /// AttestationClientLib.dll log lines into the MSAL so that
+ /// native MAA diagnostics (e.g. policy-evaluation failures) appear in MSAL logs instead of only
+ /// going to . When is null, falls back to the
+ /// (Trace) so behavior is preserved for callers without a logger.
+ ///
+ ///
+ /// The returned delegate must never throw: a managed exception escaping back into the native
+ /// caller would corrupt the interop boundary. All work is wrapped in a try/catch that swallows.
+ /// Callers must keep a strong reference to the returned delegate for as long as the native library
+ /// holds the function pointer (i.e. until UninitAttestationLib), otherwise the GC may collect
+ /// it and the native callback will crash.
+ ///
+ internal static AttestationClientLib.LogFunc CreateLoggerBridge(ILoggerAdapter logger)
+ {
+ if (logger is null)
+ {
+ return ConsoleLogger;
+ }
+
+ return (ctx, tag, lvl, func, line, msg) =>
+ {
+ try
+ {
+ LogLevel mapped = MapLevel(lvl);
+ if (!logger.IsLoggingEnabled(mapped))
+ {
+ return;
+ }
+
+ string lineText = FormatNativeLog(tag, lvl, func, line, msg);
+
+ // Native Error/Warning/Info lines (MAA policy-evaluation failures, endpoint
+ // URLs, TPM cert issuer) are operational diagnostics, not user PII, so they are
+ // logged as the scrubbed message and stay visible in standard MSAL logs. The
+ // most verbose native output (Debug -> Verbose, and any unknown level) can carry
+ // richer payload fragments, so it is routed through the PII slot and only
+ // surfaces when the caller opts into PII logging.
+ if (mapped == LogLevel.Verbose)
+ {
+ logger.Log(mapped, lineText, string.Empty);
+ }
+ else
+ {
+ logger.Log(mapped, string.Empty, lineText);
+ }
+ }
+ catch
+ {
+ // A logging callback must never throw back into native code.
+ }
+ };
+ }
+
+ ///
+ /// Maps a native to the MSAL .
+ /// Unknown values map to .
+ ///
+ internal static LogLevel MapLevel(AttestationClientLib.LogLevel lvl) => lvl switch
+ {
+ AttestationClientLib.LogLevel.Error => LogLevel.Error,
+ AttestationClientLib.LogLevel.Warn => LogLevel.Warning,
+ AttestationClientLib.LogLevel.Info => LogLevel.Info,
+ AttestationClientLib.LogLevel.Debug => LogLevel.Verbose,
+ _ => LogLevel.Verbose,
+ };
+
+ ///
+ /// Formats a single native log line into a human-readable string, robust to null/IntPtr inputs.
+ ///
+ internal static string FormatNativeLog(string tag, AttestationClientLib.LogLevel lvl, string func, int line, string msg)
+ {
+ string sTag = ToText(tag);
+ string sFunc = ToText(func);
+ string sMsg = ToText(msg);
+
+ return $"[MtlsPop][AttestationClientLib][{lvl}] {sTag} {sFunc}:{line} {sMsg}";
+ }
+
// Converts either string or IntPtr (char*) to text. Works with any LogFunc variant.
private static string ToText(object value)
{
diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs
index 21848ae129..d8803d09d5 100644
--- a/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs
+++ b/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs
@@ -4,6 +4,7 @@
using System;
using System.Threading.Tasks;
using Microsoft.Identity.Client.Core;
+using Microsoft.Identity.Client.KeyAttestation.Attestation;
namespace Microsoft.Identity.Client.KeyAttestation
{
@@ -12,6 +13,11 @@ namespace Microsoft.Identity.Client.KeyAttestation
///
public static class ManagedIdentityAttestationExtensions
{
+ // Error code surfaced when Credential Guard / KeyGuard attestation is requested but fails.
+ // The failure originates from the attestation (MAA) service, so it is surfaced as a service error.
+ // Matches the error code used by the IMDSv2 consumer so callers see a single, consistent code.
+ private const string AttestationFailedErrorCode = "attestation_failed";
+
///
/// Enables Credential Guard attestation support for managed identity mTLS Proof-of-Possession flows.
/// This method should be called after .
@@ -28,7 +34,7 @@ public static AcquireTokenForManagedIdentityParameterBuilder WithAttestationSupp
builder.CommonParameters.AttestationTokenProvider = async (endpoint, keyHandle, clientId, keyId, logger, ct) =>
{
- var result = await PopKeyAttestor.AttestCredentialGuardAsync(
+ AttestationResult result = await PopKeyAttestor.AttestCredentialGuardAsync(
endpoint,
keyHandle,
clientId,
@@ -36,8 +42,23 @@ public static AcquireTokenForManagedIdentityParameterBuilder WithAttestationSupp
logger,
ct).ConfigureAwait(false);
- // Return JWT on success, null for non-attested flow on failure
- return result.Status == Attestation.AttestationStatus.Success ? result.Jwt : null;
+ if (result.Status == AttestationStatus.Success && !string.IsNullOrWhiteSpace(result.Jwt))
+ {
+ return result.Jwt;
+ }
+
+ // Attestation failed. Surface the reason to the caller instead of returning null —
+ // returning null would cause an empty/non-attested certificate request to be sent to
+ // IMDS, silently dropping the real failure (e.g. an MAA policy-evaluation deny). The
+ // failure originates from the attestation (MAA) service, so it is a service exception.
+ string reason = string.IsNullOrEmpty(result.ErrorMessage)
+ ? "(no additional detail available)"
+ : result.ErrorMessage;
+
+ throw new MsalServiceException(
+ AttestationFailedErrorCode,
+ $"Key Guard attestation failed; no attestation token was produced. " +
+ $"Status: {result.Status}, NativeErrorCode: {result.NativeErrorCode}, Reason: {reason}");
};
return builder;
diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs
index 9b3221498e..b6cd810933 100644
--- a/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs
+++ b/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs
@@ -138,7 +138,7 @@ public static async Task AttestCredentialGuardAsync(
{
try
{
- using var client = new AttestationClient();
+ using var client = new AttestationClient(logger);
return client.Attest(endpoint, safeNCryptKeyHandle, clientId ?? string.Empty);
}
catch (Exception ex)
@@ -183,7 +183,7 @@ public static async Task AttestCredentialGuardAsync(
{
try
{
- using var client = new AttestationClient();
+ using var client = new AttestationClient(logger);
return client.Attest(endpoint, safeNCryptKeyHandle, clientId ?? string.Empty);
}
catch (Exception ex)
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs
index db1cd63d06..445b2805bd 100644
--- a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
@@ -490,18 +490,41 @@ private async Task GetAttestationJwtAsync(
if (string.IsNullOrWhiteSpace(attestationJwt))
{
- _requestContext.Logger.Info("[ImdsV2] Attestation provider returned null/empty JWT. Proceeding with non-attested flow.");
- return null;
+ // A provider was configured (we passed the _attestationTokenProvider null-check above)
+ // and this is a KeyGuard key, for which attestation is mandatory. A null/empty token
+ // means attestation did not succeed — refuse to fall back to a non-attested request,
+ // which would silently send an empty attestation token to IMDS.
+ throw MsalServiceExceptionFactory.CreateManagedIdentityException(
+ "attestation_failed",
+ "[ImdsV2] Attestation was required for this KeyGuard key but the attestation provider " +
+ "returned no token. Refusing to send a non-attested certificate request to IMDS.",
+ null,
+ ManagedIdentitySource.Imds,
+ null);
}
return attestationJwt;
}
+ catch (MsalServiceException)
+ {
+ // Already a well-formed attestation service failure (from the provider or the check above) —
+ // propagate as-is rather than double-wrapping.
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ // Cancellation (including TaskCanceledException) must propagate unchanged rather than
+ // being masked as an attestation_failed service error.
+ throw;
+ }
catch (Exception ex)
{
- throw new MsalClientException(
+ throw MsalServiceExceptionFactory.CreateManagedIdentityException(
"attestation_failed",
$"[ImdsV2] Attestation token provider failed: {ex.Message}",
- ex);
+ ex,
+ ManagedIdentitySource.Imds,
+ null);
}
}
diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs
index ea586bc078..ddfb49453c 100644
--- a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs
+++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs
@@ -11,6 +11,7 @@
using System.Xml;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.AppConfig;
+using Microsoft.Identity.Client.Core;
using Microsoft.Identity.Client.Internal;
using Microsoft.Identity.Client.Internal.Logger;
using Microsoft.Identity.Client.KeyAttestation;
@@ -792,55 +793,488 @@ public async Task MtlsPop_NoAttestationProvider_UsesNonAttestedFlow()
}
[TestMethod]
- public async Task MtlsPop_AttestationProviderReturnsNull_UsesNonAttestedFlow()
+ public async Task MtlsPop_AttestationProviderReturnsNull_ThrowsAttestationFailed()
{
using (new EnvVariableContext())
using (var httpManager = new MockHttpManager())
{
+ // Arrange
SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint);
var mi = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false);
- // Add mocks for successful non-attested flow
- AddMocksToGetEntraToken(httpManager);
+ // Only the CSR-metadata request is consumed before attestation fails; no cert/token mocks.
+ httpManager.AddMockHandler(MockHelpers.MockCsrResponse());
- // Test with null-returning attestation provider - should gracefully use non-attested flow
- var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
- .WithMtlsProofOfPossession()
- .WithAttestationProviderForTests(TestAttestationProviders.CreateNullProvider())
- .ExecuteAsync().ConfigureAwait(false);
+ // Act: a configured provider that yields no token must hard-fail for a KeyGuard key rather
+ // than silently sending a non-attested certificate request to IMDS.
+ var ex = await Assert.ThrowsAsync(async () =>
+ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithMtlsProofOfPossession()
+ .WithAttestationProviderForTests(TestAttestationProviders.CreateNullProvider())
+ .ExecuteAsync().ConfigureAwait(false)
+ ).ConfigureAwait(false);
- Assert.IsNotNull(result);
- Assert.AreEqual(MTLSPoP, result.TokenType, "Should get mTLS PoP token even with null attestation provider");
- Assert.IsNotNull(result.BindingCertificate);
+ // Assert
+ Assert.AreEqual("attestation_failed", ex.ErrorCode);
+ }
+ }
+
+ [TestMethod]
+ public async Task MtlsPop_AttestationProviderReturnsEmptyToken_ThrowsAttestationFailed()
+ {
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ // Arrange
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint);
+
+ var mi = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false);
+
+ httpManager.AddMockHandler(MockHelpers.MockCsrResponse());
+
+ // Act: whitespace/empty token is treated as a failed attestation, not a non-attested fallback.
+ var ex = await Assert.ThrowsAsync(async () =>
+ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithMtlsProofOfPossession()
+ .WithAttestationProviderForTests(TestAttestationProviders.CreateEmptyProvider())
+ .ExecuteAsync().ConfigureAwait(false)
+ ).ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual("attestation_failed", ex.ErrorCode);
+ }
+ }
+
+ [TestMethod]
+ public async Task MtlsPop_AttestationProviderThrows_ThrowsAttestationFailedWithInner()
+ {
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ // Arrange
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint);
+
+ var mi = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false);
+
+ httpManager.AddMockHandler(MockHelpers.MockCsrResponse());
+
+ // Act: an exception from the provider must surface as attestation_failed with the cause preserved.
+ var ex = await Assert.ThrowsAsync(async () =>
+ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithMtlsProofOfPossession()
+ .WithAttestationProviderForTests(TestAttestationProviders.CreateFailingProvider("native boom"))
+ .ExecuteAsync().ConfigureAwait(false)
+ ).ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual("attestation_failed", ex.ErrorCode);
+ StringAssert.Contains(ex.Message, "native boom");
+ Assert.IsNotNull(ex.InnerException);
+ Assert.IsInstanceOfType(ex.InnerException, typeof(InvalidOperationException));
}
}
[TestMethod]
- public async Task MtlsPop_AttestationProviderReturnsEmptyToken_UsesNonAttestedFlow()
+ public async Task MtlsPop_AttestationProviderCancelled_PropagatesCancellation()
{
using (new EnvVariableContext())
using (var httpManager = new MockHttpManager())
{
+ // Arrange
SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint);
var mi = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false);
- // Add mocks for successful non-attested flow
+ httpManager.AddMockHandler(MockHelpers.MockCsrResponse());
+
+ // Act: a cancellation surfaced by the attestation provider must propagate unchanged,
+ // not be masked as an attestation_failed service error.
+ Exception caught = null;
+ try
+ {
+ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithMtlsProofOfPossession()
+ .WithAttestationProviderForTests((endpoint, keyHandle, clientId, keyId, logger, ct) =>
+ throw new OperationCanceledException("attestation canceled"))
+ .ExecuteAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ caught = ex;
+ }
+
+ // Assert
+ Assert.IsInstanceOfType(caught, typeof(OperationCanceledException), "Cancellation must propagate unchanged.");
+ Assert.IsNotInstanceOfType(caught, typeof(MsalServiceException), "Cancellation must not be wrapped as attestation_failed.");
+ }
+ }
+
+ [TestMethod]
+ public async Task MtlsPop_WithAttestationSupport_NativeError_ThrowsAttestationFailedWithReason()
+ {
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ // Arrange
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint);
+
+ var mi = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false);
+
+ httpManager.AddMockHandler(MockHelpers.MockCsrResponse());
+
+ // Simulate the production failure: MAA policy/dbx deny surfaces as a native error code + reason.
+ const string reason = "The enclave rejected the evidence (key type / PCR policy).";
+ PopKeyAttestor.s_testAttestationProvider = (endpoint, keyHandle, clientId, keyId, ct) =>
+ Task.FromResult(new AttestationResult(AttestationStatus.NativeError, null, null, -6, reason));
+
+ // Act
+ var ex = await Assert.ThrowsAsync(async () =>
+ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithMtlsProofOfPossession()
+ .WithAttestationSupport()
+ .ExecuteAsync().ConfigureAwait(false)
+ ).ConfigureAwait(false);
+
+ // Assert: the real reason is propagated to the caller (not swallowed into an empty token).
+ Assert.AreEqual("attestation_failed", ex.ErrorCode);
+ StringAssert.Contains(ex.Message, reason);
+ StringAssert.Contains(ex.Message, "Status: NativeError");
+ StringAssert.Contains(ex.Message, "NativeErrorCode: -6");
+ }
+ }
+
+ [TestMethod]
+ public async Task MtlsPop_WithAttestationSupport_NativeException_ThrowsAttestationFailedWithReason()
+ {
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ // Arrange
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint);
+
+ var mi = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false);
+
+ httpManager.AddMockHandler(MockHelpers.MockCsrResponse());
+
+ const string reason = "Native library raised SEHException: 0x80090011";
+ PopKeyAttestor.s_testAttestationProvider = (endpoint, keyHandle, clientId, keyId, ct) =>
+ Task.FromResult(new AttestationResult(AttestationStatus.Exception, null, string.Empty, -1, reason));
+
+ // Act
+ var ex = await Assert.ThrowsAsync(async () =>
+ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithMtlsProofOfPossession()
+ .WithAttestationSupport()
+ .ExecuteAsync().ConfigureAwait(false)
+ ).ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual("attestation_failed", ex.ErrorCode);
+ StringAssert.Contains(ex.Message, reason);
+ StringAssert.Contains(ex.Message, "Status: Exception");
+ }
+ }
+
+ [TestMethod]
+ public async Task MtlsPop_WithAttestationSupport_TokenEmpty_ThrowsAttestationFailed()
+ {
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ // Arrange
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint);
+
+ var mi = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false);
+
+ httpManager.AddMockHandler(MockHelpers.MockCsrResponse());
+
+ PopKeyAttestor.s_testAttestationProvider = (endpoint, keyHandle, clientId, keyId, ct) =>
+ Task.FromResult(new AttestationResult(AttestationStatus.TokenEmpty, null, null, 0, "rc==0 but token buffer was null."));
+
+ // Act
+ var ex = await Assert.ThrowsAsync(async () =>
+ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithMtlsProofOfPossession()
+ .WithAttestationSupport()
+ .ExecuteAsync().ConfigureAwait(false)
+ ).ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual("attestation_failed", ex.ErrorCode);
+ StringAssert.Contains(ex.Message, "Status: TokenEmpty");
+ }
+ }
+
+ [TestMethod]
+ public async Task MtlsPop_WithAttestationSupport_NotInitialized_ThrowsAttestationFailed()
+ {
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ // Arrange
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint);
+
+ var mi = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false);
+
+ httpManager.AddMockHandler(MockHelpers.MockCsrResponse());
+
+ PopKeyAttestor.s_testAttestationProvider = (endpoint, keyHandle, clientId, keyId, ct) =>
+ Task.FromResult(new AttestationResult(AttestationStatus.NotInitialized, null, null, -1, "Native library not initialized."));
+
+ // Act
+ var ex = await Assert.ThrowsAsync(async () =>
+ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithMtlsProofOfPossession()
+ .WithAttestationSupport()
+ .ExecuteAsync().ConfigureAwait(false)
+ ).ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual("attestation_failed", ex.ErrorCode);
+ StringAssert.Contains(ex.Message, "Status: NotInitialized");
+ }
+ }
+
+ [TestMethod]
+ public async Task MtlsPop_WithAttestationSupport_SuccessButEmptyJwt_ThrowsAttestationFailed()
+ {
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ // Arrange
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint);
+
+ var mi = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false);
+
+ httpManager.AddMockHandler(MockHelpers.MockCsrResponse());
+
+ // Defensive: a "Success" status with no JWT must still be treated as a failure.
+ PopKeyAttestor.s_testAttestationProvider = (endpoint, keyHandle, clientId, keyId, ct) =>
+ Task.FromResult(new AttestationResult(AttestationStatus.Success, null, string.Empty, 0, null));
+
+ // Act
+ var ex = await Assert.ThrowsAsync(async () =>
+ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithMtlsProofOfPossession()
+ .WithAttestationSupport()
+ .ExecuteAsync().ConfigureAwait(false)
+ ).ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual("attestation_failed", ex.ErrorCode);
+ }
+ }
+
+ [TestMethod]
+ public async Task MtlsPop_WithAttestationSupport_SuccessButWhitespaceJwt_ThrowsAttestationFailedWithReason()
+ {
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ // Arrange
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint);
+
+ var mi = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false);
+
+ httpManager.AddMockHandler(MockHelpers.MockCsrResponse());
+
+ // A "Success" status with a whitespace-only JWT must be treated as a failure here so the
+ // richer Status/NativeErrorCode/Reason is surfaced, rather than leaking a blank token that
+ // trips the generic downstream "returned no token" check.
+ PopKeyAttestor.s_testAttestationProvider = (endpoint, keyHandle, clientId, keyId, ct) =>
+ Task.FromResult(new AttestationResult(AttestationStatus.Success, null, " ", 0, "whitespace token produced"));
+
+ // Act
+ var ex = await Assert.ThrowsAsync(async () =>
+ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithMtlsProofOfPossession()
+ .WithAttestationSupport()
+ .ExecuteAsync().ConfigureAwait(false)
+ ).ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual("attestation_failed", ex.ErrorCode);
+ StringAssert.Contains(ex.Message, "Status: Success");
+ StringAssert.Contains(ex.Message, "whitespace token produced");
+ }
+ }
+
+ [TestMethod]
+ public async Task MtlsPop_WithAttestationSupport_Success_IncludesAttestationToken()
+ {
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ // Arrange
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint);
+
+ var mi = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false);
+
+ // Init already injects a fake successful attestation provider; full attested flow runs end-to-end.
AddMocksToGetEntraToken(httpManager);
- // Test with empty-string-returning attestation provider - should gracefully use non-attested flow
+ // Act
var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
.WithMtlsProofOfPossession()
- .WithAttestationProviderForTests(TestAttestationProviders.CreateEmptyProvider())
+ .WithAttestationSupport()
.ExecuteAsync().ConfigureAwait(false);
+ // Assert
Assert.IsNotNull(result);
- Assert.AreEqual(MTLSPoP, result.TokenType, "Should get mTLS PoP token even with empty attestation provider");
+ Assert.AreEqual(MTLSPoP, result.TokenType);
Assert.IsNotNull(result.BindingCertificate);
}
}
+ // ---- Fix 2: native MAA logs are bridged into the MSAL ILoggerAdapter ----
+
+ [TestMethod]
+ public void AttestationLogger_MapLevel_MapsNativeLevelsToMsalLevels()
+ {
+ // Assert
+ Assert.AreEqual(LogLevel.Error, AttestationLogger.MapLevel(AttestationClientLib.LogLevel.Error));
+ Assert.AreEqual(LogLevel.Warning, AttestationLogger.MapLevel(AttestationClientLib.LogLevel.Warn));
+ Assert.AreEqual(LogLevel.Info, AttestationLogger.MapLevel(AttestationClientLib.LogLevel.Info));
+ Assert.AreEqual(LogLevel.Verbose, AttestationLogger.MapLevel(AttestationClientLib.LogLevel.Debug));
+ Assert.AreEqual(LogLevel.Verbose, AttestationLogger.MapLevel((AttestationClientLib.LogLevel)999));
+ }
+
+ [TestMethod]
+ public void AttestationLogger_FormatNativeLog_ContainsTagFuncLineAndMessage()
+ {
+ // Act
+ string formatted = AttestationLogger.FormatNativeLog(
+ "AttestationClientLib", AttestationClientLib.LogLevel.Error, "AttestKeyGuardImportKey", 614, "PolicyEvaluationError");
+
+ // Assert
+ StringAssert.Contains(formatted, "AttestationClientLib");
+ StringAssert.Contains(formatted, "AttestKeyGuardImportKey");
+ StringAssert.Contains(formatted, "614");
+ StringAssert.Contains(formatted, "PolicyEvaluationError");
+ }
+
+ [TestMethod]
+ public void AttestationLogger_FormatNativeLog_NullInputs_DoesNotThrow()
+ {
+ // Act
+ string formatted = AttestationLogger.FormatNativeLog(null, AttestationClientLib.LogLevel.Info, null, 0, null);
+
+ // Assert
+ Assert.IsNotNull(formatted);
+ }
+
+ [TestMethod]
+ public void AttestationLogger_CreateLoggerBridge_RoutesEachNativeLevelToMappedMsalLevel()
+ {
+ // Arrange
+ var logger = Substitute.For();
+ logger.IsLoggingEnabled(Arg.Any()).Returns(true);
+ var bridge = AttestationLogger.CreateLoggerBridge(logger);
+
+ // Act
+ bridge(IntPtr.Zero, "Tag", AttestationClientLib.LogLevel.Error, "Fn", 1, "e-msg");
+ bridge(IntPtr.Zero, "Tag", AttestationClientLib.LogLevel.Warn, "Fn", 2, "w-msg");
+ bridge(IntPtr.Zero, "Tag", AttestationClientLib.LogLevel.Info, "Fn", 3, "i-msg");
+ bridge(IntPtr.Zero, "Tag", AttestationClientLib.LogLevel.Debug, "Fn", 4, "d-msg");
+
+ // Assert: Error/Warning/Info are non-PII (scrubbed); Debug->Verbose is routed through the PII slot.
+ logger.Received(1).Log(LogLevel.Error, string.Empty, Arg.Is(s => s.Contains("e-msg")));
+ logger.Received(1).Log(LogLevel.Warning, string.Empty, Arg.Is(s => s.Contains("w-msg")));
+ logger.Received(1).Log(LogLevel.Info, string.Empty, Arg.Is(s => s.Contains("i-msg")));
+ logger.Received(1).Log(LogLevel.Verbose, Arg.Is(s => s.Contains("d-msg")), string.Empty);
+ }
+
+ [TestMethod]
+ public void AttestationLogger_CreateLoggerBridge_VerboseLine_LogsAsPiiNotScrubbed()
+ {
+ // Arrange
+ var logger = Substitute.For();
+ logger.IsLoggingEnabled(Arg.Any()).Returns(true);
+ var bridge = AttestationLogger.CreateLoggerBridge(logger);
+
+ // Act: native Debug maps to Verbose, the most verbose slot that may carry payload fragments.
+ bridge(IntPtr.Zero, "AttestationClientLib", AttestationClientLib.LogLevel.Debug, "Attest", 614, "raw-payload-fragment");
+
+ // Assert: verbose text goes to the PII slot and the scrubbed slot is empty, so it is
+ // redacted unless the caller enables PII logging.
+ logger.Received(1).Log(LogLevel.Verbose, Arg.Is(s => s.Contains("raw-payload-fragment")), string.Empty);
+ }
+
+ [TestMethod]
+ public void AttestationLogger_CreateLoggerBridge_LogsMessageAsScrubbedNotPii()
+ {
+ // Arrange
+ var logger = Substitute.For();
+ logger.IsLoggingEnabled(Arg.Any()).Returns(true);
+ var bridge = AttestationLogger.CreateLoggerBridge(logger);
+
+ // Act
+ bridge(IntPtr.Zero, "AttestationClientLib", AttestationClientLib.LogLevel.Error, "Attest", 614, "PolicyEvaluationError");
+
+ // Assert: diagnostic text goes to the scrubbed slot; the PII slot is empty.
+ logger.Received(1).Log(LogLevel.Error, string.Empty, Arg.Is(s => s.Contains("PolicyEvaluationError")));
+ }
+
+ [TestMethod]
+ public void AttestationLogger_CreateLoggerBridge_WhenLevelDisabled_DoesNotLog()
+ {
+ // Arrange
+ var logger = Substitute.For();
+ logger.IsLoggingEnabled(Arg.Any()).Returns(false);
+ var bridge = AttestationLogger.CreateLoggerBridge(logger);
+
+ // Act
+ bridge(IntPtr.Zero, "Tag", AttestationClientLib.LogLevel.Error, "Fn", 1, "msg");
+
+ // Assert
+ logger.DidNotReceive().Log(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [TestMethod]
+ public void AttestationLogger_CreateLoggerBridge_WhenLoggerThrows_SwallowsException()
+ {
+ // Arrange
+ var logger = Substitute.For();
+ logger.IsLoggingEnabled(Arg.Any()).Returns(true);
+ logger.When(l => l.Log(Arg.Any(), Arg.Any(), Arg.Any()))
+ .Do(_ => throw new InvalidOperationException("logger blew up"));
+ var bridge = AttestationLogger.CreateLoggerBridge(logger);
+
+ // Act + Assert: a logging callback must never throw back into native code.
+ bridge(IntPtr.Zero, "Tag", AttestationClientLib.LogLevel.Error, "Fn", 1, "msg");
+ }
+
+ [TestMethod]
+ public void AttestationLogger_CreateLoggerBridge_NullLogger_FallsBackWithoutThrowing()
+ {
+ // Act
+ var bridge = AttestationLogger.CreateLoggerBridge(null);
+
+ // Assert: returns a usable Trace fallback that does not throw.
+ Assert.IsNotNull(bridge);
+ bridge(IntPtr.Zero, "Tag", AttestationClientLib.LogLevel.Info, "Fn", 1, "msg");
+ }
+
+ [TestMethod]
+ public void AttestationErrors_Describe_KnownCodes_ReturnReadableReason()
+ {
+ // Assert
+ StringAssert.Contains(AttestationErrors.Describe(AttestationResultErrorCode.ERRORATTESTATIONFAILED), "rejected the evidence");
+ StringAssert.Contains(AttestationErrors.Describe(AttestationResultErrorCode.ERRORHTTPREQUESTFAILED), "Could not reach the attestation service");
+ StringAssert.Contains(AttestationErrors.Describe(AttestationResultErrorCode.ERRORCURLINITIALIZATION), "libcurl failed to initialize");
+ StringAssert.Contains(AttestationErrors.Describe(AttestationResultErrorCode.ERRORJWTDECRYPTIONFAILED), "could not be decrypted");
+ }
+
+ [TestMethod]
+ public void AttestationErrors_Describe_UnknownCode_ReturnsEnumName()
+ {
+ // Assert: codes without a curated message fall back to the enum name.
+ Assert.AreEqual(
+ AttestationResultErrorCode.ERRORTPMINTERNALFAILURE.ToString(),
+ AttestationErrors.Describe(AttestationResultErrorCode.ERRORTPMINTERNALFAILURE));
+ }
+
[TestMethod]
public async Task mTLSPop_RequestedWithoutKeyGuard_ThrowsClientException()
{