diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.cs index 7906b9ac60..e6d5230329 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.cs @@ -33,6 +33,16 @@ internal AcquireTokenByUserFederatedIdentityCredentialParameterBuilder( Parameters.Assertion = assertion; } + internal AcquireTokenByUserFederatedIdentityCredentialParameterBuilder( + IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor, + Guid userObjectId, + string assertion) + : base(confidentialClientApplicationExecutor) + { + Parameters.UserObjectId = userObjectId; + Parameters.Assertion = assertion; + } + internal static AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Create( IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor, IEnumerable scopes, @@ -54,6 +64,27 @@ internal static AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Cr .WithScopes(scopes); } + internal static AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Create( + IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor, + IEnumerable scopes, + Guid userObjectId, + string assertion) + { + if (userObjectId == Guid.Empty) + { + throw new ArgumentException("userObjectId must not be empty.", nameof(userObjectId)); + } + + if (string.IsNullOrEmpty(assertion)) + { + throw new ArgumentNullException(nameof(assertion)); + } + + return new AcquireTokenByUserFederatedIdentityCredentialParameterBuilder( + confidentialClientApplicationExecutor, userObjectId, assertion) + .WithScopes(scopes); + } + /// /// Forces MSAL to refresh the token from the identity provider even if a cached token is available. /// diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs index 2256f88e99..2758dd01ec 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; +using System.Text; using Microsoft.Identity.Client.Core; namespace Microsoft.Identity.Client.ApiConfig.Parameters @@ -8,6 +10,7 @@ namespace Microsoft.Identity.Client.ApiConfig.Parameters internal class AcquireTokenByUserFederatedIdentityCredentialParameters : IAcquireTokenParameters { public string Username { get; set; } + public Guid? UserObjectId { get; set; } public string Assertion { get; set; } public bool? SendX5C { get; set; } public bool ForceRefresh { get; set; } @@ -15,6 +18,30 @@ internal class AcquireTokenByUserFederatedIdentityCredentialParameters : IAcquir /// public void LogParameters(ILoggerAdapter logger) { + if (logger.IsLoggingEnabled(LogLevel.Info)) + { + // PII-enabled message: includes actual Username and UserObjectId values + var builder = new StringBuilder(); + builder.AppendLine("=== AcquireTokenByUserFederatedIdentityCredentialParameters ==="); + builder.AppendLine("SendX5C: " + SendX5C); + builder.AppendLine("ForceRefresh: " + ForceRefresh); + builder.AppendLine("Username: " + Username); + builder.AppendLine("UserObjectId: " + UserObjectId); + builder.AppendLine("Assertion set: " + !string.IsNullOrEmpty(Assertion)); + + string messageWithPii = builder.ToString(); + + // Non-PII message: redacts Username and UserObjectId to booleans + builder = new StringBuilder(); + builder.AppendLine("=== AcquireTokenByUserFederatedIdentityCredentialParameters ==="); + builder.AppendLine("SendX5C: " + SendX5C); + builder.AppendLine("ForceRefresh: " + ForceRefresh); + builder.AppendLine("Username set: " + !string.IsNullOrEmpty(Username)); + builder.AppendLine("UserObjectId set: " + UserObjectId.HasValue); + builder.AppendLine("Assertion set: " + !string.IsNullOrEmpty(Assertion)); + + logger.InfoPii(messageWithPii, builder.ToString()); + } } } } diff --git a/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs b/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs index 50d6c17568..35b19b32cc 100644 --- a/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs +++ b/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs @@ -197,6 +197,19 @@ AcquireTokenByUserFederatedIdentityCredentialParameterBuilder IByUserFederatedId assertion); } + /// + AcquireTokenByUserFederatedIdentityCredentialParameterBuilder IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential( + IEnumerable scopes, + Guid userObjectId, + string assertion) + { + return AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.Create( + ClientExecutorFactory.CreateConfidentialClientExecutor(this), + scopes, + userObjectId, + assertion); + } + AcquireTokenByRefreshTokenParameterBuilder IByRefreshToken.AcquireTokenByRefreshToken( IEnumerable scopes, string refreshToken) diff --git a/src/client/Microsoft.Identity.Client/IByUserFederatedIdentityCredential.cs b/src/client/Microsoft.Identity.Client/IByUserFederatedIdentityCredential.cs index 8eff4a19e6..9d457834e5 100644 --- a/src/client/Microsoft.Identity.Client/IByUserFederatedIdentityCredential.cs +++ b/src/client/Microsoft.Identity.Client/IByUserFederatedIdentityCredential.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.Collections.Generic; namespace Microsoft.Identity.Client @@ -15,7 +16,7 @@ public interface IByUserFederatedIdentityCredential { /// /// Acquires a token on behalf of a user using a federated identity credential assertion. - /// This uses the user_fic grant type. + /// This uses the user_fic grant type. The user is identified by UPN. /// /// Scopes requested to access a protected API. /// The UPN (User Principal Name) of the user, e.g. john.doe@contoso.com. @@ -28,5 +29,21 @@ AcquireTokenByUserFederatedIdentityCredentialParameterBuilder AcquireTokenByUser IEnumerable scopes, string username, string assertion); + + /// + /// Acquires a token on behalf of a user using a federated identity credential assertion. + /// This uses the user_fic grant type. The user is identified by Object ID (OID). + /// + /// Scopes requested to access a protected API. + /// The Object ID (OID) of the user in Entra ID. Must not be . + /// + /// The federated identity credential assertion (JWT) for the user. + /// Acquire this token from a Managed Identity or Confidential Client application before calling this method. + /// + /// A builder enabling you to add optional parameters before executing the token request. + AcquireTokenByUserFederatedIdentityCredentialParameterBuilder AcquireTokenByUserFederatedIdentityCredential( + IEnumerable scopes, + Guid userObjectId, + string assertion); } } diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/UserFederatedIdentityCredentialRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/UserFederatedIdentityCredentialRequest.cs index f73bc3490c..99bdcf768c 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/UserFederatedIdentityCredentialRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/UserFederatedIdentityCredentialRequest.cs @@ -38,10 +38,22 @@ private Dictionary GetAdditionalBodyParameters(string assertion) var dict = new Dictionary { [OAuth2Parameter.GrantType] = OAuth2GrantType.UserFic, - [OAuth2Parameter.Username] = _userFicParameters.Username, [OAuth2Parameter.UserFederatedIdentityCredential] = assertion }; + // The user_fic grant identifies the user by either OID (user_id) or UPN (username). + // The parameter builder enforces that exactly one is set via separate constructors, + // so both values cannot be populated simultaneously through the public API. + // OID is checked first because it is immutable and preferred over UPN, which can be renamed. + if (_userFicParameters.UserObjectId.HasValue) + { + dict[OAuth2Parameter.UserId] = _userFicParameters.UserObjectId.Value.ToString("D"); + } + else + { + dict[OAuth2Parameter.Username] = _userFicParameters.Username; + } + ISet unionScope = new HashSet { OAuth2Value.ScopeOpenId, @@ -58,6 +70,21 @@ private Dictionary GetAdditionalBodyParameters(string assertion) protected override KeyValuePair? GetCcsHeader(IDictionary additionalBodyParameters) { + // CCS routing hint mirrors the OID/UPN choice above — route by OID when available, UPN otherwise. + if (_userFicParameters.UserObjectId.HasValue) + { + string ccsHint = CoreHelpers.GetCcsClientInfoHint( + _userFicParameters.UserObjectId.Value.ToString("D"), + AuthenticationRequestParameters.Authority.TenantId); + + if (!string.IsNullOrEmpty(ccsHint)) + { + return new KeyValuePair(Constants.CcsRoutingHintHeader, ccsHint); + } + + return null; + } + return GetCcsUpnHeader(_userFicParameters.Username); } } diff --git a/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs b/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs index 81457feb3c..56a2d346d0 100644 --- a/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs +++ b/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs @@ -48,6 +48,7 @@ internal static class OAuth2Parameter public const string Attributes = "attributes"; // not a standard OAuth2 param public const string AttributeTokens = "attribute_tokens"; // not a standard OAuth2 param public const string UserFederatedIdentityCredential = "user_federated_identity_credential"; // user_fic grant type parameter + public const string UserId = "user_id"; // user_fic grant type parameter (OID-based user identification) } internal static class OAuth2GrantType diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt index bc21638d2a..16bd45d3ac 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -17,3 +17,4 @@ Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.IsMtlsPopS Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.MaxSupportedBindingStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.Source.get -> Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentityCapabilitiesAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt index bc21638d2a..16bd45d3ac 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -17,3 +17,4 @@ Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.IsMtlsPopS Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.MaxSupportedBindingStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.Source.get -> Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentityCapabilitiesAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt index bc21638d2a..16bd45d3ac 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt @@ -17,3 +17,4 @@ Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.IsMtlsPopS Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.MaxSupportedBindingStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.Source.get -> Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentityCapabilitiesAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt index bc21638d2a..16bd45d3ac 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt @@ -17,3 +17,4 @@ Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.IsMtlsPopS Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.MaxSupportedBindingStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.Source.get -> Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentityCapabilitiesAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt index bc21638d2a..16bd45d3ac 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt @@ -17,3 +17,4 @@ Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.IsMtlsPopS Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.MaxSupportedBindingStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.Source.get -> Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentityCapabilitiesAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt index bc21638d2a..16bd45d3ac 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt @@ -17,3 +17,4 @@ Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.IsMtlsPopS Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.MaxSupportedBindingStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.ManagedIdentity.ManagedIdentityCapabilities.Source.get -> Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentityCapabilitiesAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs index 036afe33f0..34ae703904 100644 --- a/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; using Microsoft.Identity.Client; @@ -175,5 +176,296 @@ public void AcquireTokenByUserFic_EmptyAssertion_ThrowsArgumentNullException() username: FakeUsername, assertion: string.Empty)); } + + #region OID-Based UserFIC Tests + + private static readonly Guid FakeUserOid = new Guid("11111111-2222-3333-4444-555555555555"); + + /// + /// Verifies that when the Guid overload of AcquireTokenByUserFederatedIdentityCredential is used, + /// the token request sends "user_id" (OID) instead of "username" (UPN) in the POST body. + /// + [TestMethod] + public async Task AcquireTokenByUserFic_WithOid_SendsUserIdParameter_Async() + { + // Arrange + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + + httpManager.AddMockHandler(new MockHttpMessageHandler + { + ExpectedUrl = TestConstants.AuthorityCommonTenant + "oauth2/v2.0/token", + ExpectedMethod = HttpMethod.Post, + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.GrantType, OAuth2GrantType.UserFic }, + { OAuth2Parameter.UserId, FakeUserOid.ToString("D") }, + { OAuth2Parameter.UserFederatedIdentityCredential, FakeAssertion } + }, + UnExpectedPostData = new Dictionary + { + { OAuth2Parameter.Username, "" } + }, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage() + }); + + var app = BuildCCA(httpManager); + + // Act + var result = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + FakeUserOid, + FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + } + + /// + /// Verifies that the UPN overload sends "username" and NOT "user_id" in the POST body. + /// This is the inverse of the OID test and ensures the two paths are mutually exclusive. + /// + [TestMethod] + public async Task AcquireTokenByUserFic_WithUpn_SendsUsernameParameter_Async() + { + // Arrange + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + + httpManager.AddMockHandler(new MockHttpMessageHandler + { + ExpectedUrl = TestConstants.AuthorityCommonTenant + "oauth2/v2.0/token", + ExpectedMethod = HttpMethod.Post, + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.GrantType, OAuth2GrantType.UserFic }, + { OAuth2Parameter.Username, FakeUsername }, + { OAuth2Parameter.UserFederatedIdentityCredential, FakeAssertion } + }, + UnExpectedPostData = new Dictionary + { + { OAuth2Parameter.UserId, "" } + }, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage() + }); + + var app = BuildCCA(httpManager); + + // Act + var result = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + FakeUsername, + FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + } + + /// + /// Verifies that Guid.Empty is rejected at builder creation time. + /// + [TestMethod] + public void AcquireTokenByUserFic_EmptyGuid_ThrowsArgumentException() + { + using var httpManager = new MockHttpManager(); + var app = BuildCCA(httpManager); + + AssertException.Throws(() => + (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + userObjectId: Guid.Empty, + assertion: FakeAssertion)); + } + + /// + /// Verifies that null assertion is rejected for the OID overload. + /// + [TestMethod] + public void AcquireTokenByUserFic_NullOidAssertion_ThrowsArgumentNullException() + { + using var httpManager = new MockHttpManager(); + var app = BuildCCA(httpManager); + + AssertException.Throws(() => + (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + userObjectId: FakeUserOid, + assertion: null)); + } + + /// + /// Verifies that empty assertion is rejected for the OID overload. + /// + [TestMethod] + public void AcquireTokenByUserFic_EmptyOidAssertion_ThrowsArgumentNullException() + { + using var httpManager = new MockHttpManager(); + var app = BuildCCA(httpManager); + + AssertException.Throws(() => + (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + userObjectId: FakeUserOid, + assertion: string.Empty)); + } + + #endregion + + #region Multi-User Cache Tests + + /// + /// Adds a mock handler for a UserFIC call with a specific username and a token response + /// that returns a distinct user identity (OID and preferred_username). + /// + private static void AddMockHandlerForUserFicWithIdentity( + MockHttpManager httpManager, + string username, + string userOid, + string accessToken, + string authority = TestConstants.AuthorityCommonTenant) + { + httpManager.AddMockHandler(new MockHttpMessageHandler + { + ExpectedUrl = authority + "oauth2/v2.0/token", + ExpectedMethod = HttpMethod.Post, + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.GrantType, OAuth2GrantType.UserFic }, + { OAuth2Parameter.Username, username }, + { OAuth2Parameter.UserFederatedIdentityCredential, FakeAssertion } + }, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage( + userOid, + username, + TestConstants.s_scope.ToArray(), + accessToken: accessToken) + }); + } + + /// + /// Verifies that when two different users (by UPN) acquire tokens via UserFIC on the same CCA, + /// AcquireTokenSilent returns the correct cached token for each user and does not cross-contaminate. + /// + [TestMethod] + public async Task AcquireTokenByUserFic_TwoUpns_SilentReturnsCorrectToken_Async() + { + // Arrange + const string User1Upn = "alice@contoso.com"; + const string User1Oid = "oid-alice-1111"; + const string User1Token = "access-token-alice"; + + const string User2Upn = "bob@contoso.com"; + const string User2Oid = "oid-bob-2222"; + const string User2Token = "access-token-bob"; + + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + + var app = BuildCCA(httpManager); + + // Act — Acquire token for User 1 (Alice) + AddMockHandlerForUserFicWithIdentity(httpManager, User1Upn, User1Oid, User1Token); + + var result1 = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential(TestConstants.s_scope, User1Upn, FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User1Token, result1.AccessToken); + Assert.AreEqual(User1Upn, result1.Account.Username); + + // Act — Acquire token for User 2 (Bob) + AddMockHandlerForUserFicWithIdentity(httpManager, User2Upn, User2Oid, User2Token); + + var result2 = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential(TestConstants.s_scope, User2Upn, FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User2Token, result2.AccessToken); + Assert.AreEqual(User2Upn, result2.Account.Username); + + // Assert — Both accounts cached, silent returns correct token per user + var accounts = await app.GetAccountsAsync().ConfigureAwait(false); + Assert.AreEqual(2, accounts.Count(), "Two accounts should be cached"); + + var account1 = accounts.First(a => string.Equals(a.Username, User1Upn, StringComparison.OrdinalIgnoreCase)); + var silent1 = await app.AcquireTokenSilent(TestConstants.s_scope, account1).ExecuteAsync().ConfigureAwait(false); + Assert.AreEqual(TokenSource.Cache, silent1.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User1Token, silent1.AccessToken, "Silent call for Alice should return Alice's token"); + + var account2 = accounts.First(a => string.Equals(a.Username, User2Upn, StringComparison.OrdinalIgnoreCase)); + var silent2 = await app.AcquireTokenSilent(TestConstants.s_scope, account2).ExecuteAsync().ConfigureAwait(false); + Assert.AreEqual(TokenSource.Cache, silent2.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User2Token, silent2.AccessToken, "Silent call for Bob should return Bob's token"); + } + + /// + /// Verifies that when two different users (by OID) acquire tokens via UserFIC on the same CCA, + /// AcquireTokenSilent resolves the correct account by OID and returns the correct cached token. + /// + [TestMethod] + public async Task AcquireTokenByUserFic_TwoOids_SilentReturnsCorrectToken_Async() + { + // Arrange + const string User1Upn = "carol@contoso.com"; + const string User1Oid = "oid-carol-3333"; + const string User1Token = "access-token-carol"; + + const string User2Upn = "dave@contoso.com"; + const string User2Oid = "oid-dave-4444"; + const string User2Token = "access-token-dave"; + + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + + var app = BuildCCA(httpManager); + + // Act — Acquire tokens via UPN (populates user cache) + AddMockHandlerForUserFicWithIdentity(httpManager, User1Upn, User1Oid, User1Token); + var result1 = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential(TestConstants.s_scope, User1Upn, FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + Assert.AreEqual(User1Token, result1.AccessToken); + + AddMockHandlerForUserFicWithIdentity(httpManager, User2Upn, User2Oid, User2Token); + var result2 = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential(TestConstants.s_scope, User2Upn, FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + Assert.AreEqual(User2Token, result2.AccessToken); + + // Assert — Retrieve by OID via HomeAccountId.ObjectId + var accounts = await app.GetAccountsAsync().ConfigureAwait(false); + Assert.AreEqual(2, accounts.Count(), "Two accounts should be cached"); + + var carolAccount = accounts.First(a => + string.Equals(a.HomeAccountId.ObjectId, User1Oid, StringComparison.OrdinalIgnoreCase)); + var silentCarol = await app.AcquireTokenSilent(TestConstants.s_scope, carolAccount).ExecuteAsync().ConfigureAwait(false); + Assert.AreEqual(TokenSource.Cache, silentCarol.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User1Token, silentCarol.AccessToken, "OID-based lookup for Carol should return Carol's token"); + + var daveAccount = accounts.First(a => + string.Equals(a.HomeAccountId.ObjectId, User2Oid, StringComparison.OrdinalIgnoreCase)); + var silentDave = await app.AcquireTokenSilent(TestConstants.s_scope, daveAccount).ExecuteAsync().ConfigureAwait(false); + Assert.AreEqual(TokenSource.Cache, silentDave.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User2Token, silentDave.AccessToken, "OID-based lookup for Dave should return Dave's token"); + } + + #endregion } }