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;
+ }
+ }
+}