Browse Source

Revamp HTTP response extraction to support WWW-Authenticate and enforce Content-Type validation

pull/1444/head
Kévin Chalet 4 years ago
parent
commit
3ff021a3e4
  1. 2
      src/OpenIddict.Abstractions/OpenIddictConstants.cs
  2. 38
      src/OpenIddict.Abstractions/OpenIddictResources.resx
  3. 3
      src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConstants.cs
  4. 4
      src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Discovery.cs
  5. 2
      src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Exchange.cs
  6. 56
      src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Userinfo.cs
  7. 191
      src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs
  8. 8
      src/OpenIddict.Client/OpenIddictClientService.cs
  9. 18
      src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConstants.cs
  10. 4
      src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpHandlers.Discovery.cs
  11. 2
      src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpHandlers.Introspection.cs
  12. 191
      src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpHandlers.cs
  13. 30
      test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTestClient.cs

2
src/OpenIddict.Abstractions/OpenIddictConstants.cs

@ -446,8 +446,10 @@ public static class OpenIddictConstants
public static class Separators
{
public static readonly char[] Ampersand = { '&' };
public static readonly char[] Comma = { ',' };
public static readonly char[] Dash = { '-' };
public static readonly char[] Dot = { '.' };
public static readonly char[] DoubleQuote = { '"' };
public static readonly char[] EqualsSign = { '=' };
public static readonly char[] Hash = { '#' };
public static readonly char[] QuestionMark = { '?' };

38
src/OpenIddict.Abstractions/OpenIddictResources.resx

@ -1256,6 +1256,36 @@ Alternatively, you can disable the token storage feature by calling 'services.Ad
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0324" xml:space="preserve">
<value>An error occurred while preparing the userinfo request.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0325" xml:space="preserve">
<value>An error occurred while sending the userinfo request.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0326" xml:space="preserve">
<value>An error occurred while extracting the userinfo response.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0327" xml:space="preserve">
<value>An error occurred while handling the userinfo response.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0328" xml:space="preserve">
<value>A generic error was returned by the remote authorization server.</value>
</data>
<data name="ID0329" xml:space="preserve">
<value>An unsupported response was returned by the remote authorization server.</value>
</data>
<data name="ID2000" xml:space="preserve">
<value>The security token is missing.</value>
</data>
@ -2222,7 +2252,13 @@ This may indicate that the hashed entry is corrupted or malformed.</value>
<value>A network error occured while communicating with the remote HTTP server.</value>
</data>
<data name="ID6183" xml:space="preserve">
<value>An invalid JSON response was returned by the remote HTTP server: {Payload}.</value>
<value>An invalid JSON payload was returned by the remote HTTP server: {Payload}.</value>
</data>
<data name="ID6184" xml:space="preserve">
<value>A generic {StatusCode} response was returned by the remote HTTP server: {Payload}.</value>
</data>
<data name="ID6185" xml:space="preserve">
<value>An unsupported {StatusCode} response was returned by the remote HTTP server: {ContentType} {Payload}.</value>
</data>
<data name="ID8000" xml:space="preserve">
<value>https://documentation.openiddict.com/errors/{0}</value>

3
src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConstants.cs

@ -11,8 +11,9 @@ namespace OpenIddict.Client.SystemNetHttp;
/// </summary>
public static class OpenIddictClientSystemNetHttpConstants
{
public static class ContentTypes
public static class MediaTypes
{
public const string Json = "application/json";
public const string JsonWebToken = "application/jwt";
}
}

4
src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Discovery.cs

@ -25,6 +25,8 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
* Configuration response processing:
*/
ExtractJsonHttpResponse<ExtractConfigurationResponseContext>.Descriptor,
ExtractWwwAuthenticateHeader<ExtractConfigurationResponseContext>.Descriptor,
ValidateHttpResponse<ExtractConfigurationResponseContext>.Descriptor,
DisposeHttpResponse<ExtractConfigurationResponseContext>.Descriptor,
/*
@ -39,6 +41,8 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
* Configuration response processing:
*/
ExtractJsonHttpResponse<ExtractCryptographyResponseContext>.Descriptor,
ExtractWwwAuthenticateHeader<ExtractCryptographyResponseContext>.Descriptor,
ValidateHttpResponse<ExtractCryptographyResponseContext>.Descriptor,
DisposeHttpResponse<ExtractCryptographyResponseContext>.Descriptor);
}
}

2
src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Exchange.cs

@ -29,6 +29,8 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
* Token response processing:
*/
ExtractJsonHttpResponse<ExtractTokenResponseContext>.Descriptor,
ExtractWwwAuthenticateHeader<ExtractTokenResponseContext>.Descriptor,
ValidateHttpResponse<ExtractTokenResponseContext>.Descriptor,
DisposeHttpResponse<ExtractTokenResponseContext>.Descriptor);
/// <summary>

56
src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Userinfo.cs

@ -7,8 +7,6 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpConstants;
namespace OpenIddict.Client.SystemNetHttp;
@ -30,7 +28,10 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
/*
* Userinfo response processing:
*/
ExtractUserinfoHttpResponse.Descriptor,
ExtractUserinfoTokenHttpResponse.Descriptor,
ExtractJsonHttpResponse<ExtractUserinfoResponseContext>.Descriptor,
ExtractWwwAuthenticateHeader<ExtractUserinfoResponseContext>.Descriptor,
ValidateHttpResponse<ExtractUserinfoResponseContext>.Descriptor,
DisposeHttpResponse<ExtractUserinfoResponseContext>.Descriptor);
/// <summary>
@ -77,7 +78,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
/// <summary>
/// Contains the logic responsible for extracting the response from the userinfo response.
/// </summary>
public class ExtractUserinfoHttpResponse : IOpenIddictClientHandler<ExtractUserinfoResponseContext>
public class ExtractUserinfoTokenHttpResponse : IOpenIddictClientHandler<ExtractUserinfoResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
@ -85,8 +86,8 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ExtractUserinfoResponseContext>()
.AddFilter<RequireHttpMetadataAddress>()
.UseSingletonHandler<ExtractUserinfoHttpResponse>()
.SetOrder(DisposeHttpResponse<ExtractUserinfoResponseContext>.Descriptor.Order - 50_000)
.UseSingletonHandler<ExtractUserinfoTokenHttpResponse>()
.SetOrder(ExtractJsonHttpResponse<ExtractUserinfoResponseContext>.Descriptor.Order - 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
@ -116,54 +117,17 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
// - application/json responses containing a JSON object listing the user claims as-is.
// - application/jwt responses containing a signed/encrypted JSON Web Token containing the user claims.
//
// As such, this handler implements a selection routine to extract the userinfo token as-is
// if the media type is application/jwt and fall back to JSON in any other case.
// To support both types, this handler will try to extract the userinfo token as-is if the media type
// is application/jwt and will rely on other handlers in the pipeline to process regular JSON responses.
if (string.Equals(response.Content.Headers.ContentType?.MediaType,
ContentTypes.JsonWebToken, StringComparison.OrdinalIgnoreCase))
MediaTypes.JsonWebToken, StringComparison.OrdinalIgnoreCase))
{
context.Response = new OpenIddictResponse();
context.UserinfoToken = await response.Content.ReadAsStringAsync();
return;
}
try
{
try
{
// Note: ReadFromJsonAsync() automatically validates the content encoding and transparently
// transcodes the response stream if a non-UTF-8 response is returned by the remote server.
context.Response = await response.Content.ReadFromJsonAsync<OpenIddictResponse>();
}
// Initial versions of System.Net.Http.Json were known to eagerly validate the media type returned
// as part of the HTTP Content-Type header and throw a NotSupportedException. If such an exception
// is caught, try to extract the response using the less efficient string-based deserialization,
// that will also take care of handling non-UTF-8 encodings but won't validate the media type.
catch (NotSupportedException)
{
context.Response = JsonSerializer.Deserialize<OpenIddictResponse>(
await response.Content.ReadAsStringAsync());
}
}
// If an exception is thrown at this stage, this likely means the returned response was not a valid
// JSON response or was not correctly formatted as a JSON object. This typically happens when
// a server error occurs and a default error page is returned by the remote HTTP server.
// In this case, log the error details and return a generic error to stop processing the event.
catch (Exception exception)
{
context.Logger.LogError(exception, SR.GetResourceString(SR.ID6183),
await response.Content.ReadAsStringAsync());
context.Reject(
error: Errors.ServerError,
description: SR.GetResourceString(SR.ID2137),
uri: SR.FormatID8000(SR.ID2137));
return;
}
}
}
}

191
src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs

@ -10,8 +10,9 @@ using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpConstants;
namespace OpenIddict.Client.SystemNetHttp;
@ -139,7 +140,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
var request = context.Transaction.GetHttpRequestMessage() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0173));
if (request.RequestUri is null || context.Transaction.Request.Count == 0)
if (request.RequestUri is null || context.Transaction.Request.Count is 0)
{
return default;
}
@ -333,7 +334,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
= OpenIddictClientHandlerDescriptor.CreateBuilder<TContext>()
.AddFilter<RequireHttpMetadataAddress>()
.UseSingletonHandler<ExtractJsonHttpResponse<TContext>>()
.SetOrder(DisposeHttpResponse<TContext>.Descriptor.Order - 50_000)
.SetOrder(ExtractWwwAuthenticateHeader<TContext>.Descriptor.Order - 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
@ -356,33 +357,24 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
var response = context.Transaction.GetHttpResponseMessage() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0173));
// The status code is deliberately not validated to ensure even errored responses
// (typically in the 4xx range) can be deserialized and handled by the event handlers.
// If the returned Content-Type doesn't indicate the response has a JSON payload,
// ignore it and allow other handlers in the pipeline to process the HTTP response.
if (!string.Equals(response.Content.Headers.ContentType?.MediaType,
MediaTypes.Json, StringComparison.OrdinalIgnoreCase))
{
return;
}
try
{
try
{
// Note: ReadFromJsonAsync() automatically validates the content encoding and transparently
// transcodes the response stream if a non-UTF-8 response is returned by the remote server.
context.Transaction.Response = await response.Content.ReadFromJsonAsync<OpenIddictResponse>();
}
// Initial versions of System.Net.Http.Json were known to eagerly validate the media type returned
// as part of the HTTP Content-Type header and throw a NotSupportedException. If such an exception
// is caught, try to extract the response using the less efficient string-based deserialization,
// that will also take care of handling non-UTF-8 encodings but won't validate the media type.
catch (NotSupportedException)
{
context.Transaction.Response = JsonSerializer.Deserialize<OpenIddictResponse>(
await response.Content.ReadAsStringAsync());
}
// Note: ReadFromJsonAsync() automatically validates the content encoding and transparently
// transcodes the response stream if a non-UTF-8 response is returned by the remote server.
context.Transaction.Response = await response.Content.ReadFromJsonAsync<OpenIddictResponse>();
}
// If an exception is thrown at this stage, this likely means the returned response was not a valid
// JSON response or was not correctly formatted as a JSON object. This typically happens when
// a server error occurs and a default error page is returned by the remote HTTP server.
// In this case, log the error details and return a generic error to stop processing the event.
// a server error occurs while the JSON response is being generated and returned to the client.
catch (Exception exception)
{
context.Logger.LogError(exception, SR.GetResourceString(SR.ID6183),
@ -398,6 +390,159 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
}
}
/// <summary>
/// Contains the logic responsible for extracting errors from WWW-Authenticate headers.
/// </summary>
public class ExtractWwwAuthenticateHeader<TContext> : IOpenIddictClientHandler<TContext> where TContext : BaseExternalContext
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<TContext>()
.AddFilter<RequireHttpMetadataAddress>()
.UseSingletonHandler<ExtractWwwAuthenticateHeader<TContext>>()
.SetOrder(ValidateHttpResponse<TContext>.Descriptor.Order - 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(TContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// Don't overwrite the response if one was already provided.
if (context.Transaction.Response is not null)
{
return default;
}
// This handler only applies to System.Net.Http requests. If the HTTP response cannot be resolved,
// this may indicate that the request was incorrectly processed by another client stack.
var response = context.Transaction.GetHttpResponseMessage() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0173));
if (response.Headers.WwwAuthenticate.Count is 0)
{
return default;
}
var parameters = new Dictionary<string, StringValues>(response.Headers.WwwAuthenticate.Count);
foreach (var header in response.Headers.WwwAuthenticate)
{
if (string.IsNullOrEmpty(header.Parameter))
{
continue;
}
// Note: while initially not allowed by the core OAuth 2.0 specification, multiple
// parameters with the same name are used by derived drafts like the OAuth 2.0
// token exchange specification. For consistency, multiple parameters with the
// same name are also supported when returned as part of WWW-Authentication headers.
foreach (var parameter in header.Parameter.Split(Separators.Comma, StringSplitOptions.RemoveEmptyEntries))
{
var values = parameter.Split(Separators.EqualsSign, StringSplitOptions.RemoveEmptyEntries);
if (values.Length is not 2)
{
continue;
}
var (name, value) = (
values[0]?.Trim(Separators.Space[0]),
values[1]?.Trim(Separators.Space[0], Separators.DoubleQuote[0]));
if (string.IsNullOrEmpty(name))
{
continue;
}
parameters[name] = parameters.ContainsKey(name) ?
StringValues.Concat(parameters[name], value?.Replace("\\\"", "\"")) :
new StringValues(value?.Replace("\\\"", "\""));
}
}
context.Transaction.Response = new OpenIddictResponse(parameters);
return default;
}
}
/// <summary>
/// Contains the logic responsible for extracting errors from WWW-Authenticate headers.
/// </summary>
public class ValidateHttpResponse<TContext> : IOpenIddictClientHandler<TContext> where TContext : BaseExternalContext
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<TContext>()
.AddFilter<RequireHttpMetadataAddress>()
.UseSingletonHandler<ValidateHttpResponse<TContext>>()
.SetOrder(DisposeHttpResponse<TContext>.Descriptor.Order - 50_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(TContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// This handler only applies to System.Net.Http requests. If the HTTP response cannot be resolved,
// this may indicate that the request was incorrectly processed by another client stack.
var response = context.Transaction.GetHttpResponseMessage() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0173));
// At this stage, return a generic error based on the HTTP status code if no
// error could be extracted from the payload or from the WWW-Authenticate header.
if (!response.IsSuccessStatusCode && string.IsNullOrEmpty(context.Transaction.Response?.Error))
{
context.Logger.LogError(SR.GetResourceString(SR.ID6184), response.StatusCode,
await response.Content.ReadAsStringAsync());
context.Reject(
error: (int) response.StatusCode switch
{
400 => Errors.InvalidRequest,
401 => Errors.InvalidToken,
403 => Errors.InsufficientAccess,
429 => Errors.SlowDown,
500 => Errors.ServerError,
503 => Errors.TemporarilyUnavailable,
_ => Errors.ServerError
},
description: SR.GetResourceString(SR.ID0328),
uri: SR.FormatID8000(SR.ID0328));
return;
}
// If no other event handler was able to extract the response payload at this point
// (e.g because an unsupported content type was returned), return a generic error.
if (context.Transaction.Response is null)
{
context.Logger.LogError(SR.GetResourceString(SR.ID6185), response.StatusCode,
response.Content.Headers.ContentType, await response.Content.ReadAsStringAsync());
context.Reject(
error: Errors.ServerError,
description: SR.GetResourceString(SR.ID0329),
uri: SR.FormatID8000(SR.ID0329));
return;
}
}
}
/// <summary>
/// Contains the logic responsible for disposing of the HTTP response message.
/// </summary>

8
src/OpenIddict.Client/OpenIddictClientService.cs

@ -695,7 +695,7 @@ public class OpenIddictClientService
if (context.IsRejected)
{
throw new OpenIddictExceptions.GenericException(
SR.FormatID0152(context.Error, context.ErrorDescription, context.ErrorUri),
SR.FormatID0324(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
@ -718,7 +718,7 @@ public class OpenIddictClientService
if (context.IsRejected)
{
throw new OpenIddictExceptions.GenericException(
SR.FormatID0153(context.Error, context.ErrorDescription, context.ErrorUri),
SR.FormatID0325(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
@ -741,7 +741,7 @@ public class OpenIddictClientService
if (context.IsRejected)
{
throw new OpenIddictExceptions.GenericException(
SR.FormatID0154(context.Error, context.ErrorDescription, context.ErrorUri),
SR.FormatID0326(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
@ -768,7 +768,7 @@ public class OpenIddictClientService
if (context.IsRejected)
{
throw new OpenIddictExceptions.GenericException(
SR.FormatID0155(context.Error, context.ErrorDescription, context.ErrorUri),
SR.FormatID0327(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}

18
src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConstants.cs

@ -0,0 +1,18 @@
/*
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
* See https://github.com/openiddict/openiddict-core for more information concerning
* the license and the contributors participating to this project.
*/
namespace OpenIddict.Validation.SystemNetHttp;
/// <summary>
/// Exposes common constants used by the OpenIddict System.Net.Http integration.
/// </summary>
public static class OpenIddictValidationSystemNetHttpConstants
{
public static class MediaTypes
{
public const string Json = "application/json";
}
}

4
src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpHandlers.Discovery.cs

@ -25,6 +25,8 @@ public static partial class OpenIddictValidationSystemNetHttpHandlers
* Configuration response processing:
*/
ExtractJsonHttpResponse<ExtractConfigurationResponseContext>.Descriptor,
ExtractWwwAuthenticateHeader<ExtractConfigurationResponseContext>.Descriptor,
ValidateHttpResponse<ExtractConfigurationResponseContext>.Descriptor,
DisposeHttpResponse<ExtractConfigurationResponseContext>.Descriptor,
/*
@ -39,6 +41,8 @@ public static partial class OpenIddictValidationSystemNetHttpHandlers
* Configuration response processing:
*/
ExtractJsonHttpResponse<ExtractCryptographyResponseContext>.Descriptor,
ExtractWwwAuthenticateHeader<ExtractCryptographyResponseContext>.Descriptor,
ValidateHttpResponse<ExtractCryptographyResponseContext>.Descriptor,
DisposeHttpResponse<ExtractCryptographyResponseContext>.Descriptor);
}
}

2
src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpHandlers.Introspection.cs

@ -29,6 +29,8 @@ public static partial class OpenIddictValidationSystemNetHttpHandlers
* Introspection response processing:
*/
ExtractJsonHttpResponse<ExtractIntrospectionResponseContext>.Descriptor,
ExtractWwwAuthenticateHeader<ExtractIntrospectionResponseContext>.Descriptor,
ValidateHttpResponse<ExtractIntrospectionResponseContext>.Descriptor,
DisposeHttpResponse<ExtractIntrospectionResponseContext>.Descriptor);
/// <summary>

191
src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpHandlers.cs

@ -10,8 +10,9 @@ using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using static OpenIddict.Validation.SystemNetHttp.OpenIddictValidationSystemNetHttpConstants;
namespace OpenIddict.Validation.SystemNetHttp;
@ -138,7 +139,7 @@ public static partial class OpenIddictValidationSystemNetHttpHandlers
var request = context.Transaction.GetHttpRequestMessage() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0173));
if (request.RequestUri is null || context.Transaction.Request.Count == 0)
if (request.RequestUri is null || context.Transaction.Request.Count is 0)
{
return default;
}
@ -332,7 +333,7 @@ public static partial class OpenIddictValidationSystemNetHttpHandlers
= OpenIddictValidationHandlerDescriptor.CreateBuilder<TContext>()
.AddFilter<RequireHttpMetadataAddress>()
.UseSingletonHandler<ExtractJsonHttpResponse<TContext>>()
.SetOrder(DisposeHttpResponse<TContext>.Descriptor.Order - 50_000)
.SetOrder(ExtractWwwAuthenticateHeader<TContext>.Descriptor.Order - 1_000)
.SetType(OpenIddictValidationHandlerType.BuiltIn)
.Build();
@ -355,33 +356,24 @@ public static partial class OpenIddictValidationSystemNetHttpHandlers
var response = context.Transaction.GetHttpResponseMessage() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0173));
// The status code is deliberately not validated to ensure even errored responses
// (typically in the 4xx range) can be deserialized and handled by the event handlers.
// If the returned Content-Type doesn't indicate the response has a JSON payload,
// ignore it and allow other handlers in the pipeline to process the HTTP response.
if (!string.Equals(response.Content.Headers.ContentType?.MediaType,
MediaTypes.Json, StringComparison.OrdinalIgnoreCase))
{
return;
}
try
{
try
{
// Note: ReadFromJsonAsync() automatically validates the content encoding and transparently
// transcodes the response stream if a non-UTF-8 response is returned by the remote server.
context.Transaction.Response = await response.Content.ReadFromJsonAsync<OpenIddictResponse>();
}
// Initial versions of System.Net.Http.Json were known to eagerly validate the media type returned
// as part of the HTTP Content-Type header and throw a NotSupportedException. If such an exception
// is caught, try to extract the response using the less efficient string-based deserialization,
// that will also take care of handling non-UTF-8 encodings but won't validate the media type.
catch (NotSupportedException)
{
context.Transaction.Response = JsonSerializer.Deserialize<OpenIddictResponse>(
await response.Content.ReadAsStringAsync());
}
// Note: ReadFromJsonAsync() automatically validates the content encoding and transparently
// transcodes the response stream if a non-UTF-8 response is returned by the remote server.
context.Transaction.Response = await response.Content.ReadFromJsonAsync<OpenIddictResponse>();
}
// If an exception is thrown at this stage, this likely means the returned response was not a valid
// JSON response or was not correctly formatted as a JSON object. This typically happens when
// a server error occurs and a default error page is returned by the remote HTTP server.
// In this case, log the error details and return a generic error to stop processing the event.
// a server error occurs while the JSON response is being generated and returned to the client.
catch (Exception exception)
{
context.Logger.LogError(exception, SR.GetResourceString(SR.ID6183),
@ -397,6 +389,159 @@ public static partial class OpenIddictValidationSystemNetHttpHandlers
}
}
/// <summary>
/// Contains the logic responsible for extracting errors from WWW-Authenticate headers.
/// </summary>
public class ExtractWwwAuthenticateHeader<TContext> : IOpenIddictValidationHandler<TContext> where TContext : BaseExternalContext
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictValidationHandlerDescriptor Descriptor { get; }
= OpenIddictValidationHandlerDescriptor.CreateBuilder<TContext>()
.AddFilter<RequireHttpMetadataAddress>()
.UseSingletonHandler<ExtractWwwAuthenticateHeader<TContext>>()
.SetOrder(ValidateHttpResponse<TContext>.Descriptor.Order - 1_000)
.SetType(OpenIddictValidationHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(TContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// Don't overwrite the response if one was already provided.
if (context.Transaction.Response is not null)
{
return default;
}
// This handler only applies to System.Net.Http requests. If the HTTP response cannot be resolved,
// this may indicate that the request was incorrectly processed by another client stack.
var response = context.Transaction.GetHttpResponseMessage() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0173));
if (response.Headers.WwwAuthenticate.Count is 0)
{
return default;
}
var parameters = new Dictionary<string, StringValues>(response.Headers.WwwAuthenticate.Count);
foreach (var header in response.Headers.WwwAuthenticate)
{
if (string.IsNullOrEmpty(header.Parameter))
{
continue;
}
// Note: while initially not allowed by the core OAuth 2.0 specification, multiple
// parameters with the same name are used by derived drafts like the OAuth 2.0
// token exchange specification. For consistency, multiple parameters with the
// same name are also supported when returned as part of WWW-Authentication headers.
foreach (var parameter in header.Parameter.Split(Separators.Comma, StringSplitOptions.RemoveEmptyEntries))
{
var values = parameter.Split(Separators.EqualsSign, StringSplitOptions.RemoveEmptyEntries);
if (values.Length is not 2)
{
continue;
}
var (name, value) = (
values[0]?.Trim(Separators.Space[0]),
values[1]?.Trim(Separators.Space[0], Separators.DoubleQuote[0]));
if (string.IsNullOrEmpty(name))
{
continue;
}
parameters[name] = parameters.ContainsKey(name) ?
StringValues.Concat(parameters[name], value?.Replace("\\\"", "\"")) :
new StringValues(value?.Replace("\\\"", "\""));
}
}
context.Transaction.Response = new OpenIddictResponse(parameters);
return default;
}
}
/// <summary>
/// Contains the logic responsible for extracting errors from WWW-Authenticate headers.
/// </summary>
public class ValidateHttpResponse<TContext> : IOpenIddictValidationHandler<TContext> where TContext : BaseExternalContext
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictValidationHandlerDescriptor Descriptor { get; }
= OpenIddictValidationHandlerDescriptor.CreateBuilder<TContext>()
.AddFilter<RequireHttpMetadataAddress>()
.UseSingletonHandler<ValidateHttpResponse<TContext>>()
.SetOrder(DisposeHttpResponse<TContext>.Descriptor.Order - 50_000)
.SetType(OpenIddictValidationHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(TContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// This handler only applies to System.Net.Http requests. If the HTTP response cannot be resolved,
// this may indicate that the request was incorrectly processed by another client stack.
var response = context.Transaction.GetHttpResponseMessage() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0173));
// At this stage, return a generic error based on the HTTP status code if no
// error could be extracted from the payload or from the WWW-Authenticate header.
if (!response.IsSuccessStatusCode && string.IsNullOrEmpty(context.Transaction.Response?.Error))
{
context.Logger.LogError(SR.GetResourceString(SR.ID6184), response.StatusCode,
await response.Content.ReadAsStringAsync());
context.Reject(
error: (int) response.StatusCode switch
{
400 => Errors.InvalidRequest,
401 => Errors.InvalidToken,
403 => Errors.InsufficientAccess,
429 => Errors.SlowDown,
500 => Errors.ServerError,
503 => Errors.TemporarilyUnavailable,
_ => Errors.ServerError
},
description: SR.GetResourceString(SR.ID0328),
uri: SR.FormatID8000(SR.ID0328));
return;
}
// If no other event handler was able to extract the response payload at this point
// (e.g because an unsupported content type was returned), return a generic error.
if (context.Transaction.Response is null)
{
context.Logger.LogError(SR.GetResourceString(SR.ID6185), response.StatusCode,
response.Content.Headers.ContentType, await response.Content.ReadAsStringAsync());
context.Reject(
error: Errors.ServerError,
description: SR.GetResourceString(SR.ID0329),
uri: SR.FormatID8000(SR.ID0329));
return;
}
}
}
/// <summary>
/// Contains the logic responsible for disposing of the HTTP response message.
/// </summary>

30
test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTestClient.cs

@ -307,9 +307,9 @@ public class OpenIddictServerIntegrationTestClient : IAsyncDisposable
private async Task<OpenIddictResponse> GetResponseAsync(HttpResponseMessage message)
{
if (message.Headers.WwwAuthenticate.Count != 0)
if (message.Headers.WwwAuthenticate.Count is not 0)
{
var response = new OpenIddictResponse();
var parameters = new Dictionary<string, StringValues>(message.Headers.WwwAuthenticate.Count);
foreach (var header in message.Headers.WwwAuthenticate)
{
@ -318,31 +318,35 @@ public class OpenIddictServerIntegrationTestClient : IAsyncDisposable
continue;
}
foreach (var parameter in header.Parameter.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
// Note: while initially not allowed by the core OAuth 2.0 specification, multiple
// parameters with the same name are used by derived drafts like the OAuth 2.0
// token exchange specification. For consistency, multiple parameters with the
// same name are also supported when returned as part of WWW-Authentication headers.
foreach (var parameter in header.Parameter.Split(Separators.Comma, StringSplitOptions.RemoveEmptyEntries))
{
var values = parameter.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries);
if (values.Length != 2)
var values = parameter.Split(Separators.EqualsSign, StringSplitOptions.RemoveEmptyEntries);
if (values.Length is not 2)
{
continue;
}
var name = values[0]?.Trim(' ', '"');
if (string.IsNullOrEmpty(name))
{
continue;
}
var (name, value) = (
values[0]?.Trim(Separators.Space[0]),
values[1]?.Trim(Separators.Space[0], Separators.DoubleQuote[0]));
var value = values[1]?.Trim(' ', '"');
if (string.IsNullOrEmpty(name))
{
continue;
}
response.SetParameter(name, value);
parameters[name] = parameters.ContainsKey(name) ?
StringValues.Concat(parameters[name], value?.Replace("\\\"", "\"")) :
new StringValues(value?.Replace("\\\"", "\""));
}
}
return response;
return new OpenIddictResponse(parameters);
}
else if (message.Headers.Location is not null)

Loading…
Cancel
Save