diff --git a/src/Microsoft.Identity.Web.Certificate/DefaultCertificateLoader.cs b/src/Microsoft.Identity.Web.Certificate/DefaultCertificateLoader.cs index 83845dcff..34555c752 100644 --- a/src/Microsoft.Identity.Web.Certificate/DefaultCertificateLoader.cs +++ b/src/Microsoft.Identity.Web.Certificate/DefaultCertificateLoader.cs @@ -118,6 +118,24 @@ public static void ResetCertificates(IEnumerable? certif foreach (var cert in certificateDescriptions) { cert.Certificate = null; + cert.CachedValue = null; + } + } + } + + /// + /// Resets all the certificates in the certificate description list. + /// Use, for example, before a retry. + /// + /// Description of the certificates. + public static void ResetCertificates(IEnumerable? credentialDescription) + { + if (credentialDescription != null) + { + foreach (var cert in credentialDescription.Where(c => c.Certificate != null)) + { + cert.Certificate = null; + cert.CachedValue = null; } } } diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/ConfidentialClientApplicationBuilderExtension.cs b/src/Microsoft.Identity.Web.TokenAcquisition/ConfidentialClientApplicationBuilderExtension.cs index 3bcb2f121..d1bc9943c 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/ConfidentialClientApplicationBuilderExtension.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/ConfidentialClientApplicationBuilderExtension.cs @@ -4,10 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using System.Threading.Tasks; -using Azure.Identity; using Microsoft.Extensions.Logging; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/ICertificatesObserver.cs b/src/Microsoft.Identity.Web.TokenAcquisition/ICertificatesObserver.cs new file mode 100644 index 000000000..ccd4ed88e --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/ICertificatesObserver.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography.X509Certificates; +using Microsoft.Identity.Abstractions; + +// Types in the Microsoft.Identity.Web.Experimental namespace +// are meant to get feedback from the community on proposed features, and +// may be modified or removed in future releases without obeying to the +// semantic versionning. +namespace Microsoft.Identity.Web.Experimental +{ + /// + /// Action of the token acquirer on the certificate. + /// + public enum CerticateObserverAction + { + /// + /// The certificate was selected as a client certificate. + /// + Selected, + + /// + /// The certificate was deselected as a client certificate. This + /// happens when the STS does not accept the certificate any longer. + /// + Deselected, + } + + /// + /// Event argument about the certificate consumption by the app + /// + public class CertificateChangeEventArg + { + /// + /// Action on the certificate + /// + public CerticateObserverAction Action { get; set; } + + /// + /// Certificate + /// + public X509Certificate2? Certificate { get; set; } + + /// + /// Credential description + /// + public CredentialDescription? CredentialDescription { get; set; } + } + + /// + /// Interface that apps can implement to be notified when a certificate is selected or removed. + /// + public interface ICertificatesObserver + { + /// + /// Called when a certificate is selected or removed. + /// + /// + public void OnClientCertificateChanged(CertificateChangeEventArg e); + } +} diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquirerFactory.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquirerFactory.cs index faeeb7a63..fce99a627 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquirerFactory.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquirerFactory.cs @@ -41,10 +41,6 @@ public ServiceCollection Services { get { - if (ServiceProvider != null) - { - throw new InvalidOperationException("Cannot change services once you called Build()"); - } return _services; } diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index a8ebc3969..d51917375 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -10,8 +10,10 @@ using System.Linq; using System.Net.Http; using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; @@ -21,6 +23,7 @@ using Microsoft.Identity.Web.TokenCacheProviders.InMemory; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; +using Microsoft.Identity.Web.Experimental; namespace Microsoft.Identity.Web { @@ -53,6 +56,7 @@ class OAuthConstants protected readonly IServiceProvider _serviceProvider; protected readonly ITokenAcquisitionHost _tokenAcquisitionHost; protected readonly ICredentialsLoader _credentialsLoader; + protected readonly ICertificatesObserver? _certificatesObserver; /// /// Scopes which are already requested by MSAL.NET. They should not be re-requested;. @@ -99,6 +103,7 @@ public TokenAcquisition( _serviceProvider = serviceProvider; _tokenAcquisitionHost = tokenAcquisitionHost; _credentialsLoader = credentialsLoader; + _certificatesObserver = serviceProvider.GetService(); } #if NET6_0_OR_GREATER @@ -110,9 +115,10 @@ public async Task AddAccountToCacheFromAuthorizationCodeAsyn _ = Throws.IfNull(authCodeRedemptionParameters.Scopes); MergedOptions mergedOptions = _tokenAcquisitionHost.GetOptions(authCodeRedemptionParameters.AuthenticationScheme, out string effectiveAuthenticationScheme); + IConfidentialClientApplication? application=null; try { - var application = GetOrBuildConfidentialClientApplication(mergedOptions); + application = GetOrBuildConfidentialClientApplication(mergedOptions); // Do not share the access token with ASP.NET Core otherwise ASP.NET will cache it and will not send the OAuth 2.0 request in // case a further call to AcquireTokenByAuthorizationCodeAsync in the future is required for incremental consent (getting a code requesting more scopes) @@ -171,7 +177,8 @@ public async Task AddAccountToCacheFromAuthorizationCodeAsyn } catch (MsalServiceException exMsal) when (IsInvalidClientCertificateOrSignedAssertionError(exMsal)) { - DefaultCertificateLoader.ResetCertificates(mergedOptions.ClientCertificates); + NotifyCertificateSelection(mergedOptions, application!, CerticateObserverAction.Deselected); + DefaultCertificateLoader.ResetCertificates(mergedOptions.ClientCredentials); _applicationsByAuthorityClientId[GetApplicationKey(mergedOptions)] = null; // Retry @@ -267,7 +274,8 @@ public async Task GetAuthenticationResultForUserAsync( } catch (MsalServiceException exMsal) when (IsInvalidClientCertificateOrSignedAssertionError(exMsal)) { - DefaultCertificateLoader.ResetCertificates(mergedOptions.ClientCertificates); + NotifyCertificateSelection(mergedOptions, application, CerticateObserverAction.Deselected); + DefaultCertificateLoader.ResetCertificates(mergedOptions.ClientCredentials); _applicationsByAuthorityClientId[GetApplicationKey(mergedOptions)] = null; // Retry @@ -325,7 +333,7 @@ private void LogAuthResult(AuthenticationResult? authenticationResult) /// for multi tenant apps or daemons. /// Options passed-in to create the token acquisition object which calls into MSAL .NET. /// An authentication result for the app itself, based on its scopes. - public Task GetAuthenticationResultForAppAsync( + public async Task GetAuthenticationResultForAppAsync( string scope, string? authenticationScheme = null, string? tenant = null, @@ -415,21 +423,29 @@ public Task GetAuthenticationResultForAppAsync( try { - return builder.ExecuteAsync(tokenAcquisitionOptions != null ? tokenAcquisitionOptions.CancellationToken : CancellationToken.None); + return await builder.ExecuteAsync(tokenAcquisitionOptions != null ? tokenAcquisitionOptions.CancellationToken : CancellationToken.None); } catch (MsalServiceException exMsal) when (IsInvalidClientCertificateOrSignedAssertionError(exMsal)) { - DefaultCertificateLoader.ResetCertificates(mergedOptions.ClientCertificates); + NotifyCertificateSelection(mergedOptions, application, CerticateObserverAction.Deselected); + DefaultCertificateLoader.ResetCertificates(mergedOptions.ClientCredentials); _applicationsByAuthorityClientId[GetApplicationKey(mergedOptions)] = null; // Retry _retryClientCertificate = true; - return GetAuthenticationResultForAppAsync( + return await GetAuthenticationResultForAppAsync( scope, authenticationScheme: authenticationScheme, tenant: tenant, tokenAcquisitionOptions: tokenAcquisitionOptions); } + catch (MsalException ex) + { + // GetAuthenticationResultForAppAsync is an abstraction that can be called from + // a web app or a web API + Logger.TokenAcquisitionError(_logger, ex.Message, ex); + throw; + } finally { _retryClientCertificate = false; @@ -635,6 +651,10 @@ private IConfidentialClientApplication BuildConfidentialClientApplication(Merged IConfidentialClientApplication app = builder.Build(); + // If the client application has set certificate observer, + // fire the event to notify the client app that a certificate was selected. + NotifyCertificateSelection(mergedOptions, app, CerticateObserverAction.Selected); + // Initialize token cache providers if (!(_tokenCacheProvider is MsalMemoryTokenCacheProvider)) { @@ -654,6 +674,29 @@ private IConfidentialClientApplication BuildConfidentialClientApplication(Merged } } + /// + /// Find the certificate used by the app and fire the event to notify the client app that a certificate was selected/unselected. + /// + /// + /// + /// + private void NotifyCertificateSelection(MergedOptions mergedOptions, IConfidentialClientApplication app, CerticateObserverAction action) + { + X509Certificate2 selectedCertificate = app.AppConfig.ClientCredentialCertificate; + if (_certificatesObserver != null + && selectedCertificate != null) + { + _certificatesObserver.OnClientCertificateChanged( + new CertificateChangeEventArg() + { + Action = action, + Certificate = app.AppConfig.ClientCredentialCertificate, + CredentialDescription = mergedOptions.ClientCredentials?.FirstOrDefault(c => c.Certificate == selectedCertificate) + }); + ; + } + } + private async Task GetAuthenticationResultForWebApiToCallDownstreamApiAsync( IConfidentialClientApplication application, string? tenantId, diff --git a/tests/DevApps/aspnet-mvc/OwinWebApi/Web.config b/tests/DevApps/aspnet-mvc/OwinWebApi/Web.config index 32653ca94..2ecd7c190 100644 --- a/tests/DevApps/aspnet-mvc/OwinWebApi/Web.config +++ b/tests/DevApps/aspnet-mvc/OwinWebApi/Web.config @@ -58,7 +58,7 @@ - + @@ -74,7 +74,7 @@ - + @@ -82,23 +82,23 @@ - + - + - + - + - + diff --git a/tests/DevApps/aspnet-mvc/OwinWebApp/Web.config b/tests/DevApps/aspnet-mvc/OwinWebApp/Web.config index 7be0d3aee..3ce2f54d1 100644 --- a/tests/DevApps/aspnet-mvc/OwinWebApp/Web.config +++ b/tests/DevApps/aspnet-mvc/OwinWebApp/Web.config @@ -59,7 +59,7 @@ - + @@ -75,7 +75,7 @@ - + @@ -83,23 +83,23 @@ - + - + - + - + - + diff --git a/tests/IntegrationTests/TokenAcquirerTests/CertificateRotationTest.cs b/tests/IntegrationTests/TokenAcquirerTests/CertificateRotationTest.cs new file mode 100644 index 000000000..278b7e0b2 --- /dev/null +++ b/tests/IntegrationTests/TokenAcquirerTests/CertificateRotationTest.cs @@ -0,0 +1,337 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Azure.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Graph; +using Microsoft.Graph.Models; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.Experimental; +using Xunit; + +namespace TokenAcquirerTests +{ + public sealed class CertificateRotationTest : ICertificatesObserver + { + const string MicrosoftGraphAppId = "00000003-0000-0000-c000-000000000000"; + const string tenantId = "7f58f645-c190-4ce5-9de4-e2b7acd2a6ab"; + const double validityFirstCertInMinutes = 1.5; + const double validitySecondCertInMinutes = 10; + // Application? _application; + ServicePrincipal? _servicePrincipal; + GraphServiceClient graphServiceClient; + + + public CertificateRotationTest() + { + // Instantiate a Graph client + DefaultAzureCredential credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions() + { + VisualStudioTenantId = tenantId, + }); + graphServiceClient = new GraphServiceClient(credential); + } + + X509Certificate? currentCertificate = null; + + [IgnoreOnAzureDevopsFact] + public async Task TestCertificateRotation() + { + // Prepare the environment + // ----------------------- + // Create an app registration for a daemon app + Application aadApplication = (await CreateDaemonAppRegistrationIfNeeded())!; + DateTimeOffset now = DateTimeOffset.Now; + + // Create a certificate expiring in 3 mins, add it to the local cert store + X509Certificate2 firstCertificate = CreateSelfSignedCertificateAddAddToCertStore( + "MySelfSignedCert", + now.AddMinutes(validityFirstCertInMinutes)); + + // Create a cert active in 2 mins, and expiring in 10 mins, and add it to the cert store + X509Certificate2 secondCertificate = CreateSelfSignedCertificateAddAddToCertStore( + "MySelfSignedCert", + now.AddMinutes(validitySecondCertInMinutes), + now.AddMinutes(validityFirstCertInMinutes - 0.25)); + + // and add it as client creds to the app registration + await AddClientCertificatesToApp(aadApplication!, firstCertificate, secondCertificate); + + // Code for the application + // ------------------------ + // Add the cert to the configuration + CredentialDescription[] clientCertificates = new CredentialDescription[] + { + new CertificateDescription + { + CertificateDistinguishedName = firstCertificate.SubjectName.Name, + SourceType = CertificateSource.StoreWithDistinguishedName, + CertificateStorePath = "CurrentUser/My", + } + }; + + // Use the token acquirer factory to run the app and acquire a token + var tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + tokenAcquirerFactory.Services.Configure(options => + { + options.Instance = $"https://login.microsoftonline.com/"; + options.ClientId = aadApplication!.AppId; + options.TenantId = tenantId; + options.ClientCredentials = clientCertificates; + }); + tokenAcquirerFactory.Services.AddSingleton(this); + IServiceProvider serviceProvider = tokenAcquirerFactory.Build(); + IAuthorizationHeaderProvider authorizationHeaderProvider = serviceProvider.GetRequiredService(); + + // Before acquiring a token, wait so that the certificate is considered in the app-registration + // (this is not immediate :-() + await Task.Delay(TimeSpan.FromSeconds(30)); + + string authorizationHeader; + try + { + authorizationHeader = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync( + "https://graph.microsoft.com/.default"); + Assert.NotNull(authorizationHeader); + Assert.NotEqual(string.Empty, authorizationHeader); + } + catch (Exception) + { + await RemoveAppAndCertificates(firstCertificate); + Assert.Fail("Failed to acquire token with the first certificate"); + } + finally + { + } + + // Keep acquiring tokens every minute for 5 mins + // Tokens should be acquired successfully + for (int i = 0; i < 6; i++) + { + // Wait for a minute + await Task.Delay(60 * 1000); + + // Acquire a token + try + { + authorizationHeader = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync( + "https://graph.microsoft.com/.default", + new AuthorizationHeaderProviderOptions() + { + AcquireTokenOptions = new AcquireTokenOptions + { + ForceRefresh = true // Exceptionnaly as we want to test the cert rotation. + } + }); + Assert.NotNull(authorizationHeader); + Assert.NotEqual(string.Empty, authorizationHeader); + + // If the token acquisition was successful and the cert use is the second one, the test can terminate. + if (currentCertificate != null && currentCertificate.GetPublicKeyString() == secondCertificate.GetPublicKeyString()) + { + break; + } + + } + catch (Exception) + { + await RemoveAppAndCertificates(firstCertificate, secondCertificate); + Assert.Fail("Failed to acquire token with the second certificate"); + } + } + + // Check the last certificate used is the second one. + Assert.True(currentCertificate != null && currentCertificate.GetPublicKeyString() == secondCertificate.GetPublicKeyString()); + + // Delete both certs from the cert store and remove the app registration + await RemoveAppAndCertificates(firstCertificate, secondCertificate); + } + + private async Task RemoveAppAndCertificates( + X509Certificate2 firstCertificate, + X509Certificate2? secondCertificate = null, + Application? application = null, + ServicePrincipal? servicePrincipal = null) + { + // Delete the cert from the cert store + X509Store x509Store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + x509Store.Open(OpenFlags.ReadWrite); + x509Store.Remove(firstCertificate); + if (secondCertificate != null) + { + x509Store.Remove(secondCertificate); + } + x509Store.Close(); + + // Delete the app registration + if (application != null) + { + await graphServiceClient.Applications[$"{application!.Id}"] + .DeleteAsync(); + } + if (servicePrincipal != null) + { + await graphServiceClient.ServicePrincipals[$"{_servicePrincipal!.Id}"] + .DeleteAsync(); + } + } + + + private async Task CreateDaemonAppRegistrationIfNeeded() + { + var application = (await graphServiceClient + .Applications + .GetAsync(options => options.QueryParameters.Filter = $"DisplayName eq 'Daemon app to test cert rotation'")) + ?.Value?.FirstOrDefault(); + + if (application == null) + { + application = await CreateDaemonAppRegistration(); + } + return application!; + } + + private async Task CreateDaemonAppRegistration() + { + // Get the Microsoft Graph service principal and the user.read.all role. + ServicePrincipal graphSp = (await graphServiceClient.ServicePrincipals + .GetAsync(options => options.QueryParameters.Filter = $"AppId eq '{MicrosoftGraphAppId}'"))!.Value!.First(); + AppRole userReadAllRole = graphSp!.AppRoles!.First(r => r.Value == "User.Read.All"); + + // Create an app with API permissions to user.read.all + Application application = new Application() + { + DisplayName = "Daemon app to test cert rotation", + SignInAudience = "AzureADMyOrg", + Description = "Daemon to test cert rotation", + RequiredResourceAccess = new System.Collections.Generic.List + { + new RequiredResourceAccess() + { + ResourceAppId = MicrosoftGraphAppId, + ResourceAccess = new System.Collections.Generic.List() + { + new ResourceAccess() + { + Id = userReadAllRole.Id, + Type = "Role", + } + } + } + } + }; + Application createdApp = (await graphServiceClient.Applications + .PostAsync(application))!; + + // Create a service principal for the app + var servicePrincipal = new ServicePrincipal + { + AppId = createdApp!.AppId, + }; + _servicePrincipal = await graphServiceClient.ServicePrincipals + .PostAsync(servicePrincipal).ConfigureAwait(false); + + // Grant admin consent to user.read.all + var oAuth2PermissionGrant = new OAuth2PermissionGrant + { + ClientId = _servicePrincipal!.Id, + ConsentType = "AllPrincipals", + PrincipalId = null, + ResourceId = graphSp.Id, + Scope = userReadAllRole.Value, + }; + + try + { + var effectivePermissionGrant = await graphServiceClient.Oauth2PermissionGrants + .PostAsync(oAuth2PermissionGrant); + } + catch (Exception) + { + } + + return createdApp; + } + + private X509Certificate2 CreateSelfSignedCertificateAddAddToCertStore(string certName, DateTimeOffset expiry, DateTimeOffset? notBefore = null) + { + // Create the self signed certificate +#if ECDsa + var ecdsa = ECDsa.Create(); // generate asymmetric key pair + var req = new CertificateRequest($"CN={certName}", ecdsa, HashAlgorithmName.SHA256); +#else + using RSA rsa = RSA.Create(); // generate asymmetric key pair + var req = new CertificateRequest($"CN={certName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); +#endif + + var cert = req.CreateSelfSigned(notBefore.HasValue ? notBefore.Value : DateTimeOffset.Now, expiry); + + byte[] bytes = cert.Export(X509ContentType.Pfx, (string?)null); + X509Certificate2 certWithPrivateKey = new X509Certificate2(bytes); + + // Add it to the local cert store. + X509Store x509Store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + x509Store.Open(OpenFlags.ReadWrite); + x509Store.Add(certWithPrivateKey); + x509Store.Close(); + return certWithPrivateKey; + } + + private async Task AddClientCertificatesToApp(Application application, X509Certificate2 firstCertificate, X509Certificate2 secondCertificate2) + { + Application update = new Application + { + KeyCredentials = new System.Collections.Generic.List() + { + new KeyCredential() + { + DisplayName = GetDisplayName(firstCertificate), + EndDateTime = firstCertificate.NotAfter, + StartDateTime = firstCertificate.NotBefore, + Type = "AsymmetricX509Cert", + Usage = "Verify", + Key = firstCertificate.Export(X509ContentType.Cert) + }, + new KeyCredential() + { + DisplayName = GetDisplayName(secondCertificate2), + EndDateTime = secondCertificate2.NotAfter, + StartDateTime = secondCertificate2.NotBefore, + Type = "AsymmetricX509Cert", + Usage = "Verify", + Key = secondCertificate2.Export(X509ContentType.Cert) + } + } + }; + return (await graphServiceClient.Applications[application.Id].PatchAsync(update))!; + } + + private static string GetDisplayName(X509Certificate2 cert) + { + return cert.NotBefore.ToString(CultureInfo.InvariantCulture) + + "-" + + cert.NotAfter.ToString(CultureInfo.InvariantCulture); + } + + void ICertificatesObserver.OnClientCertificateChanged(CertificateChangeEventArg e) + { + switch (e.Action) + { + case CerticateObserverAction.Selected: + currentCertificate = e.Certificate; + break; + + case CerticateObserverAction.Deselected: + currentCertificate = null; + break; + } + } + } +} diff --git a/tests/IntegrationTests/TokenAcquirerTests/TokenAcquirer.cs b/tests/IntegrationTests/TokenAcquirerTests/TokenAcquirer.cs index 5ab4c4a2a..2d7545cfc 100644 --- a/tests/IntegrationTests/TokenAcquirerTests/TokenAcquirer.cs +++ b/tests/IntegrationTests/TokenAcquirerTests/TokenAcquirer.cs @@ -287,12 +287,10 @@ private static async Task CreateGraphClientAndAssert(TokenAcquirerFactory tokenA var serviceProvider = tokenAcquirerFactory.Build(); GraphServiceClient graphServiceClient = serviceProvider.GetRequiredService(); var users = await graphServiceClient.Users - .Request() - .WithAppOnly() - .WithAuthenticationScheme(s_optionName) - // .WithAuthenticationOptions(options => options.ProtocolScheme = "Pop") - .GetAsync(); - Assert.True(users.Count >= 56); + .GetAsync(o => o.Options + .WithAppOnly() + .WithAuthenticationScheme(s_optionName)); + Assert.True(users!=null && users.Value!=null && users.Value.Count >= 56); // Alternatively to calling Microsoft Graph, you can get a token acquirer service // and get a token, and use it in an SDK. diff --git a/tests/IntegrationTests/TokenAcquirerTests/TokenAcquirerTests.csproj b/tests/IntegrationTests/TokenAcquirerTests/TokenAcquirerTests.csproj index 3f4c24145..148b17d5f 100644 --- a/tests/IntegrationTests/TokenAcquirerTests/TokenAcquirerTests.csproj +++ b/tests/IntegrationTests/TokenAcquirerTests/TokenAcquirerTests.csproj @@ -21,7 +21,7 @@ - +