From 83e59c730aa4df82f01e2412339956d95842ec30 Mon Sep 17 00:00:00 2001 From: Miha Jakovac Date: Wed, 9 Dec 2020 11:50:02 +0100 Subject: [PATCH] AzureB2C, IdentityServer 4 client credential flow support * AzureB2C, IdentityServer 4 client credential flow support, refactored foundation, new tests --- Directory.Build.props | 2 +- README.md | 46 +++++- .../AzureB2C/AzureB2CAuthenticatorTests.cs | 41 +++++ .../AzureB2C/AzureB2COptionsTests.cs | 75 ++++++++++ .../AccessDeniedExceptionTests.cs} | 6 +- .../AuthenticationExceptionTests.cs} | 6 +- .../UnauthorizedClientExceptionTests.cs} | 8 +- .../IdentityServer4AuthenticatorTests.cs | 41 +++++ .../IdentityServer4OptionsTests.cs | 75 ++++++++++ .../Keycloak/KeycloakAuthenticatorTests.cs | 3 +- .../Keycloak/KeycloakOptionsTests.cs | 5 +- .../AzureB2C/AzureB2CAuthenticator.cs | 35 +++++ .../AzureB2C/AzureB2COptions.cs | 8 + .../AzureB2C/AzureB2CTokenService.cs | 8 + src/QAToolKit.Auth/DefaultOptions.cs | 51 +++++++ src/QAToolKit.Auth/DefaultTokenService.cs | 115 ++++++++++++++ .../AccessDeniedException.cs} | 6 +- .../AuthenticationException.cs} | 6 +- .../UnauthorizedClientException.cs} | 6 +- .../IdentityServer4Authenticator.cs | 35 +++++ .../IdentityServer4/IdentityServer4Options.cs | 8 + .../IdentityServer4TokenService.cs | 8 + .../Keycloak/KeycloakAuthenticator.cs | 14 +- .../Keycloak/KeycloakOptions.cs | 68 +-------- .../Keycloak/KeycloakTokenService.cs | 141 +++--------------- src/QAToolKit.Auth/QAToolKit.Auth.csproj | 2 +- 26 files changed, 604 insertions(+), 215 deletions(-) create mode 100644 src/QAToolKit.Auth.Test/AzureB2C/AzureB2CAuthenticatorTests.cs create mode 100644 src/QAToolKit.Auth.Test/AzureB2C/AzureB2COptionsTests.cs rename src/QAToolKit.Auth.Test/{Keycloak/Exceptions/KeycloakAccessDeniedExceptionTests.cs => Exceptions/AccessDeniedExceptionTests.cs} (72%) rename src/QAToolKit.Auth.Test/{Keycloak/Exceptions/KeycloakExceptionTests.cs => Exceptions/AuthenticationExceptionTests.cs} (74%) rename src/QAToolKit.Auth.Test/{Keycloak/Exceptions/KeycloakUnauthorizedClientExceptionTests.cs => Exceptions/UnauthorizedClientExceptionTests.cs} (62%) create mode 100644 src/QAToolKit.Auth.Test/IdentityServer4/IdentityServer4AuthenticatorTests.cs create mode 100644 src/QAToolKit.Auth.Test/IdentityServer4/IdentityServer4OptionsTests.cs create mode 100644 src/QAToolKit.Auth/AzureB2C/AzureB2CAuthenticator.cs create mode 100644 src/QAToolKit.Auth/AzureB2C/AzureB2COptions.cs create mode 100644 src/QAToolKit.Auth/AzureB2C/AzureB2CTokenService.cs create mode 100644 src/QAToolKit.Auth/DefaultOptions.cs create mode 100644 src/QAToolKit.Auth/DefaultTokenService.cs rename src/QAToolKit.Auth/{Keycloak/Exceptions/KeycloakAccessDeniedException.cs => Exceptions/AccessDeniedException.cs} (67%) rename src/QAToolKit.Auth/{Keycloak/Exceptions/KeycloakException.cs => Exceptions/AuthenticationException.cs} (67%) rename src/QAToolKit.Auth/{Keycloak/Exceptions/KeycloakUnauthorizedClientException.cs => Exceptions/UnauthorizedClientException.cs} (66%) create mode 100644 src/QAToolKit.Auth/IdentityServer4/IdentityServer4Authenticator.cs create mode 100644 src/QAToolKit.Auth/IdentityServer4/IdentityServer4Options.cs create mode 100644 src/QAToolKit.Auth/IdentityServer4/IdentityServer4TokenService.cs diff --git a/Directory.Build.props b/Directory.Build.props index a9e1ca3..7f8337f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 0.1.7 + 0.2.2 \ No newline at end of file diff --git a/README.md b/README.md index b35d973..af02a79 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ Currently it supports next Identity providers and Oauth2 flows: - `Keycloak`: Library supports Keycloak [client credentials flow](https://tools.ietf.org/html/rfc6749#section-4.4) or `Protection API token (PAT)` flow. Additionally you can replace the PAT with user token by exchanging the token. +- `Azure B2C`: Library supports [AzureB2C](https://azure.microsoft.com/en-us/services/active-directory/external-identities/b2c/) client credentials flow. +- `Identity Server 4`: Library supports [Identity Server 4](https://identityserver.io/) client credentials [flow](https://identityserver4.readthedocs.io/en/latest/quickstarts/1_client_credentials.html) Supported .NET frameworks and standards: `netstandard2.0`, `netstandard2.1`, `netcoreapp3.1`, `net5.0` @@ -43,7 +45,7 @@ var auth = new KeycloakAuthenticator(options => var token = await auth.GetAccessToken(); ``` -### 1.2. Client credential flow with token exchange +### 1.2. Exchange token for user token If you want to replace the PAT token with user token, you can additionally specify a username. A mocked request looks like this: @@ -63,17 +65,53 @@ var auth = new KeycloakAuthenticator(options => new Uri("https://my.keycloakserver.com/auth/realms/realmX/protocol/openid-connect/token"), "my_client", "client_secret"); - options.AddUserNameForImpersonation("myuser@users.com"); }); +//Get client credentials flow access token +var token = await auth.GetAccessToken(); +//Replace client credentials flow token for user access token +var userToken = await auth.ExchangeForUserToken("myuser@email.com"); +``` + +## 2. Identity Server 4 support + +Under the hood it's the same code that retrieves the `client credentials flow` access token, but authenticator is explicit for Identity Server 4. + +```csharp +var auth = new IdentityServer4Authenticator(options => +{ + options.AddClientCredentialFlowParameters( + new Uri("https:///token"), + "my_client" + ""); +}); + +var token = await auth.GetAccessToken(); +``` + +## 3. Azure B2C support + +Under the hood it's the same code that retrieves the `client credentials flow` access token, but authenticator is explicit for Azure B2C. + +Azure B2C client credentials flow needs a defined scope which is usually `https://graph.windows.net/.default`. + +```csharp +var auth = new AzureB2CAuthenticator(options => +{ + options.AddClientCredentialFlowParameters( + new Uri("https://login.microsoftonline.com//oauth2/v2.0/token"), + "" + "" + new string[] { "https://graph.windows.net/.default" }); +}); + var token = await auth.GetAccessToken(); ``` ## To-do - **This library is an early alpha version** -- Add more providers identity providers. -- Add more OAuth2 flows. +- Add Password flows to AzurteB2C and IdentityServer4. ## License diff --git a/src/QAToolKit.Auth.Test/AzureB2C/AzureB2CAuthenticatorTests.cs b/src/QAToolKit.Auth.Test/AzureB2C/AzureB2CAuthenticatorTests.cs new file mode 100644 index 0000000..11f4ac2 --- /dev/null +++ b/src/QAToolKit.Auth.Test/AzureB2C/AzureB2CAuthenticatorTests.cs @@ -0,0 +1,41 @@ +using NSubstitute; +using QAToolKit.Auth.AzureB2C; +using QAToolKit.Core.Interfaces; +using System; +using System.Threading.Tasks; +using Xunit; + +namespace QAToolKit.Auth.Test.AzureB2C +{ + public class AzureB2CAuthenticatorTests + { + [Fact] + public async Task CreateAuthenticatonServiceTest_Success() + { + var authenticator = Substitute.For(); + await authenticator.GetAccessToken(); + Assert.Single(authenticator.ReceivedCalls()); + } + + [Fact] + public async Task CreateAuthenticatonServiceWithReturnsTest_Success() + { + var authenticator = Substitute.For(); + authenticator.GetAccessToken().Returns(args => "12345"); + + Assert.Equal("12345", await authenticator.GetAccessToken()); + Assert.Single(authenticator.ReceivedCalls()); + } + + [Fact] + public void CreateAzureB2COptionsTest_Success() + { + var options = new AzureB2COptions(); + options.AddClientCredentialFlowParameters(new Uri("https://api.com/token"), "12345", "12345"); + + var azureB2COptions = Substitute.For>(); + azureB2COptions.Invoke(options); + Assert.Single(azureB2COptions.ReceivedCalls()); + } + } +} diff --git a/src/QAToolKit.Auth.Test/AzureB2C/AzureB2COptionsTests.cs b/src/QAToolKit.Auth.Test/AzureB2C/AzureB2COptionsTests.cs new file mode 100644 index 0000000..906f8ea --- /dev/null +++ b/src/QAToolKit.Auth.Test/AzureB2C/AzureB2COptionsTests.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Logging; +using QAToolKit.Auth.AzureB2C; +using System; +using Xunit; +using Xunit.Abstractions; + +namespace QAToolKit.Auth.Test.AzureB2C +{ + public class AzureB2COptionsTests + { + private readonly ILogger _logger; + + public AzureB2COptionsTests(ITestOutputHelper testOutputHelper) + { + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(new XunitLoggerProvider(testOutputHelper)); + _logger = loggerFactory.CreateLogger(); + } + + [Fact] + public void KeycloakOptionsTest_Successful() + { + var options = new AzureB2COptions(); + options.AddClientCredentialFlowParameters(new Uri("https://api.com/token"), "12345", "12345"); + + Assert.Equal("12345", options.ClientId); + Assert.Equal("12345", options.Secret); + Assert.Equal(new Uri("https://api.com/token"), options.TokenEndpoint); + } + + [Fact] + public void KeycloakOptionsNoImpersonationTest_Successful() + { + var options = new AzureB2COptions(); + options.AddClientCredentialFlowParameters(new Uri("https://api.com/token"), "12345", "12345"); + + Assert.Equal("12345", options.ClientId); + Assert.Equal("12345", options.Secret); + Assert.Equal(new Uri("https://api.com/token"), options.TokenEndpoint); + } + + [Theory] + [InlineData("", "")] + [InlineData(null, null)] + [InlineData(null, "test")] + [InlineData("test", null)] + public void KeycloakOptionsUriNullTest_Fails(string clientId, string clientSecret) + { + var options = new AzureB2COptions(); + Assert.Throws(() => options.AddClientCredentialFlowParameters(null, clientId, clientSecret)); + } + + [Theory] + [InlineData("", "")] + [InlineData(null, null)] + [InlineData(null, "test")] + [InlineData("test", null)] + public void KeycloakOptionsWrongUriTest_Fails(string clientId, string clientSecret) + { + var options = new AzureB2COptions(); + Assert.Throws(() => options.AddClientCredentialFlowParameters(new Uri("https"), clientId, clientSecret)); + } + + [Theory] + [InlineData("", "")] + [InlineData(null, null)] + [InlineData(null, "test")] + [InlineData("test", null)] + public void KeycloakOptionsCorrectUriTest_Fails(string clientId, string clientSecret) + { + var options = new AzureB2COptions(); + Assert.Throws(() => options.AddClientCredentialFlowParameters(new Uri("https://localhost/token"), clientId, clientSecret)); + } + } +} diff --git a/src/QAToolKit.Auth.Test/Keycloak/Exceptions/KeycloakAccessDeniedExceptionTests.cs b/src/QAToolKit.Auth.Test/Exceptions/AccessDeniedExceptionTests.cs similarity index 72% rename from src/QAToolKit.Auth.Test/Keycloak/Exceptions/KeycloakAccessDeniedExceptionTests.cs rename to src/QAToolKit.Auth.Test/Exceptions/AccessDeniedExceptionTests.cs index 217957c..43daeb1 100644 --- a/src/QAToolKit.Auth.Test/Keycloak/Exceptions/KeycloakAccessDeniedExceptionTests.cs +++ b/src/QAToolKit.Auth.Test/Exceptions/AccessDeniedExceptionTests.cs @@ -2,14 +2,14 @@ using System; using Xunit; -namespace QAToolKit.Auth.Test.Keycloak.Exceptions +namespace QAToolKit.Auth.Test.Exceptions { public class KeycloakAccessDeniedExceptionTests : Exception { [Fact] public void CreateExceptionTest_Successful() { - var exception = new KeycloakAccessDeniedException("my error"); + var exception = new AccessDeniedException("my error"); Assert.Equal("my error", exception.Message); } @@ -18,7 +18,7 @@ public void CreateExceptionTest_Successful() public void CreateExceptionWithInnerExceptionTest_Successful() { var innerException = new Exception("Inner"); - var exception = new KeycloakAccessDeniedException("my error", innerException); + var exception = new AccessDeniedException("my error", innerException); Assert.Equal("my error", exception.Message); Assert.Equal("Inner", innerException.Message); diff --git a/src/QAToolKit.Auth.Test/Keycloak/Exceptions/KeycloakExceptionTests.cs b/src/QAToolKit.Auth.Test/Exceptions/AuthenticationExceptionTests.cs similarity index 74% rename from src/QAToolKit.Auth.Test/Keycloak/Exceptions/KeycloakExceptionTests.cs rename to src/QAToolKit.Auth.Test/Exceptions/AuthenticationExceptionTests.cs index a2dcd9f..ce7066c 100644 --- a/src/QAToolKit.Auth.Test/Keycloak/Exceptions/KeycloakExceptionTests.cs +++ b/src/QAToolKit.Auth.Test/Exceptions/AuthenticationExceptionTests.cs @@ -2,14 +2,14 @@ using System; using Xunit; -namespace QAToolKit.Auth.Test.Keycloak.Exceptions +namespace QAToolKit.Auth.Test.Exceptions { public class KeycloakExceptionTests : Exception { [Fact] public void CreateExceptionTest_Successful() { - var exception = new KeycloakException("my error"); + var exception = new AuthenticationException("my error"); Assert.Equal("my error", exception.Message); } @@ -18,7 +18,7 @@ public void CreateExceptionTest_Successful() public void CreateExceptionWithInnerExceptionTest_Successful() { var innerException = new Exception("Inner"); - var exception = new KeycloakException("my error", innerException); + var exception = new AuthenticationException("my error", innerException); Assert.Equal("my error", exception.Message); Assert.Equal("Inner", innerException.Message); diff --git a/src/QAToolKit.Auth.Test/Keycloak/Exceptions/KeycloakUnauthorizedClientExceptionTests.cs b/src/QAToolKit.Auth.Test/Exceptions/UnauthorizedClientExceptionTests.cs similarity index 62% rename from src/QAToolKit.Auth.Test/Keycloak/Exceptions/KeycloakUnauthorizedClientExceptionTests.cs rename to src/QAToolKit.Auth.Test/Exceptions/UnauthorizedClientExceptionTests.cs index 6d6848d..91904e8 100644 --- a/src/QAToolKit.Auth.Test/Keycloak/Exceptions/KeycloakUnauthorizedClientExceptionTests.cs +++ b/src/QAToolKit.Auth.Test/Exceptions/UnauthorizedClientExceptionTests.cs @@ -2,14 +2,14 @@ using System; using Xunit; -namespace QAToolKit.Auth.Test.Keycloak.Exceptions +namespace QAToolKit.Auth.Test.Exceptions { - public class KeycloakUnauthorizedClientExceptionTests : Exception + public class UnauthorizedClientExceptionTests : Exception { [Fact] public void CreateExceptionTest_Successful() { - var exception = new KeycloakUnauthorizedClientException("my error"); + var exception = new UnauthorizedClientException("my error"); Assert.Equal("my error", exception.Message); } @@ -18,7 +18,7 @@ public void CreateExceptionTest_Successful() public void CreateExceptionWithInnerExceptionTest_Successful() { var innerException = new Exception("Inner"); - var exception = new KeycloakUnauthorizedClientException("my error", innerException); + var exception = new UnauthorizedClientException("my error", innerException); Assert.Equal("my error", exception.Message); Assert.Equal("Inner", innerException.Message); diff --git a/src/QAToolKit.Auth.Test/IdentityServer4/IdentityServer4AuthenticatorTests.cs b/src/QAToolKit.Auth.Test/IdentityServer4/IdentityServer4AuthenticatorTests.cs new file mode 100644 index 0000000..6ffd0eb --- /dev/null +++ b/src/QAToolKit.Auth.Test/IdentityServer4/IdentityServer4AuthenticatorTests.cs @@ -0,0 +1,41 @@ +using NSubstitute; +using QAToolKit.Auth.IdentityServer4; +using QAToolKit.Core.Interfaces; +using System; +using System.Threading.Tasks; +using Xunit; + +namespace QAToolKit.Auth.Test.IdentityServer4 +{ + public class IdentityServer4AuthenticatorTests + { + [Fact] + public async Task CreateAuthenticatonServiceTest_Success() + { + var authenticator = Substitute.For(); + await authenticator.GetAccessToken(); + Assert.Single(authenticator.ReceivedCalls()); + } + + [Fact] + public async Task CreateAuthenticatonServiceWithReturnsTest_Success() + { + var authenticator = Substitute.For(); + authenticator.GetAccessToken().Returns(args => "12345"); + + Assert.Equal("12345", await authenticator.GetAccessToken()); + Assert.Single(authenticator.ReceivedCalls()); + } + + [Fact] + public void CreateIdentityServer4OptionsTest_Success() + { + var options = new IdentityServer4Options(); + options.AddClientCredentialFlowParameters(new Uri("https://api.com/token"), "12345", "12345"); + + var id4Options = Substitute.For>(); + id4Options.Invoke(options); + Assert.Single(id4Options.ReceivedCalls()); + } + } +} diff --git a/src/QAToolKit.Auth.Test/IdentityServer4/IdentityServer4OptionsTests.cs b/src/QAToolKit.Auth.Test/IdentityServer4/IdentityServer4OptionsTests.cs new file mode 100644 index 0000000..77a5d0a --- /dev/null +++ b/src/QAToolKit.Auth.Test/IdentityServer4/IdentityServer4OptionsTests.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Logging; +using QAToolKit.Auth.IdentityServer4; +using System; +using Xunit; +using Xunit.Abstractions; + +namespace QAToolKit.Auth.Test.IdentityServer4 +{ + public class IdentityServer4OptionsTests + { + private readonly ILogger _logger; + + public IdentityServer4OptionsTests(ITestOutputHelper testOutputHelper) + { + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(new XunitLoggerProvider(testOutputHelper)); + _logger = loggerFactory.CreateLogger(); + } + + [Fact] + public void KeycloakOptionsTest_Successful() + { + var options = new IdentityServer4Options(); + options.AddClientCredentialFlowParameters(new Uri("https://api.com/token"), "12345", "12345"); + + Assert.Equal("12345", options.ClientId); + Assert.Equal("12345", options.Secret); + Assert.Equal(new Uri("https://api.com/token"), options.TokenEndpoint); + } + + [Fact] + public void KeycloakOptionsNoImpersonationTest_Successful() + { + var options = new IdentityServer4Options(); + options.AddClientCredentialFlowParameters(new Uri("https://api.com/token"), "12345", "12345"); + + Assert.Equal("12345", options.ClientId); + Assert.Equal("12345", options.Secret); + Assert.Equal(new Uri("https://api.com/token"), options.TokenEndpoint); + } + + [Theory] + [InlineData("", "")] + [InlineData(null, null)] + [InlineData(null, "test")] + [InlineData("test", null)] + public void KeycloakOptionsUriNullTest_Fails(string clientId, string clientSecret) + { + var options = new IdentityServer4Options(); + Assert.Throws(() => options.AddClientCredentialFlowParameters(null, clientId, clientSecret)); + } + + [Theory] + [InlineData("", "")] + [InlineData(null, null)] + [InlineData(null, "test")] + [InlineData("test", null)] + public void KeycloakOptionsWrongUriTest_Fails(string clientId, string clientSecret) + { + var options = new IdentityServer4Options(); + Assert.Throws(() => options.AddClientCredentialFlowParameters(new Uri("https"), clientId, clientSecret)); + } + + [Theory] + [InlineData("", "")] + [InlineData(null, null)] + [InlineData(null, "test")] + [InlineData("test", null)] + public void KeycloakOptionsCorrectUriTest_Fails(string clientId, string clientSecret) + { + var options = new IdentityServer4Options(); + Assert.Throws(() => options.AddClientCredentialFlowParameters(new Uri("https://localhost/token"), clientId, clientSecret)); + } + } +} diff --git a/src/QAToolKit.Auth.Test/Keycloak/KeycloakAuthenticatorTests.cs b/src/QAToolKit.Auth.Test/Keycloak/KeycloakAuthenticatorTests.cs index bb67fc4..f976f12 100644 --- a/src/QAToolKit.Auth.Test/Keycloak/KeycloakAuthenticatorTests.cs +++ b/src/QAToolKit.Auth.Test/Keycloak/KeycloakAuthenticatorTests.cs @@ -1,6 +1,8 @@ using NSubstitute; +using QAToolKit.Auth.Keycloak; using QAToolKit.Core.Interfaces; using System; +using System.Linq; using System.Threading.Tasks; using Xunit; @@ -31,7 +33,6 @@ public void CreateKeycloakOptionsTest_Success() { var options = new KeycloakOptions(); options.AddClientCredentialFlowParameters(new Uri("https://api.com/token"), "12345", "12345"); - options.AddUserNameForImpersonation("myemail@email.com"); var keycloakOptions = Substitute.For>(); keycloakOptions.Invoke(options); diff --git a/src/QAToolKit.Auth.Test/Keycloak/KeycloakOptionsTests.cs b/src/QAToolKit.Auth.Test/Keycloak/KeycloakOptionsTests.cs index 108b228..9ef876b 100644 --- a/src/QAToolKit.Auth.Test/Keycloak/KeycloakOptionsTests.cs +++ b/src/QAToolKit.Auth.Test/Keycloak/KeycloakOptionsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using QAToolKit.Auth.Keycloak; using System; using Xunit; using Xunit.Abstractions; @@ -21,13 +22,10 @@ public void KeycloakOptionsTest_Successful() { var options = new KeycloakOptions(); options.AddClientCredentialFlowParameters(new Uri("https://api.com/token"), "12345", "12345"); - options.AddUserNameForImpersonation("myemail@email.com"); - Assert.Equal("myemail@email.com", options.UserName); Assert.Equal("12345", options.ClientId); Assert.Equal("12345", options.Secret); Assert.Equal(new Uri("https://api.com/token"), options.TokenEndpoint); - Assert.True(options.UseImpersonation); } [Fact] @@ -39,7 +37,6 @@ public void KeycloakOptionsNoImpersonationTest_Successful() Assert.Equal("12345", options.ClientId); Assert.Equal("12345", options.Secret); Assert.Equal(new Uri("https://api.com/token"), options.TokenEndpoint); - Assert.False(options.UseImpersonation); } [Theory] diff --git a/src/QAToolKit.Auth/AzureB2C/AzureB2CAuthenticator.cs b/src/QAToolKit.Auth/AzureB2C/AzureB2CAuthenticator.cs new file mode 100644 index 0000000..f2f9822 --- /dev/null +++ b/src/QAToolKit.Auth/AzureB2C/AzureB2CAuthenticator.cs @@ -0,0 +1,35 @@ +using QAToolKit.Core.Interfaces; +using System; +using System.Threading.Tasks; + +namespace QAToolKit.Auth.AzureB2C +{ + /// + /// AzureB2C authenticator to retrieve the AccessToken for a username. + /// + public sealed class AzureB2CAuthenticator : IAuthenticationService + { + private readonly AzureB2CTokenService _azureB2CTokenService; + + /// + /// Create AzureB2C Authenticator instance. + /// + /// AzureB2C Client credential flow parameters + public AzureB2CAuthenticator(Action options) + { + var azureB2COptions = new AzureB2COptions(); + options?.Invoke(azureB2COptions); + + _azureB2CTokenService = new AzureB2CTokenService(azureB2COptions); + } + + /// + /// Get Azure B2C access token. + /// + /// + public async Task GetAccessToken() + { + return await _azureB2CTokenService.GetAccessTokenAsync(); + } + } +} diff --git a/src/QAToolKit.Auth/AzureB2C/AzureB2COptions.cs b/src/QAToolKit.Auth/AzureB2C/AzureB2COptions.cs new file mode 100644 index 0000000..f0ca88f --- /dev/null +++ b/src/QAToolKit.Auth/AzureB2C/AzureB2COptions.cs @@ -0,0 +1,8 @@ +namespace QAToolKit.Auth.AzureB2C +{ + /// + /// AzureB2C client credential flow parameters + /// + public class AzureB2COptions : DefaultOptions + { } +} diff --git a/src/QAToolKit.Auth/AzureB2C/AzureB2CTokenService.cs b/src/QAToolKit.Auth/AzureB2C/AzureB2CTokenService.cs new file mode 100644 index 0000000..d77233f --- /dev/null +++ b/src/QAToolKit.Auth/AzureB2C/AzureB2CTokenService.cs @@ -0,0 +1,8 @@ +namespace QAToolKit.Auth.AzureB2C +{ + internal class AzureB2CTokenService : DefaultTokenService + { + public AzureB2CTokenService(AzureB2COptions azureB2COptions) : base(azureB2COptions) + { } + } +} diff --git a/src/QAToolKit.Auth/DefaultOptions.cs b/src/QAToolKit.Auth/DefaultOptions.cs new file mode 100644 index 0000000..3e77e6f --- /dev/null +++ b/src/QAToolKit.Auth/DefaultOptions.cs @@ -0,0 +1,51 @@ +using System; + +namespace QAToolKit.Auth +{ + /// + /// Default auth options object + /// + public abstract class DefaultOptions + { + /// + /// Keycloak token endpoint you want to call + /// + public Uri TokenEndpoint { get; set; } + /// + /// Keycloak client ID + /// + public string ClientId { get; set; } + /// + /// Keycloak client secret + /// + public string Secret { get; set; } + /// + /// Scopes that client has access to + /// + public string[] Scopes { get; set; } = null; + + /// + /// Add client credential flow parameters + /// + /// Keycloak token endpoint + /// Keycloak client ID + /// Keycloak client secret + /// Scopes that client has access to + /// + public virtual DefaultOptions AddClientCredentialFlowParameters(Uri tokenEndpoint, string clientId, string clientSecret, string[] scopes = null) + { + if (tokenEndpoint == null) + throw new ArgumentNullException($"{nameof(tokenEndpoint)} is null."); + if (string.IsNullOrEmpty(clientId)) + throw new ArgumentNullException($"{nameof(clientId)} is null."); + if (string.IsNullOrEmpty(clientSecret)) + throw new ArgumentNullException($"{nameof(clientSecret)} is null."); + + TokenEndpoint = tokenEndpoint; + ClientId = clientId; + Secret = clientSecret; + Scopes = scopes; + return this; + } + } +} diff --git a/src/QAToolKit.Auth/DefaultTokenService.cs b/src/QAToolKit.Auth/DefaultTokenService.cs new file mode 100644 index 0000000..f3a50ae --- /dev/null +++ b/src/QAToolKit.Auth/DefaultTokenService.cs @@ -0,0 +1,115 @@ +using Newtonsoft.Json.Linq; +using QAToolKit.Auth.Exceptions; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; + +namespace QAToolKit.Auth +{ + internal abstract class DefaultTokenService + { + protected readonly HttpClient _client; + protected readonly Uri _tokenEndpoint; + protected readonly string _clientId; + protected readonly string _secret; + protected readonly string _assemblyName; + protected readonly string _assemblyVersion; + protected string _accessToken = null; + protected string _clientCredentialsToken = null; + protected string[] _scopes = null; + + internal DefaultTokenService(DefaultOptions defaultOptions) + { + _client = new HttpClient(); + + _tokenEndpoint = defaultOptions.TokenEndpoint; + _clientId = defaultOptions.ClientId; + _secret = defaultOptions.Secret; + _scopes = defaultOptions.Scopes; + + _assemblyName = typeof(DefaultTokenService).Assembly.GetName().Name; + _assemblyVersion = typeof(DefaultTokenService).Assembly.GetName().Version.ToString(); + } + + public virtual async Task GetAccessTokenAsync() + { + await GetClientCredentialsToken(); + + return _clientCredentialsToken; + } + + private async Task GetClientCredentialsToken() + { + var request = CreateBasicTokenEndpointRequest(); + + if (request == null) + return; + + var pairs = new List>() + { + new KeyValuePair("grant_type", "client_credentials"), + new KeyValuePair("client_id", _clientId), + new KeyValuePair("client_secret", _secret) + }; + + if (_scopes != null) + { + pairs.Add(new KeyValuePair("scope", string.Join(",", _scopes))); + } + + request.Content = new FormUrlEncodedContent(pairs); + + var response = await _client.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + dynamic body = JObject.Parse(await response.Content.ReadAsStringAsync()); + + _clientCredentialsToken = body.access_token; + + return; + } + + throw new UnauthorizedClientException(await response.Content.ReadAsStringAsync()); + } + + protected HttpRequestMessage CreateBasicTokenEndpointRequest() + { + var request = new HttpRequestMessage(HttpMethod.Post, _tokenEndpoint); + + SetRequestAcceptHeader(request); + SetRequestUserAgentHeader(request); + + return request; + } + + private static void SetRequestAcceptHeader(HttpRequestMessage req) + { + req.Headers.Add("Accept", "application/json"); + } + + private void SetRequestUserAgentHeader(HttpRequestMessage req) + { + req.Headers.Add("User-Agent", $"{_assemblyName}/{_assemblyVersion}"); + } + + /// + /// Dispose the object + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose object + /// + /// + protected virtual void Dispose(bool disposing) + { + _client?.Dispose(); + } + } +} diff --git a/src/QAToolKit.Auth/Keycloak/Exceptions/KeycloakAccessDeniedException.cs b/src/QAToolKit.Auth/Exceptions/AccessDeniedException.cs similarity index 67% rename from src/QAToolKit.Auth/Keycloak/Exceptions/KeycloakAccessDeniedException.cs rename to src/QAToolKit.Auth/Exceptions/AccessDeniedException.cs index 9df6b81..d76b0f8 100644 --- a/src/QAToolKit.Auth/Keycloak/Exceptions/KeycloakAccessDeniedException.cs +++ b/src/QAToolKit.Auth/Exceptions/AccessDeniedException.cs @@ -6,13 +6,13 @@ namespace QAToolKit.Auth.Exceptions /// Keycloak access denied exception /// [Serializable] - public class KeycloakAccessDeniedException : Exception + public class AccessDeniedException : Exception { /// /// Keycloak access denied exception /// /// - public KeycloakAccessDeniedException(string message) : base(message) + public AccessDeniedException(string message) : base(message) { } @@ -21,7 +21,7 @@ public KeycloakAccessDeniedException(string message) : base(message) /// /// /// - public KeycloakAccessDeniedException(string message, Exception innerException) : base(message, innerException) + public AccessDeniedException(string message, Exception innerException) : base(message, innerException) { } } diff --git a/src/QAToolKit.Auth/Keycloak/Exceptions/KeycloakException.cs b/src/QAToolKit.Auth/Exceptions/AuthenticationException.cs similarity index 67% rename from src/QAToolKit.Auth/Keycloak/Exceptions/KeycloakException.cs rename to src/QAToolKit.Auth/Exceptions/AuthenticationException.cs index d31293a..67ba980 100644 --- a/src/QAToolKit.Auth/Keycloak/Exceptions/KeycloakException.cs +++ b/src/QAToolKit.Auth/Exceptions/AuthenticationException.cs @@ -6,13 +6,13 @@ namespace QAToolKit.Auth.Exceptions /// Keycloak exception /// [Serializable] - public class KeycloakException : Exception + public class AuthenticationException : Exception { /// /// Keycloak exception /// /// - public KeycloakException(string message) : base(message) + public AuthenticationException(string message) : base(message) { } @@ -21,7 +21,7 @@ public KeycloakException(string message) : base(message) /// /// /// - public KeycloakException(string message, Exception innerException) : base(message, innerException) + public AuthenticationException(string message, Exception innerException) : base(message, innerException) { } } diff --git a/src/QAToolKit.Auth/Keycloak/Exceptions/KeycloakUnauthorizedClientException.cs b/src/QAToolKit.Auth/Exceptions/UnauthorizedClientException.cs similarity index 66% rename from src/QAToolKit.Auth/Keycloak/Exceptions/KeycloakUnauthorizedClientException.cs rename to src/QAToolKit.Auth/Exceptions/UnauthorizedClientException.cs index 6648d77..ad84851 100644 --- a/src/QAToolKit.Auth/Keycloak/Exceptions/KeycloakUnauthorizedClientException.cs +++ b/src/QAToolKit.Auth/Exceptions/UnauthorizedClientException.cs @@ -6,13 +6,13 @@ namespace QAToolKit.Auth.Exceptions /// Keycloak unauthorized client exception /// [Serializable] - public class KeycloakUnauthorizedClientException : Exception + public class UnauthorizedClientException : Exception { /// /// Keycloak unauthorized client exception /// /// - public KeycloakUnauthorizedClientException(string message) : base(message) + public UnauthorizedClientException(string message) : base(message) { } @@ -21,7 +21,7 @@ public KeycloakUnauthorizedClientException(string message) : base(message) /// /// /// - public KeycloakUnauthorizedClientException(string message, Exception innerException) : base(message, innerException) + public UnauthorizedClientException(string message, Exception innerException) : base(message, innerException) { } } diff --git a/src/QAToolKit.Auth/IdentityServer4/IdentityServer4Authenticator.cs b/src/QAToolKit.Auth/IdentityServer4/IdentityServer4Authenticator.cs new file mode 100644 index 0000000..b96273a --- /dev/null +++ b/src/QAToolKit.Auth/IdentityServer4/IdentityServer4Authenticator.cs @@ -0,0 +1,35 @@ +using QAToolKit.Core.Interfaces; +using System; +using System.Threading.Tasks; + +namespace QAToolKit.Auth.IdentityServer4 +{ + /// + /// IdentityServer4 authenticator to retrieve the AccessToken for a username. + /// + public sealed class IdentityServer4Authenticator : IAuthenticationService + { + private readonly IdentityServer4TokenService _id4TokenService; + + /// + /// Create IdentityServer4 Authenticator instance + /// + /// IdentityServer4 Client credential flow parameters + public IdentityServer4Authenticator(Action options) + { + var id4Options = new IdentityServer4Options(); + options?.Invoke(id4Options); + + _id4TokenService = new IdentityServer4TokenService(id4Options); + } + + /// + /// Get access token + /// + /// + public async Task GetAccessToken() + { + return await _id4TokenService.GetAccessTokenAsync(); + } + } +} diff --git a/src/QAToolKit.Auth/IdentityServer4/IdentityServer4Options.cs b/src/QAToolKit.Auth/IdentityServer4/IdentityServer4Options.cs new file mode 100644 index 0000000..46ed98b --- /dev/null +++ b/src/QAToolKit.Auth/IdentityServer4/IdentityServer4Options.cs @@ -0,0 +1,8 @@ +namespace QAToolKit.Auth.IdentityServer4 +{ + /// + /// IdentityServer4 client credential flow paramteers + /// + public class IdentityServer4Options : DefaultOptions + { } +} diff --git a/src/QAToolKit.Auth/IdentityServer4/IdentityServer4TokenService.cs b/src/QAToolKit.Auth/IdentityServer4/IdentityServer4TokenService.cs new file mode 100644 index 0000000..c90c9f1 --- /dev/null +++ b/src/QAToolKit.Auth/IdentityServer4/IdentityServer4TokenService.cs @@ -0,0 +1,8 @@ +namespace QAToolKit.Auth.IdentityServer4 +{ + internal class IdentityServer4TokenService : DefaultTokenService + { + public IdentityServer4TokenService(IdentityServer4Options id4Options) : base(id4Options) + { } + } +} diff --git a/src/QAToolKit.Auth/Keycloak/KeycloakAuthenticator.cs b/src/QAToolKit.Auth/Keycloak/KeycloakAuthenticator.cs index 7ede86a..4a35368 100644 --- a/src/QAToolKit.Auth/Keycloak/KeycloakAuthenticator.cs +++ b/src/QAToolKit.Auth/Keycloak/KeycloakAuthenticator.cs @@ -2,12 +2,12 @@ using System; using System.Threading.Tasks; -namespace QAToolKit.Auth +namespace QAToolKit.Auth.Keycloak { /// /// Keycloak authenticator to retrieve the AccessToken for a username. /// - public class KeycloakAuthenticator : IAuthenticationService + public sealed class KeycloakAuthenticator : IAuthenticationService { private readonly KeycloakTokenService _keycloakTokenService; @@ -31,5 +31,15 @@ public async Task GetAccessToken() { return await _keycloakTokenService.GetAccessTokenAsync(); } + + /// + /// Exchange client credentials token for user token + /// + /// User name you want the token for + /// + public async Task ExchangeForUserToken(string userName) + { + return await _keycloakTokenService.ExchangeTokenForUserToken(userName); + } } } diff --git a/src/QAToolKit.Auth/Keycloak/KeycloakOptions.cs b/src/QAToolKit.Auth/Keycloak/KeycloakOptions.cs index 7df2629..37e9c4c 100644 --- a/src/QAToolKit.Auth/Keycloak/KeycloakOptions.cs +++ b/src/QAToolKit.Auth/Keycloak/KeycloakOptions.cs @@ -1,68 +1,8 @@ -using System; - -namespace QAToolKit.Auth +namespace QAToolKit.Auth.Keycloak { /// - /// Keycloak client credential flow paramters + /// Keycloak client credential flow parameters /// - public class KeycloakOptions - { - /// - /// Keycloak token endpoint you want to call - /// - public Uri TokenEndpoint { get; set; } - /// - /// Keycloak client ID - /// - public string ClientId { get; set; } - /// - /// Keycloak client secret - /// - public string Secret { get; set; } - /// - /// Username / email of the user for which you want to retrieve the access token - /// - public string UserName { get; set; } - /// - /// If username is set use impersonation - /// - public bool UseImpersonation { get; private set; } = false; - - /// - /// Add client credential flow parameters - /// - /// Keycloak token endpoint - /// Keycloak client ID - /// Keycloak client secret - /// - public KeycloakOptions AddClientCredentialFlowParameters(Uri tokenEndpoint, string clientId, string clientSecret) - { - if (tokenEndpoint == null) - throw new ArgumentNullException($"{nameof(tokenEndpoint)} is null."); - if (string.IsNullOrEmpty(clientId)) - throw new ArgumentNullException($"{nameof(clientId)} is null."); - if (string.IsNullOrEmpty(clientSecret)) - throw new ArgumentNullException($"{nameof(clientSecret)} is null."); - - TokenEndpoint = tokenEndpoint; - ClientId = clientId; - Secret = clientSecret; - return this; - } - - /// - /// Add username for impersonation - /// - /// Username / email of the user for which you want to retrieve the access token - /// - public KeycloakOptions AddUserNameForImpersonation(string userName) - { - if (string.IsNullOrEmpty(userName)) - throw new ArgumentNullException($"{nameof(userName)} is null."); - - UserName = userName; - UseImpersonation = true; - return this; - } - } + public class KeycloakOptions : DefaultOptions + { } } diff --git a/src/QAToolKit.Auth/Keycloak/KeycloakTokenService.cs b/src/QAToolKit.Auth/Keycloak/KeycloakTokenService.cs index 70131ab..325b1ac 100644 --- a/src/QAToolKit.Auth/Keycloak/KeycloakTokenService.cs +++ b/src/QAToolKit.Auth/Keycloak/KeycloakTokenService.cs @@ -5,147 +5,50 @@ using System.Net.Http; using System.Threading.Tasks; -namespace QAToolKit.Auth +namespace QAToolKit.Auth.Keycloak { - internal class KeycloakTokenService : IDisposable + internal class KeycloakTokenService : DefaultTokenService { - private readonly HttpClient _client; - private readonly Uri _tokenEndpoint; - private readonly string _clientId; - private readonly string _secret; - private string _accessToken = null; - private readonly string _assemblyName; - private readonly string _assemblyVersion; - private readonly string _impersonatedUsername; - private readonly bool _useImpersonation; + public KeycloakTokenService(KeycloakOptions keycloakOptions) : base(keycloakOptions) + { } - public KeycloakTokenService(KeycloakOptions keycloakOptions) + internal async Task ExchangeTokenForUserToken(string userName) { - _client = new HttpClient(); + var impersonatedTokenRequest = CreateBasicTokenEndpointRequest(); - _tokenEndpoint = keycloakOptions.TokenEndpoint; - _clientId = keycloakOptions.ClientId; - _secret = keycloakOptions.Secret; - _impersonatedUsername = keycloakOptions.UserName; - _useImpersonation = keycloakOptions.UseImpersonation; + if (impersonatedTokenRequest == null) + throw new ArgumentNullException($"{impersonatedTokenRequest} is null."); - _assemblyName = typeof(KeycloakTokenService).Assembly.GetName().Name; - _assemblyVersion = typeof(KeycloakTokenService).Assembly.GetName().Version.ToString(); - - } - - public async Task GetAccessTokenAsync() - { - await PostTokenClientCredentials(); - - return _accessToken; - } - - private async Task PostTokenClientCredentials() - { - var request = CreateBasicTokenEndpointRequest(); - - if (request == null) - return; - - request.Content = new FormUrlEncodedContent(new[] + impersonatedTokenRequest.Content = new FormUrlEncodedContent(new[] { - new KeyValuePair("grant_type", "client_credentials"), new KeyValuePair("client_id", _clientId), + new KeyValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), + new KeyValuePair("subject_token", _clientCredentialsToken), + new KeyValuePair("requested_subject", userName), new KeyValuePair("client_secret", _secret) }); - var response = await _client.SendAsync(request); + var impersonatedTokenResponse = await _client.SendAsync(impersonatedTokenRequest); - if (response.IsSuccessStatusCode) - { - dynamic body = JObject.Parse(await response.Content.ReadAsStringAsync()); + var contentStr = await impersonatedTokenResponse.Content.ReadAsStringAsync(); - if (!_useImpersonation) + if (impersonatedTokenResponse.IsSuccessStatusCode) + { + if (!string.IsNullOrEmpty(contentStr)) { - _accessToken = body.access_token; + dynamic content = JObject.Parse(contentStr); + + return content.access_token; } else { - var impersonatedTokenRequest = CreateBasicTokenEndpointRequest(); - - if (impersonatedTokenRequest == null) - return; - - impersonatedTokenRequest.Content = new FormUrlEncodedContent(new[] - { - new KeyValuePair("client_id", _clientId), - new KeyValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), - new KeyValuePair("subject_token", body.access_token.ToString()), - new KeyValuePair("requested_subject", _impersonatedUsername), - new KeyValuePair("client_secret", _secret) - }); - - var impersonatedTokenResponse = await _client.SendAsync(impersonatedTokenRequest); - - var contentStr = await impersonatedTokenResponse.Content.ReadAsStringAsync(); - - if (impersonatedTokenResponse.IsSuccessStatusCode) - { - if (!string.IsNullOrEmpty(contentStr)) - { - dynamic content = JObject.Parse(contentStr); - - _accessToken = content.access_token; - } - else - { - throw new KeycloakException(contentStr); - } - } - else - { - throw new KeycloakAccessDeniedException(contentStr); - } + throw new AuthenticationException(contentStr); } } else { - throw new KeycloakUnauthorizedClientException(await response.Content.ReadAsStringAsync()); + throw new AccessDeniedException(contentStr); } } - - private HttpRequestMessage CreateBasicTokenEndpointRequest() - { - var request = new HttpRequestMessage(HttpMethod.Post, _tokenEndpoint); - - SetRequestAcceptHeader(request); - SetRequestUserAgentHeader(request); - - return request; - } - - private static void SetRequestAcceptHeader(HttpRequestMessage req) - { - req.Headers.Add("Accept", "application/json"); - } - - private void SetRequestUserAgentHeader(HttpRequestMessage req) - { - req.Headers.Add("User-Agent", $"{_assemblyName}/{_assemblyVersion}"); - } - - /// - /// Dispose the object - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Dispose object - /// - /// - protected virtual void Dispose(bool disposing) - { - _client?.Dispose(); - } } } diff --git a/src/QAToolKit.Auth/QAToolKit.Auth.csproj b/src/QAToolKit.Auth/QAToolKit.Auth.csproj index bce1496..52663b9 100644 --- a/src/QAToolKit.Auth/QAToolKit.Auth.csproj +++ b/src/QAToolKit.Auth/QAToolKit.Auth.csproj @@ -36,6 +36,6 @@ - +