diff --git a/.gitignore b/.gitignore index 252a5b2..8281713 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ x64/ build/ [Bb]in/ [Oo]bj/ +.build/ # Enable "build/" folder in the NuGet Packages folder since NuGet packages use it for MSBuild targets !packages/*/build/ diff --git a/src/AspNet.Security.OAuth.Introspection/AspNet.Security.OAuth.Introspection.xproj b/src/AspNet.Security.OAuth.Introspection/AspNet.Security.OAuth.Introspection.xproj index c7a1b36..229ac6f 100644 --- a/src/AspNet.Security.OAuth.Introspection/AspNet.Security.OAuth.Introspection.xproj +++ b/src/AspNet.Security.OAuth.Introspection/AspNet.Security.OAuth.Introspection.xproj @@ -4,17 +4,15 @@ 14.0 $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - a8569260-142c-427a-8b14-a8df56cc15b7 - AspNet.Security.OpenIdConnect.Introspection + AspNet.Security.OAuth.Introspection ..\..\artifacts\obj\$(MSBuildProjectName) ..\..\artifacts\bin\$(MSBuildProjectName)\ - 2.0 - + \ No newline at end of file diff --git a/src/AspNet.Security.OAuth.Introspection/Events/BaseIntrospectionContext.cs b/src/AspNet.Security.OAuth.Introspection/Events/BaseIntrospectionContext.cs new file mode 100644 index 0000000..f4258f2 --- /dev/null +++ b/src/AspNet.Security.OAuth.Introspection/Events/BaseIntrospectionContext.cs @@ -0,0 +1,33 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace AspNet.Security.OAuth.Introspection { + /// + /// Base class for all introspection events that holds common properties. + /// + public abstract class BaseIntrospectionContext : BaseContext { + public BaseIntrospectionContext( + [NotNull]HttpContext context, + [NotNull]OAuthIntrospectionOptions options) + : base(context) { + Options = options; + } + + /// + /// Indicates the application has handled the event process. + /// + internal bool Handled { get; set; } + + /// + /// The middleware Options. + /// + public OAuthIntrospectionOptions Options { get; } + } +} diff --git a/src/AspNet.Security.OAuth.Introspection/Events/CreateTicketContext.cs b/src/AspNet.Security.OAuth.Introspection/Events/CreateTicketContext.cs new file mode 100644 index 0000000..3a9b38d --- /dev/null +++ b/src/AspNet.Security.OAuth.Introspection/Events/CreateTicketContext.cs @@ -0,0 +1,48 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json.Linq; + +namespace AspNet.Security.OAuth.Introspection { + /// + /// Allows interception of the AuthenticationTicket creation process. + /// + public class CreateTicketContext : BaseIntrospectionContext { + public CreateTicketContext( + [NotNull]HttpContext context, + [NotNull]OAuthIntrospectionOptions options, + [NotNull]JObject payload) + : base(context, options) { + Payload = payload; + } + + /// + /// The payload from the introspection request to the authorization server. + /// + public JObject Payload { get; } + + private AuthenticationTicket _ticket { get; set; } + + /// + /// An created by the application. + /// + /// Set this property to indicate that the application has handled the creation of the + /// ticket. Set this property to null to instruct the middleware there was a failure + /// during ticket creation. + /// + /// + public AuthenticationTicket Ticket { + get { return _ticket; } + set { + Handled = true; + _ticket = value; + } + } + } +} diff --git a/src/AspNet.Security.OAuth.Introspection/Events/IOAuthIntrospectionEvents.cs b/src/AspNet.Security.OAuth.Introspection/Events/IOAuthIntrospectionEvents.cs new file mode 100644 index 0000000..76a610e --- /dev/null +++ b/src/AspNet.Security.OAuth.Introspection/Events/IOAuthIntrospectionEvents.cs @@ -0,0 +1,34 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using System.Threading.Tasks; + +namespace AspNet.Security.OAuth.Introspection { + /// + /// Allows customization of introspection handling within the middleware. + /// + public interface IOAuthIntrospectionEvents { + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + Task ParseAccessToken(ParseAccessTokenContext context); + + /// + /// Invoked when a ticket is to be created from an introspection response. + /// + Task CreateTicket(CreateTicketContext context); + + /// + /// Invoked when a token is to be sent to the authorization server for introspection. + /// + Task RequestTokenIntrospection(RequestTokenIntrospectionContext context); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + Task ValidateToken(ValidateTokenContext context); + } +} diff --git a/src/AspNet.Security.OAuth.Introspection/Events/OAuthIntrospectionEvents.cs b/src/AspNet.Security.OAuth.Introspection/Events/OAuthIntrospectionEvents.cs new file mode 100644 index 0000000..bc55cc1 --- /dev/null +++ b/src/AspNet.Security.OAuth.Introspection/Events/OAuthIntrospectionEvents.cs @@ -0,0 +1,55 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using System; +using System.Threading.Tasks; + +namespace AspNet.Security.OAuth.Introspection { + /// + /// Allows customization of introspection handling within the middleware. + /// + public class OAuthIntrospectionEvents : IOAuthIntrospectionEvents { + /// + /// Invoked when a ticket is to be created from an introspection response. + /// + public Func OnCreateTicket { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + public Func OnParseAccessToken { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a token is to be sent to the authorization server for introspection. + /// + public Func OnRequestTokenIntrospection { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + public Func OnValidateToken { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a ticket is to be created from an introspection response. + /// + public virtual Task CreateTicket(CreateTicketContext context) => OnCreateTicket(context); + + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + public virtual Task ParseAccessToken(ParseAccessTokenContext context) => OnParseAccessToken(context); + + /// + /// Invoked when a token is to be sent to the authorization server for introspection. + /// + public virtual Task RequestTokenIntrospection(RequestTokenIntrospectionContext context) => OnRequestTokenIntrospection(context); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + public virtual Task ValidateToken(ValidateTokenContext context) => OnValidateToken(context); + } +} diff --git a/src/AspNet.Security.OAuth.Introspection/Events/ParseAccessTokenContext.cs b/src/AspNet.Security.OAuth.Introspection/Events/ParseAccessTokenContext.cs new file mode 100644 index 0000000..f9d0c13 --- /dev/null +++ b/src/AspNet.Security.OAuth.Introspection/Events/ParseAccessTokenContext.cs @@ -0,0 +1,38 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; + +namespace AspNet.Security.OAuth.Introspection { + /// + /// Allows custom parsing of access tokens from requests. + /// + public class ParseAccessTokenContext : BaseIntrospectionContext { + public ParseAccessTokenContext( + [NotNull]HttpContext context, + [NotNull]OAuthIntrospectionOptions options) + : base(context, options) { + } + + private string _token { get; set; } + + /// + /// Gets or sets the access token. + /// + /// Setting this property indicates to the middleware that the request has been processed + /// and a token extracted. Setting this to null will invalidate the token. + /// + /// + public string Token { + get { return _token; } + set { + Handled = true; + _token = value; + } + } + } +} diff --git a/src/AspNet.Security.OAuth.Introspection/Events/RequestTokenIntrospectionContext.cs b/src/AspNet.Security.OAuth.Introspection/Events/RequestTokenIntrospectionContext.cs new file mode 100644 index 0000000..382d258 --- /dev/null +++ b/src/AspNet.Security.OAuth.Introspection/Events/RequestTokenIntrospectionContext.cs @@ -0,0 +1,53 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using System.Net.Http; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json.Linq; +using JetBrains.Annotations; + +namespace AspNet.Security.OAuth.Introspection { + /// + /// Allows for custom handling of the call to the Authorization Server's Introspection endpoint. + /// + public class RequestTokenIntrospectionContext : BaseIntrospectionContext { + public RequestTokenIntrospectionContext( + [NotNull]HttpContext context, + [NotNull]OAuthIntrospectionOptions options, + [NotNull]string token) + : base(context, options) { + Token = token; + } + + /// + /// An for use by the application to call the authorization server. + /// + public HttpClient Client => Options.HttpClient; + + /// + /// The access token parsed from the client request. + /// + public string Token { get; } + + private JObject _payload { get; set; } + + /// + /// The data retrieved from the call to the introspection endpoint on the authorization server. + /// + /// Set this property to indicate that the introspection call was handled + /// by the application. Set this property to null to instruct the middleware + /// to indicate a failure. + /// + /// + public JObject Payload { + get { return _payload; } + set { + Handled = true; + Payload = value; + } + } + } +} diff --git a/src/AspNet.Security.OAuth.Introspection/Events/ValidateTokenContext.cs b/src/AspNet.Security.OAuth.Introspection/Events/ValidateTokenContext.cs new file mode 100644 index 0000000..ac011a2 --- /dev/null +++ b/src/AspNet.Security.OAuth.Introspection/Events/ValidateTokenContext.cs @@ -0,0 +1,46 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace AspNet.Security.OAuth.Introspection { + /// + /// Allows customization of the token validation logic. + /// + public class ValidateTokenContext : BaseIntrospectionContext { + public ValidateTokenContext( + [NotNull]HttpContext context, + [NotNull]OAuthIntrospectionOptions options, + [NotNull]AuthenticationTicket ticket) + : base(context, options) { + Ticket = ticket; + } + + /// + /// The created from the introspection data. + /// + public AuthenticationTicket Ticket { get; } + + private bool _isValid { get; set; } = true; + + /// + /// Indicates the ticket is valid. + /// + /// Setting this property indicates to the middleware that token validation + /// has been handled by the application. + /// + /// + public bool IsValid { + get { return _isValid; } + set { + Handled = true; + _isValid = value; + } + } + } +} diff --git a/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionConstants.cs b/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionConstants.cs index 853b7c5..56a8c02 100644 --- a/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionConstants.cs +++ b/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionConstants.cs @@ -27,6 +27,10 @@ public static class Parameters { public const string TokenTypeHint = "token_type_hint"; } + public static class Properties { + public const string Audiences = ".audiences"; + } + public static class TokenTypes { public const string AccessToken = "access_token"; } diff --git a/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionHandler.cs b/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionHandler.cs index c5764c7..b31c004 100644 --- a/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionHandler.cs +++ b/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionHandler.cs @@ -23,55 +23,94 @@ namespace AspNet.Security.OAuth.Introspection { public class OAuthIntrospectionHandler : AuthenticationHandler { protected override async Task HandleAuthenticateAsync() { - string header = Request.Headers[HeaderNames.Authorization]; - if (string.IsNullOrEmpty(header)) { - Logger.LogInformation("Authentication was skipped because no bearer token was received."); - - return AuthenticateResult.Skip(); - } + // Give the application an opportunity to parse the token from a different location, adjust, or reject token + var parseAccessTokenContext = new ParseAccessTokenContext(Context, Options); + await Options.Events.ParseAccessToken(parseAccessTokenContext); + + // Initialize the token from the event in case it was set during the event. + string token = parseAccessTokenContext.Token; + + // Bypass the default processing if the event handled the request parsing. + if (!parseAccessTokenContext.Handled) { + string header = Request.Headers[HeaderNames.Authorization]; + if (string.IsNullOrWhiteSpace(header)) { + return AuthenticateResult.Fail("Authentication failed because the bearer token " + + "was missing from the 'Authorization' header."); + } - // Ensure that the authorization header contains the mandatory "Bearer" scheme. - // See https://tools.ietf.org/html/rfc6750#section-2.1 - if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { - Logger.LogInformation("Authentication was skipped because an incompatible " + - "scheme was used in the 'Authorization' header."); + // Ensure that the authorization header contains the mandatory "Bearer" scheme. + // See https://tools.ietf.org/html/rfc6750#section-2.1 + if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { + return AuthenticateResult.Fail("Authentication failed because an invalid scheme " + + "was used in the 'Authorization' header."); + } - return AuthenticateResult.Skip(); + token = header.Substring("Bearer ".Length); } - var token = header.Substring("Bearer ".Length); if (string.IsNullOrWhiteSpace(token)) { return AuthenticateResult.Fail("Authentication failed because the bearer token " + - "was missing from the 'Authorization' header."); + "was missing from the request. The default location" + + "is in the 'Authorization' header."); } // Try to resolve the authentication ticket from the distributed cache. If none // can be found, a new introspection request is sent to the authorization server. var ticket = await RetrieveTicketAsync(token); if (ticket == null) { + // Allow interception of the introspection retrieval process via events + var requestTokenIntrospectionContext = new RequestTokenIntrospectionContext(Context, Options, token); + await Options.Events.RequestTokenIntrospection(requestTokenIntrospectionContext); + + // Flow the payload from the application possibly handling the introspection request + var payload = requestTokenIntrospectionContext.Payload; + + if (!requestTokenIntrospectionContext.Handled) { + // Set the payload using the default introspection behavior + payload = await GetIntrospectionPayloadAsync(token); + } + // Return a failed authentication result if the introspection // request failed or if the "active" claim was false. - var payload = await GetIntrospectionPayloadAsync(token); if (payload == null || !payload.Value(OAuthIntrospectionConstants.Claims.Active)) { return AuthenticateResult.Fail("Authentication failed because the authorization " + "server rejected the access token."); } - // Ensure that the access token was issued - // to be used with this resource server. - if (!await ValidateAudienceAsync(payload)) { - return AuthenticateResult.Fail("Authentication failed because the access token " + - "was not valid for this resource server."); - } + // Allow interception of the ticket creation process via events + var createTicketContext = new CreateTicketContext(Context, Options, payload); + await Options.Events.CreateTicket(createTicketContext); // Create a new authentication ticket from the introspection - // response returned by the authorization server. - ticket = await CreateTicketAsync(payload); + // response returned by the authorization server if the application + // has not handled the ticket creation process. + ticket = createTicketContext.Handled ? createTicketContext.Ticket : await CreateTicketAsync(payload); Debug.Assert(ticket != null); await StoreTicketAsync(token, ticket); } + // Allow for interception and handling of the token validated event. + var validateTokenContext = new ValidateTokenContext(Context, Options, ticket); + await Options.Events.ValidateToken(validateTokenContext); + + if (validateTokenContext.Handled) { + // Return a result based on how the validation was handled. + return validateTokenContext.IsValid ? + AuthenticateResult.Success(validateTokenContext.Ticket) : + AuthenticateResult.Fail("Authentication failed because the access token was not valid."); + } + + // Flow any changes that were made to the ticket during the validation event. + ticket = validateTokenContext.Ticket; + + // Ensure that the access token was issued + // to be used with this resource server. + if (!await ValidateAudienceAsync(ticket)) { + return AuthenticateResult.Fail("Authentication failed because the access token " + + "was not valid for this resource server."); + } + // Ensure that the authentication ticket is still valid. if (ticket.Properties.ExpiresUtc.HasValue && ticket.Properties.ExpiresUtc.Value < Options.SystemClock.UtcNow) { @@ -114,11 +153,11 @@ protected virtual async Task ResolveIntrospectionEndpointAsync(string is protected virtual async Task GetIntrospectionPayloadAsync(string token) { // Note: updating the options during a request is not thread safe but is harmless in this case: // in the worst case, it will only send multiple configuration requests to the authorization server. - if (string.IsNullOrEmpty(Options.IntrospectionEndpoint)) { + if (string.IsNullOrWhiteSpace(Options.IntrospectionEndpoint)) { Options.IntrospectionEndpoint = await ResolveIntrospectionEndpointAsync(Options.Authority); } - if (string.IsNullOrEmpty(Options.IntrospectionEndpoint)) { + if (string.IsNullOrWhiteSpace(Options.IntrospectionEndpoint)) { throw new InvalidOperationException("The OAuth2 introspection middleware was unable to retrieve " + "the provider configuration from the OAuth2 authorization server."); } @@ -149,48 +188,25 @@ protected virtual async Task GetIntrospectionPayloadAsync(string token) return JObject.Parse(await response.Content.ReadAsStringAsync()); } - protected virtual Task ValidateAudienceAsync(JObject payload) { + protected virtual Task ValidateAudienceAsync(AuthenticationTicket ticket) { // If no explicit audience has been configured, // skip the default audience validation. if (Options.Audiences.Count == 0) { return Task.FromResult(true); } - // If no "aud" claim was returned by the authorization server, - // assume the access token was not specific enough and reject it. - if (payload[OAuthIntrospectionConstants.Claims.Audience] == null) { + // Extract the audiences from the authentication ticket. + string audiences; + if (!ticket.Properties.Items.TryGetValue(OAuthIntrospectionConstants.Properties.Audiences, out audiences)) { return Task.FromResult(false); } - // Note: the "aud" claim can be either a string or an array. - // See https://tools.ietf.org/html/rfc7662#section-2.2 - switch (payload[OAuthIntrospectionConstants.Claims.Audience].Type) { - case JTokenType.Array: { - // When the "aud" claim is an array, at least one value must correspond - // to the audience registered in the introspection middleware options. - var audiences = payload.Value(OAuthIntrospectionConstants.Claims.Audience) - .Select(audience => audience.Value()); - if (audiences.Intersect(Options.Audiences, StringComparer.Ordinal).Any()) { - return Task.FromResult(true); - } - - return Task.FromResult(false); - } - - case JTokenType.String: { - // When the "aud" claim is a string, it must exactly match the - // audience registered in the introspection middleware options. - var audience = payload.Value(OAuthIntrospectionConstants.Claims.Audience); - if (Options.Audiences.Contains(audience, StringComparer.Ordinal)) { - return Task.FromResult(true); - } - - return Task.FromResult(false); - } - - default: - return Task.FromResult(false); + // Ensure that the authentication ticket contains the registered audience. + if (!audiences.Split(' ').Intersect(Options.Audiences, StringComparer.Ordinal).Any()) { + return Task.FromResult(false); } + + return Task.FromResult(true); } protected virtual Task CreateTicketAsync(JObject payload) { @@ -254,6 +270,24 @@ protected virtual Task CreateTicketAsync(JObject payload) continue; } + + // Store the audience or audiences in the ticket properties. + case OAuthIntrospectionConstants.Claims.Audience: { + if(property.Value.Type == JTokenType.Array) { + var array = (JArray)property.Value; + if(array == null) { + continue; + } + var audiences = string.Join(" ", array.Select(item => item.Value())); + properties.Items[OAuthIntrospectionConstants.Properties.Audiences] = audiences; + } + + else if(property.Value.Type == JTokenType.String) { + properties.Items[OAuthIntrospectionConstants.Properties.Audiences] = (string)property.Value; + } + + continue; + } } switch (property.Value.Type) { diff --git a/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionOptions.cs b/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionOptions.cs index 883dcc2..6282d0c 100644 --- a/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionOptions.cs +++ b/src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionOptions.cs @@ -51,6 +51,13 @@ public OAuthIntrospectionOptions() { /// public IDistributedCache Cache { get; set; } + /// + /// The object provided by the application to process events raised by the bearer authentication middleware. + /// The application may implement the interface fully, or it may create an instance of OAuthIntrospectionEvents + /// and assign delegates only to the events it wants to process. + /// + public IOAuthIntrospectionEvents Events { get; set; } = new OAuthIntrospectionEvents(); + /// /// Gets or sets the HTTP client used to communicate /// with the remote OAuth2/OpenID Connect server. diff --git a/src/AspNet.Security.OAuth.Validation/Events/BaseValidationContext.cs b/src/AspNet.Security.OAuth.Validation/Events/BaseValidationContext.cs new file mode 100644 index 0000000..e488f31 --- /dev/null +++ b/src/AspNet.Security.OAuth.Validation/Events/BaseValidationContext.cs @@ -0,0 +1,33 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace AspNet.Security.OAuth.Validation { + /// + /// Base class for all validation events that holds common properties. + /// + public abstract class BaseValidationContext : BaseContext { + public BaseValidationContext( + [NotNull]HttpContext context, + [NotNull]OAuthValidationOptions options) + : base(context) { + Options = options; + } + + /// + /// Indicates the application has handled the event process. + /// + internal bool Handled { get; set; } + + /// + /// The middleware Options. + /// + public OAuthValidationOptions Options { get; } + } +} diff --git a/src/AspNet.Security.OAuth.Validation/Events/IOAuthValidationEvents.cs b/src/AspNet.Security.OAuth.Validation/Events/IOAuthValidationEvents.cs new file mode 100644 index 0000000..203f480 --- /dev/null +++ b/src/AspNet.Security.OAuth.Validation/Events/IOAuthValidationEvents.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/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using System.Threading.Tasks; + +namespace AspNet.Security.OAuth.Validation { + /// + /// Allows customization of validation handling within the middleware. + /// + public interface IOAuthValidationEvents { + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + Task ParseAccessToken(ParseAccessTokenContext context); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + Task ValidateToken(ValidateTokenContext context); + } +} diff --git a/src/AspNet.Security.OAuth.Validation/Events/OAuthValidationEvents.cs b/src/AspNet.Security.OAuth.Validation/Events/OAuthValidationEvents.cs new file mode 100644 index 0000000..d6f5ffb --- /dev/null +++ b/src/AspNet.Security.OAuth.Validation/Events/OAuthValidationEvents.cs @@ -0,0 +1,35 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using System; +using System.Threading.Tasks; + +namespace AspNet.Security.OAuth.Validation { + /// + /// Allows customization of validation handling within the middleware. + /// + public class OAuthValidationEvents : IOAuthValidationEvents { + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + public Func OnParseAccessToken { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + public Func OnValidateToken { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + public virtual Task ParseAccessToken(ParseAccessTokenContext context) => OnParseAccessToken(context); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + public virtual Task ValidateToken(ValidateTokenContext context) => OnValidateToken(context); + } +} diff --git a/src/AspNet.Security.OAuth.Validation/Events/ParseAccessTokenContext.cs b/src/AspNet.Security.OAuth.Validation/Events/ParseAccessTokenContext.cs new file mode 100644 index 0000000..f416181 --- /dev/null +++ b/src/AspNet.Security.OAuth.Validation/Events/ParseAccessTokenContext.cs @@ -0,0 +1,38 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; + +namespace AspNet.Security.OAuth.Validation { + /// + /// Allows custom parsing of access tokens from requests. + /// + public class ParseAccessTokenContext : BaseValidationContext { + public ParseAccessTokenContext( + [NotNull]HttpContext context, + [NotNull]OAuthValidationOptions options) + : base(context, options) { + } + + private string _token { get; set; } + + /// + /// Gets or sets the access token. + /// + /// Setting this property indicates to the middleware that the request has been processed + /// and a token extracted. Setting this to null will invalidate the token. + /// + /// + public string Token { + get { return _token; } + set { + Handled = true; + _token = value; + } + } + } +} diff --git a/src/AspNet.Security.OAuth.Validation/Events/ValidateTokenContext.cs b/src/AspNet.Security.OAuth.Validation/Events/ValidateTokenContext.cs new file mode 100644 index 0000000..d91aa38 --- /dev/null +++ b/src/AspNet.Security.OAuth.Validation/Events/ValidateTokenContext.cs @@ -0,0 +1,46 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace AspNet.Security.OAuth.Validation { + /// + /// Allows customization of the token validation logic. + /// + public class ValidateTokenContext : BaseValidationContext { + public ValidateTokenContext( + [NotNull]HttpContext context, + [NotNull]OAuthValidationOptions options, + [NotNull]AuthenticationTicket ticket) + : base(context, options) { + Ticket = ticket; + } + + /// + /// The created from the introspection data. + /// + public AuthenticationTicket Ticket { get; } + + private bool _isValid { get; set; } = true; + + /// + /// Indicates the ticket is valid. + /// + /// Setting this property indicates to the middleware that token validation + /// has been handled by the application. + /// + /// + public bool IsValid { + get { return _isValid; } + set { + Handled = true; + _isValid = value; + } + } + } +} diff --git a/src/AspNet.Security.OAuth.Validation/OAuthValidationHandler.cs b/src/AspNet.Security.OAuth.Validation/OAuthValidationHandler.cs index ad6fa66..cb1d9ec 100644 --- a/src/AspNet.Security.OAuth.Validation/OAuthValidationHandler.cs +++ b/src/AspNet.Security.OAuth.Validation/OAuthValidationHandler.cs @@ -8,32 +8,40 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; namespace AspNet.Security.OAuth.Validation { public class OAuthValidationHandler : AuthenticationHandler { protected override async Task HandleAuthenticateAsync() { - string header = Request.Headers[HeaderNames.Authorization]; - if (string.IsNullOrEmpty(header)) { - Logger.LogInformation("Authentication was skipped because no bearer token was received."); + // Give the application an opportunity to parse the token from a different location, adjust, or reject token + var parseAccessTokenContext = new ParseAccessTokenContext(Context, Options); + await Options.Events.ParseAccessToken(parseAccessTokenContext); - return AuthenticateResult.Skip(); - } + // Initialize the token from the event in case it was set during the event. + string token = parseAccessTokenContext.Token; + + // Bypass the default processing if the event handled the request parsing. + if (!parseAccessTokenContext.Handled) { + string header = Request.Headers[HeaderNames.Authorization]; + if (string.IsNullOrWhiteSpace(header)) { + return AuthenticateResult.Fail("Authentication failed because the bearer token " + + "was missing from the 'Authorization' header."); + } - // Ensure that the authorization header contains the mandatory "Bearer" scheme. - // See https://tools.ietf.org/html/rfc6750#section-2.1 - if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { - Logger.LogInformation("Authentication was skipped because an incompatible " + - "scheme was used in the 'Authorization' header."); + // Ensure that the authorization header contains the mandatory "Bearer" scheme. + // See https://tools.ietf.org/html/rfc6750#section-2.1 + if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { + return AuthenticateResult.Fail("Authentication failed because an invalid scheme " + + "was used in the 'Authorization' header."); + } - return AuthenticateResult.Skip(); + token = header.Substring("Bearer ".Length); } - var token = header.Substring("Bearer ".Length); if (string.IsNullOrWhiteSpace(token)) { return AuthenticateResult.Fail("Authentication failed because the bearer token " + - "was missing from the 'Authorization' header."); + "was missing from the request. The default location" + + "is in the 'Authorization' header."); } // Try to unprotect the token and return an error @@ -43,6 +51,20 @@ protected override async Task HandleAuthenticateAsync() { return AuthenticateResult.Fail("Authentication failed because the access token was invalid."); } + // Allow for interception and handling of the token validated event. + var validateTokenContext = new ValidateTokenContext(Context, Options, ticket); + await Options.Events.ValidateToken(validateTokenContext); + + if (validateTokenContext.Handled) { + // Return a result based on how the validation was handled. + return validateTokenContext.IsValid ? + AuthenticateResult.Success(validateTokenContext.Ticket) : + AuthenticateResult.Fail("Authentication failed because the access token was not valid."); + } + + // Flow any changes that were made to the ticket during the validation event. + ticket = validateTokenContext.Ticket; + // Ensure that the access token was issued // to be used with this resource server. if (!await ValidateAudienceAsync(ticket)) { diff --git a/src/AspNet.Security.OAuth.Validation/OAuthValidationOptions.cs b/src/AspNet.Security.OAuth.Validation/OAuthValidationOptions.cs index d334d62..3e928f8 100644 --- a/src/AspNet.Security.OAuth.Validation/OAuthValidationOptions.cs +++ b/src/AspNet.Security.OAuth.Validation/OAuthValidationOptions.cs @@ -9,9 +9,12 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; -namespace AspNet.Security.OAuth.Validation { - public class OAuthValidationOptions : AuthenticationOptions { - public OAuthValidationOptions() { +namespace AspNet.Security.OAuth.Validation +{ + public class OAuthValidationOptions : AuthenticationOptions + { + public OAuthValidationOptions() + { AuthenticationScheme = OAuthValidationDefaults.AuthenticationScheme; } @@ -40,5 +43,12 @@ public OAuthValidationOptions() { /// is directly retrieved from the dependency injection container. /// public IDataProtectionProvider DataProtectionProvider { get; set; } + + /// + /// The object provided by the application to process events raised by the validation authentication middleware. + /// The application may implement the interface fully, or it may create an instance of + /// and assign delegates only to the events it wants to process. + /// + public IOAuthValidationEvents Events { get; set; } = new OAuthValidationEvents(); } } diff --git a/src/Owin.Security.OAuth.Introspection/Events/BaseIntrospectionContext.cs b/src/Owin.Security.OAuth.Introspection/Events/BaseIntrospectionContext.cs new file mode 100644 index 0000000..6a647d0 --- /dev/null +++ b/src/Owin.Security.OAuth.Introspection/Events/BaseIntrospectionContext.cs @@ -0,0 +1,28 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.Owin; +using Microsoft.Owin.Security.Provider; + +namespace Owin.Security.OAuth.Introspection +{ + /// + /// Base class for all introspection events that holds common properties. + /// + public abstract class BaseIntrospectionContext : BaseContext { + public BaseIntrospectionContext( + [NotNull]IOwinContext context, + [NotNull]OAuthIntrospectionOptions options) + : base(context, options) { + } + + /// + /// Indicates the application has handled the event process. + /// + internal bool Handled { get; set; } + } +} diff --git a/src/Owin.Security.OAuth.Introspection/Events/CreateTicketContext.cs b/src/Owin.Security.OAuth.Introspection/Events/CreateTicketContext.cs new file mode 100644 index 0000000..09a896a --- /dev/null +++ b/src/Owin.Security.OAuth.Introspection/Events/CreateTicketContext.cs @@ -0,0 +1,48 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.Owin; +using Microsoft.Owin.Security; +using Newtonsoft.Json.Linq; + +namespace Owin.Security.OAuth.Introspection { + /// + /// Allows interception of the AuthenticationTicket creation process. + /// + public class CreateTicketContext : BaseIntrospectionContext { + public CreateTicketContext( + [NotNull]IOwinContext context, + [NotNull]OAuthIntrospectionOptions options, + [NotNull]JObject payload) + : base(context, options) { + Payload = payload; + } + + /// + /// The payload from the introspection request to the authorization server. + /// + public JObject Payload { get; } + + private AuthenticationTicket _ticket { get; set; } + + /// + /// An created by the application. + /// + /// Set this property to indicate that the application has handled the creation of the + /// ticket. Set this property to null to instruct the middleware there was a failure + /// during ticket creation. + /// + /// + public AuthenticationTicket Ticket { + get { return _ticket; } + set { + Handled = true; + _ticket = value; + } + } + } +} diff --git a/src/Owin.Security.OAuth.Introspection/Events/IOAuthIntrospectionEvents.cs b/src/Owin.Security.OAuth.Introspection/Events/IOAuthIntrospectionEvents.cs new file mode 100644 index 0000000..a431db1 --- /dev/null +++ b/src/Owin.Security.OAuth.Introspection/Events/IOAuthIntrospectionEvents.cs @@ -0,0 +1,34 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using System.Threading.Tasks; + +namespace Owin.Security.OAuth.Introspection { + /// + /// Allows customization of introspection handling within the middleware. + /// + public interface IOAuthIntrospectionEvents { + /// + /// Invoked when a ticket is to be created from an introspection response. + /// + Task CreateTicket(CreateTicketContext context); + + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + Task ParseAccessToken(ParseAccessTokenContext context); + + /// + /// Invoked when a token is to be sent to the authorization server for introspection. + /// + Task RequestTokenIntrospection(RequestTokenIntrospectionContext context); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + Task ValidateToken(ValidateTokenContext context); + } +} diff --git a/src/Owin.Security.OAuth.Introspection/Events/OAuthIntrospectionEvents.cs b/src/Owin.Security.OAuth.Introspection/Events/OAuthIntrospectionEvents.cs new file mode 100644 index 0000000..85cbb6f --- /dev/null +++ b/src/Owin.Security.OAuth.Introspection/Events/OAuthIntrospectionEvents.cs @@ -0,0 +1,55 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using System; +using System.Threading.Tasks; + +namespace Owin.Security.OAuth.Introspection { + /// + /// Allows customization of introspection handling within the middleware. + /// + public class OAuthIntrospectionEvents : IOAuthIntrospectionEvents { + /// + /// Invoked when a ticket is to be created from an introspection response. + /// + public Func OnCreateTicket { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + public Func OnParseAccessToken { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a token is to be sent to the authorization server for introspection. + /// + public Func OnRequestTokenIntrospection { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + public Func OnValidateToken { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a ticket is to be created from an introspection response. + /// + public virtual Task CreateTicket(CreateTicketContext context) => OnCreateTicket(context); + + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + public virtual Task ParseAccessToken(ParseAccessTokenContext context) => OnParseAccessToken(context); + + /// + /// Invoked when a token is to be sent to the authorization server for introspection. + /// + public virtual Task RequestTokenIntrospection(RequestTokenIntrospectionContext context) => OnRequestTokenIntrospection(context); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + public virtual Task ValidateToken(ValidateTokenContext context) => OnValidateToken(context); + } +} diff --git a/src/Owin.Security.OAuth.Introspection/Events/ParseAccessTokenContext.cs b/src/Owin.Security.OAuth.Introspection/Events/ParseAccessTokenContext.cs new file mode 100644 index 0000000..281e017 --- /dev/null +++ b/src/Owin.Security.OAuth.Introspection/Events/ParseAccessTokenContext.cs @@ -0,0 +1,38 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.Owin; + +namespace Owin.Security.OAuth.Introspection { + /// + /// Allows custom parsing of access tokens from requests. + /// + public class ParseAccessTokenContext : BaseIntrospectionContext { + public ParseAccessTokenContext( + [NotNull]IOwinContext context, + [NotNull]OAuthIntrospectionOptions options) + : base(context, options) { + } + + private string _token { get; set; } + + /// + /// Gets or sets the access token. + /// + /// Setting this property indicates to the middleware that the request has been processed + /// and a token extracted. Setting this to null will invalidate the token. + /// + /// + public string Token { + get { return _token; } + set { + Handled = true; + _token = value; + } + } + } +} diff --git a/src/Owin.Security.OAuth.Introspection/Events/RequestTokenIntrospectionContext.cs b/src/Owin.Security.OAuth.Introspection/Events/RequestTokenIntrospectionContext.cs new file mode 100644 index 0000000..bd26830 --- /dev/null +++ b/src/Owin.Security.OAuth.Introspection/Events/RequestTokenIntrospectionContext.cs @@ -0,0 +1,53 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using System.Net.Http; +using JetBrains.Annotations; +using Newtonsoft.Json.Linq; +using Microsoft.Owin; + +namespace Owin.Security.OAuth.Introspection { + /// + /// Allows for custom handling of the call to the Authorization Server's Introspection endpoint. + /// + public class RequestTokenIntrospectionContext : BaseIntrospectionContext { + public RequestTokenIntrospectionContext( + [NotNull]IOwinContext context, + [NotNull]OAuthIntrospectionOptions options, + [NotNull]string token) + : base(context, options) { + Token = token; + } + + /// + /// An for use by the application to call the authorization server. + /// + public HttpClient Client => Options.HttpClient; + + /// + /// The access token parsed from the client request. + /// + public string Token { get; } + + private JObject _payload { get; set; } + + /// + /// The data retrieved from the call to the introspection endpoint on the authorization server. + /// + /// Set this property to indicate that the introspection call was handled + /// by the application. Set this property to null to instruct the middleware + /// to indicate a failure. + /// + /// + public JObject Payload { + get { return _payload; } + set { + Handled = true; + Payload = value; + } + } + } +} diff --git a/src/Owin.Security.OAuth.Introspection/Events/ValidateTokenContext.cs b/src/Owin.Security.OAuth.Introspection/Events/ValidateTokenContext.cs new file mode 100644 index 0000000..7bb446c --- /dev/null +++ b/src/Owin.Security.OAuth.Introspection/Events/ValidateTokenContext.cs @@ -0,0 +1,46 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.Owin; +using Microsoft.Owin.Security; + +namespace Owin.Security.OAuth.Introspection { + /// + /// Allows customization of the token validation logic. + /// + public class ValidateTokenContext : BaseIntrospectionContext { + public ValidateTokenContext( + [NotNull]IOwinContext context, + [NotNull]OAuthIntrospectionOptions options, + [NotNull]AuthenticationTicket ticket) + : base(context, options) { + Ticket = ticket; + } + + /// + /// The created from the introspection data. + /// + public AuthenticationTicket Ticket { get; } + + private bool _isValid { get; set; } = true; + + /// + /// Indicates the ticket is valid. + /// + /// Setting this property indicates to the middleware that token validation + /// has been handled by the application. + /// + /// + public bool IsValid { + get { return _isValid; } + set { + Handled = true; + _isValid = value; + } + } + } +} diff --git a/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionConstants.cs b/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionConstants.cs index 9441d14..5b0cc45 100644 --- a/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionConstants.cs +++ b/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionConstants.cs @@ -18,6 +18,10 @@ public static class Claims { public const string Username = "username"; } + public static class HeaderNames { + public const string Authorization = "Authorization"; + } + public static class Metadata { public const string IntrospectionEndpoint = "introspection_endpoint"; } @@ -27,6 +31,10 @@ public static class Parameters { public const string TokenTypeHint = "token_type_hint"; } + public static class Properties { + public const string Audiences = ".audiences"; + } + public static class TokenTypes { public const string AccessToken = "access_token"; } diff --git a/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionHandler.cs b/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionHandler.cs index 9879896..015c831 100644 --- a/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionHandler.cs +++ b/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionHandler.cs @@ -21,27 +21,44 @@ namespace Owin.Security.OAuth.Introspection { public class OAuthIntrospectionHandler : AuthenticationHandler { - protected override async Task AuthenticateCoreAsync() { - var header = Request.Headers.Get("Authorization"); - if (string.IsNullOrEmpty(header)) { - Options.Logger.LogInformation("Authentication was skipped because no bearer token was received."); + protected override async Task AuthenticateCoreAsync() + {// Give the application an opportunity to parse the token from a different location, adjust, or reject token + var parseAccessTokenContext = new ParseAccessTokenContext(Context, Options); + await Options.Events.ParseAccessToken(parseAccessTokenContext); + + // Initialize the token from the event in case it was set during the event. + string token = parseAccessTokenContext.Token; + + // Bypass the default processing if the event handled the request parsing. + if (!parseAccessTokenContext.Handled) + { + string header = Request.Headers[OAuthIntrospectionConstants.HeaderNames.Authorization]; + if (string.IsNullOrWhiteSpace(header)) + { + Options.Logger.LogError("Authentication failed because the bearer token " + + "was missing from the 'Authorization' header."); - return null; - } + return null; + } - // Ensure that the authorization header contains the mandatory "Bearer" scheme. - // See https://tools.ietf.org/html/rfc6750#section-2.1 - if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { - Options.Logger.LogInformation("Authentication was skipped because an incompatible " + - "scheme was used in the 'Authorization' header."); + // Ensure that the authorization header contains the mandatory "Bearer" scheme. + // See https://tools.ietf.org/html/rfc6750#section-2.1 + if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + Options.Logger.LogError("Authentication failed because an invalid scheme " + + "was used in the 'Authorization' header."); - return null; + return null; + } + + token = header.Substring("Bearer ".Length); } - var token = header.Substring("Bearer ".Length); - if (string.IsNullOrWhiteSpace(token)) { + if (string.IsNullOrWhiteSpace(token)) + { Options.Logger.LogError("Authentication failed because the bearer token " + - "was missing from the 'Authorization' header."); + "was missing from the request. The default location" + + "is in the 'Authorization' header."); return null; } @@ -49,37 +66,77 @@ protected override async Task AuthenticateCoreAsync() { // Try to resolve the authentication ticket from the distributed cache. If none // can be found, a new introspection request is sent to the authorization server. var ticket = await RetrieveTicketAsync(token); - if (ticket == null) { + if (ticket == null) + { + // Allow interception of the introspection retrieval process via events + var requestTokenIntrospectionContext = new RequestTokenIntrospectionContext(Context, Options, token); + await Options.Events.RequestTokenIntrospection(requestTokenIntrospectionContext); + + // Flow the payload from the application possibly handling the introspection request + var payload = requestTokenIntrospectionContext.Payload; + + if (!requestTokenIntrospectionContext.Handled) + { + // Set the payload using the default introspection behavior + payload = await GetIntrospectionPayloadAsync(token); + } + // Return a failed authentication result if the introspection // request failed or if the "active" claim was false. - var payload = await GetIntrospectionPayloadAsync(token); - if (payload == null || !payload.Value(OAuthIntrospectionConstants.Claims.Active)) { + if (payload == null || !payload.Value(OAuthIntrospectionConstants.Claims.Active)) + { Options.Logger.LogError("Authentication failed because the authorization " + "server rejected the access token."); return null; } - // Ensure that the access token was issued - // to be used with this resource server. - if (!await ValidateAudienceAsync(payload)) { - Options.Logger.LogError("Authentication failed because the access token " + - "was not valid for this resource server."); - - return null; - } + // Allow interception of the ticket creation process via events + var createTicketContext = new CreateTicketContext(Context, Options, payload); + await Options.Events.CreateTicket(createTicketContext); // Create a new authentication ticket from the introspection - // response returned by the authorization server. - ticket = await CreateTicketAsync(payload); + // response returned by the authorization server if the application + // has not handled the ticket creation process. + ticket = createTicketContext.Handled ? createTicketContext.Ticket : await CreateTicketAsync(payload); Debug.Assert(ticket != null); await StoreTicketAsync(token, ticket); } + // Allow for interception and handling of the token validated event. + var validateTokenContext = new ValidateTokenContext(Context, Options, ticket); + await Options.Events.ValidateToken(validateTokenContext); + + if (validateTokenContext.Handled) + { + // Return a result based on how the validation was handled. + if (validateTokenContext.IsValid) + { + return validateTokenContext.Ticket; + } + + Options.Logger.LogError("Authentication failed because the access token was not valid."); + return null; + } + + // Flow any changes that were made to the ticket during the validation event. + ticket = validateTokenContext.Ticket; + + // Ensure that the access token was issued + // to be used with this resource server. + if (!await ValidateAudienceAsync(ticket)) + { + Options.Logger.LogError("Authentication failed because the access token " + + "was not valid for this resource server."); + + return null; + } + // Ensure that the authentication ticket is still valid. if (ticket.Properties.ExpiresUtc.HasValue && - ticket.Properties.ExpiresUtc.Value < Options.SystemClock.UtcNow) { + ticket.Properties.ExpiresUtc.Value < Options.SystemClock.UtcNow) + { Options.Logger.LogError("Authentication failed because the access token was expired."); return null; @@ -121,11 +178,11 @@ protected virtual async Task ResolveIntrospectionEndpointAsync(string is protected virtual async Task GetIntrospectionPayloadAsync(string token) { // Note: updating the options during a request is not thread safe but is harmless in this case: // in the worst case, it will only send multiple configuration requests to the authorization server. - if (string.IsNullOrEmpty(Options.IntrospectionEndpoint)) { + if (string.IsNullOrWhiteSpace(Options.IntrospectionEndpoint)) { Options.IntrospectionEndpoint = await ResolveIntrospectionEndpointAsync(Options.Authority); } - if (string.IsNullOrEmpty(Options.IntrospectionEndpoint)) { + if (string.IsNullOrWhiteSpace(Options.IntrospectionEndpoint)) { throw new InvalidOperationException("The OAuth2 introspection middleware was unable to retrieve " + "the provider configuration from the OAuth2 authorization server."); } @@ -156,48 +213,29 @@ protected virtual async Task GetIntrospectionPayloadAsync(string token) return JObject.Parse(await response.Content.ReadAsStringAsync()); } - protected virtual Task ValidateAudienceAsync(JObject payload) { + protected virtual Task ValidateAudienceAsync(AuthenticationTicket ticket) + { // If no explicit audience has been configured, // skip the default audience validation. - if (Options.Audiences.Count == 0) { + if (Options.Audiences.Count == 0) + { return Task.FromResult(true); } - // If no "aud" claim was returned by the authorization server, - // assume the access token was not specific enough and reject it. - if (payload[OAuthIntrospectionConstants.Claims.Audience] == null) { + // Extract the audiences from the authentication ticket. + string audiences; + if (!ticket.Properties.Dictionary.TryGetValue(OAuthIntrospectionConstants.Properties.Audiences, out audiences)) + { return Task.FromResult(false); } - // Note: the "aud" claim can be either a string or an array. - // See https://tools.ietf.org/html/rfc7662#section-2.2 - switch (payload[OAuthIntrospectionConstants.Claims.Audience].Type) { - case JTokenType.Array: { - // When the "aud" claim is an array, at least one value must correspond - // to the audience registered in the introspection middleware options. - var audiences = payload.Value(OAuthIntrospectionConstants.Claims.Audience) - .Select(audience => audience.Value()); - if (audiences.Intersect(Options.Audiences, StringComparer.Ordinal).Any()) { - return Task.FromResult(true); - } - - return Task.FromResult(false); - } - - case JTokenType.String: { - // When the "aud" claim is a string, it must exactly match the - // audience registered in the introspection middleware options. - var audience = payload.Value(OAuthIntrospectionConstants.Claims.Audience); - if (Options.Audiences.Contains(audience, StringComparer.Ordinal)) { - return Task.FromResult(true); - } - - return Task.FromResult(false); - } - - default: - return Task.FromResult(false); + // Ensure that the authentication ticket contains the registered audience. + if (!audiences.Split(' ').Intersect(Options.Audiences, StringComparer.Ordinal).Any()) + { + return Task.FromResult(false); } + + return Task.FromResult(true); } protected virtual Task CreateTicketAsync(JObject payload) { @@ -250,6 +288,24 @@ protected virtual Task CreateTicketAsync(JObject payload) continue; } + + // Store the audience or audiences in the ticket properties. + case OAuthIntrospectionConstants.Claims.Audience: { + if (property.Value.Type == JTokenType.Array) { + var array = (JArray)property.Value; + if (array == null) { + continue; + } + var audiences = string.Join(" ", array.Select(item => item.Value())); + properties.Dictionary[OAuthIntrospectionConstants.Properties.Audiences] = audiences; + } + + else if (property.Value.Type == JTokenType.String) { + properties.Dictionary[OAuthIntrospectionConstants.Properties.Audiences] = (string)property.Value; + } + + continue; + } } switch (property.Value.Type) { diff --git a/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionOptions.cs b/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionOptions.cs index b458e6d..88e0659 100644 --- a/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionOptions.cs +++ b/src/Owin.Security.OAuth.Introspection/OAuthIntrospectionOptions.cs @@ -52,6 +52,13 @@ public OAuthIntrospectionOptions() /// public IDistributedCache Cache { get; set; } + /// + /// The object provided by the application to process events raised by the bearer authentication middleware. + /// The application may implement the interface fully, or it may create an instance of OAuthIntrospectionEvents + /// and assign delegates only to the events it wants to process. + /// + public IOAuthIntrospectionEvents Events { get; set; } = new OAuthIntrospectionEvents(); + /// /// Gets or sets the HTTP client used to communicate /// with the remote OAuth2/OpenID Connect server. diff --git a/src/Owin.Security.OAuth.Introspection/Owin.Security.OAuth.Introspection.xproj b/src/Owin.Security.OAuth.Introspection/Owin.Security.OAuth.Introspection.xproj index fd3f228..67da38f 100644 --- a/src/Owin.Security.OAuth.Introspection/Owin.Security.OAuth.Introspection.xproj +++ b/src/Owin.Security.OAuth.Introspection/Owin.Security.OAuth.Introspection.xproj @@ -7,7 +7,7 @@ f9031f69-74bf-4321-88d5-2a606d3da4e4 - AspNet.Security.OpenIdConnect.Introspection + Owin.Security.OAuth.Introspection ..\..\artifacts\obj\$(MSBuildProjectName) ..\..\artifacts\bin\$(MSBuildProjectName)\ diff --git a/src/Owin.Security.OAuth.Validation/Events/BaseValidationContext.cs b/src/Owin.Security.OAuth.Validation/Events/BaseValidationContext.cs new file mode 100644 index 0000000..d643909 --- /dev/null +++ b/src/Owin.Security.OAuth.Validation/Events/BaseValidationContext.cs @@ -0,0 +1,27 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.Owin; +using Microsoft.Owin.Security.Provider; + +namespace Owin.Security.OAuth.Validation { + /// + /// Base class for all validation events that holds common properties. + /// + public abstract class BaseValidationContext : BaseContext { + public BaseValidationContext( + [NotNull]IOwinContext context, + [NotNull]OAuthValidationOptions options) + : base(context, options) { + } + + /// + /// Indicates the application has handled the event process. + /// + internal bool Handled { get; set; } + } +} diff --git a/src/Owin.Security.OAuth.Validation/Events/IOAuthValidationEvents.cs b/src/Owin.Security.OAuth.Validation/Events/IOAuthValidationEvents.cs new file mode 100644 index 0000000..e392bf1 --- /dev/null +++ b/src/Owin.Security.OAuth.Validation/Events/IOAuthValidationEvents.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/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using System.Threading.Tasks; + +namespace Owin.Security.OAuth.Validation { + /// + /// Allows customization of validation handling within the middleware. + /// + public interface IOAuthValidationEvents { + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + Task ParseAccessToken(ParseAccessTokenContext context); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + Task ValidateToken(ValidateTokenContext context); + } +} diff --git a/src/Owin.Security.OAuth.Validation/Events/OAuthValidationEvents.cs b/src/Owin.Security.OAuth.Validation/Events/OAuthValidationEvents.cs new file mode 100644 index 0000000..193961d --- /dev/null +++ b/src/Owin.Security.OAuth.Validation/Events/OAuthValidationEvents.cs @@ -0,0 +1,35 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using System; +using System.Threading.Tasks; + +namespace Owin.Security.OAuth.Validation { + /// + /// Allows customization of validation handling within the middleware. + /// + public class OAuthValidationEvents : IOAuthValidationEvents { + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + public Func OnParseAccessToken { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + public Func OnValidateToken { get; set; } = context => Task.FromResult(0); + + /// + /// Invoked when a token is to be parsed from a newly-received request. + /// + public virtual Task ParseAccessToken(ParseAccessTokenContext context) => OnParseAccessToken(context); + + /// + /// Invoked when a token is to be validated, before final processing. + /// + public virtual Task ValidateToken(ValidateTokenContext context) => OnValidateToken(context); + } +} diff --git a/src/Owin.Security.OAuth.Validation/Events/ParseAccessTokenContext.cs b/src/Owin.Security.OAuth.Validation/Events/ParseAccessTokenContext.cs new file mode 100644 index 0000000..7b822db --- /dev/null +++ b/src/Owin.Security.OAuth.Validation/Events/ParseAccessTokenContext.cs @@ -0,0 +1,38 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.Owin; + +namespace Owin.Security.OAuth.Validation { + /// + /// Allows custom parsing of access tokens from requests. + /// + public class ParseAccessTokenContext : BaseValidationContext { + public ParseAccessTokenContext( + [NotNull]IOwinContext context, + [NotNull]OAuthValidationOptions options) + : base(context, options) { + } + + private string _token { get; set; } + + /// + /// Gets or sets the access token. + /// + /// Setting this property indicates to the middleware that the request has been processed + /// and a token extracted. Setting this to null will invalidate the token. + /// + /// + public string Token { + get { return _token; } + set { + Handled = true; + _token = value; + } + } + } +} diff --git a/src/Owin.Security.OAuth.Validation/Events/ValidateTokenContext.cs b/src/Owin.Security.OAuth.Validation/Events/ValidateTokenContext.cs new file mode 100644 index 0000000..8503d44 --- /dev/null +++ b/src/Owin.Security.OAuth.Validation/Events/ValidateTokenContext.cs @@ -0,0 +1,46 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information + * concerning the license and the contributors participating to this project. + */ + +using JetBrains.Annotations; +using Microsoft.Owin; +using Microsoft.Owin.Security; + +namespace Owin.Security.OAuth.Validation { + /// + /// Allows customization of the token validation logic. + /// + public class ValidateTokenContext : BaseValidationContext { + public ValidateTokenContext( + [NotNull]IOwinContext context, + [NotNull]OAuthValidationOptions options, + [NotNull]AuthenticationTicket ticket) + : base(context, options) { + Ticket = ticket; + } + + /// + /// The created from the introspection data. + /// + public AuthenticationTicket Ticket { get; } + + private bool _isValid { get; set; } = true; + + /// + /// Indicates the ticket is valid. + /// + /// Setting this property indicates to the middleware that token validation + /// has been handled by the application. + /// + /// + public bool IsValid { + get { return _isValid; } + set { + Handled = true; + _isValid = value; + } + } + } +} diff --git a/src/Owin.Security.OAuth.Validation/OAuthValidationConstants.cs b/src/Owin.Security.OAuth.Validation/OAuthValidationConstants.cs index 695b6fe..75d6d49 100644 --- a/src/Owin.Security.OAuth.Validation/OAuthValidationConstants.cs +++ b/src/Owin.Security.OAuth.Validation/OAuthValidationConstants.cs @@ -6,6 +6,10 @@ namespace Owin.Security.OAuth.Validation { public static class OAuthValidationConstants { + public static class HeaderNames { + public const string Authorization = "Authorization"; + } + public static class Properties { public const string Audiences = ".audiences"; } diff --git a/src/Owin.Security.OAuth.Validation/OAuthValidationHandler.cs b/src/Owin.Security.OAuth.Validation/OAuthValidationHandler.cs index d6a4fa4..7ef7fb2 100644 --- a/src/Owin.Security.OAuth.Validation/OAuthValidationHandler.cs +++ b/src/Owin.Security.OAuth.Validation/OAuthValidationHandler.cs @@ -14,26 +14,39 @@ namespace Owin.Security.OAuth.Validation { public class OAuthValidationHandler : AuthenticationHandler { protected override async Task AuthenticateCoreAsync() { - var header = Request.Headers.Get("Authorization"); - if (string.IsNullOrEmpty(header)) { - Options.Logger.LogInformation("Authentication was skipped because no bearer token was received."); + // Give the application an opportunity to parse the token from a different location, adjust, or reject token + var parseAccessTokenContext = new ParseAccessTokenContext(Context, Options); + await Options.Events.ParseAccessToken(parseAccessTokenContext); - return null; - } + // Initialize the token from the event in case it was set during the event. + string token = parseAccessTokenContext.Token; - // Ensure that the authorization header contains the mandatory "Bearer" scheme. - // See https://tools.ietf.org/html/rfc6750#section-2.1 - if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { - Options.Logger.LogInformation("Authentication was skipped because an incompatible " + - "scheme was used in the 'Authorization' header."); + // Bypass the default processing if the event handled the request parsing. + if (!parseAccessTokenContext.Handled) { + string header = Request.Headers[OAuthValidationConstants.HeaderNames.Authorization]; + if (string.IsNullOrWhiteSpace(header)) { + Options.Logger.LogInformation("Authentication failed because the bearer token " + + "was missing from the 'Authorization' header."); - return null; + return null; + } + + // Ensure that the authorization header contains the mandatory "Bearer" scheme. + // See https://tools.ietf.org/html/rfc6750#section-2.1 + if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { + Options.Logger.LogInformation("Authentication failed because an invalid scheme " + + "was used in the 'Authorization' header."); + + return null; + } + + token = header.Substring("Bearer ".Length); } - var token = header.Substring("Bearer ".Length); if (string.IsNullOrWhiteSpace(token)) { - Options.Logger.LogError("Authentication failed because the bearer token " + - "was missing from the 'Authorization' header."); + Options.Logger.LogInformation("Authentication failed because the bearer token " + + "was missing from the request. The default location" + + "is in the 'Authorization' header."); return null; } @@ -47,6 +60,23 @@ protected override async Task AuthenticateCoreAsync() { return null; } + // Allow for interception and handling of the token validated event. + var validateTokenContext = new ValidateTokenContext(Context, Options, ticket); + await Options.Events.ValidateToken(validateTokenContext); + + if (validateTokenContext.Handled) { + // Return a result based on how the validation was handled. + if (validateTokenContext.IsValid) { + return validateTokenContext.Ticket; + } + + Options.Logger.LogInformation("Authentication failed because the access token was not valid."); + return null; + } + + // Flow any changes that were made to the ticket during the validation event. + ticket = validateTokenContext.Ticket; + // Ensure that the access token was issued // to be used with this resource server. if (!await ValidateAudienceAsync(ticket)) { diff --git a/src/Owin.Security.OAuth.Validation/OAuthValidationOptions.cs b/src/Owin.Security.OAuth.Validation/OAuthValidationOptions.cs index eae9cc5..dbcbb07 100644 --- a/src/Owin.Security.OAuth.Validation/OAuthValidationOptions.cs +++ b/src/Owin.Security.OAuth.Validation/OAuthValidationOptions.cs @@ -10,10 +10,13 @@ using Microsoft.Owin.Infrastructure; using Microsoft.Owin.Security; -namespace Owin.Security.OAuth.Validation { - public class OAuthValidationOptions : AuthenticationOptions { +namespace Owin.Security.OAuth.Validation +{ + public class OAuthValidationOptions : AuthenticationOptions + { public OAuthValidationOptions() - : base(OAuthValidationDefaults.AuthenticationScheme) { + : base(OAuthValidationDefaults.AuthenticationScheme) + { } /// @@ -45,5 +48,12 @@ public OAuthValidationOptions() /// data protector used by . /// public IDataProtectionProvider DataProtectionProvider { get; set; } + + /// + /// The object provided by the application to process events raised by the bearer authentication middleware. + /// The application may implement the interface fully, or it may create an instance of OAuthValidationEvents + /// and assign delegates only to the events it wants to process. + /// + public IOAuthValidationEvents Events { get; set; } = new OAuthValidationEvents(); } } diff --git a/src/Owin.Security.OAuth.Validation/Owin.Security.OAuth.Validation.xproj b/src/Owin.Security.OAuth.Validation/Owin.Security.OAuth.Validation.xproj index a7353f9..e96587d 100644 --- a/src/Owin.Security.OAuth.Validation/Owin.Security.OAuth.Validation.xproj +++ b/src/Owin.Security.OAuth.Validation/Owin.Security.OAuth.Validation.xproj @@ -7,7 +7,7 @@ 719af040-773b-4da5-b53e-28b26d1121ff - AspNet.Security.OAuth.Validation + Owin.Security.OAuth.Validation ..\..\artifacts\obj\$(MSBuildProjectName) ..\..\artifacts\bin\$(MSBuildProjectName)\ diff --git a/test/AspNet.Security.OAuth.Introspection.Tests/OAuthIntrospectionMiddlewareTests.cs b/test/AspNet.Security.OAuth.Introspection.Tests/OAuthIntrospectionMiddlewareTests.cs index 90dac89..97066c4 100644 --- a/test/AspNet.Security.OAuth.Introspection.Tests/OAuthIntrospectionMiddlewareTests.cs +++ b/test/AspNet.Security.OAuth.Introspection.Tests/OAuthIntrospectionMiddlewareTests.cs @@ -76,7 +76,7 @@ public async Task InvalidTokenCausesInvalidAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Invalid); // Act var response = await client.SendAsync(request); @@ -96,7 +96,7 @@ public async Task ValidTokenAllowsSuccessfulAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); // Act var response = await client.SendAsync(request); @@ -118,7 +118,7 @@ public async Task MissingAudienceCausesInvalidAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); // Act var response = await client.SendAsync(request); @@ -139,8 +139,7 @@ public async Task InvalidAudienceCausesInvalidAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue( - "Bearer", "valid-token-with-single-audience"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.SingleAudience); // Act var response = await client.SendAsync(request); @@ -162,8 +161,7 @@ public async Task AnyMatchingAudienceCausesSuccessfulAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue( - "Bearer", "valid-token-with-single-audience"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.SingleAudience); // Act var response = await client.SendAsync(request); @@ -185,8 +183,7 @@ public async Task ValidAudienceAllowsSuccessfulAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue( - "Bearer", "valid-token-with-multiple-audiences"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.MultipleAudiences); // Act var response = await client.SendAsync(request); @@ -209,8 +206,7 @@ public async Task MultipleMatchingAudienceCausesSuccessfulAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue( - "Bearer", "valid-token-with-multiple-audiences"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.MultipleAudiences); // Act var response = await client.SendAsync(request); @@ -231,7 +227,7 @@ public async Task ExpiredTicketCausesInvalidAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "expired-token"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Expired); // Act var response = await client.SendAsync(request); @@ -240,6 +236,146 @@ public async Task ExpiredTicketCausesInvalidAuthentication() { Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task TokenSetToNullDuringParseAccessTokenEventCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.ClientId = "Fabrikam"; + options.ClientSecret = "B4657E03-D619"; + options.Events = new OAuthIntrospectionEvents { + OnParseAccessToken = context => { + context.Token = null; + return Task.FromResult(0); + } + }; + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TokenSetToInvalidTokenDuringParseAccessTokenEventCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.ClientId = "Fabrikam"; + options.ClientSecret = "B4657E03-D619"; + options.Events = new OAuthIntrospectionEvents { + OnParseAccessToken = context => { + context.Token = Tokens.Invalid; + return Task.FromResult(0); + } + }; + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TokenSetToValidTokenDuringParseAccessTokenEventCausesSuccessfulAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.ClientId = "Fabrikam"; + options.ClientSecret = "B4657E03-D619"; + options.Events = new OAuthIntrospectionEvents { + OnParseAccessToken = context => { + context.Token = Tokens.Valid; + return Task.FromResult(0); + } + }; + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Invalid); + + // 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 TokenValidatedEventSettingIsValidAsTrueCausesSuccessfulAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.ClientId = "Fabrikam"; + options.ClientSecret = "B4657E03-D619"; + options.Events = new OAuthIntrospectionEvents { + OnValidateToken = context => { + context.IsValid = true; + return Task.FromResult(0); + } + }; + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // 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 TokenValidatedEventSettingIsValidAsFalseCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.ClientId = "Fabrikam"; + options.ClientSecret = "B4657E03-D619"; + options.Events = new OAuthIntrospectionEvents { + OnValidateToken = context => { + context.IsValid = false; + return Task.FromResult(0); + } + }; + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + private static class Tokens { + public const string Invalid = "invalid-token"; + public const string Valid = "valid-token"; + public const string SingleAudience = "valid-token-with-single-audience"; + public const string MultipleAudiences = "valid-token-with-multiple-audiences"; + public const string Expired = "expired-token"; + } + private static TestServer CreateResourceServer(Action configuration) { var server = CreateAuthorizationServer(); @@ -317,13 +453,13 @@ private static TestServer CreateAuthorizationServer() { var form = await context.Request.ReadFormAsync(); switch (form[OAuthIntrospectionConstants.Parameters.Token]) { - case "invalid-token": { + case Tokens.Invalid: { payload[OAuthIntrospectionConstants.Claims.Active] = false; break; } - case "expired-token": { + case Tokens.Expired: { payload[OAuthIntrospectionConstants.Claims.Active] = true; payload[OAuthIntrospectionConstants.Claims.Subject] = "Fabrikam"; @@ -333,14 +469,14 @@ private static TestServer CreateAuthorizationServer() { break; } - case "valid-token": { + case Tokens.Valid: { payload[OAuthIntrospectionConstants.Claims.Active] = true; payload[OAuthIntrospectionConstants.Claims.Subject] = "Fabrikam"; break; } - case "valid-token-with-single-audience": { + case Tokens.SingleAudience: { payload[OAuthIntrospectionConstants.Claims.Active] = true; payload[OAuthIntrospectionConstants.Claims.Subject] = "Fabrikam"; payload[OAuthIntrospectionConstants.Claims.Audience] = "http://www.google.com/"; @@ -348,7 +484,7 @@ private static TestServer CreateAuthorizationServer() { break; } - case "valid-token-with-multiple-audiences": { + case Tokens.MultipleAudiences: { payload[OAuthIntrospectionConstants.Claims.Active] = true; payload[OAuthIntrospectionConstants.Claims.Subject] = "Fabrikam"; payload[OAuthIntrospectionConstants.Claims.Audience] = JArray.FromObject(new[] { diff --git a/test/AspNet.Security.OAuth.Validation.Tests/OAuthValidationMiddlewareTests.cs b/test/AspNet.Security.OAuth.Validation.Tests/OAuthValidationMiddlewareTests.cs index 642580d..675bcbb 100644 --- a/test/AspNet.Security.OAuth.Validation.Tests/OAuthValidationMiddlewareTests.cs +++ b/test/AspNet.Security.OAuth.Validation.Tests/OAuthValidationMiddlewareTests.cs @@ -32,7 +32,7 @@ public async Task InvalidTokenCausesInvalidAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Invalid); // Act var response = await client.SendAsync(request); @@ -49,7 +49,7 @@ public async Task ValidTokenAllowsSuccessfulAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-1"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); // Act var response = await client.SendAsync(request); @@ -69,7 +69,7 @@ public async Task MissingAudienceCausesInvalidAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-1"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); // Act var response = await client.SendAsync(request); @@ -88,7 +88,7 @@ public async Task InvalidAudienceCausesInvalidAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-2"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.SingleAudience); // Act var response = await client.SendAsync(request); @@ -107,7 +107,7 @@ public async Task ValidAudienceAllowsSuccessfulAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-3"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.MultipleAudiences); // Act var response = await client.SendAsync(request); @@ -128,7 +128,7 @@ public async Task AnyMatchingAudienceCausesSuccessfulAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-2"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.SingleAudience); // Act var response = await client.SendAsync(request); @@ -149,7 +149,7 @@ public async Task MultipleMatchingAudienceCausesSuccessfulAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-3"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.MultipleAudiences); // Act var response = await client.SendAsync(request); @@ -167,7 +167,7 @@ public async Task ExpiredTicketCausesInvalidAuthentication() { var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-4"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Expired); // Act var response = await client.SendAsync(request); @@ -176,15 +176,145 @@ public async Task ExpiredTicketCausesInvalidAuthentication() { Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task TokenSetToNullDuringParseAccessTokenEventCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.Events = new OAuthValidationEvents { + OnParseAccessToken = context => { + context.Token = null; + return Task.FromResult(0); + } + }; + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TokenSetToInvalidValueDuringParseAccessTokenEventCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.Events = new OAuthValidationEvents { + OnParseAccessToken = context => { + context.Token = Tokens.Invalid; + return Task.FromResult(0); + } + }; + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TokenSetToValidValueDuringParseAccessTokenEventAllowsSuccessfulAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.Events = new OAuthValidationEvents { + OnParseAccessToken = context => { + context.Token = Tokens.Valid; + return Task.FromResult(0); + } + }; + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Invalid); + + // 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 TokenValidatedEventSettingIsValidAsTrueCausesSuccessfulAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.Events = new OAuthValidationEvents { + OnValidateToken = context => { + context.IsValid = true; + return Task.FromResult(0); + } + }; + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // 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 TokenValidatedEventSettingIsValidAsFalseCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.Events = new OAuthValidationEvents { + OnValidateToken = context => { + context.IsValid = false; + return Task.FromResult(0); + } + }; + }); + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + private static class Tokens { + public const string Invalid = "invalid-token"; + public const string Valid = "valid-token"; + public const string SingleAudience = "valid-token-with-single-audience"; + public const string MultipleAudiences = "valid-token-with-multiple-audiences"; + public const string Expired = "expired-token"; + } + private static TestServer CreateResourceServer(Action configuration = null) { var builder = new WebHostBuilder(); - var format = new Mock>(); + var format = new Mock>(MockBehavior.Strict); - format.Setup(mock => mock.Unprotect(It.Is(token => token == "invalid-token"))) + format.Setup(mock => mock.Unprotect(It.Is(token => token == Tokens.Invalid))) .Returns(value: null); - format.Setup(mock => mock.Unprotect(It.Is(token => token == "token-1"))) + format.Setup(mock => mock.Unprotect(It.Is(token => token == Tokens.Valid))) .Returns(delegate { var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "Fabrikam")); @@ -195,7 +325,7 @@ private static TestServer CreateResourceServer(Action co properties, OAuthValidationDefaults.AuthenticationScheme); }); - format.Setup(mock => mock.Unprotect(It.Is(token => token == "token-2"))) + format.Setup(mock => mock.Unprotect(It.Is(token => token == Tokens.SingleAudience))) .Returns(delegate { var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "Fabrikam")); @@ -208,7 +338,7 @@ private static TestServer CreateResourceServer(Action co properties, OAuthValidationDefaults.AuthenticationScheme); }); - format.Setup(mock => mock.Unprotect(It.Is(token => token == "token-3"))) + format.Setup(mock => mock.Unprotect(It.Is(token => token == Tokens.MultipleAudiences))) .Returns(delegate { var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "Fabrikam")); @@ -221,7 +351,7 @@ private static TestServer CreateResourceServer(Action co properties, OAuthValidationDefaults.AuthenticationScheme); }); - format.Setup(mock => mock.Unprotect(It.Is(token => token == "token-4"))) + format.Setup(mock => mock.Unprotect(It.Is(token => token == Tokens.Expired))) .Returns(delegate { var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "Fabrikam")); diff --git a/test/Owin.Security.OAuth.Introspection.Tests/OAuthIntrospectionMiddlewareTests.cs b/test/Owin.Security.OAuth.Introspection.Tests/OAuthIntrospectionMiddlewareTests.cs index 23a3755..6683f58 100644 --- a/test/Owin.Security.OAuth.Introspection.Tests/OAuthIntrospectionMiddlewareTests.cs +++ b/test/Owin.Security.OAuth.Introspection.Tests/OAuthIntrospectionMiddlewareTests.cs @@ -239,6 +239,146 @@ public async Task ExpiredTicketCausesInvalidAuthentication() { Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task TokenSetToNullDuringParseAccessTokenEventCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.ClientId = "Fabrikam"; + options.ClientSecret = "B4657E03-D619"; + options.Events = new OAuthIntrospectionEvents { + OnParseAccessToken = context => { + context.Token = null; + return Task.FromResult(0); + } + }; + }); + + var client = server.HttpClient; + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TokenSetToInvalidTokenDuringParseAccessTokenEventCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.ClientId = "Fabrikam"; + options.ClientSecret = "B4657E03-D619"; + options.Events = new OAuthIntrospectionEvents { + OnParseAccessToken = context => { + context.Token = Tokens.Invalid; + return Task.FromResult(0); + } + }; + }); + + var client = server.HttpClient; + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TokenSetToValidTokenDuringParseAccessTokenEventAllowsSuccessfulAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.ClientId = "Fabrikam"; + options.ClientSecret = "B4657E03-D619"; + options.Events = new OAuthIntrospectionEvents { + OnParseAccessToken = context => { + context.Token = Tokens.Valid; + return Task.FromResult(0); + } + }; + }); + + var client = server.HttpClient; + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Invalid); + + // 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 TokenValidatedEventSettingIsValidAsTrueCausesSuccessfulAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.ClientId = "Fabrikam"; + options.ClientSecret = "B4657E03-D619"; + options.Events = new OAuthIntrospectionEvents { + OnValidateToken = context => { + context.IsValid = true; + return Task.FromResult(0); + } + }; + }); + + var client = server.HttpClient; + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // 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 TokenValidatedEventSettingIsValidAsFalseCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.ClientId = "Fabrikam"; + options.ClientSecret = "B4657E03-D619"; + options.Events = new OAuthIntrospectionEvents { + OnValidateToken = context => { + context.IsValid = false; + return Task.FromResult(0); + } + }; + }); + + var client = server.HttpClient; + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + private static class Tokens { + public const string Invalid = "invalid-token"; + public const string Valid = "valid-token"; + public const string SingleAudience = "valid-token-with-single-audience"; + public const string MultipleAudiences = "valid-token-with-multiple-audiences"; + public const string Expired = "expired-token"; + } + private static TestServer CreateResourceServer(Action configuration) { var server = CreateAuthorizationServer(); @@ -294,13 +434,13 @@ private static TestServer CreateAuthorizationServer() { var form = await context.Request.ReadFormAsync(); switch (form[OAuthIntrospectionConstants.Parameters.Token]) { - case "invalid-token": { + case Tokens.Invalid: { payload[OAuthIntrospectionConstants.Claims.Active] = false; break; } - case "expired-token": { + case Tokens.Expired: { payload[OAuthIntrospectionConstants.Claims.Active] = true; payload[OAuthIntrospectionConstants.Claims.Subject] = "Fabrikam"; @@ -310,14 +450,14 @@ private static TestServer CreateAuthorizationServer() { break; } - case "valid-token": { + case Tokens.Valid: { payload[OAuthIntrospectionConstants.Claims.Active] = true; payload[OAuthIntrospectionConstants.Claims.Subject] = "Fabrikam"; break; } - case "valid-token-with-single-audience": { + case Tokens.SingleAudience: { payload[OAuthIntrospectionConstants.Claims.Active] = true; payload[OAuthIntrospectionConstants.Claims.Subject] = "Fabrikam"; payload[OAuthIntrospectionConstants.Claims.Audience] = "http://www.google.com/"; @@ -325,7 +465,7 @@ private static TestServer CreateAuthorizationServer() { break; } - case "valid-token-with-multiple-audiences": { + case Tokens.MultipleAudiences: { payload[OAuthIntrospectionConstants.Claims.Active] = true; payload[OAuthIntrospectionConstants.Claims.Subject] = "Fabrikam"; payload[OAuthIntrospectionConstants.Claims.Audience] = JArray.FromObject(new[] { diff --git a/test/Owin.Security.OAuth.Validation.Tests/OAuthValidationMiddlewareTests.cs b/test/Owin.Security.OAuth.Validation.Tests/OAuthValidationMiddlewareTests.cs index 578b98e..e551bf7 100644 --- a/test/Owin.Security.OAuth.Validation.Tests/OAuthValidationMiddlewareTests.cs +++ b/test/Owin.Security.OAuth.Validation.Tests/OAuthValidationMiddlewareTests.cs @@ -27,7 +27,7 @@ public async Task InvalidTokenCausesInvalidAuthentication() { var client = server.HttpClient; var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Invalid); // Act var response = await client.SendAsync(request); @@ -44,7 +44,7 @@ public async Task ValidTokenAllowsSuccessfulAuthentication() { var client = server.HttpClient; var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-1"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); // Act var response = await client.SendAsync(request); @@ -64,7 +64,7 @@ public async Task MissingAudienceCausesInvalidAuthentication() { var client = server.HttpClient; var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-1"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); // Act var response = await client.SendAsync(request); @@ -83,7 +83,7 @@ public async Task InvalidAudienceCausesInvalidAuthentication() { var client = server.HttpClient; var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-2"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.SingleAudience); // Act var response = await client.SendAsync(request); @@ -102,7 +102,7 @@ public async Task ValidAudienceAllowsSuccessfulAuthentication() { var client = server.HttpClient; var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-3"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.MultipleAudiences); // Act var response = await client.SendAsync(request); @@ -123,7 +123,7 @@ public async Task AnyMatchingAudienceCausesSuccessfulAuthentication() { var client = server.HttpClient; var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-2"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.SingleAudience); // Act var response = await client.SendAsync(request); @@ -144,7 +144,7 @@ public async Task MultipleMatchingAudienceCausesSuccessfulAuthentication() { var client = server.HttpClient; var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-3"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.MultipleAudiences); // Act var response = await client.SendAsync(request); @@ -162,7 +162,7 @@ public async Task ExpiredTicketCausesInvalidAuthentication() { var client = server.HttpClient; var request = new HttpRequestMessage(HttpMethod.Get, "/"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-4"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Expired); // Act var response = await client.SendAsync(request); @@ -171,13 +171,143 @@ public async Task ExpiredTicketCausesInvalidAuthentication() { Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task TokenSetToNullDuringParseAccessTokenEventCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.Events = new OAuthValidationEvents { + OnParseAccessToken = context => { + context.Token = null; + return Task.FromResult(0); + } + }; + }); + + var client = server.HttpClient; + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TokenSetToInvalidValueDuringParseAccessTokenEventCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.Events = new OAuthValidationEvents { + OnParseAccessToken = context => { + context.Token = Tokens.Invalid; + return Task.FromResult(0); + } + }; + }); + + var client = server.HttpClient; + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TokenSetToValidValueDuringParseAccessTokenEventAllowsSuccessfulAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.Events = new OAuthValidationEvents { + OnParseAccessToken = context => { + context.Token = Tokens.Valid; + return Task.FromResult(0); + } + }; + }); + + var client = server.HttpClient; + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Invalid); + + // 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 TokenValidatedEventSettingIsValidAsTrueCausesSuccessfulAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.Events = new OAuthValidationEvents { + OnValidateToken = context => { + context.IsValid = true; + return Task.FromResult(0); + } + }; + }); + + var client = server.HttpClient; + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // 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 TokenValidatedEventSettingIsValidAsFalseCausesInvalidAuthentication() { + // Arrange + var server = CreateResourceServer(options => { + options.Events = new OAuthValidationEvents { + OnValidateToken = context => { + context.IsValid = false; + return Task.FromResult(0); + } + }; + }); + + var client = server.HttpClient; + + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Tokens.Valid); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + private static class Tokens { + public const string Invalid = "invalid-token"; + public const string Valid = "valid-token"; + public const string SingleAudience = "valid-token-with-single-audience"; + public const string MultipleAudiences = "valid-token-with-multiple-audiences"; + public const string Expired = "expired-token"; + } + private static TestServer CreateResourceServer(Action configuration = null) { var format = new Mock>(); - format.Setup(mock => mock.Unprotect(It.Is(token => token == "invalid-token"))) + format.Setup(mock => mock.Unprotect(It.Is(token => token == Tokens.Invalid))) .Returns(value: null); - format.Setup(mock => mock.Unprotect(It.Is(token => token == "token-1"))) + format.Setup(mock => mock.Unprotect(It.Is(token => token == Tokens.Valid))) .Returns(delegate { var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "Fabrikam")); @@ -185,7 +315,7 @@ private static TestServer CreateResourceServer(Action co return new AuthenticationTicket(identity, new AuthenticationProperties()); }); - format.Setup(mock => mock.Unprotect(It.Is(token => token == "token-2"))) + format.Setup(mock => mock.Unprotect(It.Is(token => token == Tokens.SingleAudience))) .Returns(delegate { var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "Fabrikam")); @@ -197,7 +327,7 @@ private static TestServer CreateResourceServer(Action co return new AuthenticationTicket(identity, properties); }); - format.Setup(mock => mock.Unprotect(It.Is(token => token == "token-3"))) + format.Setup(mock => mock.Unprotect(It.Is(token => token == Tokens.MultipleAudiences))) .Returns(delegate { var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "Fabrikam")); @@ -209,7 +339,7 @@ private static TestServer CreateResourceServer(Action co return new AuthenticationTicket(identity, properties); }); - format.Setup(mock => mock.Unprotect(It.Is(token => token == "token-4"))) + format.Setup(mock => mock.Unprotect(It.Is(token => token == Tokens.Expired))) .Returns(delegate { var identity = new ClaimsIdentity(OAuthValidationDefaults.AuthenticationScheme); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "Fabrikam"));