From d91376d8ad1da46983f5a5632a2f2e9639e9b34a Mon Sep 17 00:00:00 2001 From: Scott Schaab Date: Wed, 19 Jun 2019 15:01:19 -0700 Subject: [PATCH] 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 --- sdk/identity/Azure.Identity/src/Base64Url.cs | 35 +++++ .../src/ClientCertificateCredential.cs | 46 ++++++ .../Azure.Identity/src/IdentityClient.cs | 109 +++++++++++++- .../tests/Azure.Identity.Tests.csproj | 1 + .../Azure.Identity/tests/Data/cert.pfx | Bin 0 -> 4133 bytes .../MockClientCertificateCredentialTests.cs | 140 ++++++++++++++++++ 6 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 sdk/identity/Azure.Identity/src/Base64Url.cs create mode 100644 sdk/identity/Azure.Identity/src/ClientCertificateCredential.cs create mode 100644 sdk/identity/Azure.Identity/tests/Data/cert.pfx create mode 100644 sdk/identity/Azure.Identity/tests/Mock/MockClientCertificateCredentialTests.cs diff --git a/sdk/identity/Azure.Identity/src/Base64Url.cs b/sdk/identity/Azure.Identity/src/Base64Url.cs new file mode 100644 index 0000000000000..c5f39055ed746 --- /dev/null +++ b/sdk/identity/Azure.Identity/src/Base64Url.cs @@ -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); + } + } +} \ No newline at end of file diff --git a/sdk/identity/Azure.Identity/src/ClientCertificateCredential.cs b/sdk/identity/Azure.Identity/src/ClientCertificateCredential.cs new file mode 100644 index 0000000000000..750944599f0f8 --- /dev/null +++ b/sdk/identity/Azure.Identity/src/ClientCertificateCredential.cs @@ -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 GetTokenAsync(string[] scopes, CancellationToken cancellationToken = default) + { + return await _client.AuthenticateAsync(_tenantId, _clientId, _clientCertificate, scopes, cancellationToken); + } + } +} diff --git a/sdk/identity/Azure.Identity/src/IdentityClient.cs b/sdk/identity/Azure.Identity/src/IdentityClient.cs index f4d3f01a81a63..c61aef85f238f 100644 --- a/sdk/identity/Azure.Identity/src/IdentityClient.cs +++ b/sdk/identity/Azure.Identity/src/IdentityClient.cs @@ -9,6 +9,8 @@ using System.Collections.Generic; using System.IO; using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using System.Threading; @@ -23,7 +25,8 @@ internal class IdentityClient private readonly IdentityClientOptions _options; private readonly HttpPipeline _pipeline; private readonly Uri ImdsEndptoint = new Uri("http://169.254.169.254/metadata/identity/oauth2/token"); - private readonly string MsiApiVersion = "2018-02-01"; + private const string MsiApiVersion = "2018-02-01"; + private const string ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; public IdentityClient(IdentityClientOptions options = null) { @@ -69,6 +72,39 @@ public virtual AccessToken Authenticate(string tenantId, string clientId, string } } + public virtual async Task AuthenticateAsync(string tenantId, string clientId, X509Certificate2 clientCertificate, string[] scopes, CancellationToken cancellationToken = default) + { + using (Request request = CreateClientCertificateAuthRequest(tenantId, clientId, clientCertificate, scopes)) + { + var response = await _pipeline.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.Status == 200 || response.Status == 201) + { + var result = await DeserializeAsync(response.ContentStream, cancellationToken).ConfigureAwait(false); + + return new Response(response, result); + } + + throw await response.CreateRequestFailedExceptionAsync(); + } + } + + public virtual AccessToken Authenticate(string tenantId, string clientId, X509Certificate2 clientCertificate, string[] scopes, CancellationToken cancellationToken = default) + { + using (Request request = CreateClientCertificateAuthRequest(tenantId, clientId, clientCertificate, scopes)) + { + var response = _pipeline.SendRequest(request, cancellationToken); + + if (response.Status == 200 || response.Status == 201) + { + var result = Deserialize(response.ContentStream); + + return new Response(response, result); + } + + throw response.CreateRequestFailedException(); + } + } public virtual async Task AuthenticateManagedIdentityAsync(string[] scopes, string clientId = null, CancellationToken cancellationToken = default) { using (Request request = CreateManagedIdentityAuthRequest(scopes, clientId)) @@ -152,6 +188,77 @@ private Request CreateClientSecretAuthRequest(string tenantId, string clientId, return request; } + private Request CreateClientCertificateAuthRequest(string tenantId, string clientId, X509Certificate2 clientCertficate, string[] scopes) + { + Request request = _pipeline.CreateRequest(); + + request.Method = HttpPipelineMethod.Post; + + request.Headers.SetValue("Content-Type", "application/x-www-form-urlencoded"); + + request.UriBuilder.Uri = _options.AuthorityHost; + + request.UriBuilder.AppendPath(tenantId); + + request.UriBuilder.AppendPath("/oauth2/v2.0/token"); + + string clientAssertion = CreateClientAssertionJWT(clientId, request.UriBuilder.ToString(), clientCertficate); + + var bodyStr = $"response_type=token&grant_type=client_credentials&client_id={Uri.EscapeDataString(clientId)}&client_assertion_type={Uri.EscapeDataString(ClientAssertionType)}&client_assertion={Uri.EscapeDataString(clientAssertion)}&scope={Uri.EscapeDataString(string.Join(" ", scopes))}"; + + ReadOnlyMemory content = Encoding.UTF8.GetBytes(bodyStr).AsMemory(); + + request.Content = HttpPipelineRequestContent.Create(content); + + return request; + } + + private string CreateClientAssertionJWT(string clientId, string audience, X509Certificate2 clientCertificate) + { + var headerBuff = new ArrayBufferWriter(); + + using (var headerJson = new Utf8JsonWriter(headerBuff)) + { + headerJson.WriteStartObject(); + + headerJson.WriteString("typ", "JWT"); + headerJson.WriteString("alg", "RS256"); + headerJson.WriteString("x5t", Base64Url.HexToBase64Url(clientCertificate.Thumbprint)); + + headerJson.WriteEndObject(); + + headerJson.Flush(); + } + + var payloadBuff = new ArrayBufferWriter(); + + using (var payloadJson = new Utf8JsonWriter(payloadBuff)) + { + payloadJson.WriteStartObject(); + + payloadJson.WriteString("jti", Guid.NewGuid()); + payloadJson.WriteString("aud", audience); + payloadJson.WriteString("iss", clientId); + payloadJson.WriteString("sub", clientId); + payloadJson.WriteNumber("nbf", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + payloadJson.WriteNumber("exp", (DateTimeOffset.UtcNow + TimeSpan.FromMinutes(30)).ToUnixTimeSeconds()); + + payloadJson.WriteEndObject(); + + payloadJson.Flush(); + } + + string header = Base64Url.Encode(headerBuff.WrittenMemory.ToArray()); + + string payload = Base64Url.Encode(payloadBuff.WrittenMemory.ToArray()); + + string flattenedJws = header + "." + payload; + + byte[] signature = clientCertificate.GetRSAPrivateKey().SignData(Encoding.ASCII.GetBytes(flattenedJws), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + return flattenedJws + "." + Base64Url.Encode(signature); + } + private async Task DeserializeAsync(Stream content, CancellationToken cancellationToken) { using (JsonDocument json = await JsonDocument.ParseAsync(content, default, cancellationToken).ConfigureAwait(false)) diff --git a/sdk/identity/Azure.Identity/tests/Azure.Identity.Tests.csproj b/sdk/identity/Azure.Identity/tests/Azure.Identity.Tests.csproj index 27463e596d5ab..d410e28ca09de 100644 --- a/sdk/identity/Azure.Identity/tests/Azure.Identity.Tests.csproj +++ b/sdk/identity/Azure.Identity/tests/Azure.Identity.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/sdk/identity/Azure.Identity/tests/Data/cert.pfx b/sdk/identity/Azure.Identity/tests/Data/cert.pfx new file mode 100644 index 0000000000000000000000000000000000000000..52ed251dc2b5d41738e644f50ab9d6d823b1fb74 GIT binary patch literal 4133 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 literal 0 HcmV?d00001 diff --git a/sdk/identity/Azure.Identity/tests/Mock/MockClientCertificateCredentialTests.cs b/sdk/identity/Azure.Identity/tests/Mock/MockClientCertificateCredentialTests.cs new file mode 100644 index 0000000000000..a0baf136d87e4 --- /dev/null +++ b/sdk/identity/Azure.Identity/tests/Mock/MockClientCertificateCredentialTests.cs @@ -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(() => new ClientCertificateCredential(null, clientId, clientCertificate)); + Assert.Throws(() => new ClientCertificateCredential(tenantId, null, clientCertificate)); + Assert.Throws(() => 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 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 parsed) + { + parsed = new Dictionary(); + + 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; + } + } +}