diff --git a/OpenIddict.sln b/OpenIddict.sln index ddddeacc..3ae9bcc3 100644 --- a/OpenIddict.sln +++ b/OpenIddict.sln @@ -80,6 +80,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "root", "root", "{F6F3C8E0-B NuGet.config = NuGet.config EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Validation", "src\OpenIddict.Validation\OpenIddict.Validation.csproj", "{17C10B53-278B-416F-9090-8531179BDF2E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Validation.AspNetCore", "src\OpenIddict.Validation.AspNetCore\OpenIddict.Validation.AspNetCore.csproj", "{08892053-5CE5-4153-B754-58D067C75028}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Validation.DataProtection", "src\OpenIddict.Validation.DataProtection\OpenIddict.Validation.DataProtection.csproj", "{BD2463CF-7E1C-40AB-A33C-A44E5C9F23DF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Validation.Owin", "src\OpenIddict.Validation.Owin\OpenIddict.Validation.Owin.csproj", "{B5F6A324-AB31-47EB-BF98-48C7105C4BCE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Validation.ServerIntegration", "src\OpenIddict.Validation.ServerIntegration\OpenIddict.Validation.ServerIntegration.csproj", "{36FE030D-855F-4971-9E1A-76DACE53D349}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Validation.SystemNetHttp", "src\OpenIddict.Validation.SystemNetHttp\OpenIddict.Validation.SystemNetHttp.csproj", "{AC3F3AFC-0E3A-4D3B-A245-58211AE630E5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -190,6 +202,30 @@ Global {C3DCEB4E-0980-4C96-8D5E-A4D1970AD4A8}.Debug|Any CPU.Build.0 = Debug|Any CPU {C3DCEB4E-0980-4C96-8D5E-A4D1970AD4A8}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3DCEB4E-0980-4C96-8D5E-A4D1970AD4A8}.Release|Any CPU.Build.0 = Release|Any CPU + {17C10B53-278B-416F-9090-8531179BDF2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17C10B53-278B-416F-9090-8531179BDF2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17C10B53-278B-416F-9090-8531179BDF2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17C10B53-278B-416F-9090-8531179BDF2E}.Release|Any CPU.Build.0 = Release|Any CPU + {08892053-5CE5-4153-B754-58D067C75028}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08892053-5CE5-4153-B754-58D067C75028}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08892053-5CE5-4153-B754-58D067C75028}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08892053-5CE5-4153-B754-58D067C75028}.Release|Any CPU.Build.0 = Release|Any CPU + {BD2463CF-7E1C-40AB-A33C-A44E5C9F23DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD2463CF-7E1C-40AB-A33C-A44E5C9F23DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD2463CF-7E1C-40AB-A33C-A44E5C9F23DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD2463CF-7E1C-40AB-A33C-A44E5C9F23DF}.Release|Any CPU.Build.0 = Release|Any CPU + {B5F6A324-AB31-47EB-BF98-48C7105C4BCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5F6A324-AB31-47EB-BF98-48C7105C4BCE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5F6A324-AB31-47EB-BF98-48C7105C4BCE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5F6A324-AB31-47EB-BF98-48C7105C4BCE}.Release|Any CPU.Build.0 = Release|Any CPU + {36FE030D-855F-4971-9E1A-76DACE53D349}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {36FE030D-855F-4971-9E1A-76DACE53D349}.Debug|Any CPU.Build.0 = Debug|Any CPU + {36FE030D-855F-4971-9E1A-76DACE53D349}.Release|Any CPU.ActiveCfg = Release|Any CPU + {36FE030D-855F-4971-9E1A-76DACE53D349}.Release|Any CPU.Build.0 = Release|Any CPU + {AC3F3AFC-0E3A-4D3B-A245-58211AE630E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC3F3AFC-0E3A-4D3B-A245-58211AE630E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC3F3AFC-0E3A-4D3B-A245-58211AE630E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC3F3AFC-0E3A-4D3B-A245-58211AE630E5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -221,6 +257,12 @@ Global {97A59757-A249-4FCF-B042-BF425E117706} = {D544447C-D701-46BB-9A5B-C76C612A596B} {1BD05607-C964-477C-A26A-73F01F7BB06E} = {D544447C-D701-46BB-9A5B-C76C612A596B} {C3DCEB4E-0980-4C96-8D5E-A4D1970AD4A8} = {D544447C-D701-46BB-9A5B-C76C612A596B} + {17C10B53-278B-416F-9090-8531179BDF2E} = {D544447C-D701-46BB-9A5B-C76C612A596B} + {08892053-5CE5-4153-B754-58D067C75028} = {D544447C-D701-46BB-9A5B-C76C612A596B} + {BD2463CF-7E1C-40AB-A33C-A44E5C9F23DF} = {D544447C-D701-46BB-9A5B-C76C612A596B} + {B5F6A324-AB31-47EB-BF98-48C7105C4BCE} = {D544447C-D701-46BB-9A5B-C76C612A596B} + {36FE030D-855F-4971-9E1A-76DACE53D349} = {D544447C-D701-46BB-9A5B-C76C612A596B} + {AC3F3AFC-0E3A-4D3B-A245-58211AE630E5} = {D544447C-D701-46BB-9A5B-C76C612A596B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A710059F-0466-4D48-9B3A-0EF4F840B616} diff --git a/samples/Mvc.Server/Controllers/ResourceController.cs b/samples/Mvc.Server/Controllers/ResourceController.cs index 10d8353e..9b6bbf4e 100644 --- a/samples/Mvc.Server/Controllers/ResourceController.cs +++ b/samples/Mvc.Server/Controllers/ResourceController.cs @@ -1,7 +1,9 @@ using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Mvc.Server.Models; +using OpenIddict.Validation.AspNetCore; namespace Mvc.Server.Controllers { @@ -15,14 +17,14 @@ namespace Mvc.Server.Controllers _userManager = userManager; } - //[Authorize(AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)] + [Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] [HttpGet("message")] public async Task GetMessage() { var user = await _userManager.GetUserAsync(User); if (user == null) { - return BadRequest(); + return Challenge(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); } return Content($"{user.UserName} has been successfully authenticated."); diff --git a/samples/Mvc.Server/Startup.cs b/samples/Mvc.Server/Startup.cs index 6938406a..d21963b5 100644 --- a/samples/Mvc.Server/Startup.cs +++ b/samples/Mvc.Server/Startup.cs @@ -109,6 +109,18 @@ namespace Mvc.Server // options.IgnoreEndpointPermissions() // .IgnoreGrantTypePermissions() // .IgnoreScopePermissions(); + }) + + .AddValidation(options => + { + // Configure the audience accepted by this resource server. + options.AddAudiences("resource_server"); + + // Import the configuration from the local OpenIddict server instance. + options.UseLocalServer(); + + // Register the ASP.NET Core host. + options.UseAspNetCore(); }); services.AddTransient(); diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictMessage.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictMessage.cs index c38f46bd..7be2b39f 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictMessage.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictMessage.cs @@ -295,6 +295,22 @@ namespace OpenIddict.Abstractions return this; } + /// + /// Tries to get the value corresponding to a given parameter. + /// + /// The parameter name. + /// The parameter value. + /// true if the parameter could be found, false otherwise. + public bool TryGetParameter([NotNull] string name, out OpenIddictParameter value) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("The parameter name cannot be null or empty.", nameof(name)); + } + + return Parameters.TryGetValue(name, out value); + } + /// /// Returns a representation of the current instance that can be used in logs. /// Note: sensitive parameters like client secrets are automatically removed for security reasons. diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictParameter.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictParameter.cs index 591c9064..bd1e8361 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictParameter.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictParameter.cs @@ -264,6 +264,31 @@ namespace OpenIddict.Abstractions _ => Value.ToString() }; + /// + /// Tries to get the child item corresponding to the specified name. + /// + /// The name of the child item. + /// An instance containing the item value. + /// true if the parameter could be found, false otherwise. + public bool TryGetParameter([NotNull] string name, out OpenIddictParameter value) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("The parameter name cannot be null or empty.", nameof(name)); + } + + if (Value is JObject dictionary && dictionary.TryGetValue(name, out JToken token)) + { + value = new OpenIddictParameter(token); + + return true; + } + + value = default; + + return false; + } + /// /// Determines whether two instances are equal. /// diff --git a/src/OpenIddict.AspNetCore/OpenIddict.AspNetCore.csproj b/src/OpenIddict.AspNetCore/OpenIddict.AspNetCore.csproj index 762352df..bd5b08ce 100644 --- a/src/OpenIddict.AspNetCore/OpenIddict.AspNetCore.csproj +++ b/src/OpenIddict.AspNetCore/OpenIddict.AspNetCore.csproj @@ -15,6 +15,8 @@ + + diff --git a/src/OpenIddict.Owin/OpenIddict.Owin.csproj b/src/OpenIddict.Owin/OpenIddict.Owin.csproj index e791e6f2..70af14e4 100644 --- a/src/OpenIddict.Owin/OpenIddict.Owin.csproj +++ b/src/OpenIddict.Owin/OpenIddict.Owin.csproj @@ -14,6 +14,7 @@ + diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs index 69588892..b4e08bca 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs @@ -74,7 +74,7 @@ namespace OpenIddict.Server.AspNetCore else if (context.IsRejected) { - var notification = new ProcessErrorResponseContext(transaction) + var notification = new ProcessErrorContext(transaction) { Response = new OpenIddictResponse { @@ -151,7 +151,7 @@ namespace OpenIddict.Server.AspNetCore else if (context.IsRejected) { - var notification = new ProcessErrorResponseContext(transaction) + var notification = new ProcessErrorContext(transaction) { Response = new OpenIddictResponse { @@ -210,7 +210,7 @@ namespace OpenIddict.Server.AspNetCore else if (context.IsRejected) { - var notification = new ProcessErrorResponseContext(transaction) + var notification = new ProcessErrorContext(transaction) { Response = new OpenIddictResponse { @@ -257,7 +257,7 @@ namespace OpenIddict.Server.AspNetCore else if (context.IsRejected) { - var notification = new ProcessErrorResponseContext(transaction) + var notification = new ProcessErrorContext(transaction) { Response = new OpenIddictResponse { diff --git a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionConstants.cs b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionConstants.cs index 3b606019..13477949 100644 --- a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionConstants.cs +++ b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionConstants.cs @@ -30,5 +30,30 @@ namespace OpenIddict.Server.DataProtection public const string TokenId = ".token_id"; public const string TokenUsage = ".token_usage"; } + + public static class Purposes + { + public static class Features + { + public const string ReferenceTokens = "UseReferenceTokens"; + } + + public static class Formats + { + public const string AccessToken = "AccessTokenFormat"; + public const string AuthorizationCode = "AuthorizationCodeFormat"; + public const string RefreshToken = "RefreshTokenFormat"; + } + + public static class Handlers + { + public const string Server = "OpenIdConnectServerHandler"; + } + + public static class Schemes + { + public const string Server = "ASOS"; + } + } } } diff --git a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionFormatter.cs b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionFormatter.cs index fc149965..4752da0f 100644 --- a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionFormatter.cs +++ b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionFormatter.cs @@ -22,7 +22,7 @@ namespace OpenIddict.Server.DataProtection { public class OpenIddictServerDataProtectionFormatter : IOpenIddictServerDataProtectionFormatter { - public ClaimsPrincipal ReadToken(BinaryReader reader) + public ClaimsPrincipal ReadToken([NotNull] BinaryReader reader) { if (reader == null) { diff --git a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionHandlers.cs b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionHandlers.cs index 15db354e..9b19ec14 100644 --- a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionHandlers.cs +++ b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionHandlers.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Immutable; +using System.ComponentModel; using System.IO; using System.Security.Claims; using System.Security.Cryptography; @@ -18,14 +19,15 @@ using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Server.DataProtection.OpenIddictServerDataProtectionConstants; using static OpenIddict.Server.DataProtection.OpenIddictServerDataProtectionHandlerFilters; -using static OpenIddict.Server.OpenIddictServerConstants; using static OpenIddict.Server.OpenIddictServerEvents; using static OpenIddict.Server.OpenIddictServerHandlerFilters; using static OpenIddict.Server.OpenIddictServerHandlers; namespace OpenIddict.Server.DataProtection { + [EditorBrowsable(EditorBrowsableState.Never)] public static partial class OpenIddictServerDataProtectionHandlers { public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( @@ -81,7 +83,7 @@ namespace OpenIddict.Server.DataProtection .AddFilter() .AddFilter() .UseScopedHandler() - .SetOrder(ValidateReferenceToken.Descriptor.Order - 500) + .SetOrder(ValidateReferenceToken.Descriptor.Order + 500) .Build(); public async ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) @@ -247,7 +249,7 @@ namespace OpenIddict.Server.DataProtection = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler() - .SetOrder(ValidateSelfContainedToken.Descriptor.Order - 500) + .SetOrder(ValidateSelfContainedToken.Descriptor.Order + 500) .Build(); /// diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs index 1e526cba..7520d712 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs @@ -72,7 +72,7 @@ namespace OpenIddict.Server.Owin else if (context.IsRejected) { - var notification = new ProcessErrorResponseContext(transaction) + var notification = new ProcessErrorContext(transaction) { Response = new OpenIddictResponse { @@ -164,7 +164,7 @@ namespace OpenIddict.Server.Owin else if (context.IsRejected) { - var notification = new ProcessErrorResponseContext(transaction) + var notification = new ProcessErrorContext(transaction) { Response = new OpenIddictResponse { @@ -216,7 +216,7 @@ namespace OpenIddict.Server.Owin else if (context.IsRejected) { - var notification = new ProcessErrorResponseContext(transaction) + var notification = new ProcessErrorContext(transaction) { Response = new OpenIddictResponse { @@ -264,7 +264,7 @@ namespace OpenIddict.Server.Owin else if (context.IsRejected) { - var notification = new ProcessErrorResponseContext(transaction) + var notification = new ProcessErrorContext(transaction) { Response = new OpenIddictResponse { diff --git a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs b/src/OpenIddict.Server/OpenIddictServerConfiguration.cs index ed781d14..8f6834da 100644 --- a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs +++ b/src/OpenIddict.Server/OpenIddictServerConfiguration.cs @@ -35,6 +35,11 @@ namespace OpenIddict.Server throw new ArgumentNullException(nameof(options)); } + if (options.SecurityTokenHandler == null) + { + throw new InvalidOperationException("The security token handler cannot be null."); + } + // Ensure at least one flow has been enabled. if (options.GrantTypes.Count == 0) { diff --git a/src/OpenIddict.Server/OpenIddictServerConstants.cs b/src/OpenIddict.Server/OpenIddictServerConstants.cs index 45bcf55b..5cb60d17 100644 --- a/src/OpenIddict.Server/OpenIddictServerConstants.cs +++ b/src/OpenIddict.Server/OpenIddictServerConstants.cs @@ -15,30 +15,5 @@ namespace OpenIddict.Server public const string ValidatedPostLogoutRedirectUri = ".validated_post_logout_redirect_uri"; public const string ValidatedRedirectUri = ".validated_redirect_uri"; } - - public static class Purposes - { - public static class Features - { - public const string ReferenceTokens = "UseReferenceTokens"; - } - - public static class Formats - { - public const string AccessToken = "AccessTokenFormat"; - public const string AuthorizationCode = "AuthorizationCodeFormat"; - public const string RefreshToken = "RefreshTokenFormat"; - } - - public static class Handlers - { - public const string Server = "OpenIdConnectServerHandler"; - } - - public static class Schemes - { - public const string Server = "ASOS"; - } - } } } diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.cs b/src/OpenIddict.Server/OpenIddictServerEvents.cs index 4a5bda4a..bdaab68e 100644 --- a/src/OpenIddict.Server/OpenIddictServerEvents.cs +++ b/src/OpenIddict.Server/OpenIddictServerEvents.cs @@ -283,12 +283,12 @@ namespace OpenIddict.Server /// /// Represents an event called when processing an errored response. /// - public class ProcessErrorResponseContext : BaseRequestContext + public class ProcessErrorContext : BaseRequestContext { /// - /// Creates a new instance of the class. + /// Creates a new instance of the class. /// - public ProcessErrorResponseContext([NotNull] OpenIddictServerTransaction transaction) + public ProcessErrorContext([NotNull] OpenIddictServerTransaction transaction) : base(transaction) { } diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs index ac0498e7..571d983b 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs @@ -31,7 +31,7 @@ namespace OpenIddict.Server ValidateAuthorizationRequest.Descriptor, HandleAuthorizationRequest.Descriptor, ApplyAuthorizationResponse.Descriptor, - ApplyAuthorizationResponse.Descriptor, + ApplyAuthorizationResponse.Descriptor, ApplyAuthorizationResponse.Descriptor, ApplyAuthorizationResponse.Descriptor, diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs index ca3df25c..45e7a812 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs @@ -34,7 +34,7 @@ namespace OpenIddict.Server ExtractConfigurationRequest.Descriptor, ValidateConfigurationRequest.Descriptor, HandleConfigurationRequest.Descriptor, - ApplyConfigurationResponse.Descriptor, + ApplyConfigurationResponse.Descriptor, ApplyConfigurationResponse.Descriptor, /* @@ -58,7 +58,7 @@ namespace OpenIddict.Server ExtractCryptographyRequest.Descriptor, ValidateCryptographyRequest.Descriptor, HandleCryptographyRequest.Descriptor, - ApplyCryptographyResponse.Descriptor, + ApplyCryptographyResponse.Descriptor, ApplyCryptographyResponse.Descriptor, /* diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs index fa313286..8e6a034f 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs @@ -34,7 +34,7 @@ namespace OpenIddict.Server ValidateTokenRequest.Descriptor, HandleTokenRequest.Descriptor, ApplyTokenResponse.Descriptor, - ApplyTokenResponse.Descriptor, + ApplyTokenResponse.Descriptor, ApplyTokenResponse.Descriptor, ApplyTokenResponse.Descriptor, diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs index a0c913d6..53fb8bb2 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs @@ -35,7 +35,7 @@ namespace OpenIddict.Server ExtractIntrospectionRequest.Descriptor, ValidateIntrospectionRequest.Descriptor, HandleIntrospectionRequest.Descriptor, - ApplyIntrospectionResponse.Descriptor, + ApplyIntrospectionResponse.Descriptor, ApplyIntrospectionResponse.Descriptor, /* diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Revocation.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Revocation.cs index 682d8be4..6989c537 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Revocation.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Revocation.cs @@ -30,7 +30,7 @@ namespace OpenIddict.Server ExtractRevocationRequest.Descriptor, ValidateRevocationRequest.Descriptor, HandleRevocationRequest.Descriptor, - ApplyRevocationResponse.Descriptor, + ApplyRevocationResponse.Descriptor, ApplyRevocationResponse.Descriptor, /* diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs index 6f377e55..a4ea3221 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs @@ -30,7 +30,7 @@ namespace OpenIddict.Server ExtractLogoutRequest.Descriptor, ValidateLogoutRequest.Descriptor, HandleLogoutRequest.Descriptor, - ApplyLogoutResponse.Descriptor, + ApplyLogoutResponse.Descriptor, ApplyLogoutResponse.Descriptor, ApplyLogoutResponse.Descriptor, diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs index 8ae93a95..2e0f409e 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs @@ -32,7 +32,7 @@ namespace OpenIddict.Server ValidateUserinfoRequest.Descriptor, HandleUserinfoRequest.Descriptor, ApplyUserinfoResponse.Descriptor, - ApplyUserinfoResponse.Descriptor, + ApplyUserinfoResponse.Descriptor, ApplyUserinfoResponse.Descriptor, /* diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index eb51dcf8..796fa5c5 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -793,8 +793,7 @@ namespace OpenIddict.Server var authorization = await _authorizationManager.FindByIdAsync(identifier); if (authorization == null || !await _authorizationManager.IsValidAsync(authorization)) { - context.Logger.LogError("The authorization associated with token '{Identifier}' " + - "was no longer valid.", context.Principal.GetInternalTokenId()); + context.Logger.LogError("The authorization '{Identifier}' was no longer valid.", identifier); context.Reject( error: context.EndpointType switch @@ -1406,8 +1405,9 @@ namespace OpenIddict.Server // Actors identities are also filtered (delegation scenarios). var principal = context.Principal.Clone(claim => { - // Never exclude the subject claim. - if (string.Equals(claim.Type, Claims.Subject, StringComparison.OrdinalIgnoreCase)) + // Never exclude the subject and authorization identifier claims. + if (string.Equals(claim.Type, Claims.Subject, StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, Claims.Private.AuthorizationId, StringComparison.OrdinalIgnoreCase)) { return true; } @@ -1655,13 +1655,14 @@ namespace OpenIddict.Server // Actors identities are also filtered (delegation scenarios). var principal = context.Principal.Clone(claim => { - // Never exclude the subject claim. - if (string.Equals(claim.Type, Claims.Subject, StringComparison.OrdinalIgnoreCase)) + // Never exclude the subject and authorization identifier claims. + if (string.Equals(claim.Type, Claims.Subject, StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, Claims.Private.AuthorizationId, StringComparison.OrdinalIgnoreCase)) { return true; } - // Always exclude private claims, whose values must generally be kept secret. + // Always exclude private claims by default, whose values must generally be kept secret. if (claim.Type.StartsWith(Claims.Prefixes.Private, StringComparison.OrdinalIgnoreCase)) { return false; @@ -2056,16 +2057,17 @@ namespace OpenIddict.Server descriptor.ApplicationId = await _applicationManager.GetIdAsync(application); } - descriptor.Payload = await context.Options.SecurityTokenHandler.CreateTokenFromDescriptorAsync(new SecurityTokenDescriptor - { - Claims = new Dictionary { [Claims.Private.TokenUsage] = TokenUsages.AccessToken }, - EncryptingCredentials = context.Options.EncryptionCredentials.FirstOrDefault( - credentials => credentials.Key is SymmetricSecurityKey), - Issuer = context.Issuer?.AbsoluteUri, - SigningCredentials = context.Options.SigningCredentials.FirstOrDefault(credentials => - credentials.Key is SymmetricSecurityKey) ?? context.Options.SigningCredentials.First(), - Subject = (ClaimsIdentity) context.AccessTokenPrincipal.Identity - }); + descriptor.Payload = await context.Options.SecurityTokenHandler.CreateTokenFromDescriptorAsync( + new SecurityTokenDescriptor + { + Claims = new Dictionary { [Claims.Private.TokenUsage] = TokenUsages.AccessToken }, + EncryptingCredentials = context.Options.EncryptionCredentials.FirstOrDefault( + credentials => credentials.Key is SymmetricSecurityKey), + Issuer = context.Issuer?.AbsoluteUri, + SigningCredentials = context.Options.SigningCredentials.FirstOrDefault(credentials => + credentials.Key is SymmetricSecurityKey) ?? context.Options.SigningCredentials.First(), + Subject = (ClaimsIdentity) context.AccessTokenPrincipal.Identity + }); await _tokenManager.CreateAsync(descriptor); @@ -2166,16 +2168,17 @@ namespace OpenIddict.Server descriptor.ApplicationId = await _applicationManager.GetIdAsync(application); } - descriptor.Payload = await context.Options.SecurityTokenHandler.CreateTokenFromDescriptorAsync(new SecurityTokenDescriptor - { - Claims = new Dictionary { [Claims.Private.TokenUsage] = TokenUsages.AuthorizationCode }, - EncryptingCredentials = context.Options.EncryptionCredentials.FirstOrDefault( - credentials => credentials.Key is SymmetricSecurityKey), - Issuer = context.Issuer?.AbsoluteUri, - SigningCredentials = context.Options.SigningCredentials.FirstOrDefault(credentials => - credentials.Key is SymmetricSecurityKey) ?? context.Options.SigningCredentials.First(), - Subject = (ClaimsIdentity) context.AuthorizationCodePrincipal.Identity - }); + descriptor.Payload = await context.Options.SecurityTokenHandler.CreateTokenFromDescriptorAsync( + new SecurityTokenDescriptor + { + Claims = new Dictionary { [Claims.Private.TokenUsage] = TokenUsages.AuthorizationCode }, + EncryptingCredentials = context.Options.EncryptionCredentials.FirstOrDefault( + credentials => credentials.Key is SymmetricSecurityKey), + Issuer = context.Issuer?.AbsoluteUri, + SigningCredentials = context.Options.SigningCredentials.FirstOrDefault(credentials => + credentials.Key is SymmetricSecurityKey) ?? context.Options.SigningCredentials.First(), + Subject = (ClaimsIdentity) context.AuthorizationCodePrincipal.Identity + }); await _tokenManager.CreateAsync(descriptor); @@ -2276,15 +2279,16 @@ namespace OpenIddict.Server descriptor.ApplicationId = await _applicationManager.GetIdAsync(application); } - descriptor.Payload = await context.Options.SecurityTokenHandler.CreateTokenFromDescriptorAsync(new SecurityTokenDescriptor - { - Claims = new Dictionary { [Claims.Private.TokenUsage] = TokenUsages.RefreshToken }, - EncryptingCredentials = context.Options.EncryptionCredentials[0], - Issuer = context.Issuer?.AbsoluteUri, - SigningCredentials = context.Options.SigningCredentials.FirstOrDefault(credentials => - credentials.Key is SymmetricSecurityKey) ?? context.Options.SigningCredentials.First(), - Subject = (ClaimsIdentity) context.RefreshTokenPrincipal.Identity - }); + descriptor.Payload = await context.Options.SecurityTokenHandler.CreateTokenFromDescriptorAsync( + new SecurityTokenDescriptor + { + Claims = new Dictionary { [Claims.Private.TokenUsage] = TokenUsages.RefreshToken }, + EncryptingCredentials = context.Options.EncryptionCredentials[0], + Issuer = context.Issuer?.AbsoluteUri, + SigningCredentials = context.Options.SigningCredentials.FirstOrDefault(credentials => + credentials.Key is SymmetricSecurityKey) ?? context.Options.SigningCredentials.First(), + Subject = (ClaimsIdentity) context.RefreshTokenPrincipal.Identity + }); await _tokenManager.CreateAsync(descriptor); diff --git a/src/OpenIddict.Server/OpenIddictServerProvider.cs b/src/OpenIddict.Server/OpenIddictServerProvider.cs index 7dc1e757..dcd8b388 100644 --- a/src/OpenIddict.Server/OpenIddictServerProvider.cs +++ b/src/OpenIddict.Server/OpenIddictServerProvider.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Text; using System.Threading.Tasks; using JetBrains.Annotations; @@ -85,8 +84,11 @@ namespace OpenIddict.Server descriptors.AddRange(_options.CurrentValue.CustomHandlers); descriptors.AddRange(_options.CurrentValue.DefaultHandlers); - foreach (var descriptor in descriptors.OrderBy(descriptor => descriptor.Order)) + descriptors.Sort((left, right) => left.Order.CompareTo(right.Order)); + + for (var index = 0; index < descriptors.Count; index++) { + var descriptor = descriptors[index]; if (descriptor.ContextType != typeof(TContext) || !await IsActiveAsync(descriptor)) { continue; diff --git a/src/OpenIddict.Server/OpenIddictServerTokenHandler.cs b/src/OpenIddict.Server/OpenIddictServerTokenHandler.cs index 057901f5..c549a282 100644 --- a/src/OpenIddict.Server/OpenIddictServerTokenHandler.cs +++ b/src/OpenIddict.Server/OpenIddictServerTokenHandler.cs @@ -81,6 +81,11 @@ namespace OpenIddict.Server throw new ArgumentNullException(nameof(parameters)); } + if (parameters.PropertyBag == null) + { + throw new InvalidOperationException("The property bag cannot be null."); + } + if (!parameters.PropertyBag.TryGetValue(Claims.Private.TokenUsage, out var type) || string.IsNullOrEmpty((string) type)) { throw new InvalidOperationException("The token usage cannot be null or empty."); diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddict.Validation.AspNetCore.csproj b/src/OpenIddict.Validation.AspNetCore/OpenIddict.Validation.AspNetCore.csproj new file mode 100644 index 00000000..221e3e83 --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddict.Validation.AspNetCore.csproj @@ -0,0 +1,25 @@ + + + + netcoreapp3.0 + + + + ASP.NET Core integration package for the OpenIddict validation services. + $(PackageTags);validation;aspnetcore + + + + + + + + + + + + + + + + diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreBuilder.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreBuilder.cs new file mode 100644 index 00000000..0bda0420 --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreBuilder.cs @@ -0,0 +1,73 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.ComponentModel; +using JetBrains.Annotations; +using OpenIddict.Validation.AspNetCore; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes the necessary methods required to configure + /// the OpenIddict validation ASP.NET Core integration. + /// + public class OpenIddictValidationAspNetCoreBuilder + { + /// + /// Initializes a new instance of . + /// + /// The services collection. + public OpenIddictValidationAspNetCoreBuilder([NotNull] IServiceCollection services) + => Services = services ?? throw new ArgumentNullException(nameof(services)); + + /// + /// Gets the services collection. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IServiceCollection Services { get; } + + /// + /// Amends the default OpenIddict validation ASP.NET Core configuration. + /// + /// The delegate used to configure the OpenIddict options. + /// This extension can be safely called multiple times. + /// The . + public OpenIddictValidationAspNetCoreBuilder Configure([NotNull] Action configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + Services.Configure(configuration); + + return this; + } + + /// + /// Determines whether the specified object is equal to the current object. + /// + /// The object to compare with the current object. + /// true if the specified object is equal to the current object; otherwise, false. + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals([CanBeNull] object obj) => base.Equals(obj); + + /// + /// Serves as the default hash function. + /// + /// A hash code for the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() => base.GetHashCode(); + + /// + /// Returns a string that represents the current object. + /// + /// A string that represents the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override string ToString() => base.ToString(); + } +} diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConfiguration.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConfiguration.cs new file mode 100644 index 00000000..829ba845 --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConfiguration.cs @@ -0,0 +1,98 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Diagnostics; +using System.Text; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace OpenIddict.Validation.AspNetCore +{ + /// + /// Contains the methods required to ensure that the OpenIddict validation configuration is valid. + /// + public class OpenIddictValidationAspNetCoreConfiguration : IConfigureOptions, + IConfigureNamedOptions, + IPostConfigureOptions + { + /// + /// Registers the OpenIddict validation handler in the global authentication options. + /// + /// The options instance to initialize. + public void Configure([NotNull] AuthenticationOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + // If a handler was already registered and the type doesn't correspond to the OpenIddict handler, throw an exception. + if (options.SchemeMap.TryGetValue(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, out var builder) && + builder.HandlerType != typeof(OpenIddictValidationAspNetCoreHandler)) + { + throw new InvalidOperationException(new StringBuilder() + .AppendLine("The OpenIddict ASP.NET Core validation handler cannot be registered as an authentication scheme.") + .Append("This may indicate that an instance of another handler was registered with the same scheme.") + .ToString()); + } + + options.AddScheme( + OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, displayName: null); + } + + public void Configure([NotNull] OpenIddictValidationOptions options) + => Debug.Fail("This infrastructure method shouldn't be called"); + + public void Configure([CanBeNull] string name, [NotNull] OpenIddictValidationOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + // Register the built-in event handlers used by the OpenIddict ASP.NET Core validation components. + foreach (var handler in OpenIddictValidationAspNetCoreHandlers.DefaultHandlers) + { + options.DefaultHandlers.Add(handler); + } + } + + /// + /// Ensures that the authentication configuration is in a consistent and valid state. + /// + /// The authentication scheme associated with the handler instance. + /// The options instance to initialize. + public void PostConfigure([CanBeNull] string name, [NotNull] AuthenticationOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + bool TryValidate(string scheme) + { + // If the scheme was not set or if it cannot be found in the map, return true. + if (string.IsNullOrEmpty(scheme) || !options.SchemeMap.TryGetValue(scheme, out var builder)) + { + return true; + } + + return builder.HandlerType != typeof(OpenIddictValidationAspNetCoreHandler); + } + + if (!TryValidate(options.DefaultSignInScheme) || !TryValidate(options.DefaultSignOutScheme)) + { + throw new InvalidOperationException(new StringBuilder() + .AppendLine("The OpenIddict ASP.NET Core validation cannot be used as the default sign-in/sign-out handler.") + .Append("Make sure that neither DefaultSignInScheme nor DefaultSignOutScheme ") + .Append("point to an instance of the OpenIddict ASP.NET Core validation handler.") + .ToString()); + } + } + } +} diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConstants.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConstants.cs new file mode 100644 index 00000000..7f4ff193 --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConstants.cs @@ -0,0 +1,27 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +namespace OpenIddict.Validation.AspNetCore +{ + /// + /// Exposes common constants used by the OpenIddict ASP.NET Core host. + /// + public static class OpenIddictValidationAspNetCoreConstants + { + public static class Cache + { + public const string AuthorizationRequest = "openiddict-authorization-request:"; + public const string LogoutRequest = "openiddict-logout-request:"; + } + + public static class Properties + { + public const string Error = ".error"; + public const string ErrorDescription = ".error_description"; + public const string ErrorUri = ".error_uri"; + } + } +} diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreDefaults.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreDefaults.cs new file mode 100644 index 00000000..f2857a0b --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreDefaults.cs @@ -0,0 +1,21 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using Microsoft.AspNetCore.Authentication; + +namespace OpenIddict.Validation.AspNetCore +{ + /// + /// Exposes the default values used by the OpenIddict validation handler. + /// + public static class OpenIddictValidationAspNetCoreDefaults + { + /// + /// Default value for . + /// + public const string AuthenticationScheme = "OpenIddict.Validation.AspNetCore"; + } +} diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreExtensions.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreExtensions.cs new file mode 100644 index 00000000..d5270d30 --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreExtensions.cs @@ -0,0 +1,88 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Linq; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenIddict.Validation; +using OpenIddict.Validation.AspNetCore; +using static OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlerFilters; +using static OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes extensions allowing to register the OpenIddict validation services. + /// + public static class OpenIddictValidationAspNetCoreExtensions + { + /// + /// Registers the OpenIddict validation services for ASP.NET Core in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictValidationAspNetCoreBuilder UseAspNetCore([NotNull] this OpenIddictValidationBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.AddAuthentication(); + + builder.Services.TryAddScoped(); + + // Register the built-in event handlers used by the OpenIddict ASP.NET Core validation components. + // Note: the order used here is not important, as the actual order is set in the options. + builder.Services.TryAdd(DefaultHandlers.Select(descriptor => descriptor.ServiceDescriptor)); + + // Register the built-in filters used by the default OpenIddict ASP.NET Core validation event handlers. + builder.Services.TryAddSingleton(); + + // Register the option initializer used by the OpenIddict ASP.NET Core validation integration services. + // Note: TryAddEnumerable() is used here to ensure the initializers are only registered once. + builder.Services.TryAddEnumerable(new[] + { + ServiceDescriptor.Singleton, OpenIddictValidationAspNetCoreConfiguration>(), + ServiceDescriptor.Singleton, OpenIddictValidationAspNetCoreConfiguration>(), + + ServiceDescriptor.Singleton, OpenIddictValidationAspNetCoreConfiguration>() + }); + + return new OpenIddictValidationAspNetCoreBuilder(builder.Services); + } + + /// + /// Registers the OpenIddict validation services for ASP.NET Core in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// The configuration delegate used to configure the validation services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictValidationBuilder UseAspNetCore( + [NotNull] this OpenIddictValidationBuilder builder, + [NotNull] Action configuration) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + configuration(builder.UseAspNetCore()); + + return builder; + } + } +} diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreFeature.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreFeature.cs new file mode 100644 index 00000000..5e07e916 --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreFeature.cs @@ -0,0 +1,20 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +namespace OpenIddict.Validation.AspNetCore +{ + /// + /// Exposes the current validation transaction to the ASP.NET Core host. + /// + public class OpenIddictValidationAspNetCoreFeature + { + /// + /// Gets or sets the validation transaction that encapsulates all specific + /// information about an individual OpenID Connect validation request. + /// + public OpenIddictValidationTransaction Transaction { get; set; } + } +} diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandler.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandler.cs new file mode 100644 index 00000000..16daee24 --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandler.cs @@ -0,0 +1,225 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Validation.OpenIddictValidationEvents; +using Properties = OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreConstants.Properties; + +namespace OpenIddict.Validation.AspNetCore +{ + /// + /// Provides the logic necessary to extract, validate and handle OpenID Connect requests. + /// + public class OpenIddictValidationAspNetCoreHandler : AuthenticationHandler, + IAuthenticationRequestHandler + { + private readonly IOpenIddictValidationProvider _provider; + + /// + /// Creates a new instance of the class. + /// + public OpenIddictValidationAspNetCoreHandler( + [NotNull] IOpenIddictValidationProvider provider, + [NotNull] IOptionsMonitor options, + [NotNull] ILoggerFactory logger, + [NotNull] UrlEncoder encoder, + [NotNull] ISystemClock clock) + : base(options, logger, encoder, clock) + => _provider = provider; + + public async Task HandleRequestAsync() + { + // Note: the transaction may be already attached when replaying an ASP.NET Core request + // (e.g when using the built-in status code pages middleware with the re-execute mode). + var transaction = Context.Features.Get()?.Transaction; + if (transaction == null) + { + // Create a new transaction and attach the HTTP request to make it available to the ASP.NET Core handlers. + transaction = await _provider.CreateTransactionAsync(); + transaction.Properties[typeof(HttpRequest).FullName] = new WeakReference(Request); + + // Attach the OpenIddict validation transaction to the ASP.NET Core features + // so that it can retrieved while performing challenge/forbid operations. + Context.Features.Set(new OpenIddictValidationAspNetCoreFeature { Transaction = transaction }); + } + + var context = new ProcessRequestContext(transaction); + await _provider.DispatchAsync(context); + + if (context.IsRequestHandled) + { + return true; + } + + else if (context.IsRequestSkipped) + { + return false; + } + + else if (context.IsRejected) + { + var notification = new ProcessErrorContext(transaction) + { + Response = new OpenIddictResponse + { + Error = context.Error ?? Errors.InvalidRequest, + ErrorDescription = context.ErrorDescription, + ErrorUri = context.ErrorUri + } + }; + + await _provider.DispatchAsync(notification); + + if (notification.IsRequestHandled) + { + return true; + } + + else if (notification.IsRequestSkipped) + { + return false; + } + + throw new InvalidOperationException(new StringBuilder() + .Append("The OpenID Connect response was not correctly processed. This may indicate ") + .Append("that the event handler responsible of processing OpenID Connect responses ") + .Append("was not registered or was explicitly removed from the handlers list.") + .ToString()); + } + + return false; + } + + protected override async Task HandleAuthenticateAsync() + { + var transaction = Context.Features.Get()?.Transaction; + if (transaction == null) + { + throw new InvalidOperationException("An identity cannot be extracted from this request."); + } + + var context = new ProcessAuthenticationContext(transaction); + await _provider.DispatchAsync(context); + + if (context.Principal == null || context.IsRequestHandled || context.IsRequestSkipped) + { + return AuthenticateResult.NoResult(); + } + + else if (context.IsRejected) + { + var builder = new StringBuilder(); + + if (!string.IsNullOrEmpty(context.Error)) + { + builder.AppendLine("An error occurred while authenticating the current request:"); + builder.AppendFormat("Error code: ", context.Error); + + if (!string.IsNullOrEmpty(context.ErrorDescription)) + { + builder.AppendLine(); + builder.AppendFormat("Error description: ", context.ErrorDescription); + } + + if (!string.IsNullOrEmpty(context.ErrorUri)) + { + builder.AppendLine(); + builder.AppendFormat("Error URI: ", context.ErrorUri); + } + } + + else + { + builder.Append("An unknown error occurred while authenticating the current request."); + } + + return AuthenticateResult.Fail(new Exception(builder.ToString()) + { + // Note: the error details are stored as additional exception properties, + // which is similar to what other ASP.NET Core security handlers do. + Data = + { + [Parameters.Error] = context.Error, + [Parameters.ErrorDescription] = context.ErrorDescription, + [Parameters.ErrorUri] = context.ErrorUri + } + }); + } + + return AuthenticateResult.Success(new AuthenticationTicket( + context.Principal, + OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)); + } + + protected override async Task HandleChallengeAsync([CanBeNull] AuthenticationProperties properties) + { + var transaction = Context.Features.Get()?.Transaction; + if (transaction == null) + { + throw new InvalidOperationException("An OpenID Connect response cannot be returned from this endpoint."); + } + + var context = new ProcessChallengeContext(transaction) + { + Response = new OpenIddictResponse + { + Error = GetProperty(properties, Properties.Error), + ErrorDescription = GetProperty(properties, Properties.ErrorDescription), + ErrorUri = GetProperty(properties, Properties.ErrorUri) + } + }; + + await _provider.DispatchAsync(context); + + if (context.IsRequestHandled || context.IsRequestSkipped) + { + return; + } + + else if (context.IsRejected) + { + var notification = new ProcessErrorContext(transaction) + { + Response = new OpenIddictResponse + { + Error = context.Error ?? Errors.InvalidRequest, + ErrorDescription = context.ErrorDescription, + ErrorUri = context.ErrorUri + } + }; + + await _provider.DispatchAsync(notification); + + if (notification.IsRequestHandled || context.IsRequestSkipped) + { + return; + } + + throw new InvalidOperationException(new StringBuilder() + .Append("The OpenID Connect response was not correctly processed. This may indicate ") + .Append("that the event handler responsible of processing OpenID Connect responses ") + .Append("was not registered or was explicitly removed from the handlers list.") + .ToString()); + } + + static string GetProperty(AuthenticationProperties properties, string name) + => properties != null && properties.Items.TryGetValue(name, out string value) ? value : null; + } + + protected override Task HandleForbiddenAsync([CanBeNull] AuthenticationProperties properties) + => HandleChallengeAsync(properties); + } +} diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandlerFilters.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandlerFilters.cs new file mode 100644 index 00000000..aaf06181 --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandlerFilters.cs @@ -0,0 +1,38 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.AspNetCore; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace OpenIddict.Validation.AspNetCore +{ + /// + /// Contains a collection of event handler filters commonly used by the ASP.NET Core handlers. + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public static class OpenIddictValidationAspNetCoreHandlerFilters + { + /// + /// Represents a filter that excludes the associated handlers if no ASP.NET Core request can be found. + /// + public class RequireHttpRequest : IOpenIddictValidationHandlerFilter + { + public ValueTask IsActiveAsync([NotNull] BaseContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(context.Transaction.GetHttpRequest() != null); + } + } + } +} diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandlers.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandlers.cs new file mode 100644 index 00000000..fa77e68a --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandlers.cs @@ -0,0 +1,303 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Immutable; +using System.ComponentModel; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; +using Newtonsoft.Json; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlerFilters; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace OpenIddict.Validation.AspNetCore +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public static partial class OpenIddictValidationAspNetCoreHandlers + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Request top-level processing: + */ + InferIssuerFromHost.Descriptor, + ExtractGetOrPostRequest.Descriptor, + ExtractAccessToken.Descriptor, + + /* + * Response processing: + */ + ProcessJsonResponse.Descriptor, + ProcessJsonResponse.Descriptor); + + /// + /// Contains the logic responsible of infering the default issuer from the HTTP request host and validating it. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class InferIssuerFromHost : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(int.MinValue + 100_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessRequestContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var request = context.Transaction.GetHttpRequest(); + if (request == null) + { + throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); + } + + // Only use the current host as the issuer if the + // issuer was not explicitly set in the options. + if (context.Issuer != null) + { + return default; + } + + if (!request.Host.HasValue) + { + context.Reject( + error: Errors.InvalidRequest, + description: "The mandatory 'Host' header is missing."); + + return default; + } + + if (!Uri.TryCreate(request.Scheme + "://" + request.Host + request.PathBase, UriKind.Absolute, out Uri issuer) || + !issuer.IsWellFormedOriginalString()) + { + context.Reject( + error: Errors.InvalidRequest, + description: "The specified 'Host' header is invalid."); + + return default; + } + + context.Issuer = issuer; + + return default; + } + } + + /// + /// Contains the logic responsible of extracting OpenID Connect requests from GET or POST HTTP requests. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class ExtractGetOrPostRequest : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(InferIssuerFromHost.Descriptor.Order + 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public async ValueTask HandleAsync([NotNull] ProcessRequestContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var request = context.Transaction.GetHttpRequest(); + if (request == null) + { + throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); + } + + if (HttpMethods.IsGet(request.Method)) + { + context.Request = new OpenIddictRequest(request.Query); + } + + else if (HttpMethods.IsPost(request.Method) && !string.IsNullOrEmpty(request.ContentType) && + request.ContentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) + { + context.Request = new OpenIddictRequest(await request.ReadFormAsync(request.HttpContext.RequestAborted)); + } + + else + { + context.Request = new OpenIddictRequest(); + } + } + } + + /// + /// Contains the logic responsible of extracting an access token from the standard HTTP Authorization header. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class ExtractAccessToken : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ExtractGetOrPostRequest.Descriptor.Order + 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessRequestContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var request = context.Transaction.GetHttpRequest(); + if (request == null) + { + throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); + } + + string header = request.Headers[HeaderNames.Authorization]; + if (string.IsNullOrEmpty(header) || !header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return default; + } + + // Attach the access token to the request message. + context.Request.AccessToken = header.Substring("Bearer ".Length); + + return default; + } + } + + /// + /// Contains the logic responsible of processing OpenID Connect responses that must be returned as JSON. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class ProcessJsonResponse : IOpenIddictValidationHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(int.MinValue + 100_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public async ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response == null) + { + throw new InvalidOperationException("This handler cannot be invoked without a response attached."); + } + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var request = context.Transaction.GetHttpRequest(); + if (request == null) + { + throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); + } + + context.Logger.LogInformation("The response was successfully returned as a JSON document: {Response}.", context.Response); + + using (var buffer = new MemoryStream()) + using (var writer = new JsonTextWriter(new StreamWriter(buffer))) + { + var serializer = JsonSerializer.CreateDefault(); + serializer.Serialize(writer, context.Response); + + writer.Flush(); + + if (!string.IsNullOrEmpty(context.Response.Error)) + { + if (context.Issuer == null) + { + throw new InvalidOperationException("The issuer address cannot be inferred from the current request."); + } + + request.HttpContext.Response.StatusCode = 401; + + request.HttpContext.Response.Headers[HeaderNames.WWWAuthenticate] = new StringBuilder() + .Append(Schemes.Bearer) + .Append(' ') + .Append(Parameters.Realm) + .Append("=\"") + .Append(context.Issuer.AbsoluteUri) + .Append('"') + .ToString(); + } + + request.HttpContext.Response.ContentLength = buffer.Length; + request.HttpContext.Response.ContentType = "application/json;charset=UTF-8"; + + buffer.Seek(offset: 0, loc: SeekOrigin.Begin); + await buffer.CopyToAsync(request.HttpContext.Response.Body, 4096, request.HttpContext.RequestAborted); + } + + context.HandleRequest(); + } + } + } +} diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHelpers.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHelpers.cs new file mode 100644 index 00000000..2facfb68 --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHelpers.cs @@ -0,0 +1,92 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using OpenIddict.Abstractions; +using OpenIddict.Validation; +using OpenIddict.Validation.AspNetCore; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace Microsoft.AspNetCore +{ + /// + /// Exposes companion extensions for the OpenIddict/ASP.NET Core integration. + /// + public static class OpenIddictValidationAspNetCoreHelpers + { + /// + /// Retrieves the instance stored in the properties. + /// + /// The transaction instance. + /// The instance or null if it couldn't be found. + public static HttpRequest GetHttpRequest([NotNull] this OpenIddictValidationTransaction transaction) + { + if (transaction == null) + { + throw new ArgumentNullException(nameof(transaction)); + } + + if (!transaction.Properties.TryGetValue(typeof(HttpRequest).FullName, out object property)) + { + return null; + } + + if (property is WeakReference reference && reference.TryGetTarget(out HttpRequest request)) + { + return request; + } + + return null; + } + + /// + /// Retrieves the instance stored in . + /// + /// The context instance. + /// The . + public static OpenIddictValidationEndpointType GetOpenIddictValidationEndpointType([NotNull] this HttpContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return context.Features.Get()?.Transaction?.EndpointType ?? default; + } + + /// + /// Retrieves the instance stored in . + /// + /// The context instance. + /// The instance or null if it couldn't be found. + public static OpenIddictRequest GetOpenIddictValidationRequest([NotNull] this HttpContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return context.Features.Get()?.Transaction?.Request; + } + + /// + /// Retrieves the instance stored in . + /// + /// The context instance. + /// The instance or null if it couldn't be found. + public static OpenIddictResponse GetOpenIddictValidationResponse([NotNull] this HttpContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return context.Features.Get()?.Transaction?.Response; + } + } +} diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreOptions.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreOptions.cs new file mode 100644 index 00000000..28c76ebb --- /dev/null +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreOptions.cs @@ -0,0 +1,17 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using Microsoft.AspNetCore.Authentication; + +namespace OpenIddict.Validation.AspNetCore +{ + /// + /// Provides various settings needed to configure the OpenIddict ASP.NET Core validation integration. + /// + public class OpenIddictValidationAspNetCoreOptions : AuthenticationSchemeOptions + { + } +} diff --git a/src/OpenIddict.Validation.DataProtection/IOpenIddictValidationDataProtectionFormatter.cs b/src/OpenIddict.Validation.DataProtection/IOpenIddictValidationDataProtectionFormatter.cs new file mode 100644 index 00000000..8fa709fc --- /dev/null +++ b/src/OpenIddict.Validation.DataProtection/IOpenIddictValidationDataProtectionFormatter.cs @@ -0,0 +1,17 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.IO; +using System.Security.Claims; +using JetBrains.Annotations; + +namespace OpenIddict.Validation.DataProtection +{ + public interface IOpenIddictValidationDataProtectionFormatter + { + ClaimsPrincipal ReadToken([NotNull] BinaryReader reader); + } +} \ No newline at end of file diff --git a/src/OpenIddict.Validation.DataProtection/OpenIddict.Validation.DataProtection.csproj b/src/OpenIddict.Validation.DataProtection/OpenIddict.Validation.DataProtection.csproj new file mode 100644 index 00000000..3e5bc924 --- /dev/null +++ b/src/OpenIddict.Validation.DataProtection/OpenIddict.Validation.DataProtection.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0;netstandard2.1 + + + + ASP.NET Core Data Protection integration package for the OpenIddict validation services. + $(PackageTags);validation;dataprotection + + + + + + + + + + + + diff --git a/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionBuilder.cs b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionBuilder.cs new file mode 100644 index 00000000..9f73f73d --- /dev/null +++ b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionBuilder.cs @@ -0,0 +1,105 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.ComponentModel; +using JetBrains.Annotations; +using Microsoft.AspNetCore.DataProtection; +using OpenIddict.Validation.DataProtection; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes the necessary methods required to configure the + /// OpenIddict ASP.NET Core Data Protection integration. + /// + public class OpenIddictValidationDataProtectionBuilder + { + /// + /// Initializes a new instance of . + /// + /// The services collection. + public OpenIddictValidationDataProtectionBuilder([NotNull] IServiceCollection services) + => Services = services ?? throw new ArgumentNullException(nameof(services)); + + /// + /// Gets the services collection. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IServiceCollection Services { get; } + + /// + /// Amends the default OpenIddict validation ASP.NET Core Data Protection configuration. + /// + /// The delegate used to configure the OpenIddict options. + /// This extension can be safely called multiple times. + /// The . + public OpenIddictValidationDataProtectionBuilder Configure([NotNull] Action configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + Services.Configure(configuration); + + return this; + } + + /// + /// Configures OpenIddict to use a specific data protection provider + /// instead of relying on the default instance provided by the DI container. + /// + /// The data protection provider used to create token protectors. + /// The . + public OpenIddictValidationDataProtectionBuilder UseDataProtectionProvider([NotNull] IDataProtectionProvider provider) + { + if (provider == null) + { + throw new ArgumentNullException(nameof(provider)); + } + + return Configure(options => options.DataProtectionProvider = provider); + } + + /// + /// Configures OpenIddict to use a specific formatter instead of relying on the default instance. + /// + /// The formatter used to read and write tokens. + /// The . + public OpenIddictValidationDataProtectionBuilder UseFormatter([NotNull] IOpenIddictValidationDataProtectionFormatter formatter) + { + if (formatter == null) + { + throw new ArgumentNullException(nameof(formatter)); + } + + return Configure(options => options.Formatter = formatter); + } + + /// + /// Determines whether the specified object is equal to the current object. + /// + /// The object to compare with the current object. + /// true if the specified object is equal to the current object; otherwise, false. + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals([CanBeNull] object obj) => base.Equals(obj); + + /// + /// Serves as the default hash function. + /// + /// A hash code for the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() => base.GetHashCode(); + + /// + /// Returns a string that represents the current object. + /// + /// A string that represents the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override string ToString() => base.ToString(); + } +} diff --git a/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionConfiguration.cs b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionConfiguration.cs new file mode 100644 index 00000000..3a48f608 --- /dev/null +++ b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionConfiguration.cs @@ -0,0 +1,64 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using JetBrains.Annotations; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace OpenIddict.Validation.DataProtection +{ + /// + /// Contains the methods required to ensure that the OpenIddict ASP.NET Core Data Protection configuration is valid. + /// + public class OpenIddictValidationDataProtectionConfiguration : IConfigureOptions, + IPostConfigureOptions + { + private readonly IDataProtectionProvider _dataProtectionProvider; + + /// + /// Creates a new instance of the class. + /// + /// The ASP.NET Core Data Protection provider. + public OpenIddictValidationDataProtectionConfiguration([NotNull] IDataProtectionProvider dataProtectionProvider) + => _dataProtectionProvider = dataProtectionProvider; + + public void Configure([NotNull] OpenIddictValidationOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + // Use empty token validation parameters to ensure the core OpenIddict validation components + // don't throw an exception stating that an issuer or a metadata address was not set. + options.TokenValidationParameters = new TokenValidationParameters(); + + // Register the built-in event handlers used by the OpenIddict Data Protection validation components. + foreach (var handler in OpenIddictValidationDataProtectionHandlers.DefaultHandlers) + { + options.DefaultHandlers.Add(handler); + } + } + + /// + /// Populates the default OpenIddict ASP.NET Core Data Protection validation options + /// and ensures that the configuration is in a consistent and valid state. + /// + /// The authentication scheme associated with the handler instance. + /// The options instance to initialize. + public void PostConfigure([CanBeNull] string name, [NotNull] OpenIddictValidationDataProtectionOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + options.DataProtectionProvider ??= _dataProtectionProvider; + } + } +} diff --git a/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionConstants.cs b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionConstants.cs new file mode 100644 index 00000000..7fbe1fbf --- /dev/null +++ b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionConstants.cs @@ -0,0 +1,57 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +namespace OpenIddict.Validation.DataProtection +{ + public static class OpenIddictValidationDataProtectionConstants + { + public static class Properties + { + public const string AccessTokenLifetime = ".access_token_lifetime"; + public const string AuthorizationCodeLifetime = ".authorization_code_lifetime"; + public const string Audiences = ".audiences"; + public const string CodeChallenge = ".code_challenge"; + public const string CodeChallengeMethod = ".code_challenge_method"; + public const string DataProtector = ".data_protector"; + public const string Expires = ".expires"; + public const string IdentityTokenLifetime = ".identity_token_lifetime"; + public const string InternalAuthorizationId = ".internal_authorization_id"; + public const string InternalTokenId = ".internal_token_id"; + public const string Issued = ".issued"; + public const string Nonce = ".nonce"; + public const string OriginalRedirectUri = ".original_redirect_uri"; + public const string Presenters = ".presenters"; + public const string RefreshTokenLifetime = ".refresh_token_lifetime"; + public const string Resources = ".resources"; + public const string Scopes = ".scopes"; + public const string TokenId = ".token_id"; + public const string TokenUsage = ".token_usage"; + } + + public static class Purposes + { + public static class Features + { + public const string ReferenceTokens = "UseReferenceTokens"; + } + + public static class Formats + { + public const string AccessToken = "AccessTokenFormat"; + } + + public static class Handlers + { + public const string Server = "OpenIdConnectServerHandler"; + } + + public static class Schemes + { + public const string Server = "ASOS"; + } + } + } +} diff --git a/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionExtensions.cs b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionExtensions.cs new file mode 100644 index 00000000..7a535f78 --- /dev/null +++ b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionExtensions.cs @@ -0,0 +1,78 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Linq; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenIddict.Validation; +using OpenIddict.Validation.DataProtection; +using static OpenIddict.Validation.DataProtection.OpenIddictValidationDataProtectionHandlers; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes extensions allowing to register the OpenIddict ASP.NET Core Data Protection validation services. + /// + public static class OpenIddictValidationDataProtectionExtensions + { + /// + /// Registers the OpenIddict ASP.NET Core Data Protection validation services in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictValidationDataProtectionBuilder UseDataProtection([NotNull] this OpenIddictValidationBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.AddDataProtection(); + + // Register the built-in validation event handlers used by the OpenIddict Data Protection components. + // Note: the order used here is not important, as the actual order is set in the options. + builder.Services.TryAdd(DefaultHandlers.Select(descriptor => descriptor.ServiceDescriptor)); + + // Note: TryAddEnumerable() is used here to ensure the initializers are registered only once. + builder.Services.TryAddEnumerable(new[] + { + ServiceDescriptor.Singleton, OpenIddictValidationDataProtectionConfiguration>(), + ServiceDescriptor.Singleton, OpenIddictValidationDataProtectionConfiguration>() + }); + + return new OpenIddictValidationDataProtectionBuilder(builder.Services); + } + + /// + /// Registers the OpenIddict ASP.NET Core Data Protection validation services in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// The configuration delegate used to configure the validation services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictValidationBuilder UseDataProtection( + [NotNull] this OpenIddictValidationBuilder builder, + [NotNull] Action configuration) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + configuration(builder.UseDataProtection()); + + return builder; + } + } +} diff --git a/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionFormatter.cs b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionFormatter.cs new file mode 100644 index 00000000..bf9595f5 --- /dev/null +++ b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionFormatter.cs @@ -0,0 +1,183 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Claims; +using JetBrains.Annotations; +using Newtonsoft.Json.Linq; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; +using Properties = OpenIddict.Validation.DataProtection.OpenIddictValidationDataProtectionConstants.Properties; + +namespace OpenIddict.Validation.DataProtection +{ + public class OpenIddictValidationDataProtectionFormatter : IOpenIddictValidationDataProtectionFormatter + { + public ClaimsPrincipal ReadToken([NotNull] BinaryReader reader) + { + if (reader == null) + { + throw new ArgumentNullException(nameof(reader)); + } + + var (principal, properties) = Read(reader, version: 5); + if (principal == null) + { + return null; + } + + // Tokens serialized using the ASP.NET Core Data Protection stack are compound + // of both claims and special authentication properties. To ensure existing tokens + // can be reused, well-known properties are manually mapped to their claims equivalents. + + return principal + .SetAudiences(GetArrayProperty(properties, Properties.Audiences)) + .SetCreationDate(GetDateProperty(properties, Properties.Issued)) + .SetExpirationDate(GetDateProperty(properties, Properties.Expires)) + .SetPresenters(GetArrayProperty(properties, Properties.Presenters)) + .SetScopes(GetArrayProperty(properties, Properties.Scopes)) + + .SetClaim(Claims.Private.AccessTokenLifetime, GetProperty(properties, Properties.AccessTokenLifetime)) + .SetClaim(Claims.Private.AuthorizationCodeLifetime, GetProperty(properties, Properties.AuthorizationCodeLifetime)) + .SetClaim(Claims.Private.AuthorizationId, GetProperty(properties, Properties.InternalAuthorizationId)) + .SetClaim(Claims.Private.CodeChallenge, GetProperty(properties, Properties.CodeChallenge)) + .SetClaim(Claims.Private.CodeChallengeMethod, GetProperty(properties, Properties.CodeChallengeMethod)) + .SetClaim(Claims.Private.IdentityTokenLifetime, GetProperty(properties, Properties.IdentityTokenLifetime)) + .SetClaim(Claims.Private.Nonce, GetProperty(properties, Properties.Nonce)) + .SetClaim(Claims.Private.RedirectUri, GetProperty(properties, Properties.OriginalRedirectUri)) + .SetClaim(Claims.Private.RefreshTokenLifetime, GetProperty(properties, Properties.RefreshTokenLifetime)) + .SetClaim(Claims.Private.TokenId, GetProperty(properties, Properties.InternalTokenId)); + + static (ClaimsPrincipal principal, ImmutableDictionary properties) Read(BinaryReader reader, int version) + { + if (version != reader.ReadInt32()) + { + return (null, ImmutableDictionary.Create()); + } + + // Read the authentication scheme associated to the ticket. + _ = reader.ReadString(); + + // Read the number of identities stored in the serialized payload. + var count = reader.ReadInt32(); + if (count < 0) + { + return (null, ImmutableDictionary.Create()); + } + + var identities = new ClaimsIdentity[count]; + for (var index = 0; index != count; ++index) + { + identities[index] = ReadIdentity(reader); + } + + var properties = ReadProperties(reader, version); + + return (new ClaimsPrincipal(identities), properties); + } + + static ClaimsIdentity ReadIdentity(BinaryReader reader) + { + var identity = new ClaimsIdentity( + authenticationType: reader.ReadString(), + nameType: ReadWithDefault(reader, ClaimsIdentity.DefaultNameClaimType), + roleType: ReadWithDefault(reader, ClaimsIdentity.DefaultRoleClaimType)); + + // Read the number of claims contained in the serialized identity. + var count = reader.ReadInt32(); + + for (int index = 0; index != count; ++index) + { + var claim = ReadClaim(reader, identity); + + identity.AddClaim(claim); + } + + // Determine whether the identity has a bootstrap context attached. + if (reader.ReadBoolean()) + { + identity.BootstrapContext = reader.ReadString(); + } + + // Determine whether the identity has an actor identity attached. + if (reader.ReadBoolean()) + { + identity.Actor = ReadIdentity(reader); + } + + return identity; + } + + static Claim ReadClaim(BinaryReader reader, ClaimsIdentity identity) + { + var type = ReadWithDefault(reader, identity.NameClaimType); + var value = reader.ReadString(); + var valueType = ReadWithDefault(reader, ClaimValueTypes.String); + var issuer = ReadWithDefault(reader, ClaimsIdentity.DefaultIssuer); + var originalIssuer = ReadWithDefault(reader, issuer); + + var claim = new Claim(type, value, valueType, issuer, originalIssuer, identity); + + // Read the number of properties stored in the claim. + var count = reader.ReadInt32(); + + for (var index = 0; index != count; ++index) + { + var key = reader.ReadString(); + var propertyValue = reader.ReadString(); + + claim.Properties.Add(key, propertyValue); + } + + return claim; + } + + static ImmutableDictionary ReadProperties(BinaryReader reader, int version) + { + if (version != reader.ReadInt32()) + { + return ImmutableDictionary.Create(); + } + + var properties = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + var count = reader.ReadInt32(); + for (var index = 0; index != count; ++index) + { + properties.Add(reader.ReadString(), reader.ReadString()); + } + + return properties.ToImmutable(); + } + + static string ReadWithDefault(BinaryReader reader, string defaultValue) + { + var value = reader.ReadString(); + + if (string.Equals(value, "\0", StringComparison.Ordinal)) + { + return defaultValue; + } + + return value; + } + + static string GetProperty(IReadOnlyDictionary properties, string name) + => properties.TryGetValue(name, out var value) ? value : null; + + static IEnumerable GetArrayProperty(IReadOnlyDictionary properties, string name) + => properties.TryGetValue(name, out var value) ? JArray.Parse(value).Values() : Enumerable.Empty(); + + static DateTimeOffset? GetDateProperty(IReadOnlyDictionary properties, string name) + => properties.TryGetValue(name, out var value) ? (DateTimeOffset?) + DateTimeOffset.ParseExact(value, "r", CultureInfo.InvariantCulture) : null; + } + } +} diff --git a/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionHandlers.cs b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionHandlers.cs new file mode 100644 index 00000000..3391d672 --- /dev/null +++ b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionHandlers.cs @@ -0,0 +1,232 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Immutable; +using System.ComponentModel; +using System.IO; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Validation.DataProtection.OpenIddictValidationDataProtectionConstants; +using static OpenIddict.Validation.OpenIddictValidationEvents; +using static OpenIddict.Validation.OpenIddictValidationHandlerFilters; +using static OpenIddict.Validation.OpenIddictValidationHandlers; + +namespace OpenIddict.Validation.DataProtection +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public static partial class OpenIddictValidationDataProtectionHandlers + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Authentication processing: + */ + ValidateReferenceDataProtectionToken.Descriptor, + ValidateSelfContainedDataProtectionToken.Descriptor); + + /// + /// Contains the logic responsible of rejecting authentication + /// demands that use an invalid reference Data Protection token. + /// Note: this handler is not used when the degraded mode is enabled. + /// + public class ValidateReferenceDataProtectionToken : IOpenIddictValidationHandler + { + private readonly IOpenIddictTokenManager _tokenManager; + private readonly IOptionsMonitor _options; + + public ValidateReferenceDataProtectionToken() => throw new InvalidOperationException(new StringBuilder() + .AppendLine("The core services must be registered when enabling the OpenIddict server feature.") + .Append("To register the OpenIddict core services, reference the 'OpenIddict.Core' package ") + .AppendLine("and call 'services.AddOpenIddict().AddCore()' from 'ConfigureServices'.") + .Append("Alternatively, you can disable the built-in database-based server features by enabling ") + .Append("the degraded mode with 'services.AddOpenIddict().AddValidation().EnableDegradedMode()'.") + .ToString()); + + public ValidateReferenceDataProtectionToken( + [NotNull] IOpenIddictTokenManager tokenManager, + [NotNull] IOptionsMonitor options) + { + _tokenManager = tokenManager; + _options = options; + } + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidateReferenceToken.Descriptor.Order + 500) + .Build(); + + public async ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If a principal was already attached, don't overwrite it. + if (context.Principal != null) + { + return; + } + + // If the token cannot be validated, don't return an error to allow another handle to validate it. + var identifier = context.Request.AccessToken; + if (string.IsNullOrEmpty(identifier)) + { + return; + } + + var token = await _tokenManager.FindByReferenceIdAsync(identifier); + if (token == null || !string.Equals(await _tokenManager.GetTypeAsync(token), + TokenUsages.AccessToken, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var payload = await _tokenManager.GetPayloadAsync(token); + if (string.IsNullOrEmpty(payload)) + { + throw new InvalidOperationException(new StringBuilder() + .AppendLine("The payload associated with a reference token cannot be retrieved.") + .Append("This may indicate that the token entry was corrupted.") + .ToString()); + } + + // Create a Data Protection protector using the provider registered in the options. + var protector = _options.CurrentValue.DataProtectionProvider.CreateProtector( + Purposes.Handlers.Server, + Purposes.Formats.AccessToken, + Purposes.Features.ReferenceTokens, + Purposes.Schemes.Server); + + ClaimsPrincipal principal = null; + + try + { + using var buffer = new MemoryStream(protector.Unprotect(Base64UrlEncoder.DecodeBytes(payload))); + using var reader = new BinaryReader(buffer); + + principal = _options.CurrentValue.Formatter.ReadToken(reader); + } + + catch (Exception exception) + { + context.Logger.LogTrace(exception, "An exception occured while deserializing a token."); + } + + // If the token cannot be validated, don't return an error to allow another handle to validate it. + if (principal == null) + { + return; + } + + // Attach the principal extracted from the authorization code to the parent event context + // and restore the creation/expiration dates/identifiers from the token entry metadata. + context.Principal = principal + .SetCreationDate(await _tokenManager.GetCreationDateAsync(token)) + .SetExpirationDate(await _tokenManager.GetExpirationDateAsync(token)) + .SetInternalAuthorizationId(await _tokenManager.GetAuthorizationIdAsync(token)) + .SetInternalTokenId(await _tokenManager.GetIdAsync(token)) + .SetClaim(Claims.Private.TokenUsage, await _tokenManager.GetTypeAsync(token)); + } + } + + /// + /// Contains the logic responsible of rejecting authentication demands + /// that specify an invalid self-contained Data Protection token. + /// + public class ValidateSelfContainedDataProtectionToken : IOpenIddictValidationHandler + { + private readonly IOptionsMonitor _options; + + public ValidateSelfContainedDataProtectionToken([NotNull] IOptionsMonitor options) + => _options = options; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateSelfContainedToken.Descriptor.Order + 500) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If a principal was already attached, don't overwrite it. + if (context.Principal != null) + { + return default; + } + + // If the token cannot be validated, don't return an error to allow another handle to validate it. + var token = context.Request.AccessToken; + if (string.IsNullOrEmpty(token)) + { + return default; + } + + // Create a Data Protection protector using the provider registered in the options. + var protector = _options.CurrentValue.DataProtectionProvider.CreateProtector( + Purposes.Handlers.Server, + Purposes.Formats.AccessToken, + Purposes.Schemes.Server); + + ClaimsPrincipal principal = null; + + try + { + using var buffer = new MemoryStream(protector.Unprotect(Base64UrlEncoder.DecodeBytes(token))); + using var reader = new BinaryReader(buffer); + + principal = _options.CurrentValue.Formatter.ReadToken(reader); + } + + catch (Exception exception) + { + context.Logger.LogTrace(exception, "An exception occured while deserializing a token."); + } + + // If the token cannot be validated, don't return an error to allow another handle to validate it. + if (principal == null) + { + return default; + } + + // Note: since the data format relies on a data protector using different "purposes" strings + // per token type, the token processed at this stage is guaranteed to be of the expected type. + context.Principal = principal.SetClaim(Claims.Private.TokenUsage, TokenUsages.AccessToken); + + return default; + } + } + } +} diff --git a/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionOptions.cs b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionOptions.cs new file mode 100644 index 00000000..1234b521 --- /dev/null +++ b/src/OpenIddict.Validation.DataProtection/OpenIddictValidationDataProtectionOptions.cs @@ -0,0 +1,31 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using Microsoft.AspNetCore.DataProtection; + +namespace OpenIddict.Validation.DataProtection +{ + /// + /// Provides various settings needed to configure the OpenIddict validation handler. + /// + public class OpenIddictValidationDataProtectionOptions + { + /// + /// Gets or sets the data protection provider used to create the default + /// data protectors used by the OpenIddict Data Protection validation services. + /// When this property is set to null, the data protection provider + /// is directly retrieved from the dependency injection container. + /// + public IDataProtectionProvider DataProtectionProvider { get; set; } + + /// + /// Gets or sets the formatter used to read and write Data Protection tokens, + /// serialized using the same format as the ASP.NET Core authentication tickets. + /// + public IOpenIddictValidationDataProtectionFormatter Formatter { get; set; } + = new OpenIddictValidationDataProtectionFormatter(); + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddict.Validation.Owin.csproj b/src/OpenIddict.Validation.Owin/OpenIddict.Validation.Owin.csproj new file mode 100644 index 00000000..ae551217 --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddict.Validation.Owin.csproj @@ -0,0 +1,24 @@ + + + + net461;net472 + + + + OWIN/ASP.NET 4.x integration package for the OpenIddict validation services. + $(PackageTags);validation;aspnet;katana;owin + + + + + + + + + + + + + + + diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinBuilder.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinBuilder.cs new file mode 100644 index 00000000..0c101b1e --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinBuilder.cs @@ -0,0 +1,73 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.ComponentModel; +using JetBrains.Annotations; +using OpenIddict.Validation.Owin; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes the necessary methods required to configure + /// the OpenIddict validation OWIN/Katana integration. + /// + public class OpenIddictValidationOwinBuilder + { + /// + /// Initializes a new instance of . + /// + /// The services collection. + public OpenIddictValidationOwinBuilder([NotNull] IServiceCollection services) + => Services = services ?? throw new ArgumentNullException(nameof(services)); + + /// + /// Gets the services collection. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IServiceCollection Services { get; } + + /// + /// Amends the default OpenIddict validation OWIN/Katana configuration. + /// + /// The delegate used to configure the OpenIddict options. + /// This extension can be safely called multiple times. + /// The . + public OpenIddictValidationOwinBuilder Configure([NotNull] Action configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + Services.Configure(configuration); + + return this; + } + + /// + /// Determines whether the specified object is equal to the current object. + /// + /// The object to compare with the current object. + /// true if the specified object is equal to the current object; otherwise, false. + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals([CanBeNull] object obj) => base.Equals(obj); + + /// + /// Serves as the default hash function. + /// + /// A hash code for the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() => base.GetHashCode(); + + /// + /// Returns a string that represents the current object. + /// + /// A string that represents the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override string ToString() => base.ToString(); + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinConfiguration.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinConfiguration.cs new file mode 100644 index 00000000..77c28b8e --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinConfiguration.cs @@ -0,0 +1,36 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Diagnostics; +using JetBrains.Annotations; +using Microsoft.Extensions.Options; + +namespace OpenIddict.Validation.Owin +{ + /// + /// Contains the methods required to ensure that the OpenIddict validation configuration is valid. + /// + public class OpenIddictValidationOwinConfiguration : IConfigureNamedOptions + { + public void Configure([NotNull] OpenIddictValidationOptions options) + => Debug.Fail("This infrastructure method shouldn't be called"); + + public void Configure([CanBeNull] string name, [NotNull] OpenIddictValidationOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + // Register the built-in event handlers used by the OpenIddict OWIN validation components. + foreach (var handler in OpenIddictValidationOwinHandlers.DefaultHandlers) + { + options.DefaultHandlers.Add(handler); + } + } + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinConstants.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinConstants.cs new file mode 100644 index 00000000..0716639f --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinConstants.cs @@ -0,0 +1,27 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +namespace OpenIddict.Validation.Owin +{ + /// + /// Exposes common constants used by the OpenIddict OWIN host. + /// + public static class OpenIddictValidationOwinConstants + { + public static class Cache + { + public const string AuthorizationRequest = "openiddict-authorization-request:"; + public const string LogoutRequest = "openiddict-logout-request:"; + } + + public static class Properties + { + public const string Error = ".error"; + public const string ErrorDescription = ".error_description"; + public const string ErrorUri = ".error_uri"; + } + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinDefaults.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinDefaults.cs new file mode 100644 index 00000000..78041388 --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinDefaults.cs @@ -0,0 +1,21 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using Microsoft.Owin.Security; + +namespace OpenIddict.Validation.Owin +{ + /// + /// Exposes the default values used by the OpenIddict validation handler. + /// + public static class OpenIddictValidationOwinDefaults + { + /// + /// Default value for . + /// + public const string AuthenticationType = "OpenIddict.Validation.Owin"; + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinExtensions.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinExtensions.cs new file mode 100644 index 00000000..c235a135 --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinExtensions.cs @@ -0,0 +1,87 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Linq; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenIddict.Validation; +using OpenIddict.Validation.Owin; +using static OpenIddict.Validation.Owin.OpenIddictValidationOwinHandlerFilters; +using static OpenIddict.Validation.Owin.OpenIddictValidationOwinHandlers; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes extensions allowing to register the OpenIddict validation services. + /// + public static class OpenIddictValidationOwinExtensions + { + /// + /// Registers the OpenIddict validation services for OWIN in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictValidationOwinBuilder UseOwin([NotNull] this OpenIddictValidationBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.AddWebEncoders(); + + // Note: unlike regular OWIN middleware, the OpenIddict validation middleware is registered + // as a scoped service in the DI container. This allows containers that support middleware + // resolution (like Autofac) to use it without requiring additional configuration. + builder.Services.TryAddScoped(); + + // Register the built-in event handlers used by the OpenIddict OWIN validation components. + // Note: the order used here is not important, as the actual order is set in the options. + builder.Services.TryAdd(DefaultHandlers.Select(descriptor => descriptor.ServiceDescriptor)); + + // Register the built-in filters used by the default OpenIddict OWIN validation event handlers. + builder.Services.TryAddSingleton(); + + // Register the option initializers used by the OpenIddict OWIN validation integration services. + // Note: TryAddEnumerable() is used here to ensure the initializers are only registered once. + builder.Services.TryAddEnumerable(new[] + { + ServiceDescriptor.Singleton, OpenIddictValidationOwinConfiguration>() + }); + + return new OpenIddictValidationOwinBuilder(builder.Services); + } + + /// + /// Registers the OpenIddict validation services for OWIN in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// The configuration delegate used to configure the validation services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictValidationBuilder UseOwin( + [NotNull] this OpenIddictValidationBuilder builder, + [NotNull] Action configuration) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + configuration(builder.UseOwin()); + + return builder; + } + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandler.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandler.cs new file mode 100644 index 00000000..4d0affb5 --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandler.cs @@ -0,0 +1,214 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Microsoft.Owin; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.Infrastructure; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Validation.OpenIddictValidationEvents; +using Properties = OpenIddict.Validation.Owin.OpenIddictValidationOwinConstants.Properties; + +namespace OpenIddict.Validation.Owin +{ + /// + /// Provides the entry point necessary to register the OpenIddict validation in an OWIN pipeline. + /// + public class OpenIddictValidationOwinHandler : AuthenticationHandler + { + private readonly ILogger _logger; + private readonly IOpenIddictValidationProvider _provider; + + /// + /// Creates a new instance of the class. + /// + /// The logger used by this instance. + /// The OpenIddict validation OWIN provider used by this instance. + public OpenIddictValidationOwinHandler( + [NotNull] ILogger logger, + [NotNull] IOpenIddictValidationProvider provider) + { + _logger = logger; + _provider = provider; + } + + public override async Task InvokeAsync() + { + // Note: the transaction may be already attached when replaying an OWIN request + // (e.g when using a status code pages middleware re-invoking the OWIN pipeline). + var transaction = Context.Get(typeof(OpenIddictValidationTransaction).FullName); + if (transaction == null) + { + // Create a new transaction and attach the OWIN request to make it available to the OWIN handlers. + transaction = await _provider.CreateTransactionAsync(); + transaction.Properties[typeof(IOwinRequest).FullName] = new WeakReference(Request); + + // Attach the OpenIddict validation transaction to the OWIN shared dictionary + // so that it can retrieved while performing sign-in/sign-out operations. + Context.Set(typeof(OpenIddictValidationTransaction).FullName, transaction); + } + + var context = new ProcessRequestContext(transaction); + await _provider.DispatchAsync(context); + + if (context.IsRequestHandled) + { + return true; + } + + else if (context.IsRequestSkipped) + { + return false; + } + + else if (context.IsRejected) + { + var notification = new ProcessErrorContext(transaction) + { + Response = new OpenIddictResponse + { + Error = context.Error ?? Errors.InvalidRequest, + ErrorDescription = context.ErrorDescription, + ErrorUri = context.ErrorUri + } + }; + + await _provider.DispatchAsync(notification); + + if (notification.IsRequestHandled) + { + return true; + } + + else if (notification.IsRequestSkipped) + { + return false; + } + + throw new InvalidOperationException(new StringBuilder() + .Append("The OpenID Connect response was not correctly processed. This may indicate ") + .Append("that the event handler responsible of processing OpenID Connect responses ") + .Append("was not registered or was explicitly removed from the handlers list.") + .ToString()); + } + + return false; + } + + protected override async Task AuthenticateCoreAsync() + { + var transaction = Context.Get(typeof(OpenIddictValidationTransaction).FullName); + if (transaction?.Request == null) + { + throw new InvalidOperationException("An identity cannot be extracted from this request."); + } + + var context = new ProcessAuthenticationContext(transaction); + await _provider.DispatchAsync(context); + + if (context.Principal == null || context.IsRequestHandled || context.IsRequestSkipped) + { + return null; + } + + else if (context.IsRejected) + { + _logger.LogError("An error occurred while authenticating the current request: {Error} ; {Description}", + /* Error: */ context.Error ?? Errors.InvalidToken, + /* Description: */ context.ErrorDescription); + + return new AuthenticationTicket(identity: null, new AuthenticationProperties + { + Dictionary = + { + [Parameters.Error] = context.Error, + [Parameters.ErrorDescription] = context.ErrorDescription, + [Parameters.ErrorUri] = context.ErrorUri + } + }); + } + + return new AuthenticationTicket((ClaimsIdentity) context.Principal.Identity, new AuthenticationProperties()); + } + + protected override async Task TeardownCoreAsync() + { + // Note: OWIN authentication handlers cannot reliabily write to the response stream + // from ApplyResponseGrantAsync or ApplyResponseChallengeAsync because these methods + // are susceptible to be invoked from AuthenticationHandler.OnSendingHeaderCallback, + // where calling Write or WriteAsync on the response stream may result in a deadlock + // on hosts using streamed responses. To work around this limitation, this handler + // doesn't implement ApplyResponseGrantAsync but TeardownCoreAsync, which is never called + // by AuthenticationHandler.OnSendingHeaderCallback. In theory, this would prevent + // OpenIddictValidationOwinMiddleware from both applying the response grant and allowing + // the next middleware in the pipeline to alter the response stream but in practice, + // OpenIddictValidationOwinMiddleware is assumed to be the only middleware allowed to write + // to the response stream when a response grant (sign-in/out or challenge) was applied. + + var challenge = Helper.LookupChallenge(Options.AuthenticationType, Options.AuthenticationMode); + if (challenge != null) + { + var transaction = Context.Get(typeof(OpenIddictValidationTransaction).FullName); + if (transaction == null) + { + throw new InvalidOperationException("An OpenID Connect response cannot be returned from this endpoint."); + } + + var context = new ProcessChallengeContext(transaction) + { + Response = new OpenIddictResponse + { + Error = GetProperty(challenge.Properties, Properties.Error), + ErrorDescription = GetProperty(challenge.Properties, Properties.ErrorDescription), + ErrorUri = GetProperty(challenge.Properties, Properties.ErrorUri) + } + }; + + await _provider.DispatchAsync(context); + + if (context.IsRequestHandled || context.IsRequestSkipped) + { + return; + } + + else if (context.IsRejected) + { + var notification = new ProcessErrorContext(transaction) + { + Response = new OpenIddictResponse + { + Error = context.Error ?? Errors.InvalidRequest, + ErrorDescription = context.ErrorDescription, + ErrorUri = context.ErrorUri + } + }; + + await _provider.DispatchAsync(notification); + + if (notification.IsRequestHandled || context.IsRequestSkipped) + { + return; + } + + throw new InvalidOperationException(new StringBuilder() + .Append("The OpenID Connect response was not correctly processed. This may indicate ") + .Append("that the event handler responsible of processing OpenID Connect responses ") + .Append("was not registered or was explicitly removed from the handlers list.") + .ToString()); + } + + static string GetProperty(AuthenticationProperties properties, string name) + => properties != null && properties.Dictionary.TryGetValue(name, out string value) ? value : null; + } + } + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandlerFilters.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandlerFilters.cs new file mode 100644 index 00000000..148821a1 --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandlerFilters.cs @@ -0,0 +1,36 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Owin; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace OpenIddict.Validation.Owin +{ + /// + /// Contains a collection of event handler filters commonly used by the OWIN handlers. + /// + public static class OpenIddictValidationOwinHandlerFilters + { + /// + /// Represents a filter that excludes the associated handlers if no OWIN request can be found. + /// + public class RequireOwinRequest : IOpenIddictValidationHandlerFilter + { + public ValueTask IsActiveAsync([NotNull] BaseContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(context.Transaction.GetOwinRequest() != null); + } + } + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandlers.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandlers.cs new file mode 100644 index 00000000..f69d3d8a --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandlers.cs @@ -0,0 +1,302 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Immutable; +using System.ComponentModel; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using OpenIddict.Abstractions; +using Owin; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Validation.OpenIddictValidationEvents; +using static OpenIddict.Validation.Owin.OpenIddictValidationOwinHandlerFilters; + +namespace OpenIddict.Validation.Owin +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public static partial class OpenIddictValidationOwinHandlers + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Request top-level processing: + */ + InferIssuerFromHost.Descriptor, + ExtractGetOrPostRequest.Descriptor, + ExtractAccessToken.Descriptor, + + /* + * Response processing: + */ + ProcessJsonResponse.Descriptor, + ProcessJsonResponse.Descriptor); + + /// + /// Contains the logic responsible of infering the default issuer from the HTTP request host and validating it. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class InferIssuerFromHost : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(int.MinValue + 100_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessRequestContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var request = context.Transaction.GetOwinRequest(); + if (request == null) + { + throw new InvalidOperationException("The OWIN request cannot be resolved."); + } + + // Only use the current host as the issuer if the + // issuer was not explicitly set in the options. + if (context.Issuer != null) + { + return default; + } + + if (string.IsNullOrEmpty(request.Host.Value)) + { + context.Reject( + error: Errors.InvalidRequest, + description: "The mandatory 'Host' header is missing."); + + return default; + } + + if (!Uri.TryCreate(request.Scheme + "://" + request.Host + request.PathBase, UriKind.Absolute, out Uri issuer) || + !issuer.IsWellFormedOriginalString()) + { + context.Reject( + error: Errors.InvalidRequest, + description: "The specified 'Host' header is invalid."); + + return default; + } + + context.Issuer = issuer; + + return default; + } + } + + /// + /// Contains the logic responsible of extracting OpenID Connect requests from GET or POST HTTP requests. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class ExtractGetOrPostRequest : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(InferIssuerFromHost.Descriptor.Order + 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public async ValueTask HandleAsync([NotNull] ProcessRequestContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var request = context.Transaction.GetOwinRequest(); + if (request == null) + { + throw new InvalidOperationException("The OWIN request cannot be resolved."); + } + + if (string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase)) + { + context.Request = new OpenIddictRequest(request.Query); + } + + else if (string.Equals(request.Method, "POST", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(request.ContentType) && + request.ContentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) + { + context.Request = new OpenIddictRequest(await request.ReadFormAsync()); + } + + else + { + context.Request = new OpenIddictRequest(); + } + } + } + + /// + /// Contains the logic responsible of extracting an access token from the standard HTTP Authorization header. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class ExtractAccessToken : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ExtractGetOrPostRequest.Descriptor.Order + 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessRequestContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var request = context.Transaction.GetOwinRequest(); + if (request == null) + { + throw new InvalidOperationException("The OWIN request cannot be resolved."); + } + + string header = request.Headers["Authorization"]; + if (string.IsNullOrEmpty(header) || !header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return default; + } + + // Attach the access token to the request message. + context.Request.AccessToken = header.Substring("Bearer ".Length); + + return default; + } + } + + /// + /// Contains the logic responsible of processing OpenID Connect responses that must be returned as JSON. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class ProcessJsonResponse : IOpenIddictValidationHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(int.MinValue + 100_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public async ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response == null) + { + throw new InvalidOperationException("This handler cannot be invoked without a response attached."); + } + + // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var request = context.Transaction.GetOwinRequest(); + if (request == null) + { + throw new InvalidOperationException("The OWIN request cannot be resolved."); + } + + context.Logger.LogInformation("The response was successfully returned as a JSON document: {Response}.", context.Response); + + using (var buffer = new MemoryStream()) + using (var writer = new JsonTextWriter(new StreamWriter(buffer))) + { + var serializer = JsonSerializer.CreateDefault(); + serializer.Serialize(writer, context.Response); + + writer.Flush(); + + if (!string.IsNullOrEmpty(context.Response.Error)) + { + if (context.Issuer == null) + { + throw new InvalidOperationException("The issuer address cannot be inferred from the current request."); + } + + request.Context.Response.StatusCode = 401; + + request.Context.Response.Headers["WWW-Authenticate"] = new StringBuilder() + .Append(Schemes.Bearer) + .Append(' ') + .Append(Parameters.Realm) + .Append("=\"") + .Append(context.Issuer.AbsoluteUri) + .Append('"') + .ToString(); + } + + request.Context.Response.ContentLength = buffer.Length; + request.Context.Response.ContentType = "application/json;charset=UTF-8"; + + buffer.Seek(offset: 0, loc: SeekOrigin.Begin); + await buffer.CopyToAsync(request.Context.Response.Body, 4096, request.CallCancelled); + } + + context.HandleRequest(); + } + } + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHelpers.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHelpers.cs new file mode 100644 index 00000000..da604a0c --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHelpers.cs @@ -0,0 +1,109 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using JetBrains.Annotations; +using Microsoft.Owin; +using OpenIddict.Abstractions; +using OpenIddict.Validation; +using OpenIddict.Validation.Owin; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace Owin +{ + /// + /// Exposes companion extensions for the OpenIddict/OWIN integration. + /// + public static class OpenIddictValidationOwinHelpers + { + /// + /// Registers the OpenIddict validation OWIN middleware in the application pipeline. + /// Note: when using a dependency injection container supporting per-request + /// middleware resolution (like Autofac), calling this method is NOT recommended. + /// + /// The application builder used to register middleware instances. + /// The . + public static IAppBuilder UseOpenIddictValidation([NotNull] this IAppBuilder app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + return app.Use(); + } + + /// + /// Retrieves the instance stored in the properties. + /// + /// The transaction instance. + /// The instance or null if it couldn't be found. + public static IOwinRequest GetOwinRequest([NotNull] this OpenIddictValidationTransaction transaction) + { + if (transaction == null) + { + throw new ArgumentNullException(nameof(transaction)); + } + + if (!transaction.Properties.TryGetValue(typeof(IOwinRequest).FullName, out object property)) + { + return null; + } + + if (property is WeakReference reference && reference.TryGetTarget(out IOwinRequest request)) + { + return request; + } + + return null; + } + + /// + /// Retrieves the instance stored in . + /// + /// The context instance. + /// The . + public static OpenIddictValidationEndpointType GetOpenIddictValidationEndpointType([NotNull] this IOwinContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return context.Get(typeof(OpenIddictValidationTransaction).FullName)?.EndpointType ?? default; + } + + /// + /// Retrieves the instance stored in . + /// + /// The context instance. + /// The instance or null if it couldn't be found. + public static OpenIddictRequest GetOpenIddictValidationRequest([NotNull] this IOwinContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return context.Get(typeof(OpenIddictValidationTransaction).FullName)?.Request; + } + + /// + /// Retrieves the instance stored in . + /// + /// The context instance. + /// The instance or null if it couldn't be found. + public static OpenIddictResponse GetOpenIddictValidationResponse([NotNull] this IOwinContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return context.Get(typeof(OpenIddictValidationTransaction).FullName)?.Response; + } + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinMiddleware.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinMiddleware.cs new file mode 100644 index 00000000..3f1d372c --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinMiddleware.cs @@ -0,0 +1,51 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Owin; +using Microsoft.Owin.Security.Infrastructure; + +namespace OpenIddict.Validation.Owin +{ + /// + /// Provides the entry point necessary to register the OpenIddict validation in an OWIN pipeline. + /// Note: this middleware is intented to be used with dependency injection containers + /// that support middleware resolution, like Autofac. Since it depends on scoped services, + /// it is NOT recommended to instantiate it as a singleton like a regular OWIN middleware. + /// + public class OpenIddictValidationOwinMiddleware : AuthenticationMiddleware + { + private readonly ILogger _logger; + private readonly IOpenIddictValidationProvider _provider; + + /// + /// Creates a new instance of the class. + /// + /// The next middleware in the pipeline, if applicable. + /// The logger used by this middleware. + /// The OpenIddict validation OWIN options. + /// The OpenIddict validation provider. + public OpenIddictValidationOwinMiddleware( + [CanBeNull] OwinMiddleware next, + [NotNull] ILogger logger, + [NotNull] IOptionsMonitor options, + [NotNull] IOpenIddictValidationProvider provider) + : base(next, options.CurrentValue) + { + _logger = logger; + _provider = provider; + } + + /// + /// Creates and returns a new instance. + /// + /// A new instance of the class. + protected override AuthenticationHandler CreateHandler() + => new OpenIddictValidationOwinHandler(_logger, _provider); + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinMiddlewareFactory.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinMiddlewareFactory.cs new file mode 100644 index 00000000..e9e6a6e8 --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinMiddlewareFactory.cs @@ -0,0 +1,80 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Text; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Owin; + +namespace OpenIddict.Validation.Owin +{ + /// + /// Provides the entry point necessary to instantiate and register the scoped + /// in an OWIN/Katana pipeline. + /// + public class OpenIddictValidationOwinMiddlewareFactory : OwinMiddleware + { + /// + /// Creates a new instance of the class. + /// + /// The next middleware in the pipeline, if applicable. + public OpenIddictValidationOwinMiddlewareFactory([CanBeNull] OwinMiddleware next) + : base(next) + { + } + + /// + /// Resolves the instance from the OWIN context + /// and creates a new instance of the class, + /// which is used to register in the pipeline. + /// + /// The . + /// + /// A that can be used to monitor the asynchronous operation. + /// + public override Task Invoke([NotNull] IOwinContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var provider = context.Get(typeof(IServiceProvider).FullName); + if (provider == null) + { + throw new InvalidOperationException(new StringBuilder() + .Append("No service provider was found in the OWIN context. For the OpenIddict validation ") + .Append("services to work correctly, a per-request 'IServiceProvider' must be attached ") + .AppendLine("to the OWIN environment with the dictionary key 'System.IServiceProvider'.") + .Append("Note: when using a dependency injection container supporting middleware resolution ") + .Append("(like Autofac), the 'app.UseOpenIddictValidation()' extension MUST NOT be called.") + .ToString()); + } + + // Note: the Microsoft.Extensions.DependencyInjection container doesn't support resolving services + // with arbitrary parameters, which prevents the validation OWIN middleware from being resolved directly + // from the DI container, as the next middleware in the pipeline cannot be specified as a parameter. + // To work around this limitation, the validation OWIN middleware is manually instantiated and invoked. + var middleware = new OpenIddictValidationOwinMiddleware( + next: Next, + logger: GetRequiredService>(provider), + options: GetRequiredService>(provider), + provider: GetRequiredService(provider)); + + return middleware.Invoke(context); + + static T GetRequiredService(IServiceProvider provider) + => provider.GetService() ?? throw new InvalidOperationException(new StringBuilder() + .AppendLine("The OpenIddict validation authentication services cannot be resolved from the DI container.") + .Append("To register the OWIN services, use 'services.AddOpenIddict().AddValidation().UseOwin()'.") + .ToString()); + } + } +} diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinOptions.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinOptions.cs new file mode 100644 index 00000000..b13b9e09 --- /dev/null +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinOptions.cs @@ -0,0 +1,23 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using Microsoft.Owin.Security; + +namespace OpenIddict.Validation.Owin +{ + /// + /// Provides various settings needed to configure the OpenIddict OWIN validation integration. + /// + public class OpenIddictValidationOwinOptions : AuthenticationOptions + { + /// + /// Creates a new instance of the class. + /// + public OpenIddictValidationOwinOptions() + : base(OpenIddictValidationOwinDefaults.AuthenticationType) + => AuthenticationMode = AuthenticationMode.Passive; + } +} diff --git a/src/OpenIddict.Validation.ServerIntegration/OpenIddict.Validation.ServerIntegration.csproj b/src/OpenIddict.Validation.ServerIntegration/OpenIddict.Validation.ServerIntegration.csproj new file mode 100644 index 00000000..8ca905cb --- /dev/null +++ b/src/OpenIddict.Validation.ServerIntegration/OpenIddict.Validation.ServerIntegration.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0;netstandard2.1 + + + + OpenID Connect validation/server integration for OpenIddict. + $(PackageTags);server;validation + + + + + + + + + + + + diff --git a/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationBuilder.cs b/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationBuilder.cs new file mode 100644 index 00000000..908c7000 --- /dev/null +++ b/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationBuilder.cs @@ -0,0 +1,72 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.ComponentModel; +using JetBrains.Annotations; +using OpenIddict.Validation.ServerIntegration; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes the necessary methods required to configure the OpenIddict validation services. + /// + public class OpenIddictValidationServerIntegrationBuilder + { + /// + /// Initializes a new instance of . + /// + /// The services collection. + public OpenIddictValidationServerIntegrationBuilder([NotNull] IServiceCollection services) + => Services = services ?? throw new ArgumentNullException(nameof(services)); + + /// + /// Gets the services collection. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IServiceCollection Services { get; } + + /// + /// Amends the default OpenIddict validation/server integration configuration. + /// + /// The delegate used to configure the OpenIddict options. + /// This extension can be safely called multiple times. + /// The . + public OpenIddictValidationServerIntegrationBuilder Configure([NotNull] Action configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + Services.Configure(configuration); + + return this; + } + + /// + /// Determines whether the specified object is equal to the current object. + /// + /// The object to compare with the current object. + /// true if the specified object is equal to the current object; otherwise, false. + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals([CanBeNull] object obj) => base.Equals(obj); + + /// + /// Serves as the default hash function. + /// + /// A hash code for the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() => base.GetHashCode(); + + /// + /// Returns a string that represents the current object. + /// + /// A string that represents the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override string ToString() => base.ToString(); + } +} diff --git a/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationConfiguration.cs b/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationConfiguration.cs new file mode 100644 index 00000000..b71bd62d --- /dev/null +++ b/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationConfiguration.cs @@ -0,0 +1,65 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Linq; +using JetBrains.Annotations; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Server; + +namespace OpenIddict.Validation.ServerIntegration +{ + /// + /// Contains the methods required to ensure that the OpenIddict validation/server integration configuration is valid. + /// + public class OpenIddictValidationServerIntegrationConfiguration : IConfigureOptions + { + private readonly IOptionsMonitor _options; + + /// + /// Creates a new instance of the class. + /// + /// The OpenIddict server options. + public OpenIddictValidationServerIntegrationConfiguration([NotNull] IOptionsMonitor options) + => _options = options; + + /// + /// Populates the default OpenIddict validation/server integration options + /// and ensures that the configuration is in a consistent and valid state. + /// + /// The options instance to initialize. + public void Configure([NotNull] OpenIddictValidationOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + // Note: the issuer may be null. In this case, it will be usually be provided by + // a validation handler registered by the host (e.g ASP.NET Core or OWIN/Katana) + options.Issuer = _options.CurrentValue.Issuer; + + // Import the token validation parameters from the server configuration. + options.TokenValidationParameters = new TokenValidationParameters + { + IssuerSigningKeys = (from credentials in _options.CurrentValue.SigningCredentials + select credentials.Key).ToList() + }; + + // Import the symmetric encryption keys from the server configuration. + foreach (var credentials in _options.CurrentValue.EncryptionCredentials) + { + if (credentials.Key is SymmetricSecurityKey) + { + options.EncryptionCredentials.Add(credentials); + } + } + + options.UseReferenceTokens = _options.CurrentValue.UseReferenceTokens; + } + } +} diff --git a/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationExtensions.cs b/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationExtensions.cs new file mode 100644 index 00000000..c94d51e8 --- /dev/null +++ b/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationExtensions.cs @@ -0,0 +1,69 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenIddict.Validation; +using OpenIddict.Validation.ServerIntegration; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes extensions allowing to register the OpenIddict validation/server integration services. + /// + public static class OpenIddictValidationServerIntegrationExtensions + { + /// + /// Registers the OpenIddict validation/server integration services in the DI container + /// and automatically imports the configuration from the local OpenIddict server. + /// + /// The services builder used by OpenIddict to register new services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictValidationServerIntegrationBuilder UseLocalServer([NotNull] this OpenIddictValidationBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + // Note: TryAddEnumerable() is used here to ensure the initializer is registered only once. + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< + IConfigureOptions, OpenIddictValidationServerIntegrationConfiguration>()); + + return new OpenIddictValidationServerIntegrationBuilder(builder.Services); + } + + /// + /// Registers the OpenIddict validation/server integration services in the DI container + /// and automatically imports the configuration from the local OpenIddict server. + /// + /// The services builder used by OpenIddict to register new services. + /// The configuration delegate used to configure the validation services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictValidationBuilder UseLocalServer( + [NotNull] this OpenIddictValidationBuilder builder, + [NotNull] Action configuration) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + configuration(builder.UseLocalServer()); + + return builder; + } + } +} diff --git a/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationOptions.cs b/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationOptions.cs new file mode 100644 index 00000000..787b6fcc --- /dev/null +++ b/src/OpenIddict.Validation.ServerIntegration/OpenIddictValidationServerIntegrationOptions.cs @@ -0,0 +1,15 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +namespace OpenIddict.Validation.ServerIntegration +{ + /// + /// Provides various settings needed to configure the OpenIddict validation/server integration. + /// + public class OpenIddictValidationServerIntegrationOptions + { + } +} diff --git a/src/OpenIddict.Validation.SystemNetHttp/OpenIddict.Validation.SystemNetHttp.csproj b/src/OpenIddict.Validation.SystemNetHttp/OpenIddict.Validation.SystemNetHttp.csproj new file mode 100644 index 00000000..4bf2a9ab --- /dev/null +++ b/src/OpenIddict.Validation.SystemNetHttp/OpenIddict.Validation.SystemNetHttp.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.0;netstandard2.1 + + + + OpenID Connect validation/System.Net.Http integration for OpenIddict. + $(PackageTags);http;httpclient;validation + + + + + + + + + + + + + + diff --git a/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpBuilder.cs b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpBuilder.cs new file mode 100644 index 00000000..44b1845e --- /dev/null +++ b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpBuilder.cs @@ -0,0 +1,82 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.ComponentModel; +using System.Net.Http; +using JetBrains.Annotations; +using OpenIddict.Validation.SystemNetHttp; +using Polly; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes the necessary methods required to configure the OpenIddict validation/System.Net.Http integration. + /// + public class OpenIddictValidationSystemNetHttpBuilder + { + /// + /// Initializes a new instance of . + /// + /// The services collection. + public OpenIddictValidationSystemNetHttpBuilder([NotNull] IServiceCollection services) + => Services = services ?? throw new ArgumentNullException(nameof(services)); + + /// + /// Gets the services collection. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IServiceCollection Services { get; } + + /// + /// Amends the default OpenIddict validation/server integration configuration. + /// + /// The delegate used to configure the OpenIddict options. + /// This extension can be safely called multiple times. + /// The . + public OpenIddictValidationSystemNetHttpBuilder Configure([NotNull] Action configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + Services.Configure(configuration); + + return this; + } + + /// + /// Replaces the default HTTP error policy used by the OpenIddict validation services. + /// + /// The HTTP Polly error policy. + /// The . + public OpenIddictValidationSystemNetHttpBuilder SetHttpErrorPolicy([CanBeNull] IAsyncPolicy policy) + => Configure(options => options.HttpErrorPolicy = policy); + + /// + /// Determines whether the specified object is equal to the current object. + /// + /// The object to compare with the current object. + /// true if the specified object is equal to the current object; otherwise, false. + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals([CanBeNull] object obj) => base.Equals(obj); + + /// + /// Serves as the default hash function. + /// + /// A hash code for the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() => base.GetHashCode(); + + /// + /// Returns a string that represents the current object. + /// + /// A string that represents the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override string ToString() => base.ToString(); + } +} diff --git a/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConfiguration.cs b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConfiguration.cs new file mode 100644 index 00000000..66c7e739 --- /dev/null +++ b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConfiguration.cs @@ -0,0 +1,74 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Diagnostics; +using System.Net.Http.Headers; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Options; +using static OpenIddict.Validation.SystemNetHttp.OpenIddictValidationSystemNetHttpConstants; + +namespace OpenIddict.Validation.SystemNetHttp +{ + /// + /// Contains the methods required to ensure that the OpenIddict validation/System.Net.Http integration configuration is valid. + /// + public class OpenIddictValidationSystemNetHttpConfiguration : IConfigureOptions, + IConfigureNamedOptions + { + public void Configure([NotNull] OpenIddictValidationOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + // Register the built-in event handlers used by the OpenIddict System.Net.Http validation components. + foreach (var handler in OpenIddictValidationSystemNetHttpHandlers.DefaultHandlers) + { + options.DefaultHandlers.Add(handler); + } + } + + public void Configure([NotNull] HttpClientFactoryOptions options) + => Debug.Fail("This infrastructure method shouldn't be called."); + + public void Configure([CanBeNull] string name, [NotNull] HttpClientFactoryOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (!string.Equals(name, Clients.Discovery, StringComparison.Ordinal)) + { + return; + } + + options.HttpClientActions.Add(client => + { + var name = typeof(OpenIddictValidationSystemNetHttpConfiguration).Assembly.GetName(); + + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue( + productName: name.Name, + productVersion: name.Version.ToString())); + }); + + options.HttpMessageHandlerBuilderActions.Add(builder => + { + var options = builder.Services.GetRequiredService>(); + + var policy = options.CurrentValue.HttpErrorPolicy; + if (policy != null) + { + builder.AdditionalHandlers.Add(new PolicyHttpMessageHandler(policy)); + } + }); + } + } +} diff --git a/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConstants.cs b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConstants.cs new file mode 100644 index 00000000..3dba825b --- /dev/null +++ b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConstants.cs @@ -0,0 +1,16 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +namespace OpenIddict.Validation.SystemNetHttp +{ + public static class OpenIddictValidationSystemNetHttpConstants + { + public static class Clients + { + public const string Discovery = "OpenIddict.Validation.SystemNetHttp.Discovery"; + } + } +} diff --git a/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpExtensions.cs b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpExtensions.cs new file mode 100644 index 00000000..5b74fa16 --- /dev/null +++ b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpExtensions.cs @@ -0,0 +1,80 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Linq; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Options; +using OpenIddict.Validation; +using OpenIddict.Validation.SystemNetHttp; +using static OpenIddict.Validation.SystemNetHttp.OpenIddictValidationSystemNetHttpHandlers; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes extensions allowing to register the OpenIddict validation/System.Net.Http integration services. + /// + public static class OpenIddictValidationSystemNetHttpExtensions + { + /// + /// Registers the OpenIddict validation/System.Net.Http integration services in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictValidationSystemNetHttpBuilder UseSystemNetHttp([NotNull] this OpenIddictValidationBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.AddHttpClient(); + builder.Services.AddMemoryCache(); + + // Register the built-in validation event handlers used by the OpenIddict System.Net.Http components. + // Note: the order used here is not important, as the actual order is set in the options. + builder.Services.TryAdd(DefaultHandlers.Select(descriptor => descriptor.ServiceDescriptor)); + + // Note: TryAddEnumerable() is used here to ensure the initializers are registered only once. + builder.Services.TryAddEnumerable(new[] + { + ServiceDescriptor.Singleton, OpenIddictValidationSystemNetHttpConfiguration>(), + ServiceDescriptor.Singleton, OpenIddictValidationSystemNetHttpConfiguration>() + }); + + return new OpenIddictValidationSystemNetHttpBuilder(builder.Services); + } + + /// + /// Registers the OpenIddict validation/System.Net.Http integration services in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// The configuration delegate used to configure the validation services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictValidationBuilder UseSystemNetHttp( + [NotNull] this OpenIddictValidationBuilder builder, + [NotNull] Action configuration) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + configuration(builder.UseSystemNetHttp()); + + return builder; + } + } +} diff --git a/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpHandlers.cs b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpHandlers.cs new file mode 100644 index 00000000..b0d41494 --- /dev/null +++ b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpHandlers.cs @@ -0,0 +1,300 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Validation.OpenIddictValidationEvents; +using static OpenIddict.Validation.OpenIddictValidationHandlers; +using static OpenIddict.Validation.SystemNetHttp.OpenIddictValidationSystemNetHttpConstants; + +namespace OpenIddict.Validation.SystemNetHttp +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public static partial class OpenIddictValidationSystemNetHttpHandlers + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Authentication processing: + */ + PopulateTokenValidationParametersFromMemoryCache.Descriptor, + PopulateTokenValidationParametersFromProviderConfiguration.Descriptor, + CacheTokenValidationParameters.Descriptor); + + /// + /// Contains the logic responsible of populating the token validation parameters from the memory cache. + /// + public class PopulateTokenValidationParametersFromMemoryCache : IOpenIddictValidationHandler + { + private readonly IMemoryCache _cache; + + public PopulateTokenValidationParametersFromMemoryCache([NotNull] IMemoryCache cache) + => _cache = cache; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(PopulateTokenValidationParametersFromProviderConfiguration.Descriptor.Order - 1_000) + .Build(); + + public ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If token validation parameters were already attached, don't overwrite them. + if (context.TokenValidationParameters != null) + { + return default; + } + + // If the metadata address is not an HTTP/HTTPS address, let another handler populate the validation parameters. + if (!string.Equals(context.Options.MetadataAddress.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(context.Options.MetadataAddress.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return default; + } + + // Resolve the token validation parameters from the memory cache. + if (_cache.TryGetValue( + key: string.Concat("af84c073-c27c-49fd-a54f-584fd60320d3", "\x1e", context.Issuer?.AbsoluteUri), + value: out TokenValidationParameters parameters)) + { + context.TokenValidationParameters = parameters; + } + + return default; + } + } + + /// + /// Contains the logic responsible of populating the token validation + /// parameters using OAuth 2.0/OpenID Connect discovery. + /// + public class PopulateTokenValidationParametersFromProviderConfiguration : IOpenIddictValidationHandler + { + private readonly IHttpClientFactory _factory; + + public PopulateTokenValidationParametersFromProviderConfiguration([NotNull] IHttpClientFactory factory) + => _factory = factory; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateTokenValidationParameters.Descriptor.Order - 1_000) + .Build(); + + public async ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If token validation parameters were already attached, don't overwrite them. + if (context.TokenValidationParameters != null) + { + return; + } + + // If the metadata address is not an HTTP/HTTPS address, let another handler populate the validation parameters. + if (!string.Equals(context.Options.MetadataAddress.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(context.Options.MetadataAddress.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + using var client = _factory.CreateClient(Clients.Discovery); + var response = await SendHttpRequestMessageAsync(context.Options.MetadataAddress); + + // Ensure the JWKS endpoint URL is present and valid. + if (!response.TryGetParameter(Metadata.JwksUri, out var endpoint) || OpenIddictParameter.IsNullOrEmpty(endpoint)) + { + throw new InvalidOperationException("A discovery response containing an empty JWKS endpoint URL was returned."); + } + + if (!Uri.TryCreate((string) endpoint, UriKind.Absolute, out Uri uri)) + { + throw new InvalidOperationException("A discovery response containing an invalid JWKS endpoint URL was returned."); + } + + context.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = (string) response[Metadata.Issuer], + IssuerSigningKeys = await GetSigningKeysAsync(uri).ToListAsync() + }; + + async IAsyncEnumerable GetSigningKeysAsync(Uri address) + { + var response = await SendHttpRequestMessageAsync(address); + + var keys = response[JsonWebKeySetParameterNames.Keys]; + if (keys == null) + { + throw new InvalidOperationException("The OAuth 2.0/OpenID Connect cryptography didn't contain any JSON web key"); + } + + foreach (var payload in keys.Value.GetParameters()) + { + var type = (string) payload.Value[JsonWebKeyParameterNames.Kty]; + if (string.IsNullOrEmpty(type)) + { + throw new InvalidOperationException("A JWKS response containing an invalid key was returned."); + } + + var key = type switch + { + JsonWebAlgorithmsKeyTypes.RSA => new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.RSA, + E = (string) payload.Value[JsonWebKeyParameterNames.E], + N = (string) payload.Value[JsonWebKeyParameterNames.N] + }, + + JsonWebAlgorithmsKeyTypes.EllipticCurve => new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.EllipticCurve, + Crv = (string) payload.Value[JsonWebKeyParameterNames.Crv], + X = (string) payload.Value[JsonWebKeyParameterNames.X], + Y = (string) payload.Value[JsonWebKeyParameterNames.Y] + }, + + _ => throw new InvalidOperationException("A JWKS response containing an unsupported key was returned.") + }; + + key.KeyId = (string) payload.Value[JsonWebKeyParameterNames.Kid]; + key.X5t = (string) payload.Value[JsonWebKeyParameterNames.X5t]; + key.X5tS256 = (string) payload.Value[JsonWebKeyParameterNames.X5tS256]; + + if (payload.Value.TryGetParameter(JsonWebKeyParameterNames.X5c, out var chain)) + { + foreach (var certificate in chain.GetParameters()) + { + var value = (string) certificate.Value; + if (string.IsNullOrEmpty(value)) + { + throw new InvalidOperationException("A JWKS response containing an invalid key was returned."); + } + + key.X5c.Add(value); + } + } + + yield return key; + } + } + + async ValueTask SendHttpRequestMessageAsync(Uri address) + { + using var request = new HttpRequestMessage(HttpMethod.Get, address); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseContentRead); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, + "The OAuth 2.0/OpenID Connect discovery failed because an invalid response was received:" + + "the identity provider returned returned a {0} response with the following payload: {1} {2}.", + /* Status: */ response.StatusCode, + /* Headers: */ response.Headers.ToString(), + /* Body: */ await response.Content.ReadAsStringAsync())); + } + + var media = response.Content?.Headers.ContentType?.MediaType; + if (!string.Equals(media, "application/json", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, + "The OAuth 2.0/OpenID Connect discovery failed because an invalid content type was received:" + + "the identity provider returned returned a {0} response with the following payload: {1} {2}.", + /* Status: */ response.StatusCode, + /* Headers: */ response.Headers.ToString(), + /* Body: */ await response.Content.ReadAsStringAsync())); + } + + using var stream = await response.Content.ReadAsStreamAsync(); + using var reader = new JsonTextReader(new StreamReader(stream)); + + var serializer = JsonSerializer.CreateDefault(); + return serializer.Deserialize(reader); + } + } + } + + /// + /// Contains the logic responsible of caching the token validation parameters. + /// + public class CacheTokenValidationParameters : IOpenIddictValidationHandler + { + private readonly IMemoryCache _cache; + + public CacheTokenValidationParameters([NotNull] IMemoryCache cache) + => _cache = cache; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateTokenValidationParameters.Descriptor.Order + 500) + .Build(); + + public ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.TokenValidationParameters == null) + { + return default; + } + + // If the metadata address is not an HTTP/HTTPS address, let another handler populate the validation parameters. + if (!string.Equals(context.Options.MetadataAddress.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(context.Options.MetadataAddress.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return default; + } + + // Store the token validation parameters in the memory cache. + _ = _cache.GetOrCreate( + key: string.Concat("af84c073-c27c-49fd-a54f-584fd60320d3", "\x1e", context.Issuer?.AbsoluteUri), + factory: entry => + { + entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(30)); + entry.SetPriority(CacheItemPriority.NeverRemove); + + return context.TokenValidationParameters; + }); + + return default; + } + } + } +} diff --git a/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpOptions.cs b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpOptions.cs new file mode 100644 index 00000000..475ca159 --- /dev/null +++ b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpOptions.cs @@ -0,0 +1,28 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Net; +using System.Net.Http; +using Polly; +using Polly.Extensions.Http; + +namespace OpenIddict.Validation.SystemNetHttp +{ + /// + /// Provides various settings needed to configure the OpenIddict validation/System.Net.Http integration. + /// + public class OpenIddictValidationSystemNetHttpOptions + { + /// + /// Gets or sets the HTTP Polly error policy used by the internal OpenIddict HTTP clients. + /// + public IAsyncPolicy HttpErrorPolicy { get; set; } + = HttpPolicyExtensions.HandleTransientHttpError() + .OrResult(response => response.StatusCode == HttpStatusCode.NotFound) + .WaitAndRetryAsync(4, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))); + } +} diff --git a/src/OpenIddict.Validation/IOpenIddictValidationHandler.cs b/src/OpenIddict.Validation/IOpenIddictValidationHandler.cs new file mode 100644 index 00000000..9963329e --- /dev/null +++ b/src/OpenIddict.Validation/IOpenIddictValidationHandler.cs @@ -0,0 +1,28 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Threading.Tasks; +using JetBrains.Annotations; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace OpenIddict.Validation +{ + /// + /// Represents a handler able to process events. + /// + /// The type of the context associated with events handled by this instance. + public interface IOpenIddictValidationHandler where TContext : BaseContext + { + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + ValueTask HandleAsync([NotNull] TContext context); + } +} diff --git a/src/OpenIddict.Validation/IOpenIddictValidationHandlerFilter.cs b/src/OpenIddict.Validation/IOpenIddictValidationHandlerFilter.cs new file mode 100644 index 00000000..fb1f1fd8 --- /dev/null +++ b/src/OpenIddict.Validation/IOpenIddictValidationHandlerFilter.cs @@ -0,0 +1,17 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Threading.Tasks; +using JetBrains.Annotations; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace OpenIddict.Validation +{ + public interface IOpenIddictValidationHandlerFilter where TContext : BaseContext + { + ValueTask IsActiveAsync([NotNull] TContext context); + } +} diff --git a/src/OpenIddict.Validation/IOpenIddictValidationProvider.cs b/src/OpenIddict.Validation/IOpenIddictValidationProvider.cs new file mode 100644 index 00000000..b83a04ae --- /dev/null +++ b/src/OpenIddict.Validation/IOpenIddictValidationProvider.cs @@ -0,0 +1,18 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Threading.Tasks; +using JetBrains.Annotations; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace OpenIddict.Validation +{ + public interface IOpenIddictValidationProvider + { + ValueTask CreateTransactionAsync(); + ValueTask DispatchAsync([NotNull] TContext context) where TContext : BaseContext; + } +} \ No newline at end of file diff --git a/src/OpenIddict.Validation/OpenIddict.Validation.csproj b/src/OpenIddict.Validation/OpenIddict.Validation.csproj new file mode 100644 index 00000000..151fed11 --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddict.Validation.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0;netstandard2.1 + + + + OpenID Connect validation components for OpenIddict. + $(PackageTags);validation + + + + + + + + + + + + + diff --git a/src/OpenIddict.Validation/OpenIddictValidationBuilder.cs b/src/OpenIddict.Validation/OpenIddictValidationBuilder.cs new file mode 100644 index 00000000..1655e4dc --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationBuilder.cs @@ -0,0 +1,657 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Validation; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes the necessary methods required to configure the OpenIddict validation services. + /// + public class OpenIddictValidationBuilder + { + /// + /// Initializes a new instance of . + /// + /// The services collection. + public OpenIddictValidationBuilder([NotNull] IServiceCollection services) + => Services = services ?? throw new ArgumentNullException(nameof(services)); + + /// + /// Gets the services collection. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IServiceCollection Services { get; } + + /// + /// Registers an event handler using the specified configuration delegate. + /// + /// The event context type. + /// The configuration delegate. + /// The . + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictValidationBuilder AddEventHandler( + [NotNull] Action> configuration) + where TContext : OpenIddictValidationEvents.BaseContext + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + var builder = OpenIddictValidationHandlerDescriptor.CreateBuilder(); + configuration(builder); + + return AddEventHandler(builder.Build()); + } + + /// + /// Registers an event handler using the specified descriptor. + /// + /// The handler descriptor. + /// The . + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictValidationBuilder AddEventHandler([NotNull] OpenIddictValidationHandlerDescriptor descriptor) + { + if (descriptor == null) + { + throw new ArgumentNullException(nameof(descriptor)); + } + + // Register the handler in the services collection. + Services.Add(descriptor.ServiceDescriptor); + + return Configure(options => options.CustomHandlers.Add(descriptor)); + } + + /// + /// Removes the event handler that matches the specified descriptor. + /// + /// The descriptor corresponding to the handler to remove. + /// The . + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictValidationBuilder RemoveEventHandler([NotNull] OpenIddictValidationHandlerDescriptor descriptor) + { + if (descriptor == null) + { + throw new ArgumentNullException(nameof(descriptor)); + } + + Services.RemoveAll(descriptor.ServiceDescriptor.ServiceType); + + Services.PostConfigure(options => + { + for (var index = options.CustomHandlers.Count - 1; index >= 0; index--) + { + if (options.CustomHandlers[index].ServiceDescriptor.ServiceType == descriptor.ServiceDescriptor.ServiceType) + { + options.CustomHandlers.RemoveAt(index); + } + } + + for (var index = options.DefaultHandlers.Count - 1; index >= 0; index--) + { + if (options.DefaultHandlers[index].ServiceDescriptor.ServiceType == descriptor.ServiceDescriptor.ServiceType) + { + options.DefaultHandlers.RemoveAt(index); + } + } + }); + + return this; + } + + /// + /// Amends the default OpenIddict validation configuration. + /// + /// The delegate used to configure the OpenIddict options. + /// This extension can be safely called multiple times. + /// The . + public OpenIddictValidationBuilder Configure([NotNull] Action configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + Services.Configure(configuration); + + return this; + } + + /// + /// Registers the used to decrypt the tokens issued by OpenIddict. + /// + /// The encrypting credentials. + /// The . + public OpenIddictValidationBuilder AddEncryptionCredentials([NotNull] EncryptingCredentials credentials) + { + if (credentials == null) + { + throw new ArgumentNullException(nameof(credentials)); + } + + return Configure(options => options.EncryptionCredentials.Add(credentials)); + } + + /// + /// Registers a used to decrypt the access tokens issued by OpenIddict. + /// + /// The security key. + /// The . + public OpenIddictValidationBuilder AddEncryptionKey([NotNull] SecurityKey key) + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + // If the encryption key is an asymmetric security key, ensure it has a private key. + if (key is AsymmetricSecurityKey asymmetricSecurityKey && + asymmetricSecurityKey.PrivateKeyStatus == PrivateKeyStatus.DoesNotExist) + { + throw new InvalidOperationException("The asymmetric encryption key doesn't contain the required private key."); + } + + if (IsAlgorithmSupported(key, SecurityAlgorithms.Aes256KW)) + { + return AddEncryptionCredentials(new EncryptingCredentials(key, + SecurityAlgorithms.Aes256KW, SecurityAlgorithms.Aes256CbcHmacSha512)); + } + + if (IsAlgorithmSupported(key, SecurityAlgorithms.RsaOAEP)) + { + return AddEncryptionCredentials(new EncryptingCredentials(key, + SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512)); + } + + throw new InvalidOperationException(new StringBuilder() + .AppendLine("An encryption algorithm cannot be automatically inferred from the encrypting key.") + .Append("Consider using 'options.AddEncryptionCredentials(EncryptingCredentials)' instead.") + .ToString()); + + static bool IsAlgorithmSupported(SecurityKey key, string algorithm) => + key.CryptoProviderFactory.IsSupportedAlgorithm(algorithm, key); + } + + /// + /// Registers (and generates if necessary) a user-specific development + /// certificate used to decrypt the tokens issued by OpenIddict. + /// + /// The . + public OpenIddictValidationBuilder AddDevelopmentEncryptionCertificate() + => AddDevelopmentEncryptionCertificate(new X500DistinguishedName("CN=OpenIddict Validation Encryption Certificate")); + + /// + /// Registers (and generates if necessary) a user-specific development + /// certificate used to decrypt the tokens issued by OpenIddict. + /// + /// The subject name associated with the certificate. + /// The . + public OpenIddictValidationBuilder AddDevelopmentEncryptionCertificate([NotNull] X500DistinguishedName subject) + { + if (subject == null) + { + throw new ArgumentNullException(nameof(subject)); + } + + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + // Try to retrieve the development certificate from the specified store. + // If a certificate was found but is not yet or no longer valid, remove it + // from the store before creating and persisting a new encryption certificate. + var certificate = store.Certificates.Find(X509FindType.FindBySubjectDistinguishedName, subject.Name, validOnly: false) + .OfType() + .SingleOrDefault(); + + if (certificate != null && (certificate.NotBefore > DateTime.Now || certificate.NotAfter < DateTime.Now)) + { + store.Remove(certificate); + certificate = null; + } + +#if SUPPORTS_CERTIFICATE_GENERATION + // If no appropriate certificate can be found, generate and persist a new certificate in the specified store. + if (certificate == null) + { + using var algorithm = RSA.Create(keySizeInBits: 2048); + + var request = new CertificateRequest(subject, algorithm, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyEncipherment, critical: true)); + + certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(2)); + + // Note: setting the friendly name is not supported on Unix machines (including Linux and macOS). + // To ensure an exception is not thrown by the property setter, an OS runtime check is used here. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + certificate.FriendlyName = "OpenIddict Validation Development Encryption Certificate"; + } + + // Note: CertificateRequest.CreateSelfSigned() doesn't mark the key set associated with the certificate + // as "persisted", which eventually prevents X509Store.Add() from correctly storing the private key. + // To work around this issue, the certificate payload is manually exported and imported back + // into a new X509Certificate2 instance specifying the X509KeyStorageFlags.PersistKeySet flag. + var data = certificate.Export(X509ContentType.Pfx, string.Empty); + + try + { + var flags = X509KeyStorageFlags.PersistKeySet; + + // Note: macOS requires marking the certificate private key as exportable. + // If this flag is not set, a CryptographicException is thrown at runtime. + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + flags |= X509KeyStorageFlags.Exportable; + } + + certificate = new X509Certificate2(data, string.Empty, flags); + } + + finally + { + Array.Clear(data, 0, data.Length); + } + + store.Add(certificate); + } + + return AddEncryptionCertificate(certificate); +#else + throw new PlatformNotSupportedException("X.509 certificate generation is not supported on this platform."); +#endif + } + + /// + /// Registers a new ephemeral key used to decrypt the tokens issued by OpenIddict: the key + /// is discarded when the application shuts down and tokens encrypted using this key are + /// automatically invalidated. This method should only be used during development. + /// On production, using a X.509 certificate stored in the machine store is recommended. + /// + /// The . + public OpenIddictValidationBuilder AddEphemeralEncryptionKey() + => AddEphemeralEncryptionKey(SecurityAlgorithms.RsaOAEP); + + /// + /// Registers a new ephemeral key used to decrypt the tokens issued by OpenIddict: the key + /// is discarded when the application shuts down and tokens encrypted using this key are + /// automatically invalidated. This method should only be used during development. + /// On production, using a X.509 certificate stored in the machine store is recommended. + /// + /// The algorithm associated with the encryption key. + /// The . + public OpenIddictValidationBuilder AddEphemeralEncryptionKey([NotNull] string algorithm) + { + if (string.IsNullOrEmpty(algorithm)) + { + throw new ArgumentException("The algorithm cannot be null or empty.", nameof(algorithm)); + } + + switch (algorithm) + { + case SecurityAlgorithms.Aes256KW: + return AddEncryptionCredentials(new EncryptingCredentials(CreateSymmetricSecurityKey(256), + algorithm, SecurityAlgorithms.Aes256CbcHmacSha512)); + + case SecurityAlgorithms.RsaOAEP: + case SecurityAlgorithms.RsaOaepKeyWrap: + return AddEncryptionCredentials(new EncryptingCredentials(CreateRsaSecurityKey(2048), + algorithm, SecurityAlgorithms.Aes256CbcHmacSha512)); + + default: throw new InvalidOperationException("The specified algorithm is not supported."); + } + + static SymmetricSecurityKey CreateSymmetricSecurityKey(int size) + { + var data = new byte[size / 8]; + +#if SUPPORTS_STATIC_RANDOM_NUMBER_GENERATOR_METHODS + RandomNumberGenerator.Fill(data); +#else + using var generator = RandomNumberGenerator.Create(); + generator.GetBytes(data); +#endif + + return new SymmetricSecurityKey(data); + } + + static RsaSecurityKey CreateRsaSecurityKey(int size) + { +#if SUPPORTS_DIRECT_KEY_CREATION_WITH_SPECIFIED_SIZE + return new RsaSecurityKey(RSA.Create(size)); +#else + // Note: a 1024-bit key might be returned by RSA.Create() on .NET Desktop/Mono, + // where RSACryptoServiceProvider is still the default implementation and + // where custom implementations can be registered via CryptoConfig. + // To ensure the key size is always acceptable, replace it if necessary. + var algorithm = RSA.Create(); + if (algorithm.KeySize < size) + { + algorithm.KeySize = size; + } + + if (algorithm.KeySize < size && algorithm is RSACryptoServiceProvider) + { + algorithm.Dispose(); + algorithm = new RSACryptoServiceProvider(size); + } + + if (algorithm.KeySize < size) + { + throw new InvalidOperationException("RSA key generation failed."); + } + + return new RsaSecurityKey(algorithm); +#endif + } + } + + /// + /// Registers a that is used to decrypt the tokens issued by OpenIddict. + /// + /// The certificate used to decrypt the security tokens issued by the validation. + /// The . + public OpenIddictValidationBuilder AddEncryptionCertificate([NotNull] X509Certificate2 certificate) + { + if (certificate == null) + { + throw new ArgumentNullException(nameof(certificate)); + } + + if (certificate.NotBefore > DateTime.Now) + { + throw new InvalidOperationException("The specified certificate is not yet valid."); + } + + if (certificate.NotAfter < DateTime.Now) + { + throw new InvalidOperationException("The specified certificate is no longer valid."); + } + + if (!certificate.HasPrivateKey) + { + throw new InvalidOperationException("The specified certificate doesn't contain the required private key."); + } + + return AddEncryptionKey(new X509SecurityKey(certificate)); + } + + /// + /// Registers a retrieved from an + /// embedded resource and used to decrypt the tokens issued by OpenIddict. + /// + /// The assembly containing the certificate. + /// The name of the embedded resource. + /// The password used to open the certificate. + /// The . + public OpenIddictValidationBuilder AddEncryptionCertificate( + [NotNull] Assembly assembly, [NotNull] string resource, [NotNull] string password) +#if SUPPORTS_EPHEMERAL_KEY_SETS + // Note: ephemeral key sets are currently not supported on macOS. + => AddEncryptionCertificate(assembly, resource, password, RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? + X509KeyStorageFlags.MachineKeySet : + X509KeyStorageFlags.EphemeralKeySet); +#else + => AddEncryptionCertificate(assembly, resource, password, X509KeyStorageFlags.MachineKeySet); +#endif + + /// + /// Registers a retrieved from an + /// embedded resource and used to decrypt the tokens issued by OpenIddict. + /// + /// The assembly containing the certificate. + /// The name of the embedded resource. + /// The password used to open the certificate. + /// An enumeration of flags indicating how and where to store the private key of the certificate. + /// The . + public OpenIddictValidationBuilder AddEncryptionCertificate( + [NotNull] Assembly assembly, [NotNull] string resource, + [NotNull] string password, X509KeyStorageFlags flags) + { + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + if (string.IsNullOrEmpty(resource)) + { + throw new ArgumentException("The resource cannot be null or empty.", nameof(resource)); + } + + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentException("The password cannot be null or empty.", nameof(password)); + } + + using var stream = assembly.GetManifestResourceStream(resource); + if (stream == null) + { + throw new InvalidOperationException("The certificate was not found in the specified assembly."); + } + + return AddEncryptionCertificate(stream, password, flags); + } + + /// + /// Registers a extracted from a + /// stream and used to decrypt the tokens issued by OpenIddict. + /// + /// The stream containing the certificate. + /// The password used to open the certificate. + /// The . + public OpenIddictValidationBuilder AddEncryptionCertificate([NotNull] Stream stream, [NotNull] string password) +#if SUPPORTS_EPHEMERAL_KEY_SETS + // Note: ephemeral key sets are currently not supported on macOS. + => AddEncryptionCertificate(stream, password, RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? + X509KeyStorageFlags.MachineKeySet : + X509KeyStorageFlags.EphemeralKeySet); +#else + => AddEncryptionCertificate(stream, password, X509KeyStorageFlags.MachineKeySet); +#endif + + /// + /// Registers a extracted from a + /// stream and used to decrypt the tokens issued by OpenIddict. + /// + /// The stream containing the certificate. + /// The password used to open the certificate. + /// + /// An enumeration of flags indicating how and where + /// to store the private key of the certificate. + /// + /// The . + public OpenIddictValidationBuilder AddEncryptionCertificate( + [NotNull] Stream stream, [NotNull] string password, X509KeyStorageFlags flags) + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentException("The password cannot be null or empty.", nameof(password)); + } + + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + + return AddEncryptionCertificate(new X509Certificate2(buffer.ToArray(), password, flags)); + } + + /// + /// Registers a retrieved from the X.509 + /// machine store and used to decrypt the tokens issued by OpenIddict. + /// + /// The thumbprint of the certificate used to identify it in the X.509 store. + /// The . + public OpenIddictValidationBuilder AddEncryptionCertificate([NotNull] string thumbprint) + { + if (string.IsNullOrEmpty(thumbprint)) + { + throw new ArgumentException("The thumbprint cannot be null or empty.", nameof(thumbprint)); + } + + var certificate = GetCertificate(StoreLocation.CurrentUser, thumbprint) ?? GetCertificate(StoreLocation.LocalMachine, thumbprint); + if (certificate == null) + { + throw new InvalidOperationException("The certificate corresponding to the specified thumbprint was not found."); + } + + return AddEncryptionCertificate(certificate); + + static X509Certificate2 GetCertificate(StoreLocation location, string thumbprint) + { + using var store = new X509Store(StoreName.My, location); + store.Open(OpenFlags.ReadOnly); + + return store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) + .OfType() + .SingleOrDefault(); + } + } + + /// + /// Registers a retrieved from the given + /// X.509 store and used to decrypt the tokens issued by OpenIddict. + /// + /// The thumbprint of the certificate used to identify it in the X.509 store. + /// The name of the X.509 store. + /// The location of the X.509 store. + /// The . + public OpenIddictValidationBuilder AddEncryptionCertificate( + [NotNull] string thumbprint, StoreName name, StoreLocation location) + { + if (string.IsNullOrEmpty(thumbprint)) + { + throw new ArgumentException("The thumbprint cannot be null or empty.", nameof(thumbprint)); + } + + using var store = new X509Store(name, location); + store.Open(OpenFlags.ReadOnly); + + var certificate = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) + .OfType() + .SingleOrDefault(); + + if (certificate == null) + { + throw new InvalidOperationException("The certificate corresponding to the specified thumbprint was not found."); + } + + return AddEncryptionCertificate(certificate); + } + + /// + /// Registers the specified values as valid audiences. Setting the audiences is recommended + /// when the authorization server issues access tokens for multiple distinct resource servers. + /// + /// The audiences valid for this resource server. + /// The . + public OpenIddictValidationBuilder AddAudiences([NotNull] params string[] audiences) + { + if (audiences == null) + { + throw new ArgumentNullException(nameof(audiences)); + } + + if (audiences.Any(audience => string.IsNullOrEmpty(audience))) + { + throw new ArgumentException("Audiences cannot be null or empty.", nameof(audiences)); + } + + return Configure(options => options.Audiences.UnionWith(audiences)); + } + + /// + /// Enables authorization validation so that a database call is made for each API request + /// to ensure the authorization associated with the access token is still valid. + /// Note: enabling this option may have an impact on performance. + /// + /// The . + public OpenIddictValidationBuilder EnableAuthorizationValidation() + => Configure(options => options.EnableAuthorizationValidation = true); + + /// + /// Sets the issuer address, which is used to determine the actual location of the + /// OAuth 2.0/OpenID Connect configuration document when using provider discovery. + /// + /// The issuer address. + /// The . + public OpenIddictValidationBuilder SetIssuer([NotNull] Uri address) + { + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + return Configure(options => options.Issuer = address); + } + + /// + /// Sets the static token validation parameters. + /// + /// The issuer address. + /// The . + public OpenIddictValidationBuilder SetTokenValidationParameters([NotNull] TokenValidationParameters parameters) + { + if (parameters == null) + { + throw new ArgumentNullException(nameof(parameters)); + } + + return Configure(options => options.TokenValidationParameters = parameters); + } + + /// + /// Configures OpenIddict to use reference tokens, so that authorization codes, + /// access tokens and refresh tokens are stored as ciphertext in the database + /// (only an identifier is returned to the client application). Enabling this option + /// is useful to keep track of all the issued tokens, when storing a very large + /// number of claims in the authorization codes, access tokens and refresh tokens + /// or when immediate revocation of reference access tokens is desired. + /// Note: this option cannot be used when configuring JWT as the access token format. + /// + /// The . + public OpenIddictValidationBuilder UseReferenceTokens() + => Configure(options => options.UseReferenceTokens = true); + + /// + /// Determines whether the specified object is equal to the current object. + /// + /// The object to compare with the current object. + /// true if the specified object is equal to the current object; otherwise, false. + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals([CanBeNull] object obj) => base.Equals(obj); + + /// + /// Serves as the default hash function. + /// + /// A hash code for the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() => base.GetHashCode(); + + /// + /// Returns a string that represents the current object. + /// + /// A string that represents the current object. + [EditorBrowsable(EditorBrowsableState.Never)] + public override string ToString() => base.ToString(); + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationConfiguration.cs b/src/OpenIddict.Validation/OpenIddictValidationConfiguration.cs new file mode 100644 index 00000000..4518eb77 --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationConfiguration.cs @@ -0,0 +1,138 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Diagnostics; +using System.Linq; +using System.Text; +using JetBrains.Annotations; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace OpenIddict.Validation +{ + /// + /// Contains the methods required to ensure that the OpenIddict validation configuration is valid. + /// + public class OpenIddictValidationConfiguration : IPostConfigureOptions + { + /// + /// Populates the default OpenIddict validation options and ensures + /// that the configuration is in a consistent and valid state. + /// + /// The authentication scheme associated with the handler instance. + /// The options instance to initialize. + public void PostConfigure([CanBeNull] string name, [NotNull] OpenIddictValidationOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (options.SecurityTokenHandler == null) + { + throw new InvalidOperationException("The security token handler cannot be null."); + } + + if (options.TokenValidationParameters == null) + { + if (options.Issuer == null && options.MetadataAddress == null) + { + throw new InvalidOperationException(new StringBuilder() + .AppendLine("The authority or an absolute metadata endpoint address must be provided.") + .Append("Alternatively, token validation parameters can be manually set by calling ") + .AppendLine("'services.AddOpenIddict().AddValidation().SetTokenValidationParameters()'.") + .Append("To use the server configuration of a local OpenIddict server instance, ") + .Append("reference the 'OpenIddict.Validation.ServerIntegration' package ") + .Append("and call 'services.AddOpenIddict().AddValidation().UseLocalServer()'.") + .ToString()); + } + + if (options.MetadataAddress == null) + { + options.MetadataAddress = new Uri(".well-known/openid-configuration", UriKind.Relative); + } + + if (!options.MetadataAddress.IsAbsoluteUri) + { + if (options.Issuer == null || !options.Issuer.IsAbsoluteUri) + { + throw new InvalidOperationException("The authority must be provided and must be an absolute URL."); + } + + if (!string.IsNullOrEmpty(options.Issuer.Fragment) || !string.IsNullOrEmpty(options.Issuer.Query)) + { + throw new InvalidOperationException("The authority cannot contain a fragment or a query string."); + } + + if (!options.Issuer.OriginalString.EndsWith("/")) + { + options.Issuer = new Uri(options.Issuer.OriginalString + "/", UriKind.Absolute); + } + + options.MetadataAddress = new Uri(options.Issuer, options.MetadataAddress); + } + } + + foreach (var key in options.EncryptionCredentials.Select(credentials => credentials.Key)) + { + if (!string.IsNullOrEmpty(key.KeyId)) + { + continue; + } + + key.KeyId = GetKeyIdentifier(key); + } + + static string GetKeyIdentifier(SecurityKey key) + { + // When no key identifier can be retrieved from the security keys, a value is automatically + // inferred from the hexadecimal representation of the certificate thumbprint (SHA-1) + // when the key is bound to a X.509 certificate or from the public part of the signing key. + + if (key is X509SecurityKey x509SecurityKey) + { + return x509SecurityKey.Certificate.Thumbprint; + } + + if (key is RsaSecurityKey rsaSecurityKey) + { + // Note: if the RSA parameters are not attached to the signing key, + // extract them by calling ExportParameters on the RSA instance. + var parameters = rsaSecurityKey.Parameters; + if (parameters.Modulus == null) + { + parameters = rsaSecurityKey.Rsa.ExportParameters(includePrivateParameters: false); + + Debug.Assert(parameters.Modulus != null, + "A null modulus shouldn't be returned by RSA.ExportParameters()."); + } + + // Only use the 40 first chars of the base64url-encoded modulus. + var identifier = Base64UrlEncoder.Encode(parameters.Modulus); + return identifier.Substring(0, Math.Min(identifier.Length, 40)).ToUpperInvariant(); + } + +#if SUPPORTS_ECDSA + if (key is ECDsaSecurityKey ecsdaSecurityKey) + { + // Extract the ECDSA parameters from the signing credentials. + var parameters = ecsdaSecurityKey.ECDsa.ExportParameters(includePrivateParameters: false); + + Debug.Assert(parameters.Q.X != null, + "Invalid coordinates shouldn't be returned by ECDsa.ExportParameters()."); + + // Only use the 40 first chars of the base64url-encoded X coordinate. + var identifier = Base64UrlEncoder.Encode(parameters.Q.X); + return identifier.Substring(0, Math.Min(identifier.Length, 40)).ToUpperInvariant(); + } +#endif + + return null; + } + } + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationEndpointType.cs b/src/OpenIddict.Validation/OpenIddictValidationEndpointType.cs new file mode 100644 index 00000000..d85f32e6 --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationEndpointType.cs @@ -0,0 +1,19 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +namespace OpenIddict.Validation +{ + /// + /// Represents the type of an OpenIddict validation endpoint. + /// + public enum OpenIddictValidationEndpointType + { + /// + /// Unknown endpoint. + /// + Unknown = 0 + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationEvents.cs b/src/OpenIddict.Validation/OpenIddictValidationEvents.cs new file mode 100644 index 00000000..f839ddb6 --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationEvents.cs @@ -0,0 +1,271 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Security.Claims; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; + +namespace OpenIddict.Validation +{ + public static partial class OpenIddictValidationEvents + { + /// + /// Represents an abstract base class used for certain event contexts. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public abstract class BaseContext + { + /// + /// Creates a new instance of the class. + /// + protected BaseContext([NotNull] OpenIddictValidationTransaction transaction) + => Transaction = transaction ?? throw new ArgumentNullException(nameof(transaction)); + + /// + /// Gets the environment associated with the current request being processed. + /// + public OpenIddictValidationTransaction Transaction { get; } + + /// + /// Gets or sets the endpoint type that handled the request, if applicable. + /// + public OpenIddictValidationEndpointType EndpointType + { + get => Transaction.EndpointType; + set => Transaction.EndpointType = value; + } + + /// + /// Gets or sets the issuer address associated with the current transaction, if available. + /// + public Uri Issuer + { + get => Transaction.Issuer; + set => Transaction.Issuer = value; + } + + /// + /// Gets the logger responsible of logging processed operations. + /// + public ILogger Logger => Transaction.Logger; + + /// + /// Gets the OpenIddict validation options. + /// + public OpenIddictValidationOptions Options => Transaction.Options; + + /// + /// Gets the dictionary containing the properties associated with this event. + /// + public IDictionary Properties { get; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the OpenIddict request or null if it couldn't be extracted. + /// + public OpenIddictRequest Request + { + get => Transaction.Request; + set => Transaction.Request = value; + } + + /// + /// Gets or sets the OpenIddict response, if applicable. + /// + public OpenIddictResponse Response + { + get => Transaction.Response; + set => Transaction.Response = value; + } + } + + [EditorBrowsable(EditorBrowsableState.Never)] + public abstract class BaseRequestContext : BaseContext + { + /// + /// Creates a new instance of the class. + /// + protected BaseRequestContext([NotNull] OpenIddictValidationTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets a boolean indicating whether the request was fully handled. + /// + public bool IsRequestHandled { get; private set; } + + /// + /// Gets a boolean indicating whether the request processing was skipped. + /// + public bool IsRequestSkipped { get; private set; } + + /// + /// Marks the request as fully handled. Once declared handled, + /// a request shouldn't be processed further by the underlying host. + /// + public void HandleRequest() => IsRequestHandled = true; + + /// + /// Marks the request as skipped. Once declared skipped, a request + /// shouldn't be processed further by OpenIddict but should be allowed + /// to go through the next components in the processing pipeline + /// (if this pattern is supported by the underlying host). + /// + public void SkipRequest() => IsRequestSkipped = true; + } + + /// + /// Represents an abstract base class used for certain event contexts. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public abstract class BaseValidatingContext : BaseRequestContext + { + /// + /// Creates a new instance of the class. + /// + protected BaseValidatingContext([NotNull] OpenIddictValidationTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets a boolean indicating whether the request will be rejected. + /// + public bool IsRejected { get; protected set; } + + /// + /// Gets or sets the "error" parameter returned to the client application. + /// + public string Error { get; private set; } + + /// + /// Gets or sets the "error_description" parameter returned to the client application. + /// + public string ErrorDescription { get; private set; } + + /// + /// Gets or sets the "error_uri" parameter returned to the client application. + /// + public string ErrorUri { get; private set; } + + /// + /// Rejects the request. + /// + public virtual void Reject() => IsRejected = true; + + /// + /// Rejects the request. + /// + /// The "error" parameter returned to the client application. + public virtual void Reject(string error) + { + Error = error; + + Reject(); + } + + /// + /// Rejects the request. + /// + /// The "error" parameter returned to the client application. + /// The "error_description" parameter returned to the client application. + public virtual void Reject(string error, string description) + { + Error = error; + ErrorDescription = description; + + Reject(); + } + + /// + /// Rejects the request. + /// + /// The "error" parameter returned to the client application. + /// The "error_description" parameter returned to the client application. + /// The "error_uri" parameter returned to the client application. + public virtual void Reject(string error, string description, string uri) + { + Error = error; + ErrorDescription = description; + ErrorUri = uri; + + Reject(); + } + } + + /// + /// Represents an event called when processing an incoming request. + /// + public class ProcessRequestContext : BaseValidatingContext + { + /// + /// Creates a new instance of the class. + /// + public ProcessRequestContext([NotNull] OpenIddictValidationTransaction transaction) + : base(transaction) + { + } + } + + /// + /// Represents an event called when processing an errored response. + /// + public class ProcessErrorContext : BaseRequestContext + { + /// + /// Creates a new instance of the class. + /// + public ProcessErrorContext([NotNull] OpenIddictValidationTransaction transaction) + : base(transaction) + { + } + } + + /// + /// Represents an event called when processing an authentication operation. + /// + public class ProcessAuthenticationContext : BaseValidatingContext + { + /// + /// Creates a new instance of the class. + /// + public ProcessAuthenticationContext([NotNull] OpenIddictValidationTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the token validation parameters used for the current request. + /// + public TokenValidationParameters TokenValidationParameters { get; set; } + + /// + /// Gets or sets the security principal. + /// + public ClaimsPrincipal Principal { get; set; } + } + + /// + /// Represents an event called when processing a challenge response. + /// + public class ProcessChallengeContext : BaseValidatingContext + { + /// + /// Creates a new instance of the class. + /// + public ProcessChallengeContext([NotNull] OpenIddictValidationTransaction transaction) + : base(transaction) + { + } + } + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationExtensions.cs b/src/OpenIddict.Validation/OpenIddictValidationExtensions.cs new file mode 100644 index 00000000..408eb93c --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationExtensions.cs @@ -0,0 +1,83 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Linq; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenIddict.Validation; +using static OpenIddict.Validation.OpenIddictValidationHandlerFilters; +using static OpenIddict.Validation.OpenIddictValidationHandlers; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes extensions allowing to register the OpenIddict validation services. + /// + public static class OpenIddictValidationExtensions + { + /// + /// Registers the OpenIddict token validation services in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictValidationBuilder AddValidation([NotNull] this OpenIddictBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.AddLogging(); + builder.Services.AddOptions(); + + builder.Services.TryAddScoped(); + + // Register the built-in validation event handlers used by the OpenIddict validation components. + // Note: the order used here is not important, as the actual order is set in the options. + builder.Services.TryAdd(DefaultHandlers.Select(descriptor => descriptor.ServiceDescriptor)); + + // Register the built-in filters used by the default OpenIddict validation event handlers. + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + + // Note: TryAddEnumerable() is used here to ensure the initializer is registered only once. + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< + IPostConfigureOptions, OpenIddictValidationConfiguration>()); + + return new OpenIddictValidationBuilder(builder.Services); + } + + /// + /// Registers the OpenIddict token validation services in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// The configuration delegate used to configure the validation services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictBuilder AddValidation( + [NotNull] this OpenIddictBuilder builder, + [NotNull] Action configuration) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + configuration(builder.AddValidation()); + + return builder; + } + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandler.cs b/src/OpenIddict.Validation/OpenIddictValidationHandler.cs new file mode 100644 index 00000000..0092103d --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationHandler.cs @@ -0,0 +1,39 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Threading.Tasks; +using JetBrains.Annotations; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace OpenIddict.Validation +{ + /// + /// Represents a handler able to process events. + /// + /// The type of the events handled by this instance. + public class OpenIddictValidationHandler : IOpenIddictValidationHandler where TContext : BaseContext + { + private readonly Func _handler; + + /// + /// Creates a new event using the specified handler delegate. + /// + /// The event handler delegate. + public OpenIddictValidationHandler([NotNull] Func handler) + => _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + + /// + /// Processes the event. + /// + /// The event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + => _handler(context ?? throw new ArgumentNullException(nameof(context))); + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlerDescriptor.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlerDescriptor.cs new file mode 100644 index 00000000..0e99d80d --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlerDescriptor.cs @@ -0,0 +1,199 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace OpenIddict.Validation +{ + /// + /// Represents an immutable descriptor of an OpenIddict validation event handler. + /// + [DebuggerDisplay("{ServiceDescriptor?.ServiceType}")] + public class OpenIddictValidationHandlerDescriptor + { + /// + /// Creates a new instance of the class. + /// + private OpenIddictValidationHandlerDescriptor() { } + + /// + /// Gets the context type associated with the event. + /// + public Type ContextType { get; private set; } + + /// + /// Gets the list of filters responsible of excluding the handler + /// from the activated handlers if it doesn't meet the criteria. + /// + public ImmutableArray FilterTypes { get; private set; } = ImmutableArray.Create(); + + /// + /// Gets the order assigned to the handler. + /// + public int Order { get; private set; } + + /// + /// Gets the service descriptor associated with the handler. + /// + public ServiceDescriptor ServiceDescriptor { get; private set; } + + /// + /// Creates a builder allowing to initialize an immutable descriptor. + /// + /// The event context type. + /// A new descriptor builder. + public static Builder CreateBuilder() where TContext : BaseContext + => new Builder(); + + /// + /// Contains methods allowing to build a descriptor instance. + /// + /// The event context type. + public class Builder where TContext : BaseContext + { + private ServiceDescriptor _descriptor; + private readonly List _filterTypes = new List(); + private int _order; + + /// + /// Adds the type of a handler filter to the filters list. + /// + /// The event handler filter type. + /// The builder instance, so that calls can be easily chained. + public Builder AddFilter([NotNull] Type type) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (!typeof(IOpenIddictValidationHandlerFilter<>).MakeGenericType(typeof(TContext)).IsAssignableFrom(type)) + { + throw new InvalidOperationException("The specified service type is not valid."); + } + + _filterTypes.Add(type); + + return this; + } + + /// + /// Adds the type of a handler filter to the filters list. + /// + /// The event handler filter type. + /// The builder instance, so that calls can be easily chained. + public Builder AddFilter() + where TFilter : IOpenIddictValidationHandlerFilter + => AddFilter(typeof(TFilter)); + + /// + /// Sets the service descriptor. + /// + /// The service descriptor. + /// The builder instance, so that calls can be easily chained. + public Builder SetServiceDescriptor([NotNull] ServiceDescriptor descriptor) + { + if (descriptor == null) + { + throw new ArgumentNullException(nameof(descriptor)); + } + + var type = descriptor.ServiceType; + if (!typeof(IOpenIddictValidationHandler<>).MakeGenericType(typeof(TContext)).IsAssignableFrom(type)) + { + throw new InvalidOperationException("The specified service type is not valid."); + } + + _descriptor = descriptor; + + return this; + } + + /// + /// Sets the order in which the event handler will be invoked. + /// + /// The handler order. + /// The builder instance, so that calls can be easily chained. + public Builder SetOrder(int order) + { + _order = order; + + return this; + } + + /// + /// Configures the descriptor to use the specified inline handler. + /// + /// The handler instance. + /// The builder instance, so that calls can be easily chained. + public Builder UseInlineHandler([NotNull] Func handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + return UseSingletonHandler(new OpenIddictValidationHandler(handler)); + } + + /// + /// Configures the descriptor to use the specified scoped handler. + /// + /// The handler type. + /// The builder instance, so that calls can be easily chained. + public Builder UseScopedHandler() + where THandler : IOpenIddictValidationHandler + => SetServiceDescriptor(new ServiceDescriptor( + typeof(THandler), typeof(THandler), ServiceLifetime.Scoped)); + + /// + /// Configures the descriptor to use the specified singleton handler. + /// + /// The handler type. + /// The builder instance, so that calls can be easily chained. + public Builder UseSingletonHandler() + where THandler : IOpenIddictValidationHandler + => SetServiceDescriptor(new ServiceDescriptor( + typeof(THandler), typeof(THandler), ServiceLifetime.Singleton)); + + /// + /// Configures the descriptor to use the specified singleton handler. + /// + /// The handler type. + /// The handler instance. + /// The builder instance, so that calls can be easily chained. + public Builder UseSingletonHandler([NotNull] THandler handler) + where THandler : IOpenIddictValidationHandler + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + return SetServiceDescriptor(new ServiceDescriptor(typeof(THandler), handler)); + } + + /// + /// Build a new descriptor instance, based on the parameters that were previously set. + /// + /// The builder instance, so that calls can be easily chained. + public OpenIddictValidationHandlerDescriptor Build() => new OpenIddictValidationHandlerDescriptor + { + ContextType = typeof(TContext), + FilterTypes = _filterTypes.ToImmutableArray(), + Order = _order, + ServiceDescriptor = _descriptor ?? throw new InvalidOperationException("No service descriptor was set.") + }; + } + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlerFilters.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlerFilters.cs new file mode 100644 index 00000000..7f1f6c1d --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlerFilters.cs @@ -0,0 +1,66 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using JetBrains.Annotations; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace OpenIddict.Validation +{ + [EditorBrowsable(EditorBrowsableState.Advanced)] + public static class OpenIddictValidationHandlerFilters + { + /// + /// Represents a filter that excludes the associated handlers if authorization validation was not enabled. + /// + public class RequireAuthorizationValidationEnabled : IOpenIddictValidationHandlerFilter + { + public ValueTask IsActiveAsync([NotNull] BaseContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(context.Options.EnableAuthorizationValidation); + } + } + + /// + /// Represents a filter that excludes the associated handlers if reference tokens are enabled. + /// + public class RequireReferenceTokensDisabled : IOpenIddictValidationHandlerFilter + { + public ValueTask IsActiveAsync([NotNull] BaseContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(!context.Options.UseReferenceTokens); + } + } + + /// + /// Represents a filter that excludes the associated handlers if reference tokens are disabled. + /// + public class RequireReferenceTokensEnabled : IOpenIddictValidationHandlerFilter + { + public ValueTask IsActiveAsync([NotNull] BaseContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(context.Options.UseReferenceTokens); + } + } + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs new file mode 100644 index 00000000..f791780b --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs @@ -0,0 +1,533 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Validation.OpenIddictValidationEvents; +using static OpenIddict.Validation.OpenIddictValidationHandlerFilters; + +namespace OpenIddict.Validation +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public static partial class OpenIddictValidationHandlers + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Authentication processing: + */ + ValidateTokenValidationParameters.Descriptor, + ValidateAccessTokenParameter.Descriptor, + ValidateReferenceToken.Descriptor, + ValidateSelfContainedToken.Descriptor, + ValidatePrincipal.Descriptor, + ValidateExpirationDate.Descriptor, + ValidateAudience.Descriptor, + ValidateAuthorizationEntry.Descriptor, + + /* + * Challenge processing: + */ + AttachDefaultChallengeError.Descriptor); + + /// + /// Contains the logic responsible of ensuring the token validation parameters are populated. + /// + public class ValidateTokenValidationParameters : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(int.MinValue + 100_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Note: at this stage, throw an exception if the token validation parameters cannot be found. + var parameters = context.TokenValidationParameters ?? context.Options.TokenValidationParameters; + if (parameters == null) + { + throw new InvalidOperationException(new StringBuilder() + .AppendLine("The token validation parameters cannot be retrieved.") + .Append("To register the default client, reference the 'OpenIddict.Validation.SystemNetHttp' package ") + .AppendLine("and call 'services.AddOpenIddict().AddValidation().UseSystemNetHttp()'.") + .Append("Alternatively, you can manually provide the token validation parameters ") + .Append("by calling 'services.AddOpenIddict().AddValidation().SetTokenValidationParameters()'.") + .ToString()); + } + + // Clone the token validation parameters before mutating them to ensure the + // shared token validation parameters registered as options are not modified. + parameters = parameters.Clone(); + parameters.NameClaimType = Claims.Name; + parameters.PropertyBag = new Dictionary { [Claims.Private.TokenUsage] = TokenUsages.AccessToken }; + parameters.RoleClaimType = Claims.Role; + parameters.TokenDecryptionKeys = context.Options.EncryptionCredentials.Select(credentials => credentials.Key); + parameters.ValidIssuer = context.Issuer?.AbsoluteUri; + parameters.ValidateAudience = false; + parameters.ValidateLifetime = false; + + context.TokenValidationParameters = parameters; + + return default; + } + } + + /// + /// Contains the logic responsible of validating the access token resolved from the current request. + /// + public class ValidateAccessTokenParameter : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateTokenValidationParameters.Descriptor.Order + 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (string.IsNullOrEmpty(context.Request.AccessToken)) + { + context.Logger.LogError("The request was rejected because the access token was missing."); + + context.Reject( + error: Errors.InvalidToken, + description: "The access token is missing."); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible of rejecting authentication demands that use an invalid reference token. + /// Note: this handler is not used when the degraded mode is enabled. + /// + public class ValidateReferenceToken : IOpenIddictValidationHandler + { + private readonly IOpenIddictTokenManager _tokenManager; + + public ValidateReferenceToken() => throw new InvalidOperationException(new StringBuilder() + .AppendLine("The core services must be registered when enabling reference tokens support.") + .Append("To register the OpenIddict core services, reference the 'OpenIddict.Core' package ") + .AppendLine("and call 'services.AddOpenIddict().AddCore()' from 'ConfigureServices'.") + .ToString()); + + public ValidateReferenceToken([NotNull] IOpenIddictTokenManager tokenManager) + => _tokenManager = tokenManager; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidateAccessTokenParameter.Descriptor.Order + 1_000) + .Build(); + + public async ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If a principal was already attached, don't overwrite it. + if (context.Principal != null) + { + return; + } + + // If the reference token cannot be found, return a generic error. + var token = await _tokenManager.FindByReferenceIdAsync(context.Request.AccessToken); + if (token == null || !string.Equals(await _tokenManager.GetTypeAsync(token), + TokenUsages.AccessToken, StringComparison.OrdinalIgnoreCase)) + { + context.Reject( + error: Errors.InvalidToken, + description: "The specified token is not valid."); + + return; + } + + var payload = await _tokenManager.GetPayloadAsync(token); + if (string.IsNullOrEmpty(payload)) + { + throw new InvalidOperationException(new StringBuilder() + .AppendLine("The payload associated with a reference token cannot be retrieved.") + .Append("This may indicate that the token entry was corrupted.") + .ToString()); + } + + // If the token cannot be validated, don't return an error to allow another handle to validate it. + if (!context.Options.SecurityTokenHandler.CanReadToken(payload)) + { + return; + } + + // If the token cannot be validated, don't return an error to allow another handle to validate it. + var result = await context.Options.SecurityTokenHandler.ValidateTokenStringAsync( + payload, context.TokenValidationParameters); + + if (result.ClaimsIdentity == null) + { + return; + } + + // Attach the principal extracted from the authorization code to the parent event context + // and restore the creation/expiration dates/identifiers from the token entry metadata. + context.Principal = new ClaimsPrincipal(result.ClaimsIdentity) + .SetCreationDate(await _tokenManager.GetCreationDateAsync(token)) + .SetExpirationDate(await _tokenManager.GetExpirationDateAsync(token)) + .SetInternalAuthorizationId(await _tokenManager.GetAuthorizationIdAsync(token)) + .SetInternalTokenId(await _tokenManager.GetIdAsync(token)) + .SetClaim(Claims.Private.TokenUsage, await _tokenManager.GetTypeAsync(token)); + } + } + + /// + /// Contains the logic responsible of rejecting authentication demands that specify an invalid self-contained token. + /// + public class ValidateSelfContainedToken : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateReferenceToken.Descriptor.Order + 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public async ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If a principal was already attached, don't overwrite it. + if (context.Principal != null) + { + return; + } + + // If the token cannot be validated, don't return an error to allow another handle to validate it. + if (!context.Options.SecurityTokenHandler.CanReadToken(context.Request.AccessToken)) + { + return; + } + + // If the token cannot be validated, don't return an error to allow another handle to validate it. + var result = await context.Options.SecurityTokenHandler.ValidateTokenStringAsync( + context.Request.AccessToken, context.TokenValidationParameters); + + if (result.ClaimsIdentity == null) + { + return; + } + + // Attach the principal extracted from the token to the parent event context. + context.Principal = new ClaimsPrincipal(result.ClaimsIdentity); + } + } + + /// + /// Contains the logic responsible of rejecting authentication demands for which no valid principal was resolved. + /// + public class ValidatePrincipal : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateSelfContainedToken.Descriptor.Order + 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Principal == null) + { + context.Reject( + error: Errors.InvalidToken, + description: "The specified token is not valid."); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible of rejecting authentication demands containing expired access tokens. + /// + public class ValidateExpirationDate : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePrincipal.Descriptor.Order + 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var date = context.Principal.GetExpirationDate(); + if (date.HasValue && date.Value < DateTimeOffset.UtcNow) + { + context.Logger.LogError("The request was rejected because the access token was expired."); + + context.Reject( + error: Errors.InvalidToken, + description: "The specified access token is no longer valid."); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible of rejecting authentication demands containing + /// access tokens that were issued to be used by another audience/resource server. + /// + public class ValidateAudience : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateExpirationDate.Descriptor.Order + 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If no explicit audience has been configured, + // skip the default audience validation. + if (context.Options.Audiences.Count == 0) + { + return default; + } + + // If the access token doesn't have any audience attached, return an error. + if (!context.Principal.HasAudience()) + { + context.Logger.LogError("The request was rejected because the access token had no audience attached."); + + context.Reject( + error: Errors.InvalidToken, + description: "The specified access token doesn't contain any audience."); + + return default; + } + + // If the access token doesn't include any registered audience, return an error. + if (context.Principal.GetAudiences().Intersect(context.Options.Audiences).IsEmpty) + { + context.Logger.LogError("The request was rejected because the access token had no valid audience."); + + context.Reject( + error: Errors.InvalidToken, + description: "The specified access token cannot be used with this resource server."); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible of authentication demands a token whose + /// associated authorization entry is no longer valid (e.g was revoked). + /// Note: this handler is not used when the degraded mode is enabled. + /// + public class ValidateAuthorizationEntry : IOpenIddictValidationHandler + { + private readonly IOpenIddictAuthorizationManager _authorizationManager; + + public ValidateAuthorizationEntry() => throw new InvalidOperationException(new StringBuilder() + .AppendLine("The core services must be registered when enabling reference tokens support.") + .Append("To register the OpenIddict core services, reference the 'OpenIddict.Core' package ") + .AppendLine("and call 'services.AddOpenIddict().AddCore()' from 'ConfigureServices'.") + .ToString()); + + public ValidateAuthorizationEntry([NotNull] IOpenIddictAuthorizationManager authorizationManager) + => _authorizationManager = authorizationManager; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidateAudience.Descriptor.Order + 1_000) + .Build(); + + public async ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var identifier = context.Principal.GetInternalAuthorizationId(); + if (string.IsNullOrEmpty(identifier)) + { + return; + } + + var authorization = await _authorizationManager.FindByIdAsync(identifier); + if (authorization == null || !await _authorizationManager.IsValidAsync(authorization)) + { + context.Logger.LogError("The authorization '{Identifier}' was no longer valid.", identifier); + + context.Reject( + error: Errors.InvalidToken, + description: "The authorization associated with the token is no longer valid."); + + return; + } + } + } + + /// + /// Contains the logic responsible of ensuring that the challenge response contains an appropriate error. + /// + public class AttachDefaultChallengeError : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(int.MinValue + 100_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessChallengeContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (string.IsNullOrEmpty(context.Response.Error)) + { + context.Response.Error = Errors.InvalidToken; + } + + if (string.IsNullOrEmpty(context.Response.ErrorDescription)) + { + context.Response.ErrorDescription = "The access token is not valid."; + } + + return default; + } + } + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationOptions.cs b/src/OpenIddict.Validation/OpenIddictValidationOptions.cs new file mode 100644 index 00000000..e431f1a3 --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationOptions.cs @@ -0,0 +1,86 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Generic; +using Microsoft.IdentityModel.Tokens; + +namespace OpenIddict.Validation +{ + /// + /// Provides various settings needed to configure the OpenIddict validation handler. + /// + public class OpenIddictValidationOptions + { + /// + /// Gets the list of credentials used to encrypt the tokens issued by the + /// OpenIddict validation services. Note: only symmetric credentials are supported. + /// + public IList EncryptionCredentials { get; } = new List(); + + /// + /// Gets or sets the security token handler used to protect and unprotect tokens. + /// + public OpenIddictValidationTokenHandler SecurityTokenHandler { get; set; } = new OpenIddictValidationTokenHandler + { + SetDefaultTimesOnTokenCreation = false + }; + + /// + /// Gets the list of the user-defined/custom handlers responsible of processing the OpenIddict validation requests. + /// Note: the handlers added to this list must be also registered in the DI container using an appropriate lifetime. + /// + public IList CustomHandlers { get; } = + new List(); + + /// + /// Gets the list of the built-in handlers responsible of processing the OpenIddict validation requests + /// + public IList DefaultHandlers { get; } = + new List(OpenIddictValidationHandlers.DefaultHandlers); + + /// + /// Gets or sets a boolean indicating whether a database call is made + /// to validate the authorization associated with the received tokens. + /// + public bool EnableAuthorizationValidation { get; set; } + + /// + /// Gets or sets a boolean indicating whether reference tokens should be used. + /// When set to true, authorization codes, access tokens and refresh tokens + /// are stored as ciphertext in the database and a crypto-secure random identifier + /// is returned to the client application. Enabling this option is useful + /// to keep track of all the issued tokens, when storing a very large number + /// of claims in the authorization codes, access tokens and refresh tokens + /// or when immediate revocation of reference access tokens is desired. + /// Note: this option cannot be used when configuring JWT as the access token format. + /// + public bool UseReferenceTokens { get; set; } + + /// + /// Gets or sets the absolute URL of the OAuth 2.0/OpenID Connect server. + /// + public Uri Issuer { get; set; } + + /// + /// Gets or sets the URL of the OAuth 2.0/OpenID Connect server discovery endpoint. + /// When the URL is relative, must be set and absolute. + /// + public Uri MetadataAddress { get; set; } + + /// + /// Gets the intended audiences of this resource server. + /// Setting this property is recommended when the authorization + /// server issues access tokens for multiple distinct resource servers. + /// + public ISet Audiences { get; } = new HashSet(StringComparer.Ordinal); + + /// + /// Gets or sets the token validation parameters used by the OpenIddict validation services. + /// + public TokenValidationParameters TokenValidationParameters { get; set; } + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationProvider.cs b/src/OpenIddict.Validation/OpenIddictValidationProvider.cs new file mode 100644 index 00000000..67773208 --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationProvider.cs @@ -0,0 +1,132 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace OpenIddict.Validation +{ + public class OpenIddictValidationProvider : IOpenIddictValidationProvider + { + private readonly ILogger _logger; + private readonly IOptionsMonitor _options; + private readonly IServiceProvider _provider; + + /// + /// Creates a new instance of the class. + /// + public OpenIddictValidationProvider( + [NotNull] ILogger logger, + [NotNull] IOptionsMonitor options, + [NotNull] IServiceProvider provider) + { + _logger = logger; + _options = options; + _provider = provider; + } + + public ValueTask CreateTransactionAsync() + => new ValueTask(new OpenIddictValidationTransaction + { + Issuer = _options.CurrentValue.Issuer, + Logger = _logger, + Options = _options.CurrentValue + }); + + public async ValueTask DispatchAsync([NotNull] TContext context) where TContext : BaseContext + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + await foreach (var handler in GetHandlersAsync()) + { + await handler.HandleAsync(context); + + switch (context) + { + case BaseRequestContext notification when notification.IsRequestHandled: + _logger.LogDebug("The request was handled in user code."); + return; + + case BaseRequestContext notification when notification.IsRequestSkipped: + _logger.LogDebug("The default request handling was skipped from user code."); + return; + + case BaseValidatingContext notification when notification.IsRejected: + _logger.LogDebug("The request was rejected in user code."); + return; + + default: continue; + } + } + + async IAsyncEnumerable> GetHandlersAsync() + { + var descriptors = new List( + capacity: _options.CurrentValue.CustomHandlers.Count + + _options.CurrentValue.DefaultHandlers.Count); + + descriptors.AddRange(_options.CurrentValue.CustomHandlers); + descriptors.AddRange(_options.CurrentValue.DefaultHandlers); + + descriptors.Sort((left, right) => left.Order.CompareTo(right.Order)); + + for (var index = 0; index < descriptors.Count; index++) + { + var descriptor = descriptors[index]; + if (descriptor.ContextType != typeof(TContext) || !await IsActiveAsync(descriptor)) + { + continue; + } + + var handler = descriptor.ServiceDescriptor.ImplementationInstance != null ? + descriptor.ServiceDescriptor.ImplementationInstance as IOpenIddictValidationHandler : + _provider.GetService(descriptor.ServiceDescriptor.ServiceType) as IOpenIddictValidationHandler; + + if (handler == null) + { + throw new InvalidOperationException(new StringBuilder() + .AppendLine($"The event handler of type '{descriptor.ServiceDescriptor.ServiceType}' couldn't be resolved.") + .AppendLine("This may indicate that it was not properly registered in the dependency injection container.") + .Append("To register an event handler, use 'services.AddOpenIddict().AddValidation().AddEventHandler()'.") + .ToString()); + } + + yield return handler; + } + } + + async ValueTask IsActiveAsync(OpenIddictValidationHandlerDescriptor descriptor) + { + for (var index = 0; index < descriptor.FilterTypes.Length; index++) + { + if (!(_provider.GetService(descriptor.FilterTypes[index]) is IOpenIddictValidationHandlerFilter filter)) + { + throw new InvalidOperationException(new StringBuilder() + .AppendLine($"The event handler filter of type '{descriptor.FilterTypes[index]}' couldn't be resolved.") + .AppendLine("This may indicate that it was not properly registered in the dependency injection container.") + .ToString()); + } + + if (!await filter.IsActiveAsync(context)) + { + return false; + } + } + + return true; + } + } + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationTokenHandler.cs b/src/OpenIddict.Validation/OpenIddictValidationTokenHandler.cs new file mode 100644 index 00000000..93ed4164 --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationTokenHandler.cs @@ -0,0 +1,95 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace OpenIddict.Validation +{ + public class OpenIddictValidationTokenHandler : JsonWebTokenHandler + { + public ValueTask ValidateTokenStringAsync(string token, TokenValidationParameters parameters) + { + if (parameters == null) + { + throw new ArgumentNullException(nameof(parameters)); + } + + if (parameters.PropertyBag == null) + { + throw new InvalidOperationException("The property bag cannot be null."); + } + + if (!parameters.PropertyBag.TryGetValue(Claims.Private.TokenUsage, out var type) || string.IsNullOrEmpty((string) type)) + { + throw new InvalidOperationException("The token usage cannot be null or empty."); + } + + if (!CanReadToken(token)) + { + return new ValueTask(new TokenValidationResult + { + Exception = new SecurityTokenException("The token was not compatible with the JWT format."), + IsValid = false + }); + } + + try + { + var result = base.ValidateToken(token, parameters); + if (result == null || !result.IsValid) + { + return new ValueTask(new TokenValidationResult + { + Exception = result?.Exception, + IsValid = false + }); + } + + var assertion = ((JsonWebToken) result.SecurityToken)?.InnerToken ?? (JsonWebToken) result.SecurityToken; + + if (!assertion.TryGetPayloadValue(Claims.Private.TokenUsage, out string usage) || + !string.Equals(usage, (string) type, StringComparison.OrdinalIgnoreCase)) + { + return new ValueTask(new TokenValidationResult + { + Exception = new SecurityTokenException("The token usage associated to the token does not match the expected type."), + IsValid = false + }); + } + + // Restore the claim destinations from the special oi_cl_dstn claim (represented as a dictionary/JSON object). + if (assertion.TryGetPayloadValue(Claims.Private.ClaimDestinations, out IDictionary definitions)) + { + foreach (var definition in definitions) + { + foreach (var claim in result.ClaimsIdentity.Claims.Where(claim => claim.Type == definition.Key)) + { + claim.SetDestinations(definition.Value); + } + } + } + + return new ValueTask(result); + } + + catch (Exception exception) + { + return new ValueTask(new TokenValidationResult + { + Exception = exception, + IsValid = false + }); + } + } + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationTransaction.cs b/src/OpenIddict.Validation/OpenIddictValidationTransaction.cs new file mode 100644 index 00000000..444242f5 --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationTransaction.cs @@ -0,0 +1,55 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using OpenIddict.Abstractions; + +namespace OpenIddict.Validation +{ + /// + /// Represents the context associated with an OpenID Connect validation request. + /// + public class OpenIddictValidationTransaction + { + /// + /// Gets or sets the type of the endpoint processing the current request. + /// + public OpenIddictValidationEndpointType EndpointType { get; set; } + + /// + /// Gets or sets the issuer address associated with the current transaction, if available. + /// + public Uri Issuer { get; set; } + + /// + /// Gets or sets the logger associated with the current request. + /// + public ILogger Logger { get; set; } + + /// + /// Gets or sets the options associated with the current request. + /// + public OpenIddictValidationOptions Options { get; set; } + + /// + /// Gets the additional properties associated with the current request. + /// + public IDictionary Properties { get; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the current OpenID Connect request. + /// + public OpenIddictRequest Request { get; set; } + + /// + /// Gets or sets the current OpenID Connect response being returned. + /// + public OpenIddictResponse Response { get; set; } + } +} diff --git a/src/OpenIddict/OpenIddict.csproj b/src/OpenIddict/OpenIddict.csproj index 4d3f70f9..3d97dc8d 100644 --- a/src/OpenIddict/OpenIddict.csproj +++ b/src/OpenIddict/OpenIddict.csproj @@ -14,6 +14,9 @@ + + +