Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Jmprieur/test cert rotation #2496

Merged
merged 7 commits into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
// 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 Xunit;

namespace Microsoft.Identity.Web.Test.Integration
{
public class CertificateRotationTest
{
const string MicrosoftGraphAppId = "00000003-0000-0000-c000-000000000000";
const string tenantId = "7f58f645-c190-4ce5-9de4-e2b7acd2a6ab";
// Application? _application;
ServicePrincipal? _servicePrincipal;
GraphServiceClient graphServiceClient;


public CertificateRotationTest()
{
// Instantiate a Graph client
DefaultAzureCredential credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions()
{
VisualStudioTenantId = tenantId,
});
graphServiceClient = new GraphServiceClient(credential);
}

[Fact]
public async Task TestCertificateRotation()
{
// Create an app registration for a daemon app
Application aadApplication = await CreateDaemonAppRegistrationIfNeeded();

Check warning on line 42 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

Converting null literal or possible null value to non-nullable type.

Check warning on line 42 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

Converting null literal or possible null value to non-nullable type.

Check warning on line 42 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / Analyse

Converting null literal or possible null value to non-nullable type.

// Create a certificate expiring in 3 mins, add it to the local cert store
X509Certificate2 firstCertificate = CreateSelfSignedCertificateAddAddToCertStore(
"MySelfSignedCert",
DateTimeOffset.Now.AddMinutes(3));

// And a cert active in 2 mins, and expiring in 10 mins
X509Certificate2 secondCertificate = CreateSelfSignedCertificateAddAddToCertStore(
"MySelfSignedCert",
DateTimeOffset.Now.AddMinutes(10),
DateTimeOffset.Now.AddMinutes(2));

// and add it as client creds
await AddClientCertificatesToApp(aadApplication!, firstCertificate, secondCertificate);

// 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<MicrosoftIdentityApplicationOptions>(options =>
{
options.Instance = $"https://login.microsoftonline.com/";
options.ClientId = aadApplication!.AppId;
options.TenantId = tenantId;
options.ClientCredentials = clientCertificates;
});
IServiceProvider serviceProvider = tokenAcquirerFactory.Build();
IAuthorizationHeaderProvider authorizationHeaderProvider = serviceProvider.GetRequiredService<IAuthorizationHeaderProvider>();

// 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 ex)

Check warning on line 93 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

The variable 'ex' is declared but never used

Check warning on line 93 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

The variable 'ex' is declared but never used

Check warning on line 93 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / Analyse

The variable 'ex' is declared but never used
{
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 < 5; 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);
}
catch (Exception ex)

Check warning on line 124 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

The variable 'ex' is declared but never used

Check warning on line 124 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

The variable 'ex' is declared but never used

Check warning on line 124 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / Analyse

The variable 'ex' is declared but never used
{
await RemoveAppAndCertificates(firstCertificate, secondCertificate);
Assert.Fail("Failed to acquire token with the second certificate");
}
}


// 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<Application?> 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<Application?> 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<RequiredResourceAccess>
{
new RequiredResourceAccess()
{
ResourceAppId = MicrosoftGraphAppId,
ResourceAccess = new System.Collections.Generic.List<ResourceAccess>()
{
new ResourceAccess()
{
Id = userReadAllRole.Id,
Type = "Role",
}
}
}
}
};
Application createdApp = await graphServiceClient.Applications

Check warning on line 209 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

Converting null literal or possible null value to non-nullable type.

Check warning on line 209 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

Converting null literal or possible null value to non-nullable type.

Check warning on line 209 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / Analyse

Converting null literal or possible null value to non-nullable type.
.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 ex)

Check warning on line 235 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

The variable 'ex' is declared but never used

Check warning on line 235 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

The variable 'ex' is declared but never used

Check warning on line 235 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / Analyse

The variable 'ex' is declared but never used
{
}

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<Application> AddClientCertificatesToApp(Application application, X509Certificate2 firstCertificate, X509Certificate2 secondCertificate2)
{
Application update = new Application
{
KeyCredentials = new System.Collections.Generic.List<KeyCredential>()
{
new KeyCredential()
{
DisplayName = firstCertificate.NotAfter.ToString(CultureInfo.InvariantCulture),
EndDateTime = firstCertificate.NotAfter,
StartDateTime = firstCertificate.NotBefore,
Type = "AsymmetricX509Cert",
Usage = "Verify",
Key = firstCertificate.Export(X509ContentType.Cert)
},
new KeyCredential()
{
DisplayName = secondCertificate2.NotAfter.ToString(CultureInfo.InvariantCulture),
EndDateTime = secondCertificate2.NotAfter,
StartDateTime = secondCertificate2.NotBefore,
Type = "AsymmetricX509Cert",
Usage = "Verify",
Key = secondCertificate2.Export(X509ContentType.Cert)
}
}
};
return await graphServiceClient.Applications[application.Id].PatchAsync(update)!;

Check warning on line 292 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

Possible null reference return.

Check warning on line 292 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / IdWeb GitHub Action Test

Possible null reference return.

Check warning on line 292 in tests/Microsoft.Identity.Web.Test.Integration/CertificateRotationTest.cs

View workflow job for this annotation

GitHub Actions / Analyse

Possible null reference return.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="Newtonsoft.Json" Version="$(NewtonsoftJsonVersion)" />
<PackageReference Include="Microsoft.Graph" Version="5.12.0" />

</ItemGroup>

<ItemGroup>
Expand Down
Loading