diff --git a/src/client/Microsoft.Identity.Client/AuthenticationResult.cs b/src/client/Microsoft.Identity.Client/AuthenticationResult.cs index 570fa706b4..ccf501aba8 100644 --- a/src/client/Microsoft.Identity.Client/AuthenticationResult.cs +++ b/src/client/Microsoft.Identity.Client/AuthenticationResult.cs @@ -167,9 +167,11 @@ internal AuthenticationResult( CorrelationId = correlationID; ApiEvent = apiEvent; AuthenticationResultMetadata = new AuthenticationResultMetadata(tokenSource); + AdditionalResponseParameters = msalAccessTokenCacheItem?.PersistedCacheParameters?.Count > 0 ? (IReadOnlyDictionary)msalAccessTokenCacheItem.PersistedCacheParameters : additionalResponseParameters; + if (msalAccessTokenCacheItem != null) { ExpiresOn = msalAccessTokenCacheItem.ExpiresOn; diff --git a/src/client/Microsoft.Identity.Client/Cache/Items/MsalAccessTokenCacheItem.cs b/src/client/Microsoft.Identity.Client/Cache/Items/MsalAccessTokenCacheItem.cs index 9e2e25294d..907494fb4c 100644 --- a/src/client/Microsoft.Identity.Client/Cache/Items/MsalAccessTokenCacheItem.cs +++ b/src/client/Microsoft.Identity.Client/Cache/Items/MsalAccessTokenCacheItem.cs @@ -88,6 +88,25 @@ private IDictionary AcquireCacheParametersFromResponse( #endif return cacheParameters; } + + internal void AddAdditionalCacheParameters(Dictionary additionalCacheParameters) + { + if (additionalCacheParameters != null) + { + if (PersistedCacheParameters == null) + { + PersistedCacheParameters = new Dictionary(additionalCacheParameters); + } + else + { + foreach (var kvp in additionalCacheParameters) + { + PersistedCacheParameters[kvp.Key] = kvp.Value; + } + } + } + } + #endif internal /* for test */ MsalAccessTokenCacheItem( string preferredCacheEnv, diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientInfo.cs b/src/client/Microsoft.Identity.Client/Internal/ClientInfo.cs index 6177fe6632..482bde58c8 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientInfo.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientInfo.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Globalization; using Microsoft.Identity.Client.Utils; #if SUPPORTS_SYSTEM_TEXT_JSON @@ -13,7 +14,6 @@ namespace Microsoft.Identity.Client.Internal { - [JsonObject] [Preserve(AllMembers = true)] internal class ClientInfo @@ -24,6 +24,8 @@ internal class ClientInfo [JsonProperty(ClientInfoClaim.UniqueTenantIdentifier)] public string UniqueTenantIdentifier { get; set; } + public Dictionary AdditionalResponseParameters { get; private set; } + public static ClientInfo CreateFromJson(string clientInfo) { if (string.IsNullOrEmpty(clientInfo)) @@ -35,7 +37,34 @@ public static ClientInfo CreateFromJson(string clientInfo) try { - return JsonHelper.DeserializeFromJson(Base64UrlHelpers.DecodeBytes(clientInfo)); + var decodedBytes = Base64UrlHelpers.DecodeBytes(clientInfo); + + // Deserialize into a dictionary to get all properties + var allProperties = JsonHelper.DeserializeFromJson>(decodedBytes); + + var clientInfoObj = new ClientInfo(); + var additionalParams = new Dictionary(); + + // Extract known claims and store the rest in AdditionalResponseParameters + foreach (var kvp in allProperties) + { + if (kvp.Key == ClientInfoClaim.UniqueIdentifier) + { + clientInfoObj.UniqueObjectIdentifier = kvp.Value?.ToString(); + + } + else if (kvp.Key == ClientInfoClaim.UniqueTenantIdentifier) + { + clientInfoObj.UniqueTenantIdentifier = kvp.Value?.ToString(); + } + else + { + additionalParams[kvp.Key] = kvp.Value?.ToString() ?? string.Empty; + } + } + + clientInfoObj.AdditionalResponseParameters = additionalParams; + return clientInfoObj; } catch (Exception exc) { diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs index 48662d8f42..e7c08f0fc8 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs @@ -335,7 +335,8 @@ private Dictionary GetBodyParameters() var dict = new Dictionary { [OAuth2Parameter.GrantType] = OAuth2GrantType.ClientCredentials, - [OAuth2Parameter.Scope] = AuthenticationRequestParameters.Scope.AsSingleString() + [OAuth2Parameter.Scope] = AuthenticationRequestParameters.Scope.AsSingleString(), + [OAuth2Parameter.ClientInfo] = "2" }; return dict; diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs index 0ac5bd3627..054bf33624 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs @@ -317,28 +317,28 @@ protected async Task CacheTokenResponseAndCreateAuthentica // developer passed in user object. AuthenticationRequestParameters.RequestContext.Logger.Info("Checking client info returned from the server.."); - ClientInfo fromServer = null; + ClientInfo clientInfoFromServer = null; - if (!AuthenticationRequestParameters.IsClientCredentialRequest && - AuthenticationRequestParameters.ApiId != ApiEvent.ApiIds.AcquireTokenForSystemAssignedManagedIdentity && + if (AuthenticationRequestParameters.ApiId != ApiEvent.ApiIds.AcquireTokenForSystemAssignedManagedIdentity && AuthenticationRequestParameters.ApiId != ApiEvent.ApiIds.AcquireTokenForUserAssignedManagedIdentity && AuthenticationRequestParameters.ApiId != ApiEvent.ApiIds.AcquireTokenByRefreshToken && AuthenticationRequestParameters.AuthorityInfo.AuthorityType != AuthorityType.Adfs && !(msalTokenResponse.ClientInfo is null)) { - //client_info is not returned from client credential and managed identity flows because there is no user present. - fromServer = ClientInfo.CreateFromJson(msalTokenResponse.ClientInfo); + //client_info is not returned from managed identity flows because there is no user present. + clientInfoFromServer = ClientInfo.CreateFromJson(msalTokenResponse.ClientInfo); + ValidateAccountIdentifiers(clientInfoFromServer); } - ValidateAccountIdentifiers(fromServer); - AuthenticationRequestParameters.RequestContext.Logger.Info("Saving token response to cache.."); var tuple = await CacheManager.SaveTokenResponseAsync(msalTokenResponse).ConfigureAwait(false); var atItem = tuple.Item1; var idtItem = tuple.Item2; Account account = tuple.Item3; - +#if !MOBILE + atItem?.AddAdditionalCacheParameters(clientInfoFromServer?.AdditionalResponseParameters); +#endif return new AuthenticationResult( atItem, idtItem, diff --git a/src/client/Microsoft.Identity.Client/Platforms/net/JsonStringConverter.cs b/src/client/Microsoft.Identity.Client/Platforms/net/JsonStringConverter.cs index 337bc49b2c..b4449cb36a 100644 --- a/src/client/Microsoft.Identity.Client/Platforms/net/JsonStringConverter.cs +++ b/src/client/Microsoft.Identity.Client/Platforms/net/JsonStringConverter.cs @@ -38,5 +38,17 @@ public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOp { writer.WriteStringValue(value); } + + //Provides Support for reading dictionary key strings + public override string ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.GetString(); + } + + //Provides Support for writing dictionary key strings + public override void WriteAsPropertyName(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + writer.WritePropertyName(value); + } } } diff --git a/src/client/Microsoft.Identity.Client/Platforms/net/MsalJsonSerializerContext.cs b/src/client/Microsoft.Identity.Client/Platforms/net/MsalJsonSerializerContext.cs index ea9a757a33..4fc00ab332 100644 --- a/src/client/Microsoft.Identity.Client/Platforms/net/MsalJsonSerializerContext.cs +++ b/src/client/Microsoft.Identity.Client/Platforms/net/MsalJsonSerializerContext.cs @@ -42,6 +42,7 @@ namespace Microsoft.Identity.Client.Platforms.net [JsonSerializable(typeof(CuidInfo))] [JsonSerializable(typeof(CertificateRequestBody))] [JsonSerializable(typeof(CertificateRequestResponse))] + [JsonSerializable(typeof(Dictionary))] [JsonSourceGenerationOptions] internal partial class MsalJsonSerializerContext : JsonSerializerContext { diff --git a/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHelpers.cs b/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHelpers.cs index a8d6cdaded..0cd3aab2a6 100644 --- a/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHelpers.cs +++ b/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHelpers.cs @@ -184,8 +184,13 @@ public static string GetMsiImdsErrorResponse() "\"correlation_id\":\"77145480-bc5a-4ebe-ae4d-e4a8b7d727cf\",\"error_uri\":\"https://westus2.login.microsoft.com/error?code=500011\"}"; } - public static string CreateClientInfo(string uid = TestConstants.Uid, string utid = TestConstants.Utid) + public static string CreateClientInfo(string uid = TestConstants.Uid, string utid = TestConstants.Utid, bool CreateClientInfoForS2S = false) { + if (CreateClientInfoForS2S) + { + return Base64UrlHelpers.Encode("{\"authz\":[\"value1\",\"value2\"]}"); + } + return Base64UrlHelpers.Encode("{\"uid\":\"" + uid + "\",\"utid\":\"" + utid + "\"}"); } @@ -368,6 +373,17 @@ public static HttpResponseMessage CreateSuccessfulClientCredentialTokenResponseM "{\"token_type\":\"" + tokenType + "\",\"expires_in\":\"" + expiry + "\",\"access_token\":\"" + token + "\",\"additional_param1\":\"value1\",\"additional_param2\":\"value2\",\"additional_param3\":\"value3\"}"); } + public static HttpResponseMessage CreateSuccessfulClientCredentialTokenResponseWithClientInfoMessage( + string token = "header.payload.signature", + string expiry = "3599", + string tokenType = "Bearer", + bool CreateClientInfoForS2S = false + ) + { + return CreateSuccessResponseMessage( + "{\"token_type\":\"" + tokenType + "\",\"expires_in\":\"" + expiry + "\",\"access_token\":\"" + token + "\",\"additional_param1\":\"value1\",\"additional_param2\":\"value2\",\"additional_param3\":\"value3\",\"client_info\":\"" + CreateClientInfo(null, null, CreateClientInfoForS2S) + "\"}"); + } + public static HttpResponseMessage CreateSuccessfulClientCredentialTokenResponseWithAdditionalParamsMessage( string token = "header.payload.signature", string expiry = "3599", diff --git a/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHttpManagerExtensions.cs b/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHttpManagerExtensions.cs index c8b63a208d..8d4268f625 100644 --- a/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHttpManagerExtensions.cs +++ b/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHttpManagerExtensions.cs @@ -183,18 +183,20 @@ public static void AddMockHandlerContentNotFound(this MockHttpManager httpManage } public static MockHttpMessageHandler AddMockHandlerSuccessfulClientCredentialTokenResponseMessage( - this MockHttpManager httpManager, - string token = "header.payload.signature", + this MockHttpManager httpManager, + string token = "header.payload.signature", string expiresIn = "3599", string tokenType = "Bearer", IList unexpectedHttpHeaders = null, - Dictionary expectedPostData = null + Dictionary expectedPostData = null, + bool addClientInfo = false ) { var handler = new MockHttpMessageHandler() { ExpectedMethod = HttpMethod.Post, - ResponseMessage = MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage(token, expiresIn, tokenType), + ResponseMessage = addClientInfo? MockHelpers.CreateSuccessfulClientCredentialTokenResponseWithClientInfoMessage(token, expiresIn, tokenType, true) + : MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage(token, expiresIn, tokenType), UnexpectedRequestHeaders = unexpectedHttpHeaders, ExpectedPostData = expectedPostData }; diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs index f3b34406fa..fb3a0e58fe 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs @@ -23,6 +23,7 @@ using static Microsoft.Identity.Client.Internal.JsonWebToken; using Microsoft.Identity.Client.RP; using Microsoft.Identity.Client.Http; +using Microsoft.Identity.Client.OAuth2; namespace Microsoft.Identity.Test.Unit { @@ -1019,6 +1020,43 @@ public void EnsureNullCertDoesNotSetSerialNumberTestAsync() } } + [TestMethod] + public async Task AcquireTokenForClient_ShouldSendClientInfoParameter_WithValueTwo_Async() + { + // Arrange + using (var httpManager = new MockHttpManager()) + { + httpManager.AddInstanceDiscoveryMockHandler(); + + // Set up the expected POST data to include client_info = "2" + var expectedPostData = new Dictionary + { + [OAuth2Parameter.GrantType] = OAuth2GrantType.ClientCredentials, + [OAuth2Parameter.Scope] = TestConstants.s_scope.AsSingleString(), + [OAuth2Parameter.ClientInfo] = "2" + }; + + var handler = httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage( + expectedPostData: expectedPostData); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // Act + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + } + } + private void BeforeCacheAccess(TokenCacheNotificationArgs args) { args.TokenCache.DeserializeMsalV3(_serializedCache); diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs index e7f7e7a0ff..b2ab2b6bfb 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs @@ -2288,6 +2288,69 @@ public async Task AcquireTokenForClient_WithClaims_And_MismatchedHash_UsesCache_ } } + [TestMethod] + public async Task ConfidentialClient_acquireTokenForClient_ReturnsAuthZTestAsync() + { + using (var httpManager = new MockHttpManager()) + { + httpManager.AddInstanceDiscoveryMockHandler(); + + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithAuthority(new Uri(ClientApplicationBase.DefaultAuthority), true) + .WithRedirectUri(TestConstants.RedirectUri) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(httpManager) + .BuildConcrete(); + + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(addClientInfo: true); + var appCacheAccess = app.AppTokenCache.RecordAccess(); + var userCacheAccess = app.UserTokenCache.RecordAccess(); + + var result = await app.AcquireTokenForClient(TestConstants.s_scope.ToArray()).ExecuteAsync(CancellationToken.None).ConfigureAwait(false); + Assert.IsNotNull(result); + Assert.AreEqual("header.payload.signature", result.AccessToken); + Assert.AreEqual(TestConstants.s_scope.AsSingleString(), result.Scopes.AsSingleString()); + Assert.IsTrue(result.AdditionalResponseParameters.ContainsKey("authz")); +#if SUPPORTS_SYSTEM_TEXT_JSON + Assert.AreEqual("[\"value1\",\"value2\"]", result.AdditionalResponseParameters["authz"]); +#else + Assert.AreEqual("[\r\n \"value1\",\r\n \"value2\"\r\n]", result.AdditionalResponseParameters["authz"]); +#endif + // make sure user token cache is empty + Assert.AreEqual(0, app.UserTokenCacheInternal.Accessor.GetAllAccessTokens().Count); + Assert.AreEqual(0, app.UserTokenCacheInternal.Accessor.GetAllRefreshTokens().Count); + + // check app token cache count to be 1 + Assert.AreEqual(1, app.AppTokenCacheInternal.Accessor.GetAllAccessTokens().Count); + Assert.AreEqual(0, app.AppTokenCacheInternal.Accessor.GetAllRefreshTokens().Count); + + appCacheAccess.AssertAccessCounts(1, 1); + userCacheAccess.AssertAccessCounts(0, 0); + + // call AcquireTokenForClientAsync again to get result back from the cache + result = await app.AcquireTokenForClient(TestConstants.s_scope.ToArray()).ExecuteAsync(CancellationToken.None).ConfigureAwait(false); + Assert.IsNotNull(result); + Assert.AreEqual("header.payload.signature", result.AccessToken); + Assert.AreEqual(TestConstants.s_scope.AsSingleString(), result.Scopes.AsSingleString()); + Assert.IsTrue(result.AdditionalResponseParameters.ContainsKey("authz")); +#if SUPPORTS_SYSTEM_TEXT_JSON + Assert.AreEqual("[\"value1\",\"value2\"]", result.AdditionalResponseParameters["authz"]); +#else + Assert.AreEqual("[\r\n \"value1\",\r\n \"value2\"\r\n]", result.AdditionalResponseParameters["authz"]); +#endif + // make sure user token cache is empty + Assert.AreEqual(0, app.UserTokenCacheInternal.Accessor.GetAllAccessTokens().Count); + Assert.AreEqual(0, app.UserTokenCacheInternal.Accessor.GetAllRefreshTokens().Count); + + // check app token cache count to be 1 + Assert.AreEqual(1, app.AppTokenCacheInternal.Accessor.GetAllAccessTokens().Count); + Assert.AreEqual(0, app.AppTokenCacheInternal.Accessor.GetAllRefreshTokens().Count); + + appCacheAccess.AssertAccessCounts(2, 1); + userCacheAccess.AssertAccessCounts(0, 0); + } + } + private static string ComputeSHA256Hex(string token) { var cryptoMgr = new CommonCryptographyManager(); diff --git a/tests/Microsoft.Identity.Test.Unit/UtilTests/JsonHelperTests.cs b/tests/Microsoft.Identity.Test.Unit/UtilTests/JsonHelperTests.cs index 0365a8fa11..81cd0c4400 100644 --- a/tests/Microsoft.Identity.Test.Unit/UtilTests/JsonHelperTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/UtilTests/JsonHelperTests.cs @@ -62,11 +62,14 @@ public void Serialize_ClientInfo() ClientInfo clientInfo = new ClientInfo() { UniqueObjectIdentifier = "some_uid", UniqueTenantIdentifier = "some_tid" }; string actualJson = JsonHelper.SerializeToJson(clientInfo); +#if SUPPORTS_SYSTEM_TEXT_JSON string expectedJson = @"{ ""uid"": ""some_uid"", ""utid"": ""some_tid"" }"; - +#else + string expectedJson = @"{""uid"":""some_uid"",""utid"":""some_tid"",""AdditionalResponseParameters"":null}"; +#endif JsonTestUtils.AssertJsonDeepEquals(expectedJson, actualJson); }