From dea3490d132fbe48542961d924f619ec80926e2a Mon Sep 17 00:00:00 2001 From: Christian Prochnow Date: Thu, 22 Dec 2022 15:33:10 +0100 Subject: [PATCH 1/2] Added HttpClient user authentication using OpenID connect. --- .../samples/Web/Controllers/HomeController.cs | 11 ++ Http/samples/Web/Program.cs | 14 +- Http/samples/Web/appsettings.Development.json | 2 +- ...tpClientAuthenticationBuilderExtensions.cs | 47 ----- ...tpClientAuthenticationBuilderExtensions.cs | 101 ++++++++++ ...penIdConnectHttpClientBuilderExtensions.cs | 43 +++++ ...tions.cs => OpenIdConnectClientOptions.cs} | 2 +- ... => OpenIdConnectClientOptionsResolver.cs} | 36 ++-- .../OpenIdConnectOptionsExtensions.cs | 44 +++++ .../OpenIdConnectUserDefaults.cs | 12 ++ .../OpenIdConnectUserHandler.cs | 13 ++ .../OpenIdConnectUserOptions.cs | 8 + .../OpenIdConnectUserOptionsResolver.cs | 34 ++++ .../OpenIdConnectUserParameters.cs | 5 + .../OpenIdConnectUserTokenService.cs | 25 +++ .../OpenIdConnectUserTokenStore.cs | 94 ++++++++++ ...icationSchemeOAuthClientOptionsResolver.cs | 16 +- ...uthenticationSessionOAuthUserTokenStore.cs | 177 ++++++++++++++++++ .../IOAuthUserTokenService.cs | 42 +++++ .../IOAuthUserTokenStore.cs | 27 +++ .../OAuthUserHandler.cs | 52 +++++ .../OAuthUserOptions.cs | 31 +++ .../OAuthUserParameters.cs | 5 + .../OAuthUserToken.cs | 20 ++ .../OAuthUserTokenService.cs | 175 +++++++++++++++++ ...tpClientAuthenticationBuilderExtensions.cs | 5 +- .../IOAuthTokenClient.cs | 30 +++ .../OAuthAccessToken.cs | 2 +- .../OAuthOptions.cs | 5 + .../OAuthTokenClient.cs | 55 ++++++ .../OAuthTokenService.cs | 2 +- .../IAuthenticationSchemeHandler.cs | 2 +- 32 files changed, 1053 insertions(+), 84 deletions(-) delete mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OAuthHttpClientAuthenticationBuilderExtensions.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs rename Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/{OpenIdConnectOAuthClientOptions.cs => OpenIdConnectClientOptions.cs} (90%) rename Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/{OpenIdConnectOAuthClientOptionsResolver.cs => OpenIdConnectClientOptionsResolver.cs} (52%) create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOptionsExtensions.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserDefaults.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserHandler.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptions.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserParameters.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenService.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenStore.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSessionOAuthUserTokenStore.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenService.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenStore.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserHandler.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserOptions.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserParameters.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserToken.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserTokenService.cs diff --git a/Http/samples/Web/Controllers/HomeController.cs b/Http/samples/Web/Controllers/HomeController.cs index 98d05f2..10d58be 100644 --- a/Http/samples/Web/Controllers/HomeController.cs +++ b/Http/samples/Web/Controllers/HomeController.cs @@ -46,4 +46,15 @@ public async Task CallApiAsClient() return View("CallApi"); } + + [Authorize] + public async Task CallApiAsUser() + { + HttpClient client = _httpClientFactory.CreateClient("api-user-client"); + + string response = await client.GetStringAsync("test"); + ViewBag.Json = JsonNode.Parse(response)!.ToString(); + + return View("CallApi"); + } } \ No newline at end of file diff --git a/Http/samples/Web/Program.cs b/Http/samples/Web/Program.cs index db23c07..744bb5c 100644 --- a/Http/samples/Web/Program.cs +++ b/Http/samples/Web/Program.cs @@ -69,7 +69,8 @@ o => { o.Scope = "api"; - })); + })) + .AddOpenIdConnect(); // add HTTP client with OAuth client authentication builder.Services @@ -81,6 +82,16 @@ }) .AddOAuthClientAuthentication(); +// add HTTP client with OAuth user authentication +builder.Services + .AddHttpClient( + "api-user-client", + client => + { + client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/"); + }) + .AddOpenIdConnectAuthentication(); + WebApplication app = builder.Build(); // Configure the HTTP request pipeline. @@ -96,6 +107,7 @@ app.UseRouting(); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllerRoute( diff --git a/Http/samples/Web/appsettings.Development.json b/Http/samples/Web/appsettings.Development.json index 0c208ae..a6e86ac 100644 --- a/Http/samples/Web/appsettings.Development.json +++ b/Http/samples/Web/appsettings.Development.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Debug", "Microsoft.AspNetCore": "Warning" } } diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OAuthHttpClientAuthenticationBuilderExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OAuthHttpClientAuthenticationBuilderExtensions.cs deleted file mode 100644 index 8b38e30..0000000 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OAuthHttpClientAuthenticationBuilderExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Licensed under the MIT License. -// Copyright (c) 2018-2022 the AppCore .NET project. - -using System; -using AppCore.Diagnostics; -using AppCore.Extensions.Http.Authentication.OAuth; -using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; -using Microsoft.Extensions.DependencyInjection.Extensions; - -// ReSharper disable once CheckNamespace -namespace Microsoft.Extensions.DependencyInjection; - -/// -/// Provides extension methods to register OAuth authentication. -/// -public static class OAuthHttpClientAuthenticationBuilderExtensions -{ - /// - /// Adds OAuth client credentials authentication scheme by inferring the configuration from a OpenID connect - /// authentication scheme. - /// - /// The . - /// - /// - public static void OpenIdConnect( - this IOAuthClientFromAuthenticationSchemeBuilder builder, - Action? configure = null) - { - Ensure.Arg.NotNull(builder); - - IServiceCollection services = builder.Services; - - services.AddHttpClientAuthentication() - .AddScheme< - OpenIdConnectOAuthClientOptions, - OAuthParameters, - OAuthClientHandler>(builder.Scheme, configure); - - services.TryAddEnumerable( - new[] - { - ServiceDescriptor - .Transient(), - }); - } -} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs new file mode 100644 index 0000000..2ab363e --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs @@ -0,0 +1,101 @@ +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using System; +using AppCore.Diagnostics; +using AppCore.Extensions.Http.Authentication.OAuth; +using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; +using Microsoft.Extensions.DependencyInjection.Extensions; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides extension methods to register OAuth authentication. +/// +public static class OpenIdConnectHttpClientAuthenticationBuilderExtensions +{ + /// + /// Adds OAuth client credentials authentication scheme by inferring the configuration from a OpenID connect + /// authentication scheme. + /// + /// The . + /// + /// + public static void OpenIdConnect( + this IOAuthClientFromAuthenticationSchemeBuilder builder, + Action? configure = null) + { + Ensure.Arg.NotNull(builder); + + IServiceCollection services = builder.Services; + + services.AddHttpClientAuthentication() + .AddScheme< + OpenIdConnectClientOptions, + OAuthParameters, + OAuthClientHandler>(builder.Scheme, configure); + + services.TryAddEnumerable( + new[] + { + ServiceDescriptor + .Transient(), + }); + } + + /// + /// Adds OAuth user authentication by using authentication tokens from a ASP.NET Core + /// OpenID connect authentication scheme. + /// + /// The . + /// The name of the client authentication scheme. + /// + /// + public static IHttpClientAuthenticationBuilder AddOpenIdConnect( + this IHttpClientAuthenticationBuilder builder, + string scheme, + Action? configure = null) + { + Ensure.Arg.NotNull(builder); + + IServiceCollection services = builder.Services; + + services.AddHttpContextAccessor(); + + services.AddHttpClientAuthentication() + .AddScheme< + OpenIdConnectUserOptions, + OpenIdConnectUserParameters, + OpenIdConnectUserHandler>(scheme, configure); + + services.TryAddEnumerable( + new[] + { + ServiceDescriptor.Transient(), + }); + + services.TryAdd(new[] + { + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped() + }); + + return builder; + } + + /// + /// Adds OAuth user authentication by using authentication tokens from a ASP.NET Core + /// OpenID connect authentication scheme. + /// + /// The . + /// + /// + public static IHttpClientAuthenticationBuilder AddOpenIdConnect( + this IHttpClientAuthenticationBuilder builder, + Action? configure = null) + { + return AddOpenIdConnect(builder, OpenIdConnectUserDefaults.AuthenticationScheme, configure); + } +} diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs new file mode 100644 index 0000000..d020f32 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs @@ -0,0 +1,43 @@ +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using AppCore.Diagnostics; +using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides extension methods to add OAuth authentication to a HttpClient. +/// +public static class OpenIdConnectHttpClientBuilderExtensions +{ + /// + /// Adds OpenID connect authentications. + /// + /// The . + /// The name of the client authentication scheme. + /// + /// + public static IHttpClientBuilder AddOpenIdConnectAuthentication( + this IHttpClientBuilder builder, + string scheme, + OpenIdConnectUserParameters? parameters = null) + { + Ensure.Arg.NotNull(builder); + return builder.AddAuthentication(scheme, parameters); + } + + /// + /// Adds OpenID connect authentications with the default scheme. + /// + /// The . + /// + /// + public static IHttpClientBuilder AddOpenIdConnectAuthentication( + this IHttpClientBuilder builder, + OpenIdConnectUserParameters? parameters = null) + { + return builder.AddOpenIdConnectAuthentication(OpenIdConnectUserDefaults.AuthenticationScheme, parameters); + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOAuthClientOptions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptions.cs similarity index 90% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOAuthClientOptions.cs rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptions.cs index 0f17815..88943e6 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOAuthClientOptions.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptions.cs @@ -8,7 +8,7 @@ namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; /// /// Provides the options how to derive from . /// -public class OpenIdConnectOAuthClientOptions : AuthenticationSchemeOAuthClientOptions +public class OpenIdConnectClientOptions : AuthenticationSchemeOAuthClientOptions { /// /// Scope values as space separated list to use when client configuration is inferred from OpenID Connect scheme. diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOAuthClientOptionsResolver.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs similarity index 52% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOAuthClientOptionsResolver.cs rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs index be17e20..8fcb721 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOAuthClientOptionsResolver.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs @@ -1,51 +1,37 @@ // Licensed under the MIT License. // Copyright (c) 2018-2022 the AppCore .NET project. -using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; -internal sealed class OpenIdConnectOAuthClientOptionsResolver +internal sealed class OpenIdConnectClientOptionsResolver : AuthenticationSchemeOAuthClientOptionsResolver< - OpenIdConnectOAuthClientOptions, + OpenIdConnectClientOptions, OpenIdConnectOptions, OpenIdConnectHandler > { - public OpenIdConnectOAuthClientOptionsResolver( + public OpenIdConnectClientOptionsResolver( Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider authenticationSchemeProvider, - IOptionsMonitor clientOptions, + IOptionsMonitor clientOptions, IOptionsMonitor authenticationSchemeOptions) : base(authenticationSchemeProvider, authenticationSchemeOptions, clientOptions) { } + protected override string? GetSchemeName(OpenIdConnectClientOptions options) + { + return options.Scheme; + } + protected override async Task GetOptionsFromSchemeAsync( - OpenIdConnectOAuthClientOptions clientOptions, + OpenIdConnectClientOptions clientOptions, OpenIdConnectOptions oidcOptions) { - OpenIdConnectConfiguration oidcConfig; - try - { - oidcConfig = await oidcOptions.ConfigurationManager!.GetConfigurationAsync(default) - .ConfigureAwait(false); - } - catch (Exception e) - { - throw new InvalidOperationException( - $"Unable to load OpenID configuration for configured scheme: {e.Message}"); - } - - var result = new OAuthClientOptions - { - TokenEndpoint = new Uri(oidcConfig.TokenEndpoint), - ClientId = oidcOptions.ClientId, - ClientSecret = oidcOptions.ClientSecret - }; + OAuthClientOptions result = await oidcOptions.GetOAuthClientOptionsAsync(default); if (!string.IsNullOrWhiteSpace(clientOptions.Scope)) { diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOptionsExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOptionsExtensions.cs new file mode 100644 index 0000000..40e9383 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOptionsExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using IdentityModel; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; + +internal static class OpenIdConnectOptionsExtensions +{ + public static async Task GetOAuthClientOptionsAsync( + this OpenIdConnectOptions options, + CancellationToken cancellationToken = default) + { + OpenIdConnectConfiguration oidcConfig; + try + { + oidcConfig = await options.ConfigurationManager!.GetConfigurationAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) + { + throw new InvalidOperationException( + $"Unable to load OpenID configuration for configured scheme: {e.Message}"); + } + + string? tokenRevocationEndpoint = oidcConfig.AdditionalData.TryGetValue( + OidcConstants.Discovery.RevocationEndpoint, + out object? value) + ? value?.ToString() + : null; + + var result = new OAuthClientOptions + { + TokenEndpoint = new Uri(oidcConfig.TokenEndpoint), + TokenRevocationEndpoint = tokenRevocationEndpoint != null ? new Uri(tokenRevocationEndpoint) : null, + ClientId = options.ClientId, + ClientSecret = options.ClientSecret + }; + + return result; + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserDefaults.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserDefaults.cs new file mode 100644 index 0000000..6f7b041 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserDefaults.cs @@ -0,0 +1,12 @@ +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; + +/// +/// Provides default values for the OpenID Connect authentication. +/// +public class OpenIdConnectUserDefaults +{ + /// + /// The default value used for OpenID Connect scheme name. + /// + public const string AuthenticationScheme = "OpenIdConnect"; +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserHandler.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserHandler.cs new file mode 100644 index 0000000..eabc9d7 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserHandler.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Http; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; + +public class OpenIdConnectUserHandler : OAuthUserHandler +{ + public OpenIdConnectUserHandler( + IHttpContextAccessor httpContextAccessor, + OpenIdConnectUserTokenService authTokenService) + : base(httpContextAccessor, authTokenService) + { + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptions.cs new file mode 100644 index 0000000..52b7967 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptions.cs @@ -0,0 +1,8 @@ +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; + +/// +/// Provides options for OAuth user authentication. +/// +public class OpenIdConnectUserOptions : OAuthUserOptions +{ +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs new file mode 100644 index 0000000..4c6e448 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Options; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; + +internal sealed class OpenIdConnectUserOptionsResolver + : AuthenticationSchemeOAuthClientOptionsResolver< + OpenIdConnectUserOptions, + OpenIdConnectOptions, + OpenIdConnectHandler + > +{ + public OpenIdConnectUserOptionsResolver( + Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider authenticationSchemeProvider, + IOptionsMonitor clientOptions, + IOptionsMonitor authenticationSchemeOptions) + : base(authenticationSchemeProvider, authenticationSchemeOptions, clientOptions) + { + } + + protected override string? GetSchemeName(OpenIdConnectUserOptions options) + { + return options.ChallengeScheme; + } + + protected override async Task GetOptionsFromSchemeAsync( + OpenIdConnectUserOptions clientOptions, + OpenIdConnectOptions oidcOptions) + { + OAuthClientOptions result = await oidcOptions.GetOAuthClientOptionsAsync(default); + return result; + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserParameters.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserParameters.cs new file mode 100644 index 0000000..06c679e --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserParameters.cs @@ -0,0 +1,5 @@ +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; + +public class OpenIdConnectUserParameters : OAuthUserParameters +{ +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenService.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenService.cs new file mode 100644 index 0000000..1d02123 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenService.cs @@ -0,0 +1,25 @@ +using System; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; + +public class OpenIdConnectUserTokenService : OAuthUserTokenService +{ + public OpenIdConnectUserTokenService( + IOAuthTokenClient client, + OpenIdConnectUserTokenStore store, + ISystemClock clock, + IOptionsMonitor optionsMonitor, + ILogger logger) + : base(client, store, clock, optionsMonitor, logger) + { + } + + protected override void EnsureCompatibleScheme(AuthenticationScheme scheme) + { + if (!typeof(OpenIdConnectUserHandler).IsAssignableFrom(scheme.HandlerType)) + throw new InvalidOperationException($"The client authentication scheme {scheme.Name} is not registered for the OpenID Connect user handler."); + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenStore.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenStore.cs new file mode 100644 index 0000000..b8a7fbb --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenStore.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Authentication; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; + +public class OpenIdConnectUserTokenStore : AuthenticationSessionOAuthUserTokenStore +{ + public OpenIdConnectUserTokenStore( + IHttpContextAccessor httpContextAccessor, + IOptionsMonitor optionsMonitor, + ILogger logger) + : base(httpContextAccessor, optionsMonitor, logger) + { + } + + protected override void EnsureCompatibleScheme(AuthenticationScheme scheme) + { + if (!typeof(OpenIdConnectUserHandler).IsAssignableFrom(scheme.HandlerType)) + throw new InvalidOperationException($"The client authentication scheme {scheme.Name} is not registered for the OpenID Connect user handler."); + } + + protected override void SetUserToken( + ClaimsPrincipal principal, + AuthenticationProperties properties, + OAuthUserToken token, + OpenIdConnectUserOptions options) + { + List tokens = new(3) + { + new AuthenticationToken { Name = OpenIdConnectParameterNames.AccessToken, Value = token.AccessToken } + }; + + if (!string.IsNullOrWhiteSpace(token.RefreshToken)) + { + tokens.Add( + new AuthenticationToken + { Name = OpenIdConnectParameterNames.RefreshToken, Value = token.RefreshToken }); + } + + if (token.Expires.HasValue) + { + tokens.Add( + new AuthenticationToken + { + Name = "expires_at", + Value = ((DateTimeOffset)token.Expires).ToString("o", CultureInfo.InvariantCulture) + }); + } + + properties.StoreTokens(tokens); + } + + protected override OAuthUserToken GetUserToken( + ClaimsPrincipal principal, + AuthenticationProperties properties, + OpenIdConnectUserOptions options) + { + AuthenticationToken[] tokens = properties.GetTokens() + .ToArray(); + + if (tokens.Length == 0) + throw new AuthenticationException( + "No tokens found in authentication properties. SaveTokens must be enabled."); + + string? GetTokenValue(string tokenName) + { + return tokens.FirstOrDefault(t => string.Equals(t.Name, tokenName, StringComparison.OrdinalIgnoreCase)) + ?.Value; + } + + string? accessToken = GetTokenValue(OpenIdConnectParameterNames.AccessToken); + string? refreshToken = GetTokenValue(OpenIdConnectParameterNames.RefreshToken); + string? expiresAt = GetTokenValue("expires_at"); + + if (string.IsNullOrWhiteSpace(accessToken)) + throw new AuthenticationException("No access token found in authentication properties."); + + return new OAuthUserToken( + accessToken, + refreshToken, + expiresAt != null + ? DateTimeOffset.Parse(expiresAt, CultureInfo.InvariantCulture) + : null); + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSchemeOAuthClientOptionsResolver.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSchemeOAuthClientOptionsResolver.cs index df4f3d7..34eb7cf 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSchemeOAuthClientOptionsResolver.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSchemeOAuthClientOptionsResolver.cs @@ -14,11 +14,11 @@ namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; /// /// Provides the base class for resolving OAuth client authentication options from ASP.NET Core authentication schemes. /// -/// The type of the . +/// The type of the . /// The type of the . /// The type of the . public abstract class AuthenticationSchemeOAuthClientOptionsResolver : IOAuthOptionsResolver - where TClientOptions : AuthenticationSchemeOAuthClientOptions + where TClientOptions : AuthenticationSchemeOptions where TOptions : RemoteAuthenticationOptions where THandler : IAuthenticationHandler { @@ -56,12 +56,13 @@ protected AuthenticationSchemeOAuthClientOptionsResolver( && typeof(T) == typeof(OAuthClientOptions)) { TClientOptions clientOptions = _clientOptions.Get(scheme.Name); + string? schemeName = GetSchemeName(clientOptions); Microsoft.AspNetCore.Authentication.AuthenticationScheme? authenticationScheme = - string.IsNullOrWhiteSpace(clientOptions.Scheme) + string.IsNullOrWhiteSpace(schemeName) ? await _authenticationSchemeProvider.GetDefaultChallengeSchemeAsync() .ConfigureAwait(false) - : await _authenticationSchemeProvider.GetSchemeAsync(clientOptions.Scheme) + : await _authenticationSchemeProvider.GetSchemeAsync(schemeName) .ConfigureAwait(false); if (authenticationScheme is null) @@ -86,6 +87,13 @@ protected AuthenticationSchemeOAuthClientOptionsResolver( return result; } + /// + /// Gets the name of the authentication scheme. + /// + /// + /// + protected abstract string? GetSchemeName(TClientOptions options); + /// /// Must be implemented to resolve the from the authentication scheme options. /// diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSessionOAuthUserTokenStore.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSessionOAuthUserTokenStore.cs new file mode 100644 index 0000000..273de3e --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSessionOAuthUserTokenStore.cs @@ -0,0 +1,177 @@ +using System.Collections.Generic; +using System.Security.Authentication; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using AppCore.Diagnostics; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +public abstract class AuthenticationSessionOAuthUserTokenStore : IOAuthUserTokenStore + where TOptions : OAuthUserOptions +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOptionsMonitor _optionsMonitor; + private readonly ILogger _logger; + + // per-request cache so that if SignInAsync is used, we won't re-read the old/cached AuthenticateResult from the handler + // this requires this service to be added as scoped to the DI system + private readonly Dictionary _cache = new(); + + protected AuthenticationSessionOAuthUserTokenStore( + IHttpContextAccessor httpContextAccessor, + IOptionsMonitor optionsMonitor, + ILogger logger) + { + Ensure.Arg.NotNull(httpContextAccessor); + Ensure.Arg.NotNull(optionsMonitor); + Ensure.Arg.NotNull(logger); + + _httpContextAccessor = httpContextAccessor; + _optionsMonitor = optionsMonitor; + _logger = logger; + } + + private HttpContext GetHttpContext() + { + HttpContext? httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null) + throw new AuthenticationException("No HttpContext found."); + + return httpContext; + } + + private async Task GetSignInScheme(HttpContext context, TOptions options) + { + string? scheme = string.IsNullOrWhiteSpace(options.SignInScheme) + ? options.SignInScheme + : null; + + if (scheme == null) + { + var schemeProvider = context.RequestServices + .GetRequiredService(); + + scheme = (await schemeProvider.GetDefaultSignInSchemeAsync())?.Name; + } + + if (scheme == null) + throw new AuthenticationException("There is no default sign-in scheme configured for ASP.NET Core authentication."); + + return scheme; + } + + private async Task TryAuthenticateAsync(HttpContext httpContext, string signInScheme, TOptions options) + { + // check the cache in case the token was re-issued via StoreTokenAsync + if (!_cache.TryGetValue(signInScheme, out AuthenticateResult? result)) + { + result = await httpContext.AuthenticateAsync(signInScheme); + } + + if (!result.Succeeded) + { + _logger.LogError("Cannot authenticate scheme: {schemeName}", signInScheme); + return null; + } + + if (result.Properties == null) + { + _logger.LogError("Authentication result properties are null for scheme: {schemeName}", + signInScheme); + + return null; + } + + return result; + } + + protected abstract void EnsureCompatibleScheme(AuthenticationScheme scheme); + + public async Task StoreTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserToken token, + OAuthUserParameters? parameters = null, + CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(scheme); + Ensure.Arg.NotNull(user); + Ensure.Arg.NotNull(token); + + EnsureCompatibleScheme(scheme); + + HttpContext httpContext = GetHttpContext(); + TOptions options = _optionsMonitor.Get(scheme.Name); + string signInScheme = await GetSignInScheme(httpContext, options); + + AuthenticateResult? result = await TryAuthenticateAsync(httpContext, signInScheme, options); + if (result == null) + throw new AuthenticationException("User is not authenticated, cannot store tokens."); + + ClaimsPrincipal principal = result.Principal!; + SetUserToken(principal, result.Properties!, token, options); + + if (result.Properties!.AllowRefresh.GetValueOrDefault(true)) + { + result.Properties.IssuedUtc = null; + result.Properties.ExpiresUtc = null; + } + + await httpContext.SignInAsync(signInScheme, principal, result.Properties); + _cache[signInScheme] = AuthenticateResult.Success(new AuthenticationTicket(principal, result.Properties, signInScheme)); + } + + protected abstract void SetUserToken( + ClaimsPrincipal principal, + AuthenticationProperties properties, + OAuthUserToken token, + TOptions options); + + public async Task GetTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserParameters? parameters = null, + CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(scheme); + Ensure.Arg.NotNull(user); + + EnsureCompatibleScheme(scheme); + + HttpContext httpContext = GetHttpContext(); + TOptions options = _optionsMonitor.Get(scheme.Name); + string signInScheme = await GetSignInScheme(httpContext, options); + + AuthenticateResult? result = await TryAuthenticateAsync(httpContext, signInScheme, options); + if (result == null) + throw new AuthenticationException("User is not authenticated, cannot get tokens."); + + return GetUserToken(result.Principal!, result.Properties!, options); + } + + protected abstract OAuthUserToken GetUserToken( + ClaimsPrincipal principal, + AuthenticationProperties properties, + TOptions options); + + public Task ClearTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserParameters? parameters = null, + CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(scheme); + Ensure.Arg.NotNull(user); + + EnsureCompatibleScheme(scheme); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenService.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenService.cs new file mode 100644 index 0000000..5cb15d6 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenService.cs @@ -0,0 +1,42 @@ +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +/// +/// Abstraction for the OAuth user token service. +/// +public interface IOAuthUserTokenService +{ + /// + /// Gets the access token for the specified scheme and user. If the access token is expired + /// it will be automatically refreshed, if permitted. + /// + /// The . + /// The user. + /// Optional parameters. + /// + /// + Task GetAccessTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserParameters? parameters = null, + CancellationToken cancellationToken = default); + + /// + /// Revokes the refresh token for the specified scheme and user. + /// + /// The . + /// The user. + /// Optional parameters. + /// + Task RevokeRefreshTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserParameters? parameters = null, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenStore.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenStore.cs new file mode 100644 index 0000000..6710be7 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenStore.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +public interface IOAuthUserTokenStore +{ + Task StoreTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserToken token, + OAuthUserParameters? parameters = null, + CancellationToken cancellationToken = default); + + Task GetTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserParameters? parameters = null, + CancellationToken cancellationToken = default); + + Task ClearTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserParameters? parameters = null, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserHandler.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserHandler.cs new file mode 100644 index 0000000..1aae3dc --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserHandler.cs @@ -0,0 +1,52 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Authentication; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using AppCore.Diagnostics; +using Microsoft.AspNetCore.Http; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +/// +/// Provides a OAuth user authentication handler. +/// +public abstract class OAuthUserHandler : IAuthenticationSchemeHandler + where TParameters : OAuthUserParameters +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOAuthUserTokenService _authTokenService; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + protected OAuthUserHandler(IHttpContextAccessor httpContextAccessor, IOAuthUserTokenService authTokenService) + { + Ensure.Arg.NotNull(httpContextAccessor); + Ensure.Arg.NotNull(authTokenService); + + _httpContextAccessor = httpContextAccessor; + _authTokenService = authTokenService; + } + + /// + public async Task AuthenticateAsync( + AuthenticationScheme scheme, + TParameters? parameters, + HttpRequestMessage request, + CancellationToken cancellationToken = default) + { + ClaimsPrincipal? user = _httpContextAccessor.HttpContext?.User; + if (user == null) + throw new AuthenticationException("User is not authenticated."); + + OAuthAccessToken accessToken = + await _authTokenService.GetAccessTokenAsync(scheme, user, parameters, cancellationToken) + .ConfigureAwait(false); + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.AccessToken); + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserOptions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserOptions.cs new file mode 100644 index 0000000..c0c78f8 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserOptions.cs @@ -0,0 +1,31 @@ +using System; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +/// +/// Provides options for HTTP client OAuth user authentication. +/// +public abstract class OAuthUserOptions : AuthenticationSchemeOptions +{ + /// + /// Value to subtract from access token lifetime when testing for expiration. + /// + public TimeSpan RefreshBeforeExpiration { get; set; } = TimeSpan.FromSeconds(3); + + /// + /// Gets or sets a value whether to refresh the access token when it has expired. + /// + public bool AllowTokenRefresh { get; set; } = true; + + /// + /// Sets the scheme name of the authentication handler from which the user authentication token is being + /// fetched. This will fallback to the default sign-in scheme if left empty. + /// + public string? SignInScheme { get; set; } + + /// + /// Sets the scheme name of the authentication handler from which the token client configuration is + /// fetched. This will fallback to the default challenge scheme if left empty. + /// + public string? ChallengeScheme { get; set; } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserParameters.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserParameters.cs new file mode 100644 index 0000000..7c57e7f --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserParameters.cs @@ -0,0 +1,5 @@ +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +public class OAuthUserParameters : OAuthParameters +{ +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserToken.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserToken.cs new file mode 100644 index 0000000..222a11e --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserToken.cs @@ -0,0 +1,20 @@ +using System; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +/// +/// Represents a user OAuth token. +/// +public class OAuthUserToken : OAuthAccessToken +{ + /// + /// Gets the refresh token. + /// + public string? RefreshToken { get; } + + public OAuthUserToken(string accessToken, string? refreshToken, DateTimeOffset? expires) + : base(accessToken, expires) + { + RefreshToken = refreshToken; + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserTokenService.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserTokenService.cs new file mode 100644 index 0000000..3bb7bed --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserTokenService.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Concurrent; +using System.Security.Authentication; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using AppCore.Diagnostics; +using IdentityModel; +using IdentityModel.Client; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +public abstract class OAuthUserTokenService : IOAuthUserTokenService + where TOptions : OAuthUserOptions +{ + private static readonly ConcurrentDictionary>> _sync = new(); + private readonly IOAuthTokenClient _client; + private readonly IOAuthUserTokenStore _store; + private readonly ISystemClock _clock; + private readonly IOptionsMonitor _optionsMonitor; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// + /// + protected OAuthUserTokenService( + IOAuthTokenClient client, + IOAuthUserTokenStore store, + ISystemClock clock, + IOptionsMonitor optionsMonitor, + ILogger logger) + { + Ensure.Arg.NotNull(client); + Ensure.Arg.NotNull(store); + Ensure.Arg.NotNull(clock); + Ensure.Arg.NotNull(optionsMonitor); + Ensure.Arg.NotNull(logger); + + _client = client; + _store = store; + _clock = clock; + _optionsMonitor = optionsMonitor; + _logger = logger; + } + + protected abstract void EnsureCompatibleScheme(AuthenticationScheme scheme); + + private async Task InvokeSynchronized(string key, Func> tokenFunc) + { + try + { + return await _sync.GetOrAdd( + key, + _ => new Lazy>(tokenFunc)) + .Value.ConfigureAwait(false); + } + finally + { + _sync.TryRemove(key, out _); + } + } + + /// + public async Task GetAccessTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserParameters? parameters = null, + CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(scheme); + Ensure.Arg.NotNull(user); + + EnsureCompatibleScheme(scheme); + + OAuthUserOptions options = _optionsMonitor.Get(scheme.Name); + OAuthUserToken token = await _store.GetTokenAsync(scheme, user, parameters, cancellationToken); + + if (!options.AllowTokenRefresh) + return token; + + DateTimeOffset? refreshAt = token.Expires?.Subtract(options.RefreshBeforeExpiration); + if (refreshAt.HasValue + && refreshAt < _clock.UtcNow + || (parameters?.ForceRenewal).GetValueOrDefault()) + { + if (string.IsNullOrWhiteSpace(token.RefreshToken)) + throw new AuthenticationException("Cannot refresh access token because no refresh token was found for user."); + + return await InvokeSynchronized( + token.RefreshToken, + async () => + { + _logger.LogDebug("Refreshing access token for client scheme {schemeName} ...", scheme.Name); + + TokenResponse response = + await _client.RequestRefreshTokenAsync( + scheme, + token.RefreshToken, + parameters, + cancellationToken) + .ConfigureAwait(false); + + if (response.IsError) + { + _logger.LogError( + "Error refreshing access token for client scheme {schemeName}. Error = {error}. Error description = {errorDescription}", + scheme.Name, + response.Error, + response.ErrorDescription); + + throw new AuthenticationException( + $"Error refreshing access token for client scheme '{scheme.Name}': {response.Error}"); + } + + OAuthUserToken refreshedToken = new( + response.AccessToken, + response.RefreshToken, + response.ExpiresIn > 0 + ? DateTimeOffset.UtcNow + TimeSpan.FromSeconds(response.ExpiresIn) + : null); + + _logger.LogDebug( + "Refreshed access token for client scheme {schemeName}. Expiration: {expiration}", + scheme.Name, + refreshedToken.Expires); + + await _store.StoreTokenAsync(scheme, user, refreshedToken, parameters, cancellationToken) + .ConfigureAwait(false); + + return refreshedToken; + }) + .ConfigureAwait(false); + } + + return token; + } + + /// + public async Task RevokeRefreshTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserParameters? parameters = null, + CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(scheme); + Ensure.Arg.NotNull(user); + + EnsureCompatibleScheme(scheme); + + OAuthUserToken token = await _store.GetTokenAsync(scheme, user, parameters, cancellationToken) + .ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(token.RefreshToken)) + { + await _client.RevokeTokenAsync( + scheme, + token.RefreshToken, + OidcConstants.TokenTypeIdentifiers.RefreshToken, + parameters, + cancellationToken) + .ConfigureAwait(false); + + await _store.ClearTokenAsync(scheme, user, parameters, cancellationToken) + .ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/DependencyInjection/OAuthHttpClientAuthenticationBuilderExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/DependencyInjection/OAuthHttpClientAuthenticationBuilderExtensions.cs index 3d2d29d..48c3d0e 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/DependencyInjection/OAuthHttpClientAuthenticationBuilderExtensions.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/DependencyInjection/OAuthHttpClientAuthenticationBuilderExtensions.cs @@ -29,9 +29,10 @@ public static IHttpClientAuthenticationBuilder AddOAuthCore(this IHttpClientAuth services.AddDistributedMemoryCache(); services.AddOptions(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddTransient(); - services.TryAddTransient(); - services.TryAddTransient(); services.AddHttpClient(); return builder; diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/IOAuthTokenClient.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/IOAuthTokenClient.cs index 88af05c..73c3bc2 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/IOAuthTokenClient.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/IOAuthTokenClient.cs @@ -35,4 +35,34 @@ Task RequestPasswordAccessToken( AuthenticationScheme scheme, OAuthParameters? parameters = null, CancellationToken cancellationToken = default); + + /// + /// Requests a token refresh. + /// + /// The . + /// The refresh token. + /// The . + /// A . + /// + Task RequestRefreshTokenAsync( + AuthenticationScheme scheme, + string refreshToken, + OAuthParameters? parameters = null, + CancellationToken cancellationToken = default); + + /// + /// Revokes a token. + /// + /// The . + /// The token to revoke. + /// A hint for the type of the token. + /// The . + /// A . + /// + Task RevokeTokenAsync( + AuthenticationScheme scheme, + string token, + string tokenTypeHint, + OAuthParameters? parameters = null, + CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthAccessToken.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthAccessToken.cs index 3e99fb9..65061ca 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthAccessToken.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthAccessToken.cs @@ -9,7 +9,7 @@ namespace AppCore.Extensions.Http.Authentication.OAuth; /// /// Represents a OAuth access token. /// -public sealed class OAuthAccessToken +public class OAuthAccessToken { /// /// Gets the access token. diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthOptions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthOptions.cs index e2fc1ad..82b3609 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthOptions.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthOptions.cs @@ -17,6 +17,11 @@ public abstract class OAuthOptions : AuthenticationSchemeOptions /// public Uri? TokenEndpoint { get; set; } + /// + /// Gets or sets the URL of the OAuth token revocation endpoint. + /// + public Uri? TokenRevocationEndpoint { get; set; } + /// /// Gets or sets the client_id. /// diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenClient.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenClient.cs index e48666b..ce9af0f 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenClient.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenClient.cs @@ -89,4 +89,59 @@ await _optionsProvider.GetOptionsAsync(scheme) return await _client.RequestPasswordTokenAsync(request, cancellationToken) .ConfigureAwait(false); } + + /// + public async Task RequestRefreshTokenAsync( + AuthenticationScheme scheme, + string refreshToken, + OAuthParameters? parameters = null, + CancellationToken cancellationToken = default) + { + Ensure.Arg.NotEmpty(refreshToken); + + OAuthClientOptions options = + await _optionsProvider.GetOptionsAsync(scheme) + .ConfigureAwait(false); + + var request = new RefreshTokenRequest + { + RequestUri = options.TokenEndpoint, + ClientId = options.ClientId, + ClientSecret = options.ClientSecret, + ClientCredentialStyle = options.ClientCredentialStyle, + RefreshToken = refreshToken + }; + + return await _client.RequestRefreshTokenAsync(request, cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task RevokeTokenAsync( + AuthenticationScheme scheme, + string token, + string tokenTypeHint, + OAuthParameters? parameters = null, + CancellationToken cancellationToken = default) + { + Ensure.Arg.NotEmpty(token); + Ensure.Arg.NotEmptyButNull(tokenTypeHint); + + OAuthClientOptions options = + await _optionsProvider.GetOptionsAsync(scheme) + .ConfigureAwait(false); + + var request = new TokenRevocationRequest + { + RequestUri = options.TokenEndpoint, + ClientId = options.ClientId, + ClientSecret = options.ClientSecret, + ClientCredentialStyle = options.ClientCredentialStyle, + Token = token, + TokenTypeHint = tokenTypeHint + }; + + return await _client.RevokeTokenAsync(request, cancellationToken) + .ConfigureAwait(false); + } } \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenService.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenService.cs index ba84050..6cc38a6 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenService.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenService.cs @@ -95,7 +95,7 @@ await _client.RequestClientAccessToken(scheme, parameters, cancellationToken) response.ErrorDescription); throw new AuthenticationException( - $"Error requesting access token for client scheme '{scheme.Name}'"); + $"Error requesting access token for client scheme '{scheme.Name}': {response.Error}"); } OAuthAccessToken token = new( diff --git a/Http/src/AppCore.Extensions.Http.Authentication/IAuthenticationSchemeHandler.cs b/Http/src/AppCore.Extensions.Http.Authentication/IAuthenticationSchemeHandler.cs index 640c802..8f3155d 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication/IAuthenticationSchemeHandler.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication/IAuthenticationSchemeHandler.cs @@ -11,7 +11,7 @@ namespace AppCore.Extensions.Http.Authentication; /// Represents a HTTP client authentication scheme handler. /// /// The type of the . -public interface IAuthenticationSchemeHandler +public interface IAuthenticationSchemeHandler where TParameters : AuthenticationParameters { /// From 8925dfdb849d17d1429c958424100130409433f8 Mon Sep 17 00:00:00 2001 From: Christian Prochnow Date: Fri, 23 Dec 2022 08:15:20 +0100 Subject: [PATCH 2/2] Added OpenID Connect user authentication. --- AppCore.Extensions.sln | 2 +- Http/samples/Web/Web.csproj | 2 +- .../OpenIdConnectUserHandler.cs | 13 -- .../OpenIdConnectUserOptions.cs | 8 -- .../OpenIdConnectUserParameters.cs | 5 - .../OpenIdConnectUserTokenService.cs | 25 ---- ...uthenticationSessionOAuthUserTokenStore.cs | 54 ++++++-- .../IOAuthUserTokenService.cs | 2 - .../IOAuthUserTokenStore.cs | 30 ++++- .../OAuthUserHandler.cs | 20 ++- .../OAuthUserOptions.cs | 5 +- .../OAuthUserParameters.cs | 8 +- .../OAuthUserToken.cs | 9 ++ .../OAuthUserTokenService.cs | 118 ++++++++++-------- ...Authentication.OAuth.OpenIdConnect.csproj} | 1 + .../AuthenticationSchemeExtensions.cs | 15 +++ ...tpClientAuthenticationBuilderExtensions.cs | 2 +- ...penIdConnectHttpClientBuilderExtensions.cs | 2 +- .../OpenIdConnectClientOptions.cs | 3 +- .../OpenIdConnectClientOptionsResolver.cs | 3 +- .../OpenIdConnectOptionsExtensions.cs | 7 +- .../OpenIdConnectUserDefaults.cs | 5 +- .../OpenIdConnectUserHandler.cs | 31 +++++ .../OpenIdConnectUserOptions.cs | 13 ++ .../OpenIdConnectUserOptionsResolver.cs | 8 +- .../OpenIdConnectUserParameters.cs | 13 ++ .../OpenIdConnectUserTokenService.cs | 40 ++++++ .../OpenIdConnectUserTokenStore.cs | 30 +++-- .../IOAuthTokenClient.cs | 2 - .../OAuthClientHandler.cs | 2 +- .../OAuthPasswordHandler.cs | 2 +- .../OAuthTokenClient.cs | 1 - .../AuthenticationHandler.cs | 2 +- .../AuthenticationSchemeHandler.cs | 2 +- .../IAuthenticationSchemeHandler.cs | 4 +- 35 files changed, 338 insertions(+), 151 deletions(-) delete mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserHandler.cs delete mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptions.cs delete mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserParameters.cs delete mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenService.cs rename Http/src/{AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect.csproj => AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect.csproj} (93%) create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/AuthenticationSchemeExtensions.cs rename Http/src/{AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect => AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect}/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs (97%) rename Http/src/{AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect => AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect}/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs (95%) rename Http/src/{AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect => AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect}/OpenIdConnectClientOptions.cs (86%) rename Http/src/{AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect => AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect}/OpenIdConnectClientOptionsResolver.cs (91%) rename Http/src/{AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect => AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect}/OpenIdConnectOptionsExtensions.cs (89%) rename Http/src/{AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect => AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect}/OpenIdConnectUserDefaults.cs (65%) create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserHandler.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserOptions.cs rename Http/src/{AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect => AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect}/OpenIdConnectUserOptionsResolver.cs (81%) create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserParameters.cs create mode 100644 Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserTokenService.cs rename Http/src/{AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect => AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect}/OpenIdConnectUserTokenStore.cs (78%) diff --git a/AppCore.Extensions.sln b/AppCore.Extensions.sln index 5a88c9f..7acbb1b 100644 --- a/AppCore.Extensions.sln +++ b/AppCore.Extensions.sln @@ -64,7 +64,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkerService", "Http\sampl EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppCore.Extensions.Http.Authentication.OAuth.AspNetCore", "Http\src\AppCore.Extensions.Http.Authentication.OAuth.AspNetCore\AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.csproj", "{9301436D-85F6-4845-AAD6-33103F49F018}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect", "Http\src\AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect\AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect.csproj", "{91754F75-CC7A-48C8-A133-9F087BE10CFE}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect", "Http\src\AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect\AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect.csproj", "{91754F75-CC7A-48C8-A133-9F087BE10CFE}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web", "Http\samples\Web\Web.csproj", "{7D72ABA6-5EBE-491A-A599-65D15FD10308}" EndProject diff --git a/Http/samples/Web/Web.csproj b/Http/samples/Web/Web.csproj index cedf58b..6aff96a 100644 --- a/Http/samples/Web/Web.csproj +++ b/Http/samples/Web/Web.csproj @@ -6,7 +6,7 @@ - + diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserHandler.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserHandler.cs deleted file mode 100644 index eabc9d7..0000000 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserHandler.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; - -public class OpenIdConnectUserHandler : OAuthUserHandler -{ - public OpenIdConnectUserHandler( - IHttpContextAccessor httpContextAccessor, - OpenIdConnectUserTokenService authTokenService) - : base(httpContextAccessor, authTokenService) - { - } -} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptions.cs deleted file mode 100644 index 52b7967..0000000 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; - -/// -/// Provides options for OAuth user authentication. -/// -public class OpenIdConnectUserOptions : OAuthUserOptions -{ -} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserParameters.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserParameters.cs deleted file mode 100644 index 06c679e..0000000 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserParameters.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; - -public class OpenIdConnectUserParameters : OAuthUserParameters -{ -} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenService.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenService.cs deleted file mode 100644 index 1d02123..0000000 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenService.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; - -public class OpenIdConnectUserTokenService : OAuthUserTokenService -{ - public OpenIdConnectUserTokenService( - IOAuthTokenClient client, - OpenIdConnectUserTokenStore store, - ISystemClock clock, - IOptionsMonitor optionsMonitor, - ILogger logger) - : base(client, store, clock, optionsMonitor, logger) - { - } - - protected override void EnsureCompatibleScheme(AuthenticationScheme scheme) - { - if (!typeof(OpenIdConnectUserHandler).IsAssignableFrom(scheme.HandlerType)) - throw new InvalidOperationException($"The client authentication scheme {scheme.Name} is not registered for the OpenID Connect user handler."); - } -} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSessionOAuthUserTokenStore.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSessionOAuthUserTokenStore.cs index 273de3e..1da9516 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSessionOAuthUserTokenStore.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSessionOAuthUserTokenStore.cs @@ -1,4 +1,7 @@ -using System.Collections.Generic; +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using System.Collections.Generic; using System.Security.Authentication; using System.Security.Claims; using System.Threading; @@ -12,6 +15,11 @@ namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; +/// +/// Provides the base class for which stores tokens in the authentication +/// session. +/// +/// The type of the . public abstract class AuthenticationSessionOAuthUserTokenStore : IOAuthUserTokenStore where TOptions : OAuthUserOptions { @@ -23,6 +31,12 @@ public abstract class AuthenticationSessionOAuthUserTokenStore : IOAut // this requires this service to be added as scoped to the DI system private readonly Dictionary _cache = new(); + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . protected AuthenticationSessionOAuthUserTokenStore( IHttpContextAccessor httpContextAccessor, IOptionsMonitor optionsMonitor, @@ -67,7 +81,7 @@ private async Task GetSignInScheme(HttpContext context, TOptions options return scheme; } - private async Task TryAuthenticateAsync(HttpContext httpContext, string signInScheme, TOptions options) + private async Task TryAuthenticateAsync(HttpContext httpContext, string signInScheme) { // check the cache in case the token was re-issued via StoreTokenAsync if (!_cache.TryGetValue(signInScheme, out AuthenticateResult? result)) @@ -92,13 +106,17 @@ private async Task GetSignInScheme(HttpContext context, TOptions options return result; } + /// + /// Ensures that the is compatible. + /// + /// The . protected abstract void EnsureCompatibleScheme(AuthenticationScheme scheme); + /// public async Task StoreTokenAsync( AuthenticationScheme scheme, ClaimsPrincipal user, OAuthUserToken token, - OAuthUserParameters? parameters = null, CancellationToken cancellationToken = default) { Ensure.Arg.NotNull(scheme); @@ -111,12 +129,12 @@ public async Task StoreTokenAsync( TOptions options = _optionsMonitor.Get(scheme.Name); string signInScheme = await GetSignInScheme(httpContext, options); - AuthenticateResult? result = await TryAuthenticateAsync(httpContext, signInScheme, options); + AuthenticateResult? result = await TryAuthenticateAsync(httpContext, signInScheme); if (result == null) throw new AuthenticationException("User is not authenticated, cannot store tokens."); ClaimsPrincipal principal = result.Principal!; - SetUserToken(principal, result.Properties!, token, options); + StoreToken(principal, result.Properties!, token, options); if (result.Properties!.AllowRefresh.GetValueOrDefault(true)) { @@ -128,16 +146,23 @@ public async Task StoreTokenAsync( _cache[signInScheme] = AuthenticateResult.Success(new AuthenticationTicket(principal, result.Properties, signInScheme)); } - protected abstract void SetUserToken( + /// + /// Stores the token in the authentication session. + /// + /// + /// + /// + /// + protected abstract void StoreToken( ClaimsPrincipal principal, AuthenticationProperties properties, OAuthUserToken token, TOptions options); + /// public async Task GetTokenAsync( AuthenticationScheme scheme, ClaimsPrincipal user, - OAuthUserParameters? parameters = null, CancellationToken cancellationToken = default) { Ensure.Arg.NotNull(scheme); @@ -149,22 +174,29 @@ public async Task GetTokenAsync( TOptions options = _optionsMonitor.Get(scheme.Name); string signInScheme = await GetSignInScheme(httpContext, options); - AuthenticateResult? result = await TryAuthenticateAsync(httpContext, signInScheme, options); + AuthenticateResult? result = await TryAuthenticateAsync(httpContext, signInScheme); if (result == null) throw new AuthenticationException("User is not authenticated, cannot get tokens."); - return GetUserToken(result.Principal!, result.Properties!, options); + return GetToken(result.Principal!, result.Properties!, options); } - protected abstract OAuthUserToken GetUserToken( + /// + /// Gets the token from the authentication session. + /// + /// + /// + /// + /// + protected abstract OAuthUserToken GetToken( ClaimsPrincipal principal, AuthenticationProperties properties, TOptions options); + /// public Task ClearTokenAsync( AuthenticationScheme scheme, ClaimsPrincipal user, - OAuthUserParameters? parameters = null, CancellationToken cancellationToken = default) { Ensure.Arg.NotNull(scheme); diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenService.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenService.cs index 5cb15d6..7a76914 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenService.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenService.cs @@ -32,11 +32,9 @@ Task GetAccessTokenAsync( /// /// The . /// The user. - /// Optional parameters. /// Task RevokeRefreshTokenAsync( AuthenticationScheme scheme, ClaimsPrincipal user, - OAuthUserParameters? parameters = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenStore.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenStore.cs index 6710be7..faeb9e8 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenStore.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenStore.cs @@ -1,27 +1,49 @@ -using System.Security.Claims; +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; +/// +/// Abstraction for the OAuth user token store. +/// public interface IOAuthUserTokenStore { + /// + /// Stores the token for the specified authentication scheme and user. + /// + /// The . + /// The . + /// The . + /// Optional . Task StoreTokenAsync( AuthenticationScheme scheme, ClaimsPrincipal user, OAuthUserToken token, - OAuthUserParameters? parameters = null, CancellationToken cancellationToken = default); + /// + /// Gets the token for the specified authentication scheme and user. + /// + /// The . + /// The . + /// Optional . Task GetTokenAsync( AuthenticationScheme scheme, ClaimsPrincipal user, - OAuthUserParameters? parameters = null, CancellationToken cancellationToken = default); + /// + /// Clears the token for the specified authentication scheme and user. + /// + /// The . + /// The . + /// Optional . Task ClearTokenAsync( AuthenticationScheme scheme, ClaimsPrincipal user, - OAuthUserParameters? parameters = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserHandler.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserHandler.cs index 1aae3dc..1fe904d 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserHandler.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserHandler.cs @@ -1,4 +1,7 @@ -using System.Net.Http; +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using System.Net.Http; using System.Net.Http.Headers; using System.Security.Authentication; using System.Security.Claims; @@ -10,7 +13,7 @@ namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; /// -/// Provides a OAuth user authentication handler. +/// Provides a base class for a OAuth user authentication handler. /// public abstract class OAuthUserHandler : IAuthenticationSchemeHandler where TParameters : OAuthUserParameters @@ -32,13 +35,24 @@ protected OAuthUserHandler(IHttpContextAccessor httpContextAccessor, IOAuthUserT _authTokenService = authTokenService; } + /// + /// Ensures that the is compatible. + /// + /// The . + protected abstract void EnsureCompatibleScheme(AuthenticationScheme scheme); + /// public async Task AuthenticateAsync( AuthenticationScheme scheme, - TParameters? parameters, HttpRequestMessage request, + TParameters? parameters = null, CancellationToken cancellationToken = default) { + Ensure.Arg.NotNull(scheme); + Ensure.Arg.NotNull(request); + + EnsureCompatibleScheme(scheme); + ClaimsPrincipal? user = _httpContextAccessor.HttpContext?.User; if (user == null) throw new AuthenticationException("User is not authenticated."); diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserOptions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserOptions.cs index c0c78f8..d90e025 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserOptions.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserOptions.cs @@ -1,4 +1,7 @@ -using System; +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using System; namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserParameters.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserParameters.cs index 7c57e7f..fce778b 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserParameters.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserParameters.cs @@ -1,5 +1,11 @@ -namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +/// +/// Represents the parameters used during OAuth user authentication. +/// public class OAuthUserParameters : OAuthParameters { } \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserToken.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserToken.cs index 222a11e..1559a76 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserToken.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserToken.cs @@ -1,3 +1,6 @@ +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + using System; namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; @@ -12,6 +15,12 @@ public class OAuthUserToken : OAuthAccessToken /// public string? RefreshToken { get; } + /// + /// Initializes a new instance of the class. + /// + /// The access token. + /// The refresh token. + /// A value indicating when the access token will expire; null if the token will never expire. public OAuthUserToken(string accessToken, string? refreshToken, DateTimeOffset? expires) : base(accessToken, expires) { diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserTokenService.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserTokenService.cs index 3bb7bed..4e408e6 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserTokenService.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserTokenService.cs @@ -1,4 +1,7 @@ -using System; +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using System; using System.Collections.Concurrent; using System.Security.Authentication; using System.Security.Claims; @@ -13,6 +16,10 @@ namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; +/// +/// Provides the base class for see . +/// +/// The type of the . public abstract class OAuthUserTokenService : IOAuthUserTokenService where TOptions : OAuthUserOptions { @@ -51,6 +58,10 @@ protected OAuthUserTokenService( _logger = logger; } + /// + /// Ensures that the is compatible. + /// + /// The . protected abstract void EnsureCompatibleScheme(AuthenticationScheme scheme); private async Task InvokeSynchronized(string key, Func> tokenFunc) @@ -80,74 +91,80 @@ public async Task GetAccessTokenAsync( EnsureCompatibleScheme(scheme); - OAuthUserOptions options = _optionsMonitor.Get(scheme.Name); - OAuthUserToken token = await _store.GetTokenAsync(scheme, user, parameters, cancellationToken); - - if (!options.AllowTokenRefresh) - return token; + TOptions options = _optionsMonitor.Get(scheme.Name); + OAuthUserToken token = await _store.GetTokenAsync(scheme, user, cancellationToken); DateTimeOffset? refreshAt = token.Expires?.Subtract(options.RefreshBeforeExpiration); if (refreshAt.HasValue && refreshAt < _clock.UtcNow || (parameters?.ForceRenewal).GetValueOrDefault()) { + if (!options.AllowTokenRefresh) + throw new AuthenticationException("Cannot refresh access token because it has been disabled."); + if (string.IsNullOrWhiteSpace(token.RefreshToken)) throw new AuthenticationException("Cannot refresh access token because no refresh token was found for user."); return await InvokeSynchronized( token.RefreshToken, - async () => - { - _logger.LogDebug("Refreshing access token for client scheme {schemeName} ...", scheme.Name); - - TokenResponse response = - await _client.RequestRefreshTokenAsync( - scheme, - token.RefreshToken, - parameters, - cancellationToken) - .ConfigureAwait(false); - - if (response.IsError) - { - _logger.LogError( - "Error refreshing access token for client scheme {schemeName}. Error = {error}. Error description = {errorDescription}", - scheme.Name, - response.Error, - response.ErrorDescription); - - throw new AuthenticationException( - $"Error refreshing access token for client scheme '{scheme.Name}': {response.Error}"); - } - - OAuthUserToken refreshedToken = new( - response.AccessToken, - response.RefreshToken, - response.ExpiresIn > 0 - ? DateTimeOffset.UtcNow + TimeSpan.FromSeconds(response.ExpiresIn) - : null); - - _logger.LogDebug( - "Refreshed access token for client scheme {schemeName}. Expiration: {expiration}", - scheme.Name, - refreshedToken.Expires); - - await _store.StoreTokenAsync(scheme, user, refreshedToken, parameters, cancellationToken) - .ConfigureAwait(false); - - return refreshedToken; - }) + () => RefreshAccessTokenAsync(scheme, user, token, parameters, cancellationToken)) .ConfigureAwait(false); } return token; } + private async Task RefreshAccessTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserToken token, + OAuthUserParameters? parameters = null, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Refreshing access token for client scheme {schemeName} ...", scheme.Name); + + TokenResponse response = + await _client.RequestRefreshTokenAsync( + scheme, + token.RefreshToken!, + parameters, + cancellationToken) + .ConfigureAwait(false); + + if (response.IsError) + { + _logger.LogError( + "Error refreshing access token for client scheme {schemeName}. Error = {error}. Error description = {errorDescription}", + scheme.Name, + response.Error, + response.ErrorDescription); + + throw new AuthenticationException( + $"Error refreshing access token for client scheme '{scheme.Name}': {response.Error}"); + } + + OAuthUserToken refreshedToken = new( + response.AccessToken, + response.RefreshToken, + response.ExpiresIn > 0 + ? DateTimeOffset.UtcNow + TimeSpan.FromSeconds(response.ExpiresIn) + : null); + + _logger.LogDebug( + "Refreshed access token for client scheme {schemeName}. Expiration: {expiration}", + scheme.Name, + refreshedToken.Expires); + + await _store.StoreTokenAsync(scheme, user, refreshedToken, cancellationToken) + .ConfigureAwait(false); + + return refreshedToken; + } + /// public async Task RevokeRefreshTokenAsync( AuthenticationScheme scheme, ClaimsPrincipal user, - OAuthUserParameters? parameters = null, CancellationToken cancellationToken = default) { Ensure.Arg.NotNull(scheme); @@ -155,7 +172,7 @@ public async Task RevokeRefreshTokenAsync( EnsureCompatibleScheme(scheme); - OAuthUserToken token = await _store.GetTokenAsync(scheme, user, parameters, cancellationToken) + OAuthUserToken token = await _store.GetTokenAsync(scheme, user, cancellationToken) .ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(token.RefreshToken)) @@ -164,11 +181,10 @@ await _client.RevokeTokenAsync( scheme, token.RefreshToken, OidcConstants.TokenTypeIdentifiers.RefreshToken, - parameters, cancellationToken) .ConfigureAwait(false); - await _store.ClearTokenAsync(scheme, user, parameters, cancellationToken) + await _store.ClearTokenAsync(scheme, user, cancellationToken) .ConfigureAwait(false); } } diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect.csproj b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect.csproj similarity index 93% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect.csproj rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect.csproj index 43587cb..f2a2ec6 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect.csproj +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect.csproj @@ -7,6 +7,7 @@ from ASP.NET Core OpenID connect authentication schemes. $(PackageTags);Security;Authentication;OAuth;OAuth2;OAuth 2.0;IdentityModel;ASP.NET Core;OpenID Connect + AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/AuthenticationSchemeExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/AuthenticationSchemeExtensions.cs new file mode 100644 index 0000000..d468bb6 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/AuthenticationSchemeExtensions.cs @@ -0,0 +1,15 @@ +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using System; + +namespace AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; + +internal static class AuthenticationSchemeExtensions +{ + public static void EnsureOpenIdConnectScheme(this AuthenticationScheme scheme) + { + if (!typeof(OpenIdConnectUserHandler).IsAssignableFrom(scheme.HandlerType)) + throw new InvalidOperationException($"The client authentication scheme {scheme.Name} is not registered for the OpenID Connect user handler."); + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs similarity index 97% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs index 2ab363e..54629d3 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs @@ -4,7 +4,7 @@ using System; using AppCore.Diagnostics; using AppCore.Extensions.Http.Authentication.OAuth; -using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; +using AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; using Microsoft.Extensions.DependencyInjection.Extensions; // ReSharper disable once CheckNamespace diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs similarity index 95% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs index d020f32..cb19ab6 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs @@ -2,7 +2,7 @@ // Copyright (c) 2018-2022 the AppCore .NET project. using AppCore.Diagnostics; -using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; +using AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; // ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection; diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectClientOptions.cs similarity index 86% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptions.cs rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectClientOptions.cs index 88943e6..45136b8 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptions.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectClientOptions.cs @@ -1,9 +1,10 @@ // Licensed under the MIT License. // Copyright (c) 2018-2022 the AppCore .NET project. +using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; using Microsoft.AspNetCore.Authentication.OpenIdConnect; -namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; +namespace AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; /// /// Provides the options how to derive from . diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs similarity index 91% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs index 8fcb721..f7a4784 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs @@ -2,10 +2,11 @@ // Copyright (c) 2018-2022 the AppCore .NET project. using System.Threading.Tasks; +using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.Options; -namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; +namespace AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; internal sealed class OpenIdConnectClientOptionsResolver : AuthenticationSchemeOAuthClientOptionsResolver< diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOptionsExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectOptionsExtensions.cs similarity index 89% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOptionsExtensions.cs rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectOptionsExtensions.cs index 40e9383..7493c3d 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOptionsExtensions.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectOptionsExtensions.cs @@ -1,11 +1,14 @@ -using System; +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using System; using System.Threading; using System.Threading.Tasks; using IdentityModel; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect; -namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; +namespace AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; internal static class OpenIdConnectOptionsExtensions { diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserDefaults.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserDefaults.cs similarity index 65% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserDefaults.cs rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserDefaults.cs index 6f7b041..a4ce9e0 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserDefaults.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserDefaults.cs @@ -1,4 +1,7 @@ -namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +namespace AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; /// /// Provides default values for the OpenID Connect authentication. diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserHandler.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserHandler.cs new file mode 100644 index 0000000..acd2e8e --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserHandler.cs @@ -0,0 +1,31 @@ +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; +using Microsoft.AspNetCore.Http; + +namespace AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; + +/// +/// Represents the OpenID Connect authentication handler. +/// +public class OpenIdConnectUserHandler : OAuthUserHandler +{ + /// + /// Initializes a new instance of the . + /// + /// The . + /// The . + public OpenIdConnectUserHandler( + IHttpContextAccessor httpContextAccessor, + OpenIdConnectUserTokenService authTokenService) + : base(httpContextAccessor, authTokenService) + { + } + + /// + protected override void EnsureCompatibleScheme(AuthenticationScheme scheme) + { + scheme.EnsureOpenIdConnectScheme(); + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserOptions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserOptions.cs new file mode 100644 index 0000000..02028d8 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserOptions.cs @@ -0,0 +1,13 @@ +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +namespace AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; + +/// +/// Provides options for OpenID Connect user authentication. +/// +public class OpenIdConnectUserOptions : OAuthUserOptions +{ +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs similarity index 81% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs index 4c6e448..43cbdf4 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs @@ -1,8 +1,12 @@ -using System.Threading.Tasks; +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using System.Threading.Tasks; +using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.Options; -namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; +namespace AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; internal sealed class OpenIdConnectUserOptionsResolver : AuthenticationSchemeOAuthClientOptionsResolver< diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserParameters.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserParameters.cs new file mode 100644 index 0000000..b37db9a --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserParameters.cs @@ -0,0 +1,13 @@ +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +namespace AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; + +/// +/// Provides the parameters used when authentication with OpenID Connect. +/// +public class OpenIdConnectUserParameters : OAuthUserParameters +{ +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserTokenService.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserTokenService.cs new file mode 100644 index 0000000..317f60e --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserTokenService.cs @@ -0,0 +1,40 @@ +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; + +/// +/// Represents the OpenID Connect token service. +/// +public class OpenIdConnectUserTokenService : OAuthUserTokenService +{ + /// + /// Initializes a new instance of the . + /// + /// + /// + /// + /// + /// + public OpenIdConnectUserTokenService( + IOAuthTokenClient client, + OpenIdConnectUserTokenStore store, + ISystemClock clock, + IOptionsMonitor optionsMonitor, + ILogger logger + ) + : base(client, store, clock, optionsMonitor, logger) + { + } + + /// + protected override void EnsureCompatibleScheme(AuthenticationScheme scheme) + { + scheme.EnsureOpenIdConnectScheme(); + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenStore.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserTokenStore.cs similarity index 78% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenStore.cs rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserTokenStore.cs index b8a7fbb..91d3ce0 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectUserTokenStore.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserTokenStore.cs @@ -1,34 +1,49 @@ -using System; +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Security.Authentication; using System.Security.Claims; +using AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Protocols.OpenIdConnect; -namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect; +namespace AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect; +/// +/// Represents the OpenID Connect user token store. +/// public class OpenIdConnectUserTokenStore : AuthenticationSessionOAuthUserTokenStore { + /// + /// Initializes a new instance of the . + /// + /// + /// + /// public OpenIdConnectUserTokenStore( IHttpContextAccessor httpContextAccessor, IOptionsMonitor optionsMonitor, - ILogger logger) + ILogger logger + ) : base(httpContextAccessor, optionsMonitor, logger) { } + /// protected override void EnsureCompatibleScheme(AuthenticationScheme scheme) { - if (!typeof(OpenIdConnectUserHandler).IsAssignableFrom(scheme.HandlerType)) - throw new InvalidOperationException($"The client authentication scheme {scheme.Name} is not registered for the OpenID Connect user handler."); + scheme.EnsureOpenIdConnectScheme(); } - protected override void SetUserToken( + /// + protected override void StoreToken( ClaimsPrincipal principal, AuthenticationProperties properties, OAuthUserToken token, @@ -59,7 +74,8 @@ protected override void SetUserToken( properties.StoreTokens(tokens); } - protected override OAuthUserToken GetUserToken( + /// + protected override OAuthUserToken GetToken( ClaimsPrincipal principal, AuthenticationProperties properties, OpenIdConnectUserOptions options) diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/IOAuthTokenClient.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/IOAuthTokenClient.cs index 73c3bc2..ecc8107 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/IOAuthTokenClient.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/IOAuthTokenClient.cs @@ -56,13 +56,11 @@ Task RequestRefreshTokenAsync( /// The . /// The token to revoke. /// A hint for the type of the token. - /// The . /// A . /// Task RevokeTokenAsync( AuthenticationScheme scheme, string token, string tokenTypeHint, - OAuthParameters? parameters = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthClientHandler.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthClientHandler.cs index 266a6ea..c077e3b 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthClientHandler.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthClientHandler.cs @@ -29,8 +29,8 @@ public OAuthClientHandler(IOAuthTokenService authTokenService) /// public async Task AuthenticateAsync( AuthenticationScheme scheme, - OAuthParameters? parameters, HttpRequestMessage request, + OAuthParameters? parameters, CancellationToken cancellationToken = default) { OAuthAccessToken accessToken = diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthPasswordHandler.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthPasswordHandler.cs index 95fa94d..76875b5 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthPasswordHandler.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthPasswordHandler.cs @@ -26,8 +26,8 @@ public OAuthPasswordHandler(IOAuthTokenService authTokenService) /// public async Task AuthenticateAsync( AuthenticationScheme scheme, - OAuthParameters? parameters, HttpRequestMessage request, + OAuthParameters? parameters, CancellationToken cancellationToken = default) { OAuthAccessToken accessToken = diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenClient.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenClient.cs index ce9af0f..322e52d 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenClient.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenClient.cs @@ -121,7 +121,6 @@ public async Task RevokeTokenAsync( AuthenticationScheme scheme, string token, string tokenTypeHint, - OAuthParameters? parameters = null, CancellationToken cancellationToken = default) { Ensure.Arg.NotEmpty(token); diff --git a/Http/src/AppCore.Extensions.Http.Authentication/AuthenticationHandler.cs b/Http/src/AppCore.Extensions.Http.Authentication/AuthenticationHandler.cs index f0cac40..594c39f 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication/AuthenticationHandler.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication/AuthenticationHandler.cs @@ -136,7 +136,7 @@ protected virtual async Task AuthenticateAsync( parameters.ForceRenewal = forceRenewal; } - await _schemeHandler.AuthenticateAsync(scheme, parameters, request, cancellationToken) + await _schemeHandler.AuthenticateAsync(scheme, request, parameters, cancellationToken) .ConfigureAwait(false); } } \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication/AuthenticationSchemeHandler.cs b/Http/src/AppCore.Extensions.Http.Authentication/AuthenticationSchemeHandler.cs index a0c8a32..e3870db 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication/AuthenticationSchemeHandler.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication/AuthenticationSchemeHandler.cs @@ -48,8 +48,8 @@ protected abstract Task AuthenticateAsync( async Task IAuthenticationSchemeHandler.AuthenticateAsync( AuthenticationScheme scheme, - TParameters? parameters, HttpRequestMessage request, + TParameters? parameters, CancellationToken cancellationToken) { await AuthenticateAsync(scheme, _optionsMonitor.Get(scheme.Name), parameters, request, cancellationToken) diff --git a/Http/src/AppCore.Extensions.Http.Authentication/IAuthenticationSchemeHandler.cs b/Http/src/AppCore.Extensions.Http.Authentication/IAuthenticationSchemeHandler.cs index 8f3155d..bbe49ea 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication/IAuthenticationSchemeHandler.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication/IAuthenticationSchemeHandler.cs @@ -18,13 +18,13 @@ public interface IAuthenticationSchemeHandler /// Authenticates a with the specified scheme and parameters. /// /// The . - /// The . /// The . + /// The . /// A . /// The asynchronous operation. Task AuthenticateAsync( AuthenticationScheme scheme, - TParameters? parameters, HttpRequestMessage request, + TParameters? parameters = null, CancellationToken cancellationToken = default); } \ No newline at end of file