Skip to content

Commit

Permalink
Merge pull request #35 from microsoft/certAuth
Browse files Browse the repository at this point in the history
Add certificate authentication.
  • Loading branch information
azchohfi committed May 11, 2024
2 parents cf90cff + f8a86ee commit aa99dc4
Show file tree
Hide file tree
Showing 12 changed files with 355 additions and 49 deletions.
4 changes: 2 additions & 2 deletions MSStore.API/MSStore.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Identity.Client" Version="4.60.3" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.60.3" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.61.0" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.61.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
</ItemGroup>

Expand Down
73 changes: 63 additions & 10 deletions MSStore.API/Packaged/StorePackagedAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Threading;
Expand Down Expand Up @@ -53,6 +54,44 @@ public class StorePackagedAPI : IStorePackagedAPI, IDisposable
string? devCenterUrl,
string? devCenterScope,
ILogger? logger = null)
: this(configurations, devCenterUrl, devCenterScope, logger)
{
ClientSecret = clientSecret;
Certificate = null;
}

/// <summary>
/// Initializes a new instance of the <see cref="StorePackagedAPI"/> class.
/// </summary>
/// <param name="configurations">An instance of ClientConfiguration that contains all parameters populated</param>
/// <param name="certificate">The client certificate of the Azure AD Application that is registered to call Store APIs</param>
/// <param name="devCenterUrl">The DevCenter URL used to make the API calls.</param>
/// <param name="devCenterScope">The Scope from DevCenter that will be used to request the access token.</param>
/// <param name="logger">ILogger for logs.</param>
public StorePackagedAPI(
StoreConfigurations configurations,
X509Certificate2 certificate,
string? devCenterUrl,
string? devCenterScope,
ILogger? logger = null)
: this(configurations, devCenterUrl, devCenterScope, logger)
{
ClientSecret = null;
Certificate = certificate;
}

/// <summary>
/// Initializes a new instance of the <see cref="StorePackagedAPI"/> class.
/// </summary>
/// <param name="configurations">An instance of ClientConfiguration that contains all parameters populated</param>
/// <param name="devCenterUrl">The DevCenter URL used to make the API calls.</param>
/// <param name="devCenterScope">The Scope from DevCenter that will be used to request the access token.</param>
/// <param name="logger">ILogger for logs.</param>
private StorePackagedAPI(
StoreConfigurations configurations,
string? devCenterUrl,
string? devCenterScope,
ILogger? logger = null)
{
if (configurations.ClientId == null)
{
Expand All @@ -65,15 +104,15 @@ public class StorePackagedAPI : IStorePackagedAPI, IDisposable
}

Config = configurations;
ClientSecret = clientSecret;
DevCenterUrl = string.IsNullOrEmpty(devCenterUrl) ? "https://manage.devcenter.microsoft.com" : devCenterUrl;
DevCenterScope = string.IsNullOrEmpty(devCenterScope) ? "https://manage.devcenter.microsoft.com/.default" : devCenterScope;
Logger = logger;
}

private ILogger? Logger { get; }

public string ClientSecret { get; }
public string? ClientSecret { get; }
public X509Certificate2? Certificate { get; }
public string DevCenterUrl { get; set; }
public string DevCenterScope { get; set; }

Expand All @@ -98,15 +137,29 @@ public async Task InitAsync(HttpClient? httpClient = null, CancellationToken ct
{
// Get authorization token.
Logger?.LogInformation("Getting DevCenter authorization token");
var devCenterAccessToken = await SubmissionClient.GetClientCredentialAccessTokenAsync(
Config.TenantId!.Value.ToString(),
Config.ClientId!.Value.ToString(),
ClientSecret,
DevCenterScope,
Logger,
ct);
Microsoft.Identity.Client.AuthenticationResult? devCenterAccessToken = null;
if (Certificate != null)
{
devCenterAccessToken = await SubmissionClient.GetClientCredentialAccessTokenAsync(
Config.TenantId!.Value.ToString(),
Config.ClientId!.Value.ToString(),
Certificate,
DevCenterScope,
Logger,
ct);
}
else if (ClientSecret != null)
{
devCenterAccessToken = await SubmissionClient.GetClientCredentialAccessTokenAsync(
Config.TenantId!.Value.ToString(),
Config.ClientId!.Value.ToString(),
ClientSecret,
DevCenterScope,
Logger,
ct);
}

if (string.IsNullOrEmpty(devCenterAccessToken.AccessToken))
if (string.IsNullOrEmpty(devCenterAccessToken?.AccessToken))
{
Logger?.LogError("DevCenter Access Token should not be null");
return;
Expand Down
66 changes: 56 additions & 10 deletions MSStore.API/StoreAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Threading;
Expand Down Expand Up @@ -48,6 +49,37 @@ public class StoreAPI : IStoreAPI, IDisposable
string? serviceUrl,
string? scope,
ILogger? logger = null)
: this(configurations, serviceUrl, scope, logger)
{
ClientSecret = clientSecret;
Certificate = null;
}

/// <summary>
/// Initializes a new instance of the <see cref="StoreAPI"/> class.
/// </summary>
/// <param name="configurations">An instance of ClientConfiguration that contains all parameters populated</param>
/// <param name="certificate">The client certificate of the Azure AD Application that is registered to call Store APIs</param>
/// <param name="serviceUrl">The Store API URL used to make the API calls.</param>
/// <param name="scope">The Scope from the Store APIs that will be used to request the access token.</param>
/// <param name="logger">ILogger for logs.</param>
public StoreAPI(
StoreConfigurations configurations,
X509Certificate2 certificate,
string? serviceUrl,
string? scope,
ILogger? logger = null)
: this(configurations, serviceUrl, scope, logger)
{
ClientSecret = null;
Certificate = certificate;
}

private StoreAPI(
StoreConfigurations configurations,
string? serviceUrl,
string? scope,
ILogger? logger = null)
{
if (configurations.SellerId == null)
{
Expand All @@ -65,15 +97,15 @@ public class StoreAPI : IStoreAPI, IDisposable
}

Config = configurations;
ClientSecret = clientSecret;
ServiceUrl = string.IsNullOrEmpty(serviceUrl) ? "https://api.store.microsoft.com" : serviceUrl;
Scope = string.IsNullOrEmpty(scope) ? "https://api.store.microsoft.com/.default" : scope;
Logger = logger;
}

private ILogger? Logger { get; }

public string ClientSecret { get; }
public string? ClientSecret { get; }
public X509Certificate2? Certificate { get; }
public string ServiceUrl { get; set; }
public string Scope { get; set; }

Expand All @@ -98,15 +130,29 @@ public async Task InitAsync(HttpClient? httpClient = null, CancellationToken ct
{
// Get authorization token.
Logger?.LogInformation("Getting authorization token");
var accessToken = await SubmissionClient.GetClientCredentialAccessTokenAsync(
Config.TenantId!.Value.ToString(),
Config.ClientId!.Value.ToString(),
ClientSecret,
Scope,
Logger,
ct);
Microsoft.Identity.Client.AuthenticationResult? accessToken = null;
if (Certificate != null)
{
accessToken = await SubmissionClient.GetClientCredentialAccessTokenAsync(
Config.TenantId!.Value.ToString(),
Config.ClientId!.Value.ToString(),
Certificate,
Scope,
Logger,
ct);
}
else if (!string.IsNullOrEmpty(ClientSecret))
{
accessToken = await SubmissionClient.GetClientCredentialAccessTokenAsync(
Config.TenantId!.Value.ToString(),
Config.ClientId!.Value.ToString(),
ClientSecret,
Scope,
Logger,
ct);
}

if (string.IsNullOrEmpty(accessToken.AccessToken))
if (string.IsNullOrEmpty(accessToken?.AccessToken))
{
Logger?.LogError("Access Token should not be null");
return;
Expand Down
48 changes: 42 additions & 6 deletions MSStore.API/SubmissionClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Threading;
Expand Down Expand Up @@ -125,17 +126,53 @@ protected virtual void Dispose(bool disposing)
/// <param name="ct">Cancelation token.</param>
/// <returns>Autorization token. Prepend it with "Bearer: " and pass it in the request header as the
/// value for "Authorization: " header.</returns>
public static async Task<AuthenticationResult> GetClientCredentialAccessTokenAsync(
public static Task<AuthenticationResult> GetClientCredentialAccessTokenAsync(
string tenantId,
string clientId,
string clientSecret,
string scope,
ILogger? logger = null,
CancellationToken ct = default)
{
return GetClientCredentialAccessTokenAsync(tenantId, clientId, (builder) => builder.WithClientSecret(clientSecret), scope, logger, ct);
}

/// <summary>
/// Gets the authorization token for the provided client id, client secret, and the scope.
/// This token is usually valid for 1 hour, so if your submission takes longer than that to complete,
/// make sure to get a new one periodically.
/// </summary>
/// <param name="tenantId">The tenantId used to get the access token, specific to your
/// Azure Active Directory app. Example: "d454d300-128e-2d81-334a-27d9b2baf002"</param>
/// <param name="clientId">Client Id of your Azure Active Directory app. Example: "ba3c223b-03ab-4a44-aa32-38aa10c27e32"</param>
/// <param name="certificate">Client certificate of your Azure Active Directory app</param>
/// <param name="scope">Scope. If not provided, default one is used for the production API endpoint.</param>
/// <param name="logger">ILogger for logs.</param>
/// <param name="ct">Cancelation token.</param>
/// <returns>Autorization token. Prepend it with "Bearer: " and pass it in the request header as the
/// value for "Authorization: " header.</returns>
public static Task<AuthenticationResult> GetClientCredentialAccessTokenAsync(
string tenantId,
string clientId,
X509Certificate2 certificate,
string scope,
ILogger? logger = null,
CancellationToken ct = default)
{
return GetClientCredentialAccessTokenAsync(tenantId, clientId, (builder) => builder.WithCertificate(certificate), scope, logger, ct);
}

private static async Task<AuthenticationResult> GetClientCredentialAccessTokenAsync(
string tenantId,
string clientId,
Func<ConfidentialClientApplicationBuilder, ConfidentialClientApplicationBuilder> authorizationBuilder,
string scope,
ILogger? logger = null,
CancellationToken ct = default)
{
using ((logger ?? NullLogger.Instance).BeginScope("GetClientCredentialAccessToken"))
{
var app = await CreateAppAsync(clientId, clientSecret);
var app = await CreateAppAsync(clientId, authorizationBuilder);

AuthenticationResult authenticationResult;

Expand All @@ -155,7 +192,7 @@ protected virtual void Dispose(bool disposing)
}
}

private static async Task<IConfidentialClientApplication> CreateAppAsync(string clientId, string clientSecret)
private static async Task<IConfidentialClientApplication> CreateAppAsync(string clientId, Func<ConfidentialClientApplicationBuilder, ConfidentialClientApplicationBuilder> authorizationBuilder)
{
var cacheDirectory = Path.Combine(MsalCacheHelper.UserRootDirectory, "Microsoft", "MSStore.API", "Cache");

Expand All @@ -173,9 +210,8 @@ private static async Task<IConfidentialClientApplication> CreateAppAsync(string
KeyChainAccountName)
.Build();

var app = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithClientSecret(clientSecret)
var app = authorizationBuilder(ConfidentialClientApplicationBuilder
.Create(clientId))
.Build();

var cacheHelper = await MsalCacheHelper.CreateAsync(storageProperties);
Expand Down
50 changes: 50 additions & 0 deletions MSStore.CLI.UnitTests/ReconfigureCommandUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,5 +173,55 @@ public async Task ReconfigureCommandWithAllInfoShouldReturnZero()

result.Should().Contain("Awesome! It seems to be working!");
}

[TestMethod]
public async Task ReconfigureCommandWithAllInfoAndCertPathShouldReturnZero()
{
var result = await ParseAndInvokeAsync(
new string[]
{
"reconfigure",
"--tenantId",
DefaultOrganization.Id!.Value.ToString(),
"--sellerId",
"12345",
"--clientId",
"3F0BCAEF-6334-48CF-837F-81CB0F1F2C45",
"--certificateFilePath",
"C:\\x.pfx"
});

TokenManager
.Verify(x => x.SelectAccountAsync(It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
TokenManager
.Verify(x => x.GetTokenAsync(It.IsAny<string[]>(), It.IsAny<CancellationToken>()), Times.Never);

result.Should().Contain("Awesome! It seems to be working!");
}

[TestMethod]
public async Task ReconfigureCommandWithAllInfoAndCertThumbprintShouldReturnZero()
{
var result = await ParseAndInvokeAsync(
new string[]
{
"reconfigure",
"--tenantId",
DefaultOrganization.Id!.Value.ToString(),
"--sellerId",
"12345",
"--clientId",
"3F0BCAEF-6334-48CF-837F-81CB0F1F2C45",
"--certificateThumbprint",
"abc"
});

TokenManager
.Verify(x => x.SelectAccountAsync(It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
TokenManager
.Verify(x => x.GetTokenAsync(It.IsAny<string[]>(), It.IsAny<CancellationToken>()), Times.Never);

result.Should().Contain("Awesome! It seems to be working!");
}
}
}
10 changes: 10 additions & 0 deletions MSStore.CLI/Commands/InfoCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ public async Task<int> InvokeAsync(InvocationContext context)
table.AddRow($"[bold u]Tenant Id[/]", $"[bold u]{config.TenantId}[/]");
table.AddRow($"[bold u]Client Id[/]", $"[bold u]{config.ClientId}[/]");

if (!string.IsNullOrEmpty(config.CertificateThumbprint))
{
table.AddRow($"[bold u]Certificate Thumbprint[/]", $"[bold u]{config.CertificateThumbprint}[/]");
}

if (!string.IsNullOrEmpty(config.CertificateFilePath))
{
table.AddRow($"[bold u]Certificate Path[/]", $"[bold u]{config.CertificateFilePath}[/]");
}

bool verbose = context.ParseResult.IsVerbose();

if (verbose && !string.IsNullOrEmpty(config.StoreApiServiceUrl))
Expand Down
Loading

0 comments on commit aa99dc4

Please sign in to comment.