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 2efcff37..17ccc045 100644
--- a/samples/Mvc.Server/Mvc.Server.csproj
+++ b/samples/Mvc.Server/Mvc.Server.csproj
@@ -23,11 +23,10 @@
+
-
-
diff --git a/samples/Mvc.Server/Startup.cs b/samples/Mvc.Server/Startup.cs
index 20c0195c..a4630f4e 100644
--- a/samples/Mvc.Server/Startup.cs
+++ b/samples/Mvc.Server/Startup.cs
@@ -65,7 +65,7 @@ namespace Mvc.Server
options.AddEntityFrameworkCoreStores();
})
- // Register the OpenIddict server handler.
+ // Register the OpenIddict server services.
.AddServer(options =>
{
// Register the ASP.NET Core MVC binder used by OpenIddict.
@@ -112,7 +112,10 @@ namespace Mvc.Server
//
// options.UseJsonWebTokens();
// options.AddEphemeralSigningKey();
- });
+ })
+
+ // Register the OpenIddict validation services.
+ .AddValidation();
services.AddTransient();
services.AddTransient();
@@ -126,9 +129,10 @@ namespace Mvc.Server
app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), branch =>
{
- // Add a middleware used to validate access
- // tokens and protect the API endpoints.
- branch.UseOAuthValidation();
+ // 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.
+ branch.UseOpenIddictValidation();
// If you prefer using JWT, don't forget to disable the automatic
// JWT -> WS-Federation claims mapping used by the JWT middleware:
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..68b62aea
--- /dev/null
+++ b/src/OpenIddict.Validation/Internal/OpenIddictValidationHandler.cs
@@ -0,0 +1,287 @@
+/*
+ * 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.Threading.Tasks;
+using AspNet.Security.OAuth.Validation;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+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 { }
+
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public class OpenIddictValidationHandler : OpenIddictValidationHandler where TToken : class
+ {
+ protected override async Task HandleAuthenticateAsync()
+ {
+ var context = new RetrieveTokenContext(Context, Options);
+ await Options.Events.RetrieveToken(context);
+
+ if (context.HandledResponse)
+ {
+ // If no ticket has been provided, return a failed result to
+ // indicate that authentication was rejected by application code.
+ if (context.Ticket == null)
+ {
+ return AuthenticateResult.Fail("Authentication was stopped by application code.");
+ }
+
+ return AuthenticateResult.Success(context.Ticket);
+ }
+
+ else if (context.Skipped)
+ {
+ Logger.LogInformation("Authentication was skipped by application code.");
+
+ return AuthenticateResult.Skip();
+ }
+
+ 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.Skip();
+ }
+
+ // 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.Skip();
+ }
+
+ // 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.Skip();
+ }
+ }
+
+ // 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 AuthenticateResult.Fail("Authentication failed because the access token was invalid.");
+ }
+
+ // 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, Options, ticket);
+ await Options.Events.ValidateToken(notification);
+
+ if (notification.HandledResponse)
+ {
+ // If no ticket has been provided, return a failed result to
+ // indicate that authentication was rejected by application code.
+ if (notification.Ticket == null)
+ {
+ return AuthenticateResult.Fail("Authentication was stopped by application code.");
+ }
+
+ return AuthenticateResult.Success(notification.Ticket);
+ }
+
+ else if (notification.Skipped)
+ {
+ Logger.LogInformation("Authentication was skipped by application code.");
+
+ return AuthenticateResult.Skip();
+ }
+
+ // Allow the application code to replace the ticket
+ // reference from the ValidateToken event.
+ ticket = notification.Ticket;
+
+ if (ticket == null)
+ {
+ return AuthenticateResult.Fail("Authentication was stopped by application code.");
+ }
+
+ return AuthenticateResult.Success(ticket);
+ }
+
+ 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, Options, ticket);
+ await Options.Events.CreateTicket(notification);
+
+ if (notification.HandledResponse)
+ {
+ // If no ticket has been provided, return a failed result to
+ // indicate that authentication was rejected by application code.
+ if (notification.Ticket == null)
+ {
+ return AuthenticateResult.Skip();
+ }
+
+ return AuthenticateResult.Success(notification.Ticket);
+ }
+
+ else if (notification.Skipped)
+ {
+ return AuthenticateResult.Skip();
+ }
+
+ return AuthenticateResult.Success(notification.Ticket);
+ }
+ }
+}
diff --git a/src/OpenIddict.Validation/Internal/OpenIddictValidationHelpers.cs b/src/OpenIddict.Validation/Internal/OpenIddictValidationHelpers.cs
new file mode 100644
index 00000000..5cba6cbd
--- /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.Http.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/OpenIddictValidationMiddleware.cs b/src/OpenIddict.Validation/Internal/OpenIddictValidationMiddleware.cs
new file mode 100644
index 00000000..8f06da9f
--- /dev/null
+++ b/src/OpenIddict.Validation/Internal/OpenIddictValidationMiddleware.cs
@@ -0,0 +1,52 @@
+/*
+ * 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.ComponentModel;
+using System.Text.Encodings.Web;
+using AspNet.Security.OAuth.Validation;
+using JetBrains.Annotations;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace OpenIddict.Validation
+{
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public class OpenIddictValidationMiddleware : OAuthValidationMiddleware
+ {
+ public OpenIddictValidationMiddleware(
+ [NotNull] RequestDelegate next,
+ [NotNull] IOptions options,
+ [NotNull] ILoggerFactory loggerFactory,
+ [NotNull] UrlEncoder encoder,
+ [NotNull] IDataProtectionProvider dataProtectionProvider)
+ : base(next, options, loggerFactory, encoder, dataProtectionProvider)
+ {
+ }
+
+ protected override AuthenticationHandler CreateHandler()
+ => new OpenIddictValidationHandler();
+ }
+
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public class OpenIddictValidationMiddleware : OpenIddictValidationMiddleware where TToken : class
+ {
+ public OpenIddictValidationMiddleware(
+ [NotNull] RequestDelegate next,
+ [NotNull] IOptions options,
+ [NotNull] ILoggerFactory loggerFactory,
+ [NotNull] UrlEncoder encoder,
+ [NotNull] IDataProtectionProvider dataProtectionProvider)
+ : base(next, options, loggerFactory, encoder, dataProtectionProvider)
+ {
+ }
+
+ protected override AuthenticationHandler CreateHandler()
+ => new OpenIddictValidationHandler();
+ }
+}
diff --git a/src/OpenIddict.Validation/OpenIddict.Validation.csproj b/src/OpenIddict.Validation/OpenIddict.Validation.csproj
new file mode 100644
index 00000000..fc006f24
--- /dev/null
+++ b/src/OpenIddict.Validation/OpenIddict.Validation.csproj
@@ -0,0 +1,25 @@
+
+
+
+
+
+ net451;netstandard1.3
+
+
+
+ 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..f626f9be
--- /dev/null
+++ b/src/OpenIddict.Validation/OpenIddictValidationBuilder.cs
@@ -0,0 +1,126 @@
+/*
+ * 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 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(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..e06f0185
--- /dev/null
+++ b/src/OpenIddict.Validation/OpenIddictValidationExtensions.cs
@@ -0,0 +1,124 @@
+/*
+ * 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 AspNet.Security.OAuth.Validation;
+using JetBrains.Annotations;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.Options;
+using OpenIddict.Core;
+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();
+
+ 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;
+ }
+
+ ///
+ /// Registers the OpenIddict validation middleware in the ASP.NET Core pipeline.
+ ///
+ /// The application builder used to register middleware instances.
+ /// The .
+ public static IApplicationBuilder UseOpenIddictValidation([NotNull] this IApplicationBuilder app)
+ {
+ if (app == null)
+ {
+ throw new ArgumentNullException(nameof(app));
+ }
+
+ var configuration = app.ApplicationServices.GetRequiredService>().Value;
+
+ var options = app.ApplicationServices.GetRequiredService>().Value;
+ if (options.Events == null)
+ {
+ options.Events = new OAuthValidationEvents();
+ }
+
+ if (options.DataProtectionProvider == null)
+ {
+ options.DataProtectionProvider = app.ApplicationServices.GetDataProtectionProvider();
+ }
+
+ 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 = configuration.DefaultTokenType;
+ }
+
+ 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());
+ }
+
+ return app.UseMiddleware(typeof(OpenIddictValidationMiddleware<>)
+ .MakeGenericType(options.TokenType), new OptionsWrapper(options));
+ }
+
+ return app.UseMiddleware(new OptionsWrapper(options));
+ }
+ }
+}
\ 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 0534de6e..c0d428eb 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 100644
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 100644
index 00000000..1ebc54f2
--- /dev/null
+++ b/test/OpenIddict.Validation.Tests/OpenIddictValidationHandlerTests.cs
@@ -0,0 +1,897 @@
+/*
+ * 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.Http.Authentication;
+using Microsoft.AspNetCore.Http.Features.Authentication;
+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_SkipToNextMiddlewareFromReceiveTokenCausesInvalidAuthentication()
+ {
+ // Arrange
+ var server = CreateResourceServer(builder =>
+ {
+ builder.Configure(options => options.Events.OnRetrieveToken = context =>
+ {
+ context.SkipToNextMiddleware();
+
+ 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_NullTicketAndHandleResponseFromReceiveTokenCauseInvalidAuthentication()
+ {
+ // Arrange
+ var server = CreateResourceServer(builder =>
+ {
+ builder.Configure(options => options.Events.OnRetrieveToken = context =>
+ {
+ context.Ticket = null;
+ context.HandleResponse();
+
+ 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_ReplacedTicketAndHandleResponseFromReceiveTokenCauseSuccessfulAuthentication()
+ {
+ // Arrange
+ var server = CreateResourceServer(builder =>
+ {
+ builder.Configure(options => options.Events.OnRetrieveToken = context =>
+ {
+ var identity = new ClaimsIdentity(context.Options.AuthenticationScheme);
+ identity.AddClaim(new Claim(OAuthValidationConstants.Claims.Subject, "Fabrikam"));
+
+ context.Ticket = new AuthenticationTicket(
+ new ClaimsPrincipal(identity),
+ new AuthenticationProperties(),
+ context.Options.AuthenticationScheme);
+
+ context.HandleResponse();
+
+ 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_SkipToNextMiddlewareFromValidateTokenCausesInvalidAuthentication()
+ {
+ // Arrange
+ var server = CreateResourceServer(builder =>
+ {
+ builder.Configure(options => options.Events.OnValidateToken = context =>
+ {
+ context.SkipToNextMiddleware();
+
+ 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_NullTicketAndHandleResponseFromValidateTokenCauseInvalidAuthentication()
+ {
+ // Arrange
+ var server = CreateResourceServer(builder =>
+ {
+ builder.Configure(options => options.Events.OnValidateToken = context =>
+ {
+ context.Ticket = null;
+ context.HandleResponse();
+
+ 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_ReplacedTicketAndHandleResponseFromValidateTokenCauseSuccessfulAuthentication()
+ {
+ // Arrange
+ var server = CreateResourceServer(builder =>
+ {
+ builder.Configure(options => options.Events.OnValidateToken = context =>
+ {
+ var identity = new ClaimsIdentity(context.Options.AuthenticationScheme);
+ identity.AddClaim(new Claim(OAuthValidationConstants.Claims.Subject, "Contoso"));
+
+ context.Ticket = new AuthenticationTicket(
+ new ClaimsPrincipal(identity),
+ new AuthenticationProperties(),
+ context.Options.AuthenticationScheme);
+
+ context.HandleResponse();
+
+ 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 HandleAuthenticateAsync_UpdatedTicketFromValidateTokenCausesSuccessfulAuthentication()
+ {
+ // Arrange
+ var server = CreateResourceServer(builder =>
+ {
+ builder.Configure(options => options.Events.OnValidateToken = context =>
+ {
+ var identity = new ClaimsIdentity(context.Options.AuthenticationScheme);
+ identity.AddClaim(new Claim(OAuthValidationConstants.Claims.Subject, "Contoso"));
+
+ context.Ticket = new AuthenticationTicket(
+ new ClaimsPrincipal(identity),
+ new AuthenticationProperties(),
+ context.Options.AuthenticationScheme);
+
+ context.HandleResponse();
+
+ 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(context.Error, "custom_error");
+ Assert.Equal(context.ErrorDescription, "custom_error_description");
+ Assert.Equal(context.ErrorUri, "custom_error_uri");
+ Assert.Equal(context.Realm, "custom_realm");
+ Assert.Equal(context.Scope, "custom_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"));
+ }
+
+ [Fact]
+ public async Task HandleUnauthorizedAsync_ApplyChallenge_AllowsSkippingToNextMiddleware()
+ {
+ // Arrange
+ var server = CreateResourceServer(builder =>
+ {
+ builder.Configure(options => options.Events.OnApplyChallenge = context =>
+ {
+ context.SkipToNextMiddleware();
+
+ 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.Empty(await response.Content.ReadAsStringAsync());
+ }
+
+ [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 =>
+ {
+ options.UseDefaultModels();
+
+ // 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.UseOpenIddictValidation();
+
+ app.Map("/ticket", map => map.Run(async context =>
+ {
+ var ticket = new AuthenticateContext(OAuthValidationDefaults.AuthenticationScheme);
+ await context.Authentication.AuthenticateAsync(ticket);
+
+ if (!ticket.Accepted || ticket.Principal == null || ticket.Properties == null)
+ {
+ await context.Authentication.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 ticket.Principal.Claims
+ select new { claim.Type, claim.Value },
+
+ Properties = from property in ticket.Properties
+ 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.Authentication.ChallengeAsync(OAuthValidationDefaults.AuthenticationScheme, properties);
+ }));
+
+ app.Run(context =>
+ {
+ if (!context.User.Identities.Any(identity => identity.IsAuthenticated))
+ {
+ return context.Authentication.ChallengeAsync();
+ }
+
+ var identifier = context.User.FindFirst(OAuthValidationConstants.Claims.Subject).Value;
+ return context.Response.WriteAsync(identifier);
+ });
+ });
+
+ 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;
+ }
+ }
+}