-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adding ClientCertificateCredential to Azure.Idenity (#6636)
* Adding ClientCertificateCredential to Azure.Idenity * fixing test assertion * adding cert with password to work around https://github.com/dotnet/corefx/issues/24225
- Loading branch information
Showing
6 changed files
with
330 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
// Copyright (c) Microsoft Corporation. All rights reserved. | ||
// Licensed under the MIT License. See License.txt in the project root for | ||
// license information. | ||
|
||
using System; | ||
using System.Text; | ||
|
||
namespace Azure.Identity | ||
{ | ||
internal static class Base64Url | ||
{ | ||
public static byte[] Decode(string str) | ||
{ | ||
str = new StringBuilder(str).Replace('-', '+').Replace('_', '/').Append('=', (str.Length % 4 == 0) ? 0 : 4 - (str.Length % 4)).ToString(); | ||
|
||
return Convert.FromBase64String(str); | ||
} | ||
|
||
public static string Encode(byte[] bytes) | ||
{ | ||
return new StringBuilder(Convert.ToBase64String(bytes)).Replace('+', '-').Replace('/', '_').Replace("=", "").ToString(); | ||
} | ||
|
||
|
||
public static string HexToBase64Url(string hex) | ||
{ | ||
byte[] bytes = new byte[hex.Length / 2]; | ||
|
||
for (int i = 0; i < hex.Length; i += 2) | ||
bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); | ||
|
||
return Base64Url.Encode(bytes); | ||
} | ||
} | ||
} |
46 changes: 46 additions & 0 deletions
46
sdk/identity/Azure.Identity/src/ClientCertificateCredential.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
// Copyright (c) Microsoft Corporation. All rights reserved. | ||
// Licensed under the MIT License. See License.txt in the project root for | ||
// license information. | ||
|
||
using Azure.Core; | ||
using System; | ||
using System.Security.Cryptography.X509Certificates; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
|
||
namespace Azure.Identity | ||
{ | ||
public class ClientCertificateCredential : TokenCredential | ||
{ | ||
private string _tenantId; | ||
private string _clientId; | ||
private X509Certificate2 _clientCertificate; | ||
private IdentityClient _client; | ||
|
||
public ClientCertificateCredential(string tenantId, string clientId, X509Certificate2 clientCertificate) | ||
: this(tenantId, clientId, clientCertificate, null) | ||
{ | ||
} | ||
|
||
public ClientCertificateCredential(string tenantId, string clientId, X509Certificate2 clientCertificate, IdentityClientOptions options) | ||
{ | ||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); | ||
|
||
_clientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); | ||
|
||
_clientCertificate = clientCertificate ?? throw new ArgumentNullException(nameof(clientCertificate)); | ||
|
||
_client = (options != null) ? new IdentityClient(options) : IdentityClient.SharedClient; | ||
} | ||
|
||
public override AccessToken GetToken(string[] scopes, CancellationToken cancellationToken = default) | ||
{ | ||
return _client.Authenticate(_tenantId, _clientId, _clientCertificate, scopes, cancellationToken); | ||
} | ||
|
||
public override async Task<AccessToken> GetTokenAsync(string[] scopes, CancellationToken cancellationToken = default) | ||
{ | ||
return await _client.AuthenticateAsync(_tenantId, _clientId, _clientCertificate, scopes, cancellationToken); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file not shown.
140 changes: 140 additions & 0 deletions
140
sdk/identity/Azure.Identity/tests/Mock/MockClientCertificateCredentialTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,140 @@ | ||
using Azure.Core; | ||
using Azure.Core.Testing; | ||
using NUnit.Framework; | ||
using System; | ||
using System.Collections.Generic; | ||
using System.IO; | ||
using System.Security.Cryptography; | ||
using System.Security.Cryptography.X509Certificates; | ||
using System.Text; | ||
using System.Text.Json; | ||
using System.Threading.Tasks; | ||
|
||
namespace Azure.Identity.Tests.Mock | ||
{ | ||
public class MockClientCertificateCredentialTests | ||
{ | ||
[Test] | ||
public void VerifyCtorErrorHandling() | ||
{ | ||
var clientCertificate = new X509Certificate2(@"./Data/cert.pfx", "password"); | ||
|
||
var tenantId = Guid.NewGuid().ToString(); | ||
|
||
var clientId = Guid.NewGuid().ToString(); | ||
|
||
Assert.Throws<ArgumentNullException>(() => new ClientCertificateCredential(null, clientId, clientCertificate)); | ||
Assert.Throws<ArgumentNullException>(() => new ClientCertificateCredential(tenantId, null, clientCertificate)); | ||
Assert.Throws<ArgumentNullException>(() => new ClientCertificateCredential(tenantId, clientId, null)); | ||
} | ||
|
||
[Test] | ||
public async Task VerifyClientCertificateRequestAsync() | ||
{ | ||
var response = new MockResponse(200); | ||
|
||
var expectedToken = "mock-msi-access-token"; | ||
|
||
response.SetContent($"{{ \"access_token\": \"{expectedToken}\", \"expires_in\": 3600 }}"); | ||
|
||
var mockTransport = new MockTransport(response); | ||
|
||
var options = new IdentityClientOptions() { Transport = mockTransport }; | ||
|
||
var expectedTenantId = Guid.NewGuid().ToString(); | ||
|
||
var expectedClientId = Guid.NewGuid().ToString(); | ||
|
||
var mockCert = new X509Certificate2("./Data/cert.pfx", "password"); | ||
|
||
var credential = new ClientCertificateCredential(expectedTenantId, expectedClientId, mockCert, options: options); | ||
|
||
AccessToken actualToken = await credential.GetTokenAsync(MockScopes.Default); | ||
|
||
Assert.AreEqual(expectedToken, actualToken.Token); | ||
|
||
MockRequest request = mockTransport.SingleRequest; | ||
|
||
Assert.IsTrue(request.Content.TryComputeLength(out long contentLen)); | ||
|
||
var content = new byte[contentLen]; | ||
|
||
await request.Content.WriteToAsync(new MemoryStream(content), default); | ||
|
||
Assert.IsTrue(TryParseFormEncodedBody(content, out Dictionary<string, string> parsedBody)); | ||
|
||
Assert.IsTrue(parsedBody.TryGetValue("response_type", out string responseType) && responseType == "token"); | ||
|
||
Assert.IsTrue(parsedBody.TryGetValue("grant_type", out string grantType) && grantType == "client_credentials"); | ||
|
||
Assert.IsTrue(parsedBody.TryGetValue("client_assertion_type", out string assertionType) && assertionType == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); | ||
|
||
Assert.IsTrue(parsedBody.TryGetValue("client_id", out string actualClientId) && actualClientId == expectedClientId); | ||
|
||
Assert.IsTrue(parsedBody.TryGetValue("scope", out string actualScope) && actualScope == MockScopes.Default.ToString()); | ||
|
||
Assert.IsTrue(parsedBody.TryGetValue("client_assertion", out string clientAssertion)); | ||
|
||
// var header | ||
VerifyClientAssertion(clientAssertion, expectedTenantId, expectedClientId, mockCert); | ||
} | ||
|
||
public void VerifyClientAssertion(string clientAssertion, string expectedTenantId, string expectedClientId, X509Certificate2 clientCertificate) | ||
{ | ||
var splitAssertion = clientAssertion.Split('.'); | ||
|
||
Assert.IsTrue(splitAssertion.Length == 3); | ||
|
||
var compactHeader = splitAssertion[0]; | ||
var compactPayload = splitAssertion[1]; | ||
var encodedSignature = splitAssertion[2]; | ||
|
||
// verify the JWT header | ||
using (JsonDocument json = JsonDocument.Parse(Base64Url.Decode(compactHeader))) | ||
{ | ||
Assert.IsTrue(json.RootElement.TryGetProperty("typ", out JsonElement typProp) && typProp.GetString() == "JWT"); | ||
Assert.IsTrue(json.RootElement.TryGetProperty("alg", out JsonElement algProp) && algProp.GetString() == "RS256"); | ||
Assert.IsTrue(json.RootElement.TryGetProperty("x5t", out JsonElement x5tProp) && x5tProp.GetString() == Base64Url.HexToBase64Url(clientCertificate.Thumbprint)); | ||
} | ||
|
||
// verify the JWT payload | ||
using (JsonDocument json = JsonDocument.Parse(Base64Url.Decode(compactPayload))) | ||
{ | ||
Assert.IsTrue(json.RootElement.TryGetProperty("aud", out JsonElement audProp) && audProp.GetString() == $"https://login.microsoftonline.com/{expectedTenantId}/oauth2/v2.0/token"); | ||
Assert.IsTrue(json.RootElement.TryGetProperty("iss", out JsonElement issProp) && issProp.GetString() == expectedClientId); | ||
Assert.IsTrue(json.RootElement.TryGetProperty("sub", out JsonElement subProp) && subProp.GetString() == expectedClientId); | ||
Assert.IsTrue(json.RootElement.TryGetProperty("nbf", out JsonElement nbfProp) && nbfProp.GetInt64() <= DateTimeOffset.UtcNow.ToUnixTimeSeconds()); | ||
Assert.IsTrue(json.RootElement.TryGetProperty("exp", out JsonElement expProp) && expProp.GetInt64() > DateTimeOffset.UtcNow.ToUnixTimeSeconds()); ; | ||
} | ||
|
||
// verify the JWT signature | ||
Assert.IsTrue(clientCertificate.GetRSAPublicKey().VerifyData(Encoding.ASCII.GetBytes(compactHeader + "." + compactPayload), Base64Url.Decode(encodedSignature), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); | ||
} | ||
|
||
public bool TryParseFormEncodedBody(byte[] content, out Dictionary<string, string> parsed) | ||
{ | ||
parsed = new Dictionary<string, string>(); | ||
|
||
var contentStr = Encoding.UTF8.GetString(content); | ||
|
||
foreach (string parameter in contentStr.Split('&')) | ||
{ | ||
if (string.IsNullOrEmpty(parameter)) | ||
{ | ||
return false; | ||
} | ||
|
||
var splitParam = parameter.Split('='); | ||
|
||
if(splitParam.Length != 2) | ||
{ | ||
return false; | ||
} | ||
|
||
parsed[splitParam[0]] = Uri.UnescapeDataString(splitParam[1]); | ||
} | ||
|
||
return true; | ||
} | ||
} | ||
} |