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() {