diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs index 9237f3ed49..96591a6aee 100644 --- a/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs +++ b/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs @@ -32,12 +32,12 @@ public static AcquireTokenForManagedIdentityParameterBuilder WithAttestationSupp throw new ArgumentNullException(nameof(builder)); } - builder.CommonParameters.AttestationTokenProvider = async (endpoint, keyHandle, clientId, keyId, logger, ct) => + builder.CommonParameters.AttestationTokenProvider = async (endpoint, keyHandle, clientIdMetadata, keyId, logger, ct) => { AttestationResult result = await PopKeyAttestor.AttestCredentialGuardAsync( endpoint, keyHandle, - clientId, + clientIdMetadata, keyId, logger, ct).ConfigureAwait(false); diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs index b6cd810933..5f17dacc95 100644 --- a/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs +++ b/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs @@ -31,10 +31,11 @@ internal static class PopKeyAttestor // ephemeral keys. Callers always supply a non-empty keyId; null/empty is // treated as a defensive fallback that bypasses the cache. // - // clientId – intentionally NOT included. MAA attests the CNG key itself; clientId is - // forwarded to the service as metadata but does not change which attestation - // token is valid for a given key. Different managed identities (SAMI vs UAMI) - // sharing the same CNG key at the same endpoint correctly share the cached token. + // clientId – intentionally NOT included. MAA attests the CNG key itself; the + // client_id value is forwarded to the service as metadata but does not change + // which attestation token is valid for a given key. Different managed identities + // (SAMI vs UAMI) sharing the same CNG key at the same endpoint correctly share + // the cached token. // // Why a dictionary and not a single field? // A process could theoretically use multiple MAA endpoints (e.g. in multi-region @@ -96,7 +97,7 @@ internal static void ResetCacheForTest() /// /// Attestation service endpoint (required). /// Valid SafeNCryptKeyHandle (must remain valid for duration of call). - /// Optional client identifier (may be null/empty). + /// Optional value forwarded as MAA client_id metadata (may be null/empty). /// CNG key identifier scoping the cache entry. Callers should pass the /// CNG key's when available, or a /// stable derived identifier (e.g. SHA-256 fingerprint of the RSA public key) for ephemeral keys. diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index b0fe39edbf..3e1d78e2a4 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -81,7 +81,7 @@ internal class AcquireTokenCommonParameters /// Optional delegate for obtaining attestation JWT for Credential Guard keys. /// Set by the KeyAttestation package via .WithAttestationSupport(). /// Returns null for non-attested flows. - /// Signature: (endpoint, keyHandle, clientId, keyId, logger, cancellationToken) → JWT or null. + /// Signature: (endpoint, keyHandle, clientIdMetadata, keyId, logger, cancellationToken) → JWT or null. /// public Func> AttestationTokenProvider { get; set; } diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs index 8c9a6628fd..a82f902097 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs @@ -45,7 +45,7 @@ internal class AcquireTokenForManagedIdentityParameters : IAcquireTokenParameter /// /// Optional delegate for obtaining attestation JWT for Credential Guard keys. /// Set by the KeyAttestation package via .WithAttestationSupport(). - /// Signature: (endpoint, keyHandle, clientId, keyId, logger, cancellationToken) → JWT or null. + /// Signature: (endpoint, keyHandle, clientIdMetadata, keyId, logger, cancellationToken) → JWT or null. /// public Func> AttestationTokenProvider { get; set; } diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs index 09f8ee6e97..59390bbd79 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs @@ -213,7 +213,7 @@ internal static bool IsSchannelFailure(Exception ex) } private async Task ExecuteCertificateRequestAsync( - string clientId, + string tenantId, string attestationEndpoint, string csr, ManagedIdentityKeyInfo managedIdentityKeyInfo) @@ -236,7 +236,7 @@ private async Task ExecuteCertificateRequestAsync( if (managedIdentityKeyInfo.Type == ManagedIdentityKeyType.KeyGuard) { attestationJwt = await GetAttestationJwtAsync( - clientId, + tenantId, attestationUri, managedIdentityKeyInfo, _requestContext.UserCancellationToken).ConfigureAwait(false); @@ -362,7 +362,7 @@ private async Task AcquireMtlsBindingAsync() var privateKey = csrAndKey.privateKey; var certificateRequestResponse = await ExecuteCertificateRequestAsync( - csrMetadata.ClientId, + csrMetadata.TenantId, csrMetadata.AttestationEndpoint, csr, keyInfo).ConfigureAwait(false); @@ -431,13 +431,13 @@ public async Task AcquireMtlsBindingForDelegationAsync( /// Obtains an attestation JWT for the Credential Guard/CSR payload using the configured /// attestation token provider delegate. /// - /// Client ID to be sent to the attestation provider. + /// Tenant ID sent as the MAA client_id telemetry metadata. /// The attestation endpoint. /// The key information. /// Cancellation token. /// JWT string suitable for the IMDSv2 attested PoP flow, or null for non-attested flow. private async Task GetAttestationJwtAsync( - string clientId, + string tenantId, Uri attestationEndpoint, ManagedIdentityKeyInfo keyInfo, CancellationToken cancellationToken) @@ -468,7 +468,7 @@ private async Task GetAttestationJwtAsync( string attestationJwt = await _attestationTokenProvider( attestationEndpoint.AbsoluteUri, rsaCng.Key.Handle, - clientId, + tenantId, keyId, _requestContext.Logger, cancellationToken).ConfigureAwait(false); diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs index e566f42925..c3d2877ce2 100644 --- a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs +++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs @@ -2261,6 +2261,44 @@ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) } } + [TestMethod] + public async Task AttestationProvider_ReceivesTenantIdAsClientIdMetadata() + { + // Arrange + string capturedClientIdMetadata = null; + PopKeyAttestor.s_testAttestationProvider = (endpoint, keyHandle, clientIdMetadata, keyId, ct) => + { + capturedClientIdMetadata = clientIdMetadata; + const string fakeJwt = "fake.jwt.token"; + var token = new AttestationToken(fakeJwt, DateTimeOffset.UtcNow.AddHours(1)); + return Task.FromResult(new AttestationResult(AttestationStatus.Success, token, fakeJwt, 0, string.Empty)); + }; + + using (new EnvVariableContext()) + using (var httpManager = new MockHttpManager()) + { + ManagedIdentityClient.ResetSourceForTest(); + ImdsV2ManagedIdentitySource.ResetCertCacheForTest(); + SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint); + + var managedIdentityApp = await CreateManagedIdentityAsync( + httpManager, + managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false); + + AddMocksToGetEntraToken(httpManager); + + // Act + await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .WithMtlsProofOfPossession() + .WithAttestationSupport() + .ExecuteAsync().ConfigureAwait(false); + + // Assert + Assert.AreEqual(TestConstants.TenantId, capturedClientIdMetadata); + Assert.AreNotEqual(TestConstants.ClientId, capturedClientIdMetadata); + } + } + [TestMethod] public async Task MaaTokenCache_Hit_DoesNotCallAttestationProviderAgain() { @@ -2330,6 +2368,53 @@ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) } } + [TestMethod] + public async Task MaaTokenCache_DifferentClientIdMetadata_SharesCacheEntry() + { + // Arrange + int providerCallCount = 0; + string providerClientIdMetadata = null; + PopKeyAttestor.s_testAttestationProvider = (endpoint, keyHandle, clientIdMetadata, keyId, ct) => + { + Interlocked.Increment(ref providerCallCount); + providerClientIdMetadata = clientIdMetadata; + const string fakeJwt = "fake.jwt.token"; + var token = new AttestationToken(fakeJwt, DateTimeOffset.UtcNow.AddHours(1)); + return Task.FromResult(new AttestationResult(AttestationStatus.Success, token, fakeJwt, 0, string.Empty)); + }; + + const string endpoint = "https://eastus.attestation.azure.net"; + const string keyId = "TestKeyClientIdMetadata"; + const string managedIdentityClientId = "11111111-1111-1111-1111-111111111111"; + const string tenantId = "22222222-2222-2222-2222-222222222222"; + + using var rsa = new RSACng(2048); + var keyHandle = rsa.Key.Handle; + + // Act + AttestationResult firstResult = await PopKeyAttestor.AttestCredentialGuardAsync( + endpoint, + keyHandle, + managedIdentityClientId, + keyId, + cancellationToken: CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(1, providerCallCount); + Assert.AreEqual(managedIdentityClientId, providerClientIdMetadata); + + AttestationResult secondResult = await PopKeyAttestor.AttestCredentialGuardAsync( + endpoint, + keyHandle, + tenantId, + keyId, + cancellationToken: CancellationToken.None).ConfigureAwait(false); + + // Assert + Assert.AreEqual(1, providerCallCount, "Changing client_id metadata must not create a second MAA cache entry."); + Assert.AreEqual(managedIdentityClientId, providerClientIdMetadata, "The provider must not be called on the cache hit."); + Assert.AreEqual(firstResult.Jwt, secondResult.Jwt); + } + [TestMethod] public async Task MaaTokenCache_ExpiredToken_CallsAttestationProviderAgain() {