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/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/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/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/OpenIdConnectOAuthClientOptionsResolver.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOAuthClientOptionsResolver.cs deleted file mode 100644 index be17e20..0000000 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOAuthClientOptionsResolver.cs +++ /dev/null @@ -1,62 +0,0 @@ -// 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 - : AuthenticationSchemeOAuthClientOptionsResolver< - OpenIdConnectOAuthClientOptions, - OpenIdConnectOptions, - OpenIdConnectHandler - > -{ - public OpenIdConnectOAuthClientOptionsResolver( - Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider authenticationSchemeProvider, - IOptionsMonitor clientOptions, - IOptionsMonitor authenticationSchemeOptions) - : base(authenticationSchemeProvider, authenticationSchemeOptions, clientOptions) - { - } - - protected override async Task GetOptionsFromSchemeAsync( - OpenIdConnectOAuthClientOptions 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 - }; - - if (!string.IsNullOrWhiteSpace(clientOptions.Scope)) - { - result.Scope = clientOptions.Scope; - } - - if (!string.IsNullOrWhiteSpace(clientOptions.Resource)) - { - result.Resource.Add(clientOptions.Resource); - } - - return result; - } -} \ 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..1da9516 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/AuthenticationSessionOAuthUserTokenStore.cs @@ -0,0 +1,209 @@ +// 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; +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; + +/// +/// Provides the base class for which stores tokens in the authentication +/// session. +/// +/// The type of the . +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(); + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + 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) + { + // 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; + } + + /// + /// Ensures that the is compatible. + /// + /// The . + protected abstract void EnsureCompatibleScheme(AuthenticationScheme scheme); + + /// + public async Task StoreTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + OAuthUserToken token, + 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); + if (result == null) + throw new AuthenticationException("User is not authenticated, cannot store tokens."); + + ClaimsPrincipal principal = result.Principal!; + StoreToken(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)); + } + + /// + /// 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, + 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); + if (result == null) + throw new AuthenticationException("User is not authenticated, cannot get tokens."); + + return GetToken(result.Principal!, result.Properties!, options); + } + + /// + /// Gets the token from the authentication session. + /// + /// + /// + /// + /// + protected abstract OAuthUserToken GetToken( + ClaimsPrincipal principal, + AuthenticationProperties properties, + TOptions options); + + /// + public Task ClearTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + 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..7a76914 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenService.cs @@ -0,0 +1,40 @@ +// 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. + /// + Task RevokeRefreshTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + 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..faeb9e8 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/IOAuthUserTokenStore.cs @@ -0,0 +1,49 @@ +// 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, + CancellationToken cancellationToken = default); + + /// + /// Gets the token for the specified authentication scheme and user. + /// + /// The . + /// The . + /// Optional . + Task GetTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + CancellationToken cancellationToken = default); + + /// + /// Clears the token for the specified authentication scheme and user. + /// + /// The . + /// The . + /// Optional . + Task ClearTokenAsync( + AuthenticationScheme scheme, + ClaimsPrincipal user, + 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..1fe904d --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserHandler.cs @@ -0,0 +1,66 @@ +// 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; +using System.Threading; +using System.Threading.Tasks; +using AppCore.Diagnostics; +using Microsoft.AspNetCore.Http; + +namespace AppCore.Extensions.Http.Authentication.OAuth.AspNetCore; + +/// +/// Provides a base class for 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; + } + + /// + /// Ensures that the is compatible. + /// + /// The . + protected abstract void EnsureCompatibleScheme(AuthenticationScheme scheme); + + /// + public async Task AuthenticateAsync( + AuthenticationScheme scheme, + 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."); + + 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..d90e025 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserOptions.cs @@ -0,0 +1,34 @@ +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +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..fce778b --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserParameters.cs @@ -0,0 +1,11 @@ +// 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 new file mode 100644 index 0000000..1559a76 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserToken.cs @@ -0,0 +1,29 @@ +// Licensed under the MIT License. +// Copyright (c) 2018-2022 the AppCore .NET project. + +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; } + + /// + /// 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) + { + 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..4e408e6 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore/OAuthUserTokenService.cs @@ -0,0 +1,191 @@ +// 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; +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; + +/// +/// Provides the base class for see . +/// +/// The type of the . +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; + } + + /// + /// Ensures that the is compatible. + /// + /// The . + 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); + + 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, + () => 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, + CancellationToken cancellationToken = default) + { + Ensure.Arg.NotNull(scheme); + Ensure.Arg.NotNull(user); + + EnsureCompatibleScheme(scheme); + + OAuthUserToken token = await _store.GetTokenAsync(scheme, user, cancellationToken) + .ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(token.RefreshToken)) + { + await _client.RevokeTokenAsync( + scheme, + token.RefreshToken, + OidcConstants.TokenTypeIdentifiers.RefreshToken, + cancellationToken) + .ConfigureAwait(false); + + await _store.ClearTokenAsync(scheme, user, cancellationToken) + .ConfigureAwait(false); + } + } +} \ No newline at end of file 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.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientAuthenticationBuilderExtensions.cs new file mode 100644 index 0000000..54629d3 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.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.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.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/DependencyInjection/OpenIdConnectHttpClientBuilderExtensions.cs new file mode 100644 index 0000000..cb19ab6 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.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.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.OpenIdConnect/OpenIdConnectClientOptions.cs similarity index 78% rename from Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOAuthClientOptions.cs rename to Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectClientOptions.cs index 0f17815..45136b8 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.AspNetCore.OpenIdConnect/OpenIdConnectOAuthClientOptions.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectClientOptions.cs @@ -1,14 +1,15 @@ // 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 . /// -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.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs new file mode 100644 index 0000000..f7a4784 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectClientOptionsResolver.cs @@ -0,0 +1,49 @@ +// 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.OpenIdConnect; + +internal sealed class OpenIdConnectClientOptionsResolver + : AuthenticationSchemeOAuthClientOptionsResolver< + OpenIdConnectClientOptions, + OpenIdConnectOptions, + OpenIdConnectHandler + > +{ + public OpenIdConnectClientOptionsResolver( + Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider authenticationSchemeProvider, + IOptionsMonitor clientOptions, + IOptionsMonitor authenticationSchemeOptions) + : base(authenticationSchemeProvider, authenticationSchemeOptions, clientOptions) + { + } + + protected override string? GetSchemeName(OpenIdConnectClientOptions options) + { + return options.Scheme; + } + + protected override async Task GetOptionsFromSchemeAsync( + OpenIdConnectClientOptions clientOptions, + OpenIdConnectOptions oidcOptions) + { + OAuthClientOptions result = await oidcOptions.GetOAuthClientOptionsAsync(default); + + if (!string.IsNullOrWhiteSpace(clientOptions.Scope)) + { + result.Scope = clientOptions.Scope; + } + + if (!string.IsNullOrWhiteSpace(clientOptions.Resource)) + { + result.Resource.Add(clientOptions.Resource); + } + + return result; + } +} \ No newline at end of file diff --git a/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectOptionsExtensions.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectOptionsExtensions.cs new file mode 100644 index 0000000..7493c3d --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectOptionsExtensions.cs @@ -0,0 +1,47 @@ +// 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.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.OpenIdConnect/OpenIdConnectUserDefaults.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserDefaults.cs new file mode 100644 index 0000000..a4ce9e0 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserDefaults.cs @@ -0,0 +1,15 @@ +// 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. +/// +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.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.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs new file mode 100644 index 0000000..43cbdf4 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserOptionsResolver.cs @@ -0,0 +1,38 @@ +// 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.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.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.OpenIdConnect/OpenIdConnectUserTokenStore.cs b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserTokenStore.cs new file mode 100644 index 0000000..91d3ce0 --- /dev/null +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth.OpenIdConnect/OpenIdConnectUserTokenStore.cs @@ -0,0 +1,110 @@ +// 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.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 + ) + : base(httpContextAccessor, optionsMonitor, logger) + { + } + + /// + protected override void EnsureCompatibleScheme(AuthenticationScheme scheme) + { + scheme.EnsureOpenIdConnectScheme(); + } + + /// + protected override void StoreToken( + 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 GetToken( + 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/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..ecc8107 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/IOAuthTokenClient.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/IOAuthTokenClient.cs @@ -35,4 +35,32 @@ 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. + /// A . + /// + Task RevokeTokenAsync( + AuthenticationScheme scheme, + string token, + string tokenTypeHint, + 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/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/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/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 e48666b..322e52d 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenClient.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication.OAuth/OAuthTokenClient.cs @@ -89,4 +89,58 @@ 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, + 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/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 640c802..bbe49ea 100644 --- a/Http/src/AppCore.Extensions.Http.Authentication/IAuthenticationSchemeHandler.cs +++ b/Http/src/AppCore.Extensions.Http.Authentication/IAuthenticationSchemeHandler.cs @@ -11,20 +11,20 @@ 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 { /// /// 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