From 03a27051ec51061151895e54458bf1ebf51bd484 Mon Sep 17 00:00:00 2001 From: Chino Chang Date: Thu, 19 Apr 2018 06:49:17 +0800 Subject: [PATCH] Create a validation middleware with reference tokens support --- OpenIddict.sln | 14 + samples/Mvc.Server/Mvc.Server.csproj | 3 +- samples/Mvc.Server/Startup.cs | 12 +- .../OpenIddictConstants.cs | 1 + .../Internal/OpenIddictValidationHandler.cs | 286 ++++++ .../Internal/OpenIddictValidationHelpers.cs | 78 ++ .../OpenIddictValidationInitializer.cs | 83 ++ .../OpenIddict.Validation.csproj | 25 + .../OpenIddictValidationBuilder.cs | 127 +++ .../OpenIddictValidationExtensions.cs | 129 +++ .../OpenIddictValidationOptions.cs | 24 + src/OpenIddict/OpenIddict.csproj | 1 + .../OpenIddict.Validation.Tests.csproj | 28 + .../OpenIddictValidationHandlerTests.cs | 828 ++++++++++++++++++ 14 files changed, 1633 insertions(+), 6 deletions(-) create mode 100644 src/OpenIddict.Validation/Internal/OpenIddictValidationHandler.cs create mode 100644 src/OpenIddict.Validation/Internal/OpenIddictValidationHelpers.cs create mode 100644 src/OpenIddict.Validation/Internal/OpenIddictValidationInitializer.cs create mode 100644 src/OpenIddict.Validation/OpenIddict.Validation.csproj create mode 100644 src/OpenIddict.Validation/OpenIddictValidationBuilder.cs create mode 100644 src/OpenIddict.Validation/OpenIddictValidationExtensions.cs create mode 100644 src/OpenIddict.Validation/OpenIddictValidationOptions.cs create mode 100755 test/OpenIddict.Validation.Tests/OpenIddict.Validation.Tests.csproj create mode 100755 test/OpenIddict.Validation.Tests/OpenIddictValidationHandlerTests.cs diff --git a/OpenIddict.sln b/OpenIddict.sln index 538707b4..677af767 100644 --- a/OpenIddict.sln +++ b/OpenIddict.sln @@ -54,6 +54,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Server.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenIddict.Abstractions", "src\OpenIddict.Abstractions\OpenIddict.Abstractions.csproj", "{886A16DA-C9CF-4979-9B38-D06DF8A714B6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenIddict.Validation.Tests", "test\OpenIddict.Validation.Tests\OpenIddict.Validation.Tests.csproj", "{F470E734-F4B6-4355-AF32-53412B619E41}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenIddict.Validation", "src\OpenIddict.Validation\OpenIddict.Validation.csproj", "{6AB8F9E7-47F8-4A40-837F-C8753362AF54}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -128,6 +132,14 @@ Global {886A16DA-C9CF-4979-9B38-D06DF8A714B6}.Debug|Any CPU.Build.0 = Debug|Any CPU {886A16DA-C9CF-4979-9B38-D06DF8A714B6}.Release|Any CPU.ActiveCfg = Release|Any CPU {886A16DA-C9CF-4979-9B38-D06DF8A714B6}.Release|Any CPU.Build.0 = Release|Any CPU + {F470E734-F4B6-4355-AF32-53412B619E41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F470E734-F4B6-4355-AF32-53412B619E41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F470E734-F4B6-4355-AF32-53412B619E41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F470E734-F4B6-4355-AF32-53412B619E41}.Release|Any CPU.Build.0 = Release|Any CPU + {6AB8F9E7-47F8-4A40-837F-C8753362AF54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AB8F9E7-47F8-4A40-837F-C8753362AF54}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AB8F9E7-47F8-4A40-837F-C8753362AF54}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AB8F9E7-47F8-4A40-837F-C8753362AF54}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -150,6 +162,8 @@ Global {21A7F241-CBE7-4F5C-9787-F2C50D135AEA} = {D544447C-D701-46BB-9A5B-C76C612A596B} {07B02B98-8A68-432D-A932-48E6D52B221A} = {5FC71D6A-A994-4F62-977F-88A7D25379D7} {886A16DA-C9CF-4979-9B38-D06DF8A714B6} = {D544447C-D701-46BB-9A5B-C76C612A596B} + {F470E734-F4B6-4355-AF32-53412B619E41} = {5FC71D6A-A994-4F62-977F-88A7D25379D7} + {6AB8F9E7-47F8-4A40-837F-C8753362AF54} = {D544447C-D701-46BB-9A5B-C76C612A596B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A710059F-0466-4D48-9B3A-0EF4F840B616} diff --git a/samples/Mvc.Server/Mvc.Server.csproj b/samples/Mvc.Server/Mvc.Server.csproj index 7dc4f783..ed22a33b 100644 --- a/samples/Mvc.Server/Mvc.Server.csproj +++ b/samples/Mvc.Server/Mvc.Server.csproj @@ -18,11 +18,10 @@ + - - diff --git a/samples/Mvc.Server/Startup.cs b/samples/Mvc.Server/Startup.cs index f2513442..ea773833 100644 --- a/samples/Mvc.Server/Startup.cs +++ b/samples/Mvc.Server/Startup.cs @@ -64,9 +64,7 @@ namespace Mvc.Server { options.ConsumerKey = "6XaCTaLbMqfj6ww3zvZ5g"; options.ConsumerSecret = "Il2eFzGIrYhz6BWjYhVXBPQSfZuS4xoHpSSyD9PI"; - }) - - .AddOAuthValidation(); + }); services.AddOpenIddict() @@ -127,7 +125,13 @@ namespace Mvc.Server // // options.UseJsonWebTokens(); // options.AddEphemeralSigningKey(); - }); + }) + + // Register the OpenIddict validation handler. + // Note: the OpenIddict validation handler is only compatible with the + // default token format or with reference tokens and cannot be used with + // JWT tokens. For JWT tokens, use the Microsoft JWT bearer handler. + .AddValidation(); services.AddTransient(); services.AddTransient(); diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index 8f47c7fa..6f1fc759 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -79,6 +79,7 @@ namespace OpenIddict.Abstractions public const string AuthorizationId = ".authorization_id"; public const string ReferenceToken = ".reference_token"; public const string Token = ".token"; + public const string TokenId = ".token_id"; } public static class PropertyTypes diff --git a/src/OpenIddict.Validation/Internal/OpenIddictValidationHandler.cs b/src/OpenIddict.Validation/Internal/OpenIddictValidationHandler.cs new file mode 100644 index 00000000..e4f37de8 --- /dev/null +++ b/src/OpenIddict.Validation/Internal/OpenIddictValidationHandler.cs @@ -0,0 +1,286 @@ +/* + * 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.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using AspNet.Security.OAuth.Validation; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using Newtonsoft.Json.Linq; +using OpenIddict.Abstractions; +using OpenIddict.Core; + +namespace OpenIddict.Validation +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public class OpenIddictValidationHandler : OAuthValidationHandler + { + public OpenIddictValidationHandler( + [NotNull] IOptionsMonitor options, + [NotNull] ILoggerFactory logger, + [NotNull] UrlEncoder encoder, + [NotNull] ISystemClock clock) + : base(options, logger, encoder, clock) + { + } + } + + [EditorBrowsable(EditorBrowsableState.Never)] + public class OpenIddictValidationHandler : OpenIddictValidationHandler where TToken : class + { + public OpenIddictValidationHandler( + [NotNull] IOptionsMonitor options, + [NotNull] ILoggerFactory logger, + [NotNull] UrlEncoder encoder, + [NotNull] ISystemClock clock) + : base(options, logger, encoder, clock) + { + } + + protected override async Task HandleAuthenticateAsync() + { + var context = new RetrieveTokenContext(Context, Scheme, Options); + await Events.RetrieveToken(context); + + if (context.Result != null) + { + Logger.LogInformation("The default authentication handling was skipped from user code."); + + return context.Result; + } + + var token = context.Token; + + if (string.IsNullOrEmpty(token)) + { + // Try to retrieve the access token from the authorization header. + string header = Request.Headers[HeaderNames.Authorization]; + if (string.IsNullOrEmpty(header)) + { + Logger.LogDebug("Authentication was skipped because no bearer token was received."); + + return AuthenticateResult.NoResult(); + } + + // Ensure that the authorization header contains the mandatory "Bearer" scheme. + // See https://tools.ietf.org/html/rfc6750#section-2.1 + if (!header.StartsWith(OAuthValidationConstants.Schemes.Bearer + ' ', StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug("Authentication was skipped because an incompatible " + + "scheme was used in the 'Authorization' header."); + + return AuthenticateResult.NoResult(); + } + + // Extract the token from the authorization header. + token = header.Substring(OAuthValidationConstants.Schemes.Bearer.Length + 1).Trim(); + + if (string.IsNullOrEmpty(token)) + { + Logger.LogDebug("Authentication was skipped because the bearer token " + + "was missing from the 'Authorization' header."); + + return AuthenticateResult.NoResult(); + } + } + + // Try to unprotect the token and return an error + // if the ticket can't be decrypted or validated. + var result = await CreateTicketAsync(token); + if (!result.Succeeded) + { + Context.Features.Set(new OAuthValidationFeature + { + Error = new OAuthValidationError + { + Error = OAuthValidationConstants.Errors.InvalidToken, + ErrorDescription = "The access token is not valid." + } + }); + + return result; + } + + // Ensure that the authentication ticket is still valid. + var ticket = result.Ticket; + if (ticket.Properties.ExpiresUtc.HasValue && + ticket.Properties.ExpiresUtc.Value < Options.SystemClock.UtcNow) + { + Context.Features.Set(new OAuthValidationFeature + { + Error = new OAuthValidationError + { + Error = OAuthValidationConstants.Errors.InvalidToken, + ErrorDescription = "The access token is no longer valid." + } + }); + + return AuthenticateResult.Fail("Authentication failed because the access token was expired."); + } + + // Ensure that the access token was issued + // to be used with this resource server. + if (!ValidateAudience(ticket)) + { + Context.Features.Set(new OAuthValidationFeature + { + Error = new OAuthValidationError + { + Error = OAuthValidationConstants.Errors.InvalidToken, + ErrorDescription = "The access token is not valid for this resource server." + } + }); + + return AuthenticateResult.Fail("Authentication failed because the access token " + + "was not valid for this resource server."); + } + + var notification = new ValidateTokenContext(Context, Scheme, Options, ticket); + await Events.ValidateToken(notification); + + if (notification.Result != null) + { + Logger.LogInformation("The default authentication handling was skipped from user code."); + + return notification.Result; + } + + // Optimization: avoid allocating a new AuthenticationTicket + // if the principal/properties instances were not replaced. + if (ReferenceEquals(notification.Principal, ticket.Principal) && + ReferenceEquals(notification.Properties, ticket.Properties)) + { + return AuthenticateResult.Success(ticket); + } + + return AuthenticateResult.Success(new AuthenticationTicket( + notification.Principal, notification.Properties, Scheme.Name)); + } + + private bool ValidateAudience(AuthenticationTicket ticket) + { + // If no explicit audience has been configured, + // skip the default audience validation. + if (Options.Audiences.Count == 0) + { + return true; + } + + // Extract the audiences from the authentication ticket. + var audiences = ticket.Properties.GetProperty(OAuthValidationConstants.Properties.Audiences); + if (string.IsNullOrEmpty(audiences)) + { + return false; + } + + // Ensure that the authentication ticket contains one of the registered audiences. + foreach (var audience in JArray.Parse(audiences).Values()) + { + if (Options.Audiences.Contains(audience)) + { + return true; + } + } + + return false; + } + + private async Task CreateTicketAsync(string payload) + { + var manager = Context.RequestServices.GetService>(); + if (manager == null) + { + throw new InvalidOperationException("The token manager was not correctly registered."); + } + + // Retrieve the token entry from the database. If it + // cannot be found, assume the token is not valid. + var token = await manager.FindByReferenceIdAsync(payload); + if (token == null) + { + return AuthenticateResult.Fail("Authentication failed because the access token cannot be found in the database."); + } + + // Extract the encrypted payload from the token. If it's null or empty, + // assume the token is not a reference token and consider it as invalid. + var ciphertext = await manager.GetPayloadAsync(token); + if (string.IsNullOrEmpty(ciphertext)) + { + return AuthenticateResult.Fail("Authentication failed because the access token is not a reference token."); + } + + var ticket = Options.AccessTokenFormat.Unprotect(ciphertext); + if (ticket == null) + { + return AuthenticateResult.Fail( + "Authentication failed because the reference token cannot be decrypted. " + + "This may indicate that the token entry is corrupted or tampered."); + } + + // Dynamically set the creation and expiration dates. + ticket.Properties.IssuedUtc = await manager.GetCreationDateAsync(token); + ticket.Properties.ExpiresUtc = await manager.GetExpirationDateAsync(token); + + // Restore the token and authorization identifiers attached with the database entry. + ticket.Properties.SetProperty(OpenIddictConstants.Properties.TokenId, await manager.GetIdAsync(token)); + ticket.Properties.SetProperty(OpenIddictConstants.Properties.AuthorizationId, + await manager.GetAuthorizationIdAsync(token)); + + if (Options.SaveToken) + { + // Store the access token in the authentication ticket. + ticket.Properties.StoreTokens(new[] + { + new AuthenticationToken { Name = OAuthValidationConstants.Properties.Token, Value = payload } + }); + } + + // Resolve the primary identity associated with the principal. + var identity = (ClaimsIdentity) ticket.Principal.Identity; + + // Copy the scopes extracted from the authentication ticket to the + // ClaimsIdentity to make them easier to retrieve from application code. + var scopes = ticket.Properties.GetProperty(OAuthValidationConstants.Properties.Scopes); + if (!string.IsNullOrEmpty(scopes)) + { + foreach (var scope in JArray.Parse(scopes).Values()) + { + identity.AddClaim(new Claim(OAuthValidationConstants.Claims.Scope, scope)); + } + } + + var notification = new CreateTicketContext(Context, Scheme, Options, ticket); + await Events.CreateTicket(notification); + + if (notification.Result != null) + { + Logger.LogInformation("The default authentication handling was skipped from user code."); + + return notification.Result; + } + + // Optimization: avoid allocating a new AuthenticationTicket + // if the principal/properties instances were not replaced. + if (ReferenceEquals(notification.Principal, ticket.Principal) && + ReferenceEquals(notification.Properties, ticket.Properties)) + { + return AuthenticateResult.Success(ticket); + } + + return AuthenticateResult.Success(new AuthenticationTicket( + notification.Principal, notification.Properties, Scheme.Name)); + } + + private new OAuthValidationEvents Events => (OAuthValidationEvents) base.Events; + } +} diff --git a/src/OpenIddict.Validation/Internal/OpenIddictValidationHelpers.cs b/src/OpenIddict.Validation/Internal/OpenIddictValidationHelpers.cs new file mode 100644 index 00000000..65cee419 --- /dev/null +++ b/src/OpenIddict.Validation/Internal/OpenIddictValidationHelpers.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 JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; + +namespace OpenIddict.Validation +{ + /// + /// Defines a set of commonly used helpers. + /// + internal static class OpenIddictValidationHelpers + { + /// + /// Gets a given property from the authentication properties. + /// + /// The authentication properties. + /// The specific property to look for. + /// The value corresponding to the property, or null if the property cannot be found. + public static string GetProperty([NotNull] this AuthenticationProperties properties, [NotNull] string property) + { + if (properties == null) + { + throw new ArgumentNullException(nameof(properties)); + } + + if (string.IsNullOrEmpty(property)) + { + throw new ArgumentException("The property name cannot be null or empty.", nameof(property)); + } + + if (!properties.Items.TryGetValue(property, out string value)) + { + return null; + } + + return value; + } + + /// + /// Sets the specified property in the authentication properties. + /// + /// The authentication properties. + /// The property name. + /// The property value. + /// The so that multiple calls can be chained. + public static AuthenticationProperties SetProperty( + [NotNull] this AuthenticationProperties properties, + [NotNull] string property, [CanBeNull] string value) + { + if (properties == null) + { + throw new ArgumentNullException(nameof(properties)); + } + + if (string.IsNullOrEmpty(property)) + { + throw new ArgumentException("The property name cannot be null or empty.", nameof(property)); + } + + if (string.IsNullOrEmpty(value)) + { + properties.Items.Remove(property); + } + + else + { + properties.Items[property] = value; + } + + return properties; + } + } +} diff --git a/src/OpenIddict.Validation/Internal/OpenIddictValidationInitializer.cs b/src/OpenIddict.Validation/Internal/OpenIddictValidationInitializer.cs new file mode 100644 index 00000000..a241ee33 --- /dev/null +++ b/src/OpenIddict.Validation/Internal/OpenIddictValidationInitializer.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.ComponentModel; +using AspNet.Security.OAuth.Validation; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Options; +using OpenIddict.Core; + +namespace OpenIddict.Validation +{ + /// + /// Contains the methods required to ensure that the configuration used by + /// the OpenIddict validation handler is in a consistent and valid state. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public class OpenIddictValidationInitializer : IPostConfigureOptions + { + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly IOptionsMonitor _options; + + /// + /// Creates a new instance of the class. + /// + public OpenIddictValidationInitializer( + [NotNull] IDataProtectionProvider dataProtectionProvider, + [NotNull] IOptionsMonitor options) + { + _dataProtectionProvider = dataProtectionProvider; + _options = options; + } + + /// + /// Populates the default OpenIddict validation options and ensure + /// 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([NotNull] string name, [NotNull] OpenIddictValidationOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("The options instance name cannot be null or empty.", nameof(name)); + } + + if (options.Events == null) + { + options.Events = new OAuthValidationEvents(); + } + + if (options.DataProtectionProvider == null) + { + options.DataProtectionProvider = _dataProtectionProvider; + } + + if (options.UseReferenceTokens && options.AccessTokenFormat == null) + { + var protector = options.DataProtectionProvider.CreateProtector( + "OpenIdConnectServerHandler", + nameof(options.AccessTokenFormat), + nameof(options.UseReferenceTokens), "ASOS"); + + options.AccessTokenFormat = new TicketDataFormat(protector); + } + + if (options.TokenType == null) + { + options.TokenType = _options.CurrentValue.DefaultTokenType; + } + } + } +} diff --git a/src/OpenIddict.Validation/OpenIddict.Validation.csproj b/src/OpenIddict.Validation/OpenIddict.Validation.csproj new file mode 100644 index 00000000..c9e71ee6 --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddict.Validation.csproj @@ -0,0 +1,25 @@ + + + + + + netstandard2.0 + + + + OpenIddict token validation middleware for ASP.NET Core. + Kévin Chalet;Chino Chang + aspnetcore;authentication;jwt;openidconnect;openiddict;security + + + + + + + + + + + + + diff --git a/src/OpenIddict.Validation/OpenIddictValidationBuilder.cs b/src/OpenIddict.Validation/OpenIddictValidationBuilder.cs new file mode 100644 index 00000000..272790ff --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationBuilder.cs @@ -0,0 +1,127 @@ +/* + * 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.Linq; +using AspNet.Security.OAuth.Validation; +using JetBrains.Annotations; +using Microsoft.AspNetCore.DataProtection; +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) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + Services = services; + } + + /// + /// Gets the services collection. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IServiceCollection Services { get; } + + /// + /// 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(OAuthValidationDefaults.AuthenticationScheme, configuration); + + return this; + } + + /// + /// 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)); + } + + /// + /// Configures OpenIddict not to return the authentication error + /// details as part of the standard WWW-Authenticate response header. + /// + /// The . + public OpenIddictValidationBuilder RemoveErrorDetails() + => Configure(options => options.IncludeErrorDetails = false); + + /// + /// Sets the realm, which is used to compute the WWW-Authenticate response header. + /// + /// The realm. + /// The . + public OpenIddictValidationBuilder SetRealm([NotNull] string realm) + { + if (string.IsNullOrEmpty(realm)) + { + throw new ArgumentException("The realm cannot be null or empty.", nameof(realm)); + } + + return Configure(options => options.Realm = realm); + } + + /// + /// Configures the OpenIddict validation handler to use reference tokens. + /// + /// The . + public OpenIddictValidationBuilder UseReferenceTokens() + => Configure(options => options.UseReferenceTokens = true); + + /// + /// 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 OpenIddictValidationBuilder UseDataProtectionProvider([NotNull] IDataProtectionProvider provider) + { + if (provider == null) + { + throw new ArgumentNullException(nameof(provider)); + } + + return Configure(options => options.DataProtectionProvider = provider); + } + } +} \ No newline at end of file diff --git a/src/OpenIddict.Validation/OpenIddictValidationExtensions.cs b/src/OpenIddict.Validation/OpenIddictValidationExtensions.cs new file mode 100644 index 00000000..2af3333f --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationExtensions.cs @@ -0,0 +1,129 @@ +/* + * 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 AspNet.Security.OAuth.Validation; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenIddict.Validation; + +namespace Microsoft.Extensions.DependencyInjection +{ + 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.AddAuthentication(); + + // Note: TryAddEnumerable() is used here to ensure the initializer is only registered once. + builder.Services.TryAddEnumerable(new[] + { + ServiceDescriptor.Singleton, + OpenIddictValidationInitializer>(), + ServiceDescriptor.Singleton, + OAuthValidationInitializer>() + }); + + builder.Services.TryAddScoped(typeof(OpenIddictValidationHandler<>)); + builder.Services.TryAddScoped(provider => + { + var options = provider.GetRequiredService>() + .Get(OAuthValidationDefaults.AuthenticationScheme); + + if (options == null) + { + throw new InvalidOperationException("The OpenIddict validation options cannot be resolved."); + } + + if (options.UseReferenceTokens) + { + if (options.TokenType == null) + { + throw new InvalidOperationException(new StringBuilder() + .AppendLine("The entity types must be configured for the token validation services to work correctly.") + .Append("To configure the entities, use either 'services.AddOpenIddict().AddCore().UseDefaultModels()' ") + .Append("or 'services.AddOpenIddict().AddCore().UseCustomModels()'.") + .ToString()); + } + + var type = typeof(OpenIddictValidationHandler<>).MakeGenericType(options.TokenType); + return (OpenIddictValidationHandler) provider.GetService(type); + } + + return new OpenIddictValidationHandler( + provider.GetRequiredService>(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService()); + }); + + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton, + OAuthValidationInitializer>()); + + // Register the OpenIddict validation handler in the authentication options, + // so it can be discovered by the default authentication handler provider. + builder.Services.Configure(options => + { + // Note: this method is guaranteed to be idempotent. To prevent multiple schemes from being + // registered (which would result in an exception being thrown), a manual check is made here. + if (options.SchemeMap.ContainsKey(OAuthValidationDefaults.AuthenticationScheme)) + { + return; + } + + options.AddScheme(OAuthValidationDefaults.AuthenticationScheme, scheme => + { + scheme.HandlerType = typeof(OpenIddictValidationHandler); + }); + }); + + 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; + } + } +} \ No newline at end of file diff --git a/src/OpenIddict.Validation/OpenIddictValidationOptions.cs b/src/OpenIddict.Validation/OpenIddictValidationOptions.cs new file mode 100644 index 00000000..e520419c --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationOptions.cs @@ -0,0 +1,24 @@ +/* + * 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 AspNet.Security.OAuth.Validation; + +namespace OpenIddict.Validation +{ + public class OpenIddictValidationOptions : OAuthValidationOptions + { + /// + /// Gets or sets the type corresponding to the Token entity. + /// + public Type TokenType { get; set; } + + /// + /// Gets or sets a boolean indicating whether reference tokens are used. + /// + public bool UseReferenceTokens { get; set; } + } +} diff --git a/src/OpenIddict/OpenIddict.csproj b/src/OpenIddict/OpenIddict.csproj index b36def11..996c49fb 100644 --- a/src/OpenIddict/OpenIddict.csproj +++ b/src/OpenIddict/OpenIddict.csproj @@ -15,6 +15,7 @@ + diff --git a/test/OpenIddict.Validation.Tests/OpenIddict.Validation.Tests.csproj b/test/OpenIddict.Validation.Tests/OpenIddict.Validation.Tests.csproj new file mode 100755 index 00000000..0ccaf453 --- /dev/null +++ b/test/OpenIddict.Validation.Tests/OpenIddict.Validation.Tests.csproj @@ -0,0 +1,28 @@ + + + + + + netcoreapp2.0;net461 + netcoreapp2.0 + + + + + + + + + + + + + + + + + + + + + diff --git a/test/OpenIddict.Validation.Tests/OpenIddictValidationHandlerTests.cs b/test/OpenIddict.Validation.Tests/OpenIddictValidationHandlerTests.cs new file mode 100755 index 00000000..5ce329b6 --- /dev/null +++ b/test/OpenIddict.Validation.Tests/OpenIddictValidationHandlerTests.cs @@ -0,0 +1,828 @@ +/* + * 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.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Threading.Tasks; +using AspNet.Security.OAuth.Validation; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OpenIddict.Abstractions; +using OpenIddict.Core; +using OpenIddict.Models; +using Xunit; + +namespace OpenIddict.Validation.Tests +{ + public class OpenIddictValidationHandlerTests + { + [Fact] + public async Task HandleAuthenticateAsync_InvalidTokenCausesInvalidAuthentication() + { + // Arrange + var server = CreateResourceServer(); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task HandleAuthenticateAsync_ValidTokenAllowsSuccessfulAuthentication() + { + // Arrange + var server = CreateResourceServer(); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Fabrikam", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task HandleAuthenticateAsync_MissingAudienceCausesInvalidAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.AddAudiences("http://www.fabrikam.com/"); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task HandleAuthenticateAsync_InvalidAudienceCausesInvalidAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.AddAudiences("http://www.fabrikam.com/"); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token-with-single-audience"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task HandleAuthenticateAsync_ValidAudienceAllowsSuccessfulAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.AddAudiences("http://www.fabrikam.com/"); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token-with-multiple-audiences"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Fabrikam", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task HandleAuthenticateAsync_AnyMatchingAudienceCausesSuccessfulAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.AddAudiences("http://www.contoso.com/"); + builder.AddAudiences("http://www.fabrikam.com/"); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token-with-single-audience"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Fabrikam", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task HandleAuthenticateAsync_MultipleMatchingAudienceCausesSuccessfulAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.AddAudiences("http://www.contoso.com/"); + builder.AddAudiences("http://www.fabrikam.com/"); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token-with-multiple-audiences"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Fabrikam", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task HandleAuthenticateAsync_ExpiredTicketCausesInvalidAuthentication() + { + // Arrange + var server = CreateResourceServer(); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "expired-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task HandleAuthenticateAsync_AuthenticationTicketContainsRequiredClaims() + { + // Arrange + var server = CreateResourceServer(); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/ticket"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token-with-scopes"); + + // Act + var response = await client.SendAsync(request); + + var ticket = JObject.Parse(await response.Content.ReadAsStringAsync()); + var claims = from claim in ticket.Value("Claims") + select new + { + Type = claim.Value(nameof(Claim.Type)), + Value = claim.Value(nameof(Claim.Value)) + }; + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.Contains(claims, claim => claim.Type == OAuthValidationConstants.Claims.Subject && + claim.Value == "Fabrikam"); + + Assert.Contains(claims, claim => claim.Type == OAuthValidationConstants.Claims.Scope && + claim.Value == "C54A8F5E-0387-43F4-BA43-FD4B50DC190D"); + + Assert.Contains(claims, claim => claim.Type == OAuthValidationConstants.Claims.Scope && + claim.Value == "5C57E3BD-9EFB-4224-9AB8-C8C5E009FFD7"); + } + + [Fact] + public async Task HandleAuthenticateAsync_AuthenticationTicketContainsRequiredProperties() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.Configure(options => options.SaveToken = true); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/ticket"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.SendAsync(request); + + var ticket = JObject.Parse(await response.Content.ReadAsStringAsync()); + var properties = from claim in ticket.Value("Properties") + select new + { + Name = claim.Value("Name"), + Value = claim.Value("Value") + }; + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.Contains(properties, property => property.Name == ".Token.access_token" && + property.Value == "valid-token"); + } + + [Fact] + public async Task HandleAuthenticateAsync_InvalidReplacedTokenCausesInvalidAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.Configure(options => options.Events.OnRetrieveToken = context => + { + context.Token = "invalid-token"; + + return Task.FromResult(0); + }); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task HandleAuthenticateAsync_ValidReplacedTokenCausesSuccessfulAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.Configure(options => options.Events.OnRetrieveToken = context => + { + context.Token = "valid-token"; + + return Task.FromResult(0); + }); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Fabrikam", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task HandleAuthenticateAsync_FailFromReceiveTokenCausesInvalidAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.Configure(options => options.Events.OnRetrieveToken = context => + { + context.Fail(new Exception()); + + return Task.FromResult(0); + }); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task HandleAuthenticateAsync_NoResultFromReceiveTokenCauseInvalidAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.Configure(options => options.Events.OnRetrieveToken = context => + { + context.NoResult(); + + return Task.FromResult(0); + }); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task HandleAuthenticateAsync_SuccessFromReceiveTokenCauseSuccessfulAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.Configure(options => options.Events.OnRetrieveToken = context => + { + var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(OAuthValidationConstants.Claims.Subject, "Fabrikam")); + + context.Principal = new ClaimsPrincipal(identity); + context.Success(); + + return Task.FromResult(0); + }); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Fabrikam", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task HandleAuthenticateAsync_FailFromValidateTokenCausesInvalidAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.Configure(options => options.Events.OnValidateToken = context => + { + context.Fail(new Exception()); + + return Task.FromResult(0); + }); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task HandleAuthenticateAsync_NoResultFromValidateTokenCauseInvalidAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.Configure(options => options.Events.OnValidateToken = context => + { + context.NoResult(); + + return Task.FromResult(0); + }); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task HandleAuthenticateAsync_SuccessFromValidateTokenCauseSuccessfulAuthentication() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.Configure(options => options.Events.OnValidateToken = context => + { + var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(OAuthValidationConstants.Claims.Subject, "Contoso")); + + context.Principal = new ClaimsPrincipal(identity); + context.Success(); + + return Task.FromResult(0); + }); + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Contoso", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task HandleUnauthorizedAsync_ErrorDetailsAreResolvedFromChallengeContext() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.RemoveErrorDetails(); + builder.SetRealm("global_realm"); + + builder.Configure(options => options.Events.OnApplyChallenge = context => + { + // Assert + Assert.Equal("custom_error", context.Error); + Assert.Equal("custom_error_description", context.ErrorDescription); + Assert.Equal("custom_error_uri", context.ErrorUri); + Assert.Equal("custom_realm", context.Realm); + Assert.Equal("custom_scope", context.Scope); + + return Task.FromResult(0); + }); + }); + + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("/challenge"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(@"Bearer realm=""custom_realm"", error=""custom_error"", error_description=""custom_error_description"", " + + @"error_uri=""custom_error_uri"", scope=""custom_scope""", response.Headers.WwwAuthenticate.ToString()); + } + + [Theory] + [InlineData("invalid-token", OAuthValidationConstants.Errors.InvalidToken, "The access token is not valid.")] + [InlineData("expired-token", OAuthValidationConstants.Errors.InvalidToken, "The access token is no longer valid.")] + public async Task HandleUnauthorizedAsync_ErrorDetailsAreInferredFromAuthenticationFailure( + string token, string error, string description) + { + // Arrange + var server = CreateResourceServer(); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal($@"Bearer error=""{error}"", error_description=""{description}""", + response.Headers.WwwAuthenticate.ToString()); + } + + [Fact] + public async Task HandleUnauthorizedAsync_ApplyChallenge_AllowsHandlingResponse() + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.Configure(options => options.Events.OnApplyChallenge = context => + { + context.HandleResponse(); + context.HttpContext.Response.Headers["X-Custom-Authentication-Header"] = "Bearer"; + + return Task.FromResult(0); + }); + }); + + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("/challenge"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(response.Headers.WwwAuthenticate); + Assert.Equal(new[] { "Bearer" }, response.Headers.GetValues("X-Custom-Authentication-Header")); + } + + [Theory] + [InlineData(null, null, null, null, null, "Bearer")] + [InlineData("custom_error", null, null, null, null, @"Bearer error=""custom_error""")] + [InlineData(null, "custom_error_description", null, null, null, @"Bearer error_description=""custom_error_description""")] + [InlineData(null, null, "custom_error_uri", null, null, @"Bearer error_uri=""custom_error_uri""")] + [InlineData(null, null, null, "custom_realm", null, @"Bearer realm=""custom_realm""")] + [InlineData(null, null, null, null, "custom_scope", @"Bearer scope=""custom_scope""")] + [InlineData("custom_error", "custom_error_description", "custom_error_uri", "custom_realm", "custom_scope", + @"Bearer realm=""custom_realm"", error=""custom_error"", " + + @"error_description=""custom_error_description"", " + + @"error_uri=""custom_error_uri"", scope=""custom_scope""")] + public async Task HandleUnauthorizedAsync_ReturnsExpectedWwwAuthenticateHeader( + string error, string description, string uri, string realm, string scope, string header) + { + // Arrange + var server = CreateResourceServer(builder => + { + builder.Configure(options => options.Events.OnApplyChallenge = context => + { + context.Error = error; + context.ErrorDescription = description; + context.ErrorUri = uri; + context.Realm = realm; + context.Scope = scope; + + return Task.FromResult(0); + }); + }); + + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("/challenge"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(header, response.Headers.WwwAuthenticate.ToString()); + } + + private static TestServer CreateResourceServer(Action configuration = null) + { + var format = new Mock>(MockBehavior.Strict); + + format.Setup(mock => mock.Unprotect(It.Is(token => token == "invalid-token"))) + .Returns(value: null); + + format.Setup(mock => mock.Unprotect(It.Is(token => token == "valid-token"))) + .Returns(delegate + { + var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(OAuthValidationConstants.Claims.Subject, "Fabrikam")); + + var properties = new AuthenticationProperties(); + + return new AuthenticationTicket(new ClaimsPrincipal(identity), + properties, OAuthValidationDefaults.AuthenticationScheme); + }); + + format.Setup(mock => mock.Unprotect(It.Is(token => token == "valid-token-with-scopes"))) + .Returns(delegate + { + var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(OAuthValidationConstants.Claims.Subject, "Fabrikam")); + + var properties = new AuthenticationProperties(); + properties.Items[OAuthValidationConstants.Properties.Scopes] = + @"[""C54A8F5E-0387-43F4-BA43-FD4B50DC190D"",""5C57E3BD-9EFB-4224-9AB8-C8C5E009FFD7""]"; + + return new AuthenticationTicket(new ClaimsPrincipal(identity), + properties, OAuthValidationDefaults.AuthenticationScheme); + }); + + format.Setup(mock => mock.Unprotect(It.Is(token => token == "valid-token-with-single-audience"))) + .Returns(delegate + { + var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(OAuthValidationConstants.Claims.Subject, "Fabrikam")); + + var properties = new AuthenticationProperties(new Dictionary + { + [OAuthValidationConstants.Properties.Audiences] = @"[""http://www.contoso.com/""]" + }); + + return new AuthenticationTicket(new ClaimsPrincipal(identity), + properties, OAuthValidationDefaults.AuthenticationScheme); + }); + + format.Setup(mock => mock.Unprotect(It.Is(token => token == "valid-token-with-multiple-audiences"))) + .Returns(delegate + { + var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(OAuthValidationConstants.Claims.Subject, "Fabrikam")); + + var properties = new AuthenticationProperties(new Dictionary + { + [OAuthValidationConstants.Properties.Audiences] = @"[""http://www.contoso.com/"",""http://www.fabrikam.com/""]" + }); + + return new AuthenticationTicket(new ClaimsPrincipal(identity), + properties, OAuthValidationDefaults.AuthenticationScheme); + }); + + format.Setup(mock => mock.Unprotect(It.Is(token => token == "expired-token"))) + .Returns(delegate + { + var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(OAuthValidationConstants.Claims.Subject, "Fabrikam")); + + var properties = new AuthenticationProperties(); + properties.ExpiresUtc = DateTimeOffset.UtcNow - TimeSpan.FromDays(1); + + return new AuthenticationTicket(new ClaimsPrincipal(identity), + properties, OAuthValidationDefaults.AuthenticationScheme); + }); + + var builder = new WebHostBuilder(); + builder.UseEnvironment("Testing"); + + builder.ConfigureLogging(options => options.AddDebug()); + + builder.ConfigureServices(services => + { + services.AddOpenIddict() + .AddCore(options => + { + // Replace the default OpenIddict managers. + options.Services.AddSingleton(CreateApplicationManager()); + options.Services.AddSingleton(CreateAuthorizationManager()); + options.Services.AddSingleton(CreateScopeManager()); + options.Services.AddSingleton(CreateTokenManager()); + }) + + .AddValidation(options => + { + options.Configure(settings => settings.AccessTokenFormat = format.Object); + + // Note: overriding the default data protection provider is not necessary for the tests to pass, + // but is useful to ensure unnecessary keys are not persisted in testing environments, which also + // helps make the unit tests run faster, as no registry or disk access is required in this case. + options.UseDataProtectionProvider(new EphemeralDataProtectionProvider()); + + // Run the configuration delegate + // registered by the unit tests. + configuration?.Invoke(options); + }); + }); + + builder.Configure(app => + { + app.Map("/ticket", map => map.Run(async context => + { + var result = await context.AuthenticateAsync(OAuthValidationDefaults.AuthenticationScheme); + if (result.Principal == null) + { + await context.ChallengeAsync(); + + return; + } + + context.Response.ContentType = "application/json"; + + // Return the authentication ticket as a JSON object. + await context.Response.WriteAsync(JsonConvert.SerializeObject(new + { + Claims = from claim in result.Principal.Claims + select new { claim.Type, claim.Value }, + + Properties = from property in result.Properties.Items + select new { Name = property.Key, property.Value } + })); + })); + + app.Map("/challenge", map => map.Run(context => + { + var properties = new AuthenticationProperties(new Dictionary + { + [OAuthValidationConstants.Properties.Error] = "custom_error", + [OAuthValidationConstants.Properties.ErrorDescription] = "custom_error_description", + [OAuthValidationConstants.Properties.ErrorUri] = "custom_error_uri", + [OAuthValidationConstants.Properties.Realm] = "custom_realm", + [OAuthValidationConstants.Properties.Scope] = "custom_scope", + }); + + return context.ChallengeAsync(OAuthValidationDefaults.AuthenticationScheme, properties); + })); + + app.Run(async context => + { + var result = await context.AuthenticateAsync(OAuthValidationDefaults.AuthenticationScheme); + if (result.Principal == null) + { + await context.ChallengeAsync(OAuthValidationDefaults.AuthenticationScheme); + + return; + } + + var subject = result.Principal.FindFirst(OAuthValidationConstants.Claims.Subject)?.Value; + if (string.IsNullOrEmpty(subject)) + { + await context.ChallengeAsync(OAuthValidationDefaults.AuthenticationScheme); + + return; + } + + await context.Response.WriteAsync(subject); + }); + }); + + return new TestServer(builder); + } + + private static OpenIddictApplicationManager CreateApplicationManager( + Action>> configuration = null) + { + var manager = new Mock>( + Mock.Of(), + Mock.Of>>(), + Mock.Of>()); + + configuration?.Invoke(manager); + + return manager.Object; + } + + private static OpenIddictAuthorizationManager CreateAuthorizationManager( + Action>> configuration = null) + { + var manager = new Mock>( + Mock.Of(), + Mock.Of>>(), + Mock.Of>()); + + configuration?.Invoke(manager); + + return manager.Object; + } + + private static OpenIddictScopeManager CreateScopeManager( + Action>> configuration = null) + { + var manager = new Mock>( + Mock.Of(), + Mock.Of>>(), + Mock.Of>()); + + configuration?.Invoke(manager); + + return manager.Object; + } + + private static OpenIddictTokenManager CreateTokenManager( + Action>> configuration = null) + { + var manager = new Mock>( + Mock.Of(), + Mock.Of>>(), + Mock.Of>()); + + configuration?.Invoke(manager); + + return manager.Object; + } + } +}