diff --git a/Microsoft.Kiota.sln b/Microsoft.Kiota.sln index c2087eab..24c7dba3 100644 --- a/Microsoft.Kiota.sln +++ b/Microsoft.Kiota.sln @@ -14,31 +14,40 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution SUPPORT.md = SUPPORT.md EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Abstractions", "src\abstractions\Microsoft.Kiota.Abstractions.csproj", "{7D843C30-B856-49BD-97BC-B74F9F234EF9}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Abstractions.Tests", "tests\abstractions\Microsoft.Kiota.Abstractions.Tests.csproj", "{B112E9CF-055E-45FB-A32F-25CAB57936DB}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Abstractions", "src\abstractions\Microsoft.Kiota.Abstractions.csproj", "{61B7F639-6456-41CA-B53E-492115888F84}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Authentication.Azure", "src\authentication\azure\Microsoft.Kiota.Authentication.Azure.csproj", "{ED943ED1-CC3E-41B9-BE79-C3C301D2267B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Authentication.Azure.Tests", "tests\authentication\azure\Microsoft.Kiota.Authentication.Azure.Tests.csproj", "{71161CE4-C748-4CD3-A5ED-A2B806B24360}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7D843C30-B856-49BD-97BC-B74F9F234EF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7D843C30-B856-49BD-97BC-B74F9F234EF9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7D843C30-B856-49BD-97BC-B74F9F234EF9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7D843C30-B856-49BD-97BC-B74F9F234EF9}.Release|Any CPU.Build.0 = Release|Any CPU {B112E9CF-055E-45FB-A32F-25CAB57936DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B112E9CF-055E-45FB-A32F-25CAB57936DB}.Debug|Any CPU.Build.0 = Debug|Any CPU {B112E9CF-055E-45FB-A32F-25CAB57936DB}.Release|Any CPU.ActiveCfg = Release|Any CPU {B112E9CF-055E-45FB-A32F-25CAB57936DB}.Release|Any CPU.Build.0 = Release|Any CPU + {61B7F639-6456-41CA-B53E-492115888F84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61B7F639-6456-41CA-B53E-492115888F84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61B7F639-6456-41CA-B53E-492115888F84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61B7F639-6456-41CA-B53E-492115888F84}.Release|Any CPU.Build.0 = Release|Any CPU + {ED943ED1-CC3E-41B9-BE79-C3C301D2267B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED943ED1-CC3E-41B9-BE79-C3C301D2267B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED943ED1-CC3E-41B9-BE79-C3C301D2267B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED943ED1-CC3E-41B9-BE79-C3C301D2267B}.Release|Any CPU.Build.0 = Release|Any CPU + {71161CE4-C748-4CD3-A5ED-A2B806B24360}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71161CE4-C748-4CD3-A5ED-A2B806B24360}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71161CE4-C748-4CD3-A5ED-A2B806B24360}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71161CE4-C748-4CD3-A5ED-A2B806B24360}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {7D843C30-B856-49BD-97BC-B74F9F234EF9} = {812D9C21-411D-4987-AB8E-C96185F01AD0} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B0E38E50-7BC8-4E28-BDAC-BF38F7AF2CD2} EndGlobalSection diff --git a/README.md b/README.md index a691079b..ed25bbeb 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README ## Libraries 1. [Abstractions](./src/abstractions/README.md) +1. [Authentication - Azure](./src/authentication/azure/README.md) ## Debugging diff --git a/src/abstractions/README.md b/src/abstractions/README.md index 3417c172..6e48fdc1 100644 --- a/src/abstractions/README.md +++ b/src/abstractions/README.md @@ -11,7 +11,7 @@ Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README ## Using the Abstractions Library ```shell -dotnet add package Microsoft.Kiota.Abstractions --prerelease +dotnet add package Microsoft.Kiota.Abstractions ``` ## Debugging @@ -38,4 +38,4 @@ This project may contain trademarks or logos for projects, products, or services trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. -Any use of third-party trademarks or logos are subject to those third-party's policies. \ No newline at end of file +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/src/authentication/azure/35MSSharedLib1024.snk b/src/authentication/azure/35MSSharedLib1024.snk new file mode 100644 index 00000000..695f1b38 Binary files /dev/null and b/src/authentication/azure/35MSSharedLib1024.snk differ diff --git a/src/authentication/azure/AzureIdentityAccessTokenProvider.cs b/src/authentication/azure/AzureIdentityAccessTokenProvider.cs new file mode 100644 index 00000000..b8a0abb0 --- /dev/null +++ b/src/authentication/azure/AzureIdentityAccessTokenProvider.cs @@ -0,0 +1,103 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Kiota.Abstractions.Authentication; + +namespace Microsoft.Kiota.Authentication.Azure; +/// +/// Provides an implementation of for Azure.Identity. +/// +public class AzureIdentityAccessTokenProvider : IAccessTokenProvider, IDisposable +{ + private static readonly object BoxedTrue = true; + private static readonly object BoxedFalse = false; + + private readonly TokenCredential _credential; + private readonly ActivitySource _activitySource; + private readonly HashSet _scopes; + /// + public AllowedHostsValidator AllowedHostsValidator { get; protected set; } + + /// + /// The constructor + /// + /// The credential implementation to use to obtain the access token. + /// The list of allowed hosts for which to request access tokens. + /// The scopes to request the access token for. + /// The observability options to use for the authentication provider. + public AzureIdentityAccessTokenProvider(TokenCredential credential, string []? allowedHosts = null, ObservabilityOptions? observabilityOptions = null, params string[] scopes) + { + _credential = credential ?? throw new ArgumentNullException(nameof(credential)); + + AllowedHostsValidator = new AllowedHostsValidator(allowedHosts); + + if(scopes == null) + _scopes = new(); + else + _scopes = new(scopes, StringComparer.OrdinalIgnoreCase); + + _activitySource = new((observabilityOptions ?? new()).TracerInstrumentationName); + } + + private const string ClaimsKey = "claims"; + + private readonly HashSet _localHostStrings = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "localhost", + "[::1]", + "::1", + "127.0.0.1" + }; + + /// + public async Task GetAuthorizationTokenAsync(Uri uri, Dictionary? additionalAuthenticationContext = default, CancellationToken cancellationToken = default) + { + using var span = _activitySource?.StartActivity(nameof(GetAuthorizationTokenAsync)); + if(!AllowedHostsValidator.IsUrlHostValid(uri)) { + span?.SetTag("com.microsoft.kiota.authentication.is_url_valid", BoxedFalse); + return string.Empty; + } + + if(!uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && !_localHostStrings.Contains(uri.Host)) { + span?.SetTag("com.microsoft.kiota.authentication.is_url_valid", BoxedFalse); + throw new ArgumentException("Only https is supported"); + } + span?.SetTag("com.microsoft.kiota.authentication.is_url_valid", BoxedTrue); + + string? decodedClaim = null; + if (additionalAuthenticationContext is not null && + additionalAuthenticationContext.ContainsKey(ClaimsKey) && + additionalAuthenticationContext[ClaimsKey] is string claims) { + span?.SetTag("com.microsoft.kiota.authentication.additional_claims_provided", BoxedTrue); + var decodedBase64Bytes = Convert.FromBase64String(claims); + decodedClaim = Encoding.UTF8.GetString(decodedBase64Bytes); + } else + span?.SetTag("com.microsoft.kiota.authentication.additional_claims_provided", BoxedFalse); + + string[] scopes; + if (_scopes.Count > 0) { + scopes = new string[_scopes.Count]; + _scopes.CopyTo(scopes); + } else + scopes = [ $"{uri.Scheme}://{uri.Host}/.default" ]; + span?.SetTag("com.microsoft.kiota.authentication.scopes", string.Join(",", scopes)); + + var result = await _credential.GetTokenAsync(new TokenRequestContext(scopes, claims: decodedClaim), cancellationToken).ConfigureAwait(false); + return result.Token; + } + + /// + public void Dispose() + { + _activitySource?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/authentication/azure/AzureIdentityAuthenticationProvider.cs b/src/authentication/azure/AzureIdentityAuthenticationProvider.cs new file mode 100644 index 00000000..d5d083e4 --- /dev/null +++ b/src/authentication/azure/AzureIdentityAuthenticationProvider.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using Azure.Core; +using Microsoft.Kiota.Abstractions.Authentication; + +namespace Microsoft.Kiota.Authentication.Azure; +/// +/// The implementation that supports implementations of from Azure.Identity. +/// +public class AzureIdentityAuthenticationProvider : BaseBearerTokenAuthenticationProvider +{ + /// + /// The constructor + /// + /// The credential implementation to use to obtain the access token. + /// The list of allowed hosts for which to request access tokens. + /// The scopes to request the access token for. + /// The observability options to use for the authentication provider. + public AzureIdentityAuthenticationProvider(TokenCredential credential, string[]? allowedHosts = null, ObservabilityOptions? observabilityOptions = null, params string[] scopes) + : base(new AzureIdentityAccessTokenProvider(credential, allowedHosts, observabilityOptions, scopes)) + { + } +} diff --git a/src/authentication/azure/Microsoft.Kiota.Authentication.Azure.csproj b/src/authentication/azure/Microsoft.Kiota.Authentication.Azure.csproj new file mode 100644 index 00000000..eeffa37a --- /dev/null +++ b/src/authentication/azure/Microsoft.Kiota.Authentication.Azure.csproj @@ -0,0 +1,67 @@ + + + + + Kiota authentication provider implementation with Azure Identity. + © Microsoft Corporation. All rights reserved. + Kiota Azure Identity Authentication Library for dotnet + Microsoft + + netstandard2.0;netstandard2.1;net5.0;net6.0;net8.0 + latest + true + http://go.microsoft.com/fwlink/?LinkID=288890 + https://github.com/microsoft/kiota-authentication-azure-dotnet + https://aka.ms/kiota/docs + true + true + 1.1.7 + + true + true + + + false + false + 35MSSharedLib1024.snk + latest + enable + true + + https://github.com/microsoft/kiota-authentication-azure-dotnet/releases + + true + MIT + README.md + $(NoWarn);NU5048;NETSDK1138 + + + true + + + true + + + + + + + + + + + + + + + true + + + + + True + + + + + diff --git a/src/authentication/azure/ObservabilityOptions.cs b/src/authentication/azure/ObservabilityOptions.cs new file mode 100644 index 00000000..28c31cd4 --- /dev/null +++ b/src/authentication/azure/ObservabilityOptions.cs @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; + +namespace Microsoft.Kiota.Authentication.Azure; +/// +/// Holds the tracing, metrics and logging configuration for the authentication provider +/// +public class ObservabilityOptions { + private static readonly Lazy _name = new Lazy(() => typeof(ObservabilityOptions).Namespace!); + /// + /// Gets the observability name to use for the tracer + /// + public string TracerInstrumentationName => _name.Value; +} diff --git a/src/authentication/azure/README.md b/src/authentication/azure/README.md new file mode 100644 index 00000000..a691768b --- /dev/null +++ b/src/authentication/azure/README.md @@ -0,0 +1,41 @@ +# Kiota Azure Identity authentication provider library for dotnet + +[![Build and Test](https://github.com/microsoft/kiota-authentication-azure-dotnet/actions/workflows/build-and-test.yml/badge.svg?branch=main)](https://github.com/microsoft/kiota-authentication-azure-dotnet/actions/workflows/build-and-test.yml) [![NuGet Version](https://buildstats.info/nuget/Microsoft.Kiota.Authentication.Azure?includePreReleases=true)](https://www.nuget.org/packages/Microsoft.Kiota.Authentication.Azure/) + +The Kiota Azure Identity authentication provider library for dotnet is the authentication provider implementation with [Azure.Identity](https://docs.microsoft.com/en-us/dotnet/api/overview/azure/identity-readme). + +A [Kiota](https://github.com/microsoft/kiota) generated project will need a reference to a authentication provider library to authenticate HTTP requests to an API endpoint. + +Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README.md). + +## Using Azure Identity authentication provider library for dotnet + +```shell +dotnet add package Microsoft.Kiota.Authentication.Azure +``` + +## Debugging + +If you are using Visual Studio Code as your IDE, the **launch.json** file already contains the configuration to build and test the library. Otherwise, you can open the **Microsoft.Kiota.Authentication.Azure.sln** with Visual Studio. + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit [https://cla.opensource.microsoft.com](https://cla.opensource.microsoft.com). + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/tests/authentication/azure/AzureIdentityAuthenticationProviderTests.cs b/tests/authentication/azure/AzureIdentityAuthenticationProviderTests.cs new file mode 100644 index 00000000..2c2e63c3 --- /dev/null +++ b/tests/authentication/azure/AzureIdentityAuthenticationProviderTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Azure.Core; +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Authentication.Azure.Tests; +public class AzureIdentityAuthenticationProviderTests +{ + [Fact] + public void ConstructorThrowsArgumentNullExceptionOnNullTokenCredential() + { + // Arrange + var exception = Assert.Throws(() => new AzureIdentityAccessTokenProvider(null, null)); + + // Assert + Assert.Equal("credential", exception.ParamName); + } + + [Theory] + [InlineData("https://localhost", "")] + [InlineData("https://graph.microsoft.com", "token")] + [InlineData("https://graph.microsoft.com/v1.0/me", "token")] + public async Task GetAuthorizationTokenAsyncGetsToken(string url, string expectedToken) + { + // Arrange + var uri = new Uri(url); + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())).Returns(new ValueTask(new AccessToken(expectedToken, DateTimeOffset.Now))); + var azureIdentityAuthenticationProvider = new AzureIdentityAccessTokenProvider(mockTokenCredential.Object); + + // Act + var token = await azureIdentityAuthenticationProvider.GetAuthorizationTokenAsync(uri); + + // Assert + Assert.Equal(expectedToken, token); + mockTokenCredential.Verify(x => x.GetTokenAsync(It.Is(t => + t.Scopes.Any(s => $"{uri.Scheme}://{uri.Host}/.default".Equals(s, StringComparison.OrdinalIgnoreCase))), It.IsAny())); + } + + [Theory] + [InlineData("https://localhost", "")] + [InlineData("https://graph.microsoft.com", "token")] + [InlineData("https://graph.microsoft.com/v1.0/me", "token")] + public async Task AuthenticateRequestAsyncSetsBearerHeader(string url, string expectedToken) + { + // Arrange + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())).Returns(new ValueTask(new AccessToken(expectedToken, DateTimeOffset.Now))); + var azureIdentityAuthenticationProvider = new AzureIdentityAuthenticationProvider(mockTokenCredential.Object, scopes: "User.Read"); + var testRequest = new RequestInformation() + { + HttpMethod = Method.GET, + URI = new Uri(url) + }; + Assert.Empty(testRequest.Headers); // header collection is empty + + // Act + await azureIdentityAuthenticationProvider.AuthenticateRequestAsync(testRequest); + + // Assert + if(string.IsNullOrEmpty(expectedToken)) + { + Assert.Empty(testRequest.Headers); // header collection is still empty + } + else + { + Assert.NotEmpty(testRequest.Headers); // header collection is no longer empty + Assert.Equal("Authorization", testRequest.Headers.First().Key); // First element is Auth header + Assert.Equal($"Bearer {expectedToken}", testRequest.Headers["Authorization"].First()); // First element is Auth header + } + } + + [Fact] + public async Task GetAuthorizationTokenAsyncThrowsExcpetionForNonHTTPsUrl() + { + // Arrange + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())).Returns(new ValueTask(new AccessToken(string.Empty, DateTimeOffset.Now))); + var azureIdentityAuthenticationProvider = new AzureIdentityAccessTokenProvider(mockTokenCredential.Object); + + var nonHttpsUrl = "http://graph.microsoft.com"; + + // Assert + var exception = await Assert.ThrowsAsync(() => azureIdentityAuthenticationProvider.GetAuthorizationTokenAsync(new Uri(nonHttpsUrl))); + Assert.Equal("Only https is supported", exception.Message); + } + + [Theory] + [InlineData("http://localhost/test")] + [InlineData("http://localhost:8080/test")] + [InlineData("http://127.0.0.1:8080/test")] + [InlineData("http://127.0.0.1/test")] + public async Task GetAuthorizationTokenAsyncDoesNotThrowsExcpetionForNonHTTPsUrlIfLocalHost(string nonHttpsUrl) + { + // Arrange + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())).Returns(new ValueTask(new AccessToken(string.Empty, DateTimeOffset.Now))); + var azureIdentityAuthenticationProvider = new AzureIdentityAccessTokenProvider(mockTokenCredential.Object); + + // Assert + var token = await azureIdentityAuthenticationProvider.GetAuthorizationTokenAsync(new Uri(nonHttpsUrl)); + Assert.Empty(token); + } + [Fact] + public async Task AddsClaimsToTheTokenContext() + { + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())) + .Returns((context, cToken) => { + Assert.NotNull(context.Claims); + return new ValueTask(new AccessToken(string.Empty, DateTimeOffset.Now)); + }); + var azureIdentityAuthenticationProvider = new AzureIdentityAuthenticationProvider(mockTokenCredential.Object, scopes: "User.Read"); + var testRequest = new RequestInformation() + { + HttpMethod = Method.GET, + URI = new Uri("https://graph.microsoft.com/v1.0/me") + }; + Assert.Empty(testRequest.Headers); // header collection is empty + + // Act + await azureIdentityAuthenticationProvider.AuthenticateRequestAsync(testRequest, new() { {"claims", "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTY1MjgxMzUwOCJ9fX0="}}); + mockTokenCredential.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/tests/authentication/azure/Microsoft.Kiota.Authentication.Azure.Tests.csproj b/tests/authentication/azure/Microsoft.Kiota.Authentication.Azure.Tests.csproj new file mode 100644 index 00000000..c0412b9d --- /dev/null +++ b/tests/authentication/azure/Microsoft.Kiota.Authentication.Azure.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0;net462 + latest + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + +