diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.cs new file mode 100644 index 0000000000..7906b9ac60 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.ApiConfig.Executors; +using Microsoft.Identity.Client.ApiConfig.Parameters; +using Microsoft.Identity.Client.TelemetryCore.Internal.Events; + +namespace Microsoft.Identity.Client +{ + /// + /// Parameter builder for the + /// operation. + /// +#if !SUPPORTS_CONFIDENTIAL_CLIENT + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide confidential client on mobile +#endif + public sealed class AcquireTokenByUserFederatedIdentityCredentialParameterBuilder : + AbstractConfidentialClientAcquireTokenParameterBuilder + { + private AcquireTokenByUserFederatedIdentityCredentialParameters Parameters { get; } = new AcquireTokenByUserFederatedIdentityCredentialParameters(); + + internal AcquireTokenByUserFederatedIdentityCredentialParameterBuilder( + IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor, + string username, + string assertion) + : base(confidentialClientApplicationExecutor) + { + Parameters.Username = username; + Parameters.Assertion = assertion; + } + + internal static AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Create( + IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor, + IEnumerable scopes, + string username, + string assertion) + { + if (string.IsNullOrEmpty(username)) + { + throw new ArgumentNullException(nameof(username)); + } + + if (string.IsNullOrEmpty(assertion)) + { + throw new ArgumentNullException(nameof(assertion)); + } + + return new AcquireTokenByUserFederatedIdentityCredentialParameterBuilder( + confidentialClientApplicationExecutor, username, assertion) + .WithScopes(scopes); + } + + /// + /// Forces MSAL to refresh the token from the identity provider even if a cached token is available. + /// + /// true to bypass the cache; otherwise false. Default is false. + /// The builder to chain the .With methods + public AcquireTokenByUserFederatedIdentityCredentialParameterBuilder WithForceRefresh(bool forceRefresh) + { + Parameters.ForceRefresh = forceRefresh; + return this; + } + + /// + /// Applicable to first-party applications only, this method also allows to specify + /// if the x5c claim should be sent to Azure AD. + /// Sending the x5c enables application developers to achieve easy certificate roll-over in Azure AD: + /// this method will send the certificate chain to Azure AD along with the token request, + /// so that Azure AD can use it to validate the subject name based on a trusted issuer policy. + /// This saves the application admin from the need to explicitly manage the certificate rollover + /// (either via portal or PowerShell/CLI operation). For details see https://aka.ms/msal-net-sni + /// + /// true if the x5c should be sent. Otherwise false. + /// The default is false + /// The builder to chain the .With methods + public AcquireTokenByUserFederatedIdentityCredentialParameterBuilder WithSendX5C(bool withSendX5C) + { + Parameters.SendX5C = withSendX5C; + return this; + } + + /// + internal override Task ExecuteInternalAsync(CancellationToken cancellationToken) + { + return ConfidentialClientApplicationExecutor.ExecuteAsync(CommonParameters, Parameters, cancellationToken); + } + + /// + internal override ApiEvent.ApiIds CalculateApiEventId() + { + return ApiEvent.ApiIds.AcquireTokenByUserFederatedIdentityCredential; + } + + /// + protected override void Validate() + { + base.Validate(); + + if (Parameters.SendX5C == null) + { + Parameters.SendX5C = ServiceBundle.Config?.SendX5C ?? false; + } + } + } +} diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs index 671870daee..8b1178cf78 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs @@ -164,5 +164,28 @@ public async Task ExecuteAsync( return await handler.RunAsync(cancellationToken).ConfigureAwait(false); } + + public async Task ExecuteAsync( + AcquireTokenCommonParameters commonParameters, + AcquireTokenByUserFederatedIdentityCredentialParameters userFicParameters, + CancellationToken cancellationToken) + { + RequestContext requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken); + + AuthenticationRequestParameters requestParams = await _confidentialClientApplication.CreateRequestParametersAsync( + commonParameters, + requestContext, + _confidentialClientApplication.UserTokenCacheInternal, + cancellationToken).ConfigureAwait(false); + + requestParams.SendX5C = userFicParameters.SendX5C ?? false; + + var handler = new UserFederatedIdentityCredentialRequest( + ServiceBundle, + requestParams, + userFicParameters); + + return await handler.RunAsync(cancellationToken).ConfigureAwait(false); + } } } diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/IConfidentialClientApplicationExecutor.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/IConfidentialClientApplicationExecutor.cs index 60bdb24189..87a7ee4c75 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/IConfidentialClientApplicationExecutor.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/IConfidentialClientApplicationExecutor.cs @@ -37,5 +37,10 @@ Task ExecuteAsync( AcquireTokenCommonParameters commonParameters, AcquireTokenByUsernamePasswordParameters usernamePasswordParameters, CancellationToken cancellationToken); + + Task ExecuteAsync( + AcquireTokenCommonParameters commonParameters, + AcquireTokenByUserFederatedIdentityCredentialParameters userFicParameters, + CancellationToken cancellationToken); } } diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs new file mode 100644 index 0000000000..2256f88e99 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Identity.Client.Core; + +namespace Microsoft.Identity.Client.ApiConfig.Parameters +{ + internal class AcquireTokenByUserFederatedIdentityCredentialParameters : IAcquireTokenParameters + { + public string Username { get; set; } + public string Assertion { get; set; } + public bool? SendX5C { get; set; } + public bool ForceRefresh { get; set; } + + /// + public void LogParameters(ILoggerAdapter logger) + { + } + } +} diff --git a/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs b/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs index cea2e10ccb..50d6c17568 100644 --- a/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs +++ b/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs @@ -25,7 +25,8 @@ public sealed partial class ConfidentialClientApplication IConfidentialClientApplication, IByRefreshToken, ILongRunningWebApi, - IByUsernameAndPassword + IByUsernameAndPassword, + IByUserFederatedIdentityCredential { /// /// Instructs MSAL to try to auto discover the Azure region. @@ -183,6 +184,19 @@ AcquireTokenByUsernameAndPasswordConfidentialParameterBuilder IByUsernameAndPass password); } + /// + AcquireTokenByUserFederatedIdentityCredentialParameterBuilder IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential( + IEnumerable scopes, + string username, + string assertion) + { + return AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.Create( + ClientExecutorFactory.CreateConfidentialClientExecutor(this), + scopes, + username, + 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 new file mode 100644 index 0000000000..8eff4a19e6 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/IByUserFederatedIdentityCredential.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Microsoft.Identity.Client +{ + /// + /// Provides an interface for acquiring tokens using the User Federated Identity Credential (UserFIC) flow. + /// +#if !SUPPORTS_CONFIDENTIAL_CLIENT + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide confidential client on mobile +#endif + public interface IByUserFederatedIdentityCredential + { + /// + /// Acquires a token on behalf of a user using a federated identity credential assertion. + /// This uses the user_fic grant type. + /// + /// Scopes requested to access a protected API. + /// The UPN (User Principal Name) of the user, e.g. john.doe@contoso.com. + /// + /// 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, + string username, + string assertion); + } +} diff --git a/src/client/Microsoft.Identity.Client/IConfidentialClientApplication.cs b/src/client/Microsoft.Identity.Client/IConfidentialClientApplication.cs index 90f6e7791d..8eced85a84 100644 --- a/src/client/Microsoft.Identity.Client/IConfidentialClientApplication.cs +++ b/src/client/Microsoft.Identity.Client/IConfidentialClientApplication.cs @@ -21,7 +21,7 @@ namespace Microsoft.Identity.Client #if !SUPPORTS_CONFIDENTIAL_CLIENT [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide confidential client on mobile #endif - public partial interface IConfidentialClientApplication : IClientApplicationBase + public partial interface IConfidentialClientApplication : IClientApplicationBase, IByUserFederatedIdentityCredential { /// /// Application token cache which holds access tokens for this application. It's maintained diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/UserFederatedIdentityCredentialRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/UserFederatedIdentityCredentialRequest.cs new file mode 100644 index 0000000000..f73bc3490c --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/UserFederatedIdentityCredentialRequest.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.ApiConfig.Parameters; +using Microsoft.Identity.Client.OAuth2; +using Microsoft.Identity.Client.Utils; + +namespace Microsoft.Identity.Client.Internal.Requests +{ + internal class UserFederatedIdentityCredentialRequest : RequestBase + { + private readonly AcquireTokenByUserFederatedIdentityCredentialParameters _userFicParameters; + + public UserFederatedIdentityCredentialRequest( + IServiceBundle serviceBundle, + AuthenticationRequestParameters authenticationRequestParameters, + AcquireTokenByUserFederatedIdentityCredentialParameters userFicParameters) + : base(serviceBundle, authenticationRequestParameters, userFicParameters) + { + _userFicParameters = userFicParameters; + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + await ResolveAuthorityAsync().ConfigureAwait(false); + + var additionalBodyParameters = GetAdditionalBodyParameters(_userFicParameters.Assertion); + MsalTokenResponse msalTokenResponse = await SendTokenRequestAsync(additionalBodyParameters, cancellationToken).ConfigureAwait(false); + + return await CacheTokenResponseAndCreateAuthenticationResultAsync(msalTokenResponse, cancellationToken).ConfigureAwait(false); + } + + private Dictionary GetAdditionalBodyParameters(string assertion) + { + var dict = new Dictionary + { + [OAuth2Parameter.GrantType] = OAuth2GrantType.UserFic, + [OAuth2Parameter.Username] = _userFicParameters.Username, + [OAuth2Parameter.UserFederatedIdentityCredential] = assertion + }; + + ISet unionScope = new HashSet + { + OAuth2Value.ScopeOpenId, + OAuth2Value.ScopeOfflineAccess, + OAuth2Value.ScopeProfile + }; + + unionScope.UnionWith(AuthenticationRequestParameters.Scope); + dict[OAuth2Parameter.Scope] = unionScope.AsSingleString(); + dict[OAuth2Parameter.ClientInfo] = "1"; + + return dict; + } + + protected override KeyValuePair? GetCcsHeader(IDictionary additionalBodyParameters) + { + 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 3ef7a55a6b..9a15c34ad8 100644 --- a/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs +++ b/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs @@ -46,6 +46,7 @@ internal static class OAuth2Parameter public const string SpaCode = "return_spa_code"; // not a standard OAuth2 param public const string FmiPath = "fmi_path"; // not a standard OAuth2 param public const string Attributes = "attributes"; // not a standard OAuth2 param + public const string UserFederatedIdentityCredential = "user_federated_identity_credential"; // user_fic grant type parameter } internal static class OAuth2GrantType @@ -58,6 +59,7 @@ internal static class OAuth2GrantType public const string JwtBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer"; public const string Password = "password"; public const string DeviceCode = "device_code"; + public const string UserFic = "user_fic"; } internal static class OAuth2ResponseType 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 e69de29bb2..101adba535 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -0,0 +1,5 @@ +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, 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 e69de29bb2..101adba535 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -0,0 +1,5 @@ +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, 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 e69de29bb2..101adba535 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 @@ -0,0 +1,5 @@ +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, 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 e69de29bb2..101adba535 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 @@ -0,0 +1,5 @@ +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, 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 e69de29bb2..101adba535 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 @@ -0,0 +1,5 @@ +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, 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 e69de29bb2..101adba535 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 @@ -0,0 +1,5 @@ +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/TelemetryCore/Internal/Events/ApiEvent.cs b/src/client/Microsoft.Identity.Client/TelemetryCore/Internal/Events/ApiEvent.cs index ade197d99f..d6ecf2a84c 100644 --- a/src/client/Microsoft.Identity.Client/TelemetryCore/Internal/Events/ApiEvent.cs +++ b/src/client/Microsoft.Identity.Client/TelemetryCore/Internal/Events/ApiEvent.cs @@ -39,6 +39,9 @@ public enum ApiIds InitiateLongRunningObo = 1017, AcquireTokenInLongRunningObo = 1018, + // UserFIC + AcquireTokenByUserFederatedIdentityCredential = 1019, + // "2002" is reserved for 1p OTEL signal that telemetry is disabled } diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs index 3e8d7b4946..7d150e8eb5 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs @@ -54,6 +54,21 @@ private static async Task AgentGetsAppTokenForGraph() private static async Task AgentUserIdentityGetsTokenForGraphAsync() { + // Assertion app: acquires the user_fic assertion via FMI path + var assertionApp = ConfidentialClientApplicationBuilder + .Create(AgentIdentity) + .WithAuthority("https://login.microsoftonline.com/", TenantId) + .WithExperimentalFeatures(true) + .WithCacheOptions(CacheOptions.EnableSharedCacheOptions) + .WithClientAssertion(async (AssertionRequestOptions a) => + { + Assert.AreEqual(AgentIdentity, a.ClientAssertionFmiPath); + var cred = await GetAppCredentialAsync(a.ClientAssertionFmiPath).ConfigureAwait(false); + return cred; + }) + .Build(); + + // Main app: acquires the final user token via user_fic grant var cca = ConfidentialClientApplicationBuilder .Create(AgentIdentity) .WithAuthority("https://login.microsoftonline.com/", TenantId) @@ -63,24 +78,18 @@ private static async Task AgentUserIdentityGetsTokenForGraphAsync() .WithClientAssertion((AssertionRequestOptions _) => GetAppCredentialAsync(AgentIdentity)) .Build(); - var result = await (cca as IByUsernameAndPassword).AcquireTokenByUsernamePassword([Scope], UserUpn, "no_password") - .OnBeforeTokenRequest( - async (request) => - { - string userFicAssertion = await GetUserFic().ConfigureAwait(false); - request.BodyParameters["user_federated_identity_credential"] = userFicAssertion; - request.BodyParameters["grant_type"] = "user_fic"; - - // remove the password - request.BodyParameters.Remove("password"); - - if (request.BodyParameters.TryGetValue("client_secret", out var secret) - && secret.Equals("default", StringComparison.OrdinalIgnoreCase)) - { - request.BodyParameters.Remove("client_secret"); - } - } - ) + // Assertion provider using the assertion app with FMI path + var assertionResult = await assertionApp + .AcquireTokenForClient([TokenExchangeUrl]) + .WithFmiPathForClientAssertion(AgentIdentity) + .ExecuteAsync() + .ConfigureAwait(false); + + Trace.WriteLine($"User FIC credential from : {assertionResult.AuthenticationResultMetadata.TokenSource}"); + string assertion = assertionResult.AccessToken; + + var result = await (cca as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential([Scope], UserUpn, assertion) .ExecuteAsync() .ConfigureAwait(false); @@ -112,29 +121,5 @@ private static async Task GetAppCredentialAsync(string fmiPath) return result.AccessToken; } - - private static async Task GetUserFic() - { - var cca1 = ConfidentialClientApplicationBuilder - .Create(AgentIdentity) - .WithAuthority("https://login.microsoftonline.com/", TenantId) - .WithExperimentalFeatures(true) - .WithCacheOptions(CacheOptions.EnableSharedCacheOptions) - .WithClientAssertion(async (AssertionRequestOptions a) => - { - Assert.AreEqual(AgentIdentity, a.ClientAssertionFmiPath); - var cred = await GetAppCredentialAsync(a.ClientAssertionFmiPath).ConfigureAwait(false); - return cred; - }) - .Build(); - - var result = await cca1.AcquireTokenForClient([TokenExchangeUrl]) - .WithFmiPathForClientAssertion(AgentIdentity) - .ExecuteAsync().ConfigureAwait(false); - - Trace.WriteLine($"User FIC credential from : {result.AuthenticationResultMetadata.TokenSource}"); - - return result.AccessToken; - } } } diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs new file mode 100644 index 0000000000..036afe33f0 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.OAuth2; +using Microsoft.Identity.Test.Common; +using Microsoft.Identity.Test.Common.Core.Helpers; +using Microsoft.Identity.Test.Common.Core.Mocks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Identity.Test.Unit.RequestsTests +{ + [TestClass] + public class UserFederatedIdentityCredentialTests : TestBase + { + private const string FakeAssertion = "fake.assertion.jwt"; + private const string FakeUsername = "user@contoso.com"; + + private MockHttpMessageHandler AddMockHandlerForUserFic( + MockHttpManager httpManager, + string authority = TestConstants.AuthorityCommonTenant) + { + var handler = new MockHttpMessageHandler + { + ExpectedUrl = authority + "oauth2/v2.0/token", + ExpectedMethod = HttpMethod.Post, + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.GrantType, OAuth2GrantType.UserFic }, + { OAuth2Parameter.Username, FakeUsername }, + { OAuth2Parameter.UserFederatedIdentityCredential, FakeAssertion } + }, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage() + }; + + httpManager.AddMockHandler(handler); + return handler; + } + + private ConfidentialClientApplication BuildCCA(MockHttpManager httpManager) + { + return ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithHttpManager(httpManager) + .BuildConcrete(); + } + + [TestMethod] + public async Task AcquireTokenByUserFic_SendsCorrectOAuth2Parameters_Async() + { + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + AddMockHandlerForUserFic(httpManager); + + var app = BuildCCA(httpManager); + + var result = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + FakeUsername, + FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + } + + [TestMethod] + public async Task AcquireTokenByUserFic_TokenIsStoredInUserCache_Async() + { + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + AddMockHandlerForUserFic(httpManager); + + var app = BuildCCA(httpManager); + + var result = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + FakeUsername, + FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.IsNotNull(result.Account, "Account should be returned from the user cache."); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + + // Token should be stored in user token cache + var accounts = await app.GetAccountsAsync().ConfigureAwait(false); + Assert.IsNotNull(accounts, "Accounts should not be null after token is stored in user cache."); + } + + [TestMethod] + public async Task AcquireTokenByUserFic_WithForceRefresh_CallsIdentityProvider_Async() + { + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + + // Two mock handlers are added; MockHttpManager verifies both are consumed, + // confirming that two separate calls to the identity provider were made. + AddMockHandlerForUserFic(httpManager); + var app = BuildCCA(httpManager); + + var firstResult = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + FakeUsername, + FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, firstResult.AuthenticationResultMetadata.TokenSource); + + // Second call with ForceRefresh - should call the identity provider again + AddMockHandlerForUserFic(httpManager); + + var secondResult = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + FakeUsername, + FakeAssertion) + .WithForceRefresh(true) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, secondResult.AuthenticationResultMetadata.TokenSource); + } + + [TestMethod] + public void AcquireTokenByUserFic_NullUsername_ThrowsArgumentNullException() + { + using var httpManager = new MockHttpManager(); + var app = BuildCCA(httpManager); + + AssertException.Throws(() => + (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + username: null, + assertion: FakeAssertion)); + } + + [TestMethod] + public void AcquireTokenByUserFic_NullAssertion_ThrowsArgumentNullException() + { + using var httpManager = new MockHttpManager(); + var app = BuildCCA(httpManager); + + AssertException.Throws(() => + (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + username: FakeUsername, + assertion: null)); + } + + [TestMethod] + public void AcquireTokenByUserFic_EmptyAssertion_ThrowsArgumentNullException() + { + using var httpManager = new MockHttpManager(); + var app = BuildCCA(httpManager); + + AssertException.Throws(() => + (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + username: FakeUsername, + assertion: string.Empty)); + } + } +}