Browse Source

Implement client_id support for logout requests and native id_token_hint validation for both authorization and logout requests

pull/1505/head
Kévin Chalet 4 years ago
parent
commit
4ef3c51a01
  1. 15
      src/OpenIddict.Abstractions/OpenIddictResources.resx
  2. 12
      src/OpenIddict.Server/OpenIddictServerEvents.Authentication.cs
  3. 19
      src/OpenIddict.Server/OpenIddictServerEvents.Session.cs
  4. 155
      src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs
  5. 388
      src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs
  6. 15
      src/OpenIddict.Server/OpenIddictServerHandlers.cs
  7. 152
      test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs
  8. 280
      test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs

15
src/OpenIddict.Abstractions/OpenIddictResources.resx

@ -1733,6 +1733,12 @@ Alternatively, you can disable the token storage feature by calling 'services.Ad
<data name="ID2139" xml:space="preserve">
<value>The specified state token has already been redeemed.</value>
</data>
<data name="ID2140" xml:space="preserve">
<value>This client application is not allowed to use the logout endpoint.</value>
</data>
<data name="ID2141" xml:space="preserve">
<value>The client application is not allowed to use the specified identity token hint.</value>
</data>
<data name="ID4000" xml:space="preserve">
<value>The '{0}' parameter shouldn't be null or empty at this point.</value>
</data>
@ -2329,6 +2335,15 @@ This may indicate that the hashed entry is corrupted or malformed.</value>
<data name="ID6195" xml:space="preserve">
<value>The userinfo response returned by {Address} was successfully extracted: {Response}.</value>
</data>
<data name="ID6196" xml:space="preserve">
<value>The logout request was rejected because the client application was not found: '{ClientId}'.</value>
</data>
<data name="ID6197" xml:space="preserve">
<value>The authorization request was rejected because the identity token used as a hint was issued to a different client.</value>
</data>
<data name="ID6198" xml:space="preserve">
<value>The logout request was rejected because the identity token used as a hint was issued to a different client.</value>
</data>
<data name="ID8000" xml:space="preserve">
<value>https://documentation.openiddict.com/errors/{0}</value>
</data>

12
src/OpenIddict.Server/OpenIddictServerEvents.Authentication.cs

@ -69,6 +69,12 @@ public static partial class OpenIddictServerEvents
/// </summary>
public string? RedirectUri { get; private set; }
/// <summary>
/// Gets or sets the security principal extracted
/// from the identity token hint, if applicable.
/// </summary>
public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; }
/// <summary>
/// Populates the <see cref="RedirectUri"/> property with the specified redirect_uri.
/// </summary>
@ -115,6 +121,12 @@ public static partial class OpenIddictServerEvents
set => Transaction.Request = value;
}
/// <summary>
/// Gets or sets the security principal extracted
/// from the identity token hint, if applicable.
/// </summary>
public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; }
/// <summary>
/// Gets the additional parameters returned to the client application.
/// </summary>

19
src/OpenIddict.Server/OpenIddictServerEvents.Session.cs

@ -4,6 +4,8 @@
* the license and the contributors participating to this project.
*/
using System.Security.Claims;
namespace OpenIddict.Server;
public static partial class OpenIddictServerEvents
@ -55,11 +57,22 @@ public static partial class OpenIddictServerEvents
set => Transaction.Request = value;
}
/// <summary>
/// Gets the client_id specified by the client application, if available.
/// </summary>
public string? ClientId => Request?.ClientId;
/// <summary>
/// Gets the post_logout_redirect_uri specified by the client application.
/// </summary>
public string? PostLogoutRedirectUri { get; private set; }
/// <summary>
/// Gets or sets the security principal extracted
/// from the identity token hint, if applicable.
/// </summary>
public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; }
/// <summary>
/// Populates the <see cref="PostLogoutRedirectUri"/> property with the specified redirect_uri.
/// </summary>
@ -106,6 +119,12 @@ public static partial class OpenIddictServerEvents
set => Transaction.Request = value;
}
/// <summary>
/// Gets or sets the security principal extracted
/// from the identity token hint, if applicable.
/// </summary>
public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; }
/// <summary>
/// Gets a boolean indicating whether a sign-out should be triggered.
/// </summary>

155
src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs

@ -51,6 +51,13 @@ public static partial class OpenIddictServerHandlers
ValidateResponseTypePermissions.Descriptor,
ValidateScopePermissions.Descriptor,
ValidateProofKeyForCodeExchangeRequirement.Descriptor,
ValidateToken.Descriptor,
ValidateAuthorizedParty.Descriptor,
/*
* Authorization request handling:
*/
AttachPrincipal.Descriptor,
/*
* Authorization response processing:
@ -1616,6 +1623,152 @@ public static partial class OpenIddictServerHandlers
}
}
/// <summary>
/// Contains the logic responsible for rejecting authorization
/// requests that don't specify a valid id_token_hint.
/// </summary>
public class ValidateToken : IOpenIddictServerHandler<ValidateAuthorizationRequestContext>
{
private readonly IOpenIddictServerDispatcher _dispatcher;
public ValidateToken(IOpenIddictServerDispatcher dispatcher)
=> _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateAuthorizationRequestContext>()
.UseScopedHandler<ValidateToken>()
.SetOrder(ValidateProofKeyForCodeExchangeRequirement.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ValidateAuthorizationRequestContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var notification = new ProcessAuthenticationContext(context.Transaction);
await _dispatcher.DispatchAsync(notification);
// Store the context object in the transaction so it can be later retrieved by handlers
// that want to access the authentication result without triggering a new authentication flow.
context.Transaction.SetProperty(typeof(ProcessAuthenticationContext).FullName!, notification);
if (notification.IsRequestHandled)
{
context.HandleRequest();
return;
}
else if (notification.IsRequestSkipped)
{
context.SkipRequest();
return;
}
else if (notification.IsRejected)
{
context.Reject(
error: notification.Error ?? Errors.InvalidRequest,
description: notification.ErrorDescription,
uri: notification.ErrorUri);
return;
}
// Attach the security principal extracted from the token to the validation context.
context.IdentityTokenHintPrincipal = notification.IdentityTokenPrincipal;
}
}
/// <summary>
/// Contains the logic responsible for rejecting authorization requests that specify an identity
/// token hint that cannot be used by the client application sending the authorization request.
/// </summary>
public class ValidateAuthorizedParty : IOpenIddictServerHandler<ValidateAuthorizationRequestContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateAuthorizationRequestContext>()
.UseSingletonHandler<ValidateAuthorizedParty>()
.SetOrder(ValidateToken.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ValidateAuthorizationRequestContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.IdentityTokenHintPrincipal is null)
{
return default;
}
Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));
// When an identity token hint is specified, the client_id (when present) must be
// listed either as a valid audience or as a presenter to be considered valid.
if (!context.IdentityTokenHintPrincipal.HasAudience(context.ClientId) &&
!context.IdentityTokenHintPrincipal.HasPresenter(context.ClientId))
{
context.Logger.LogWarning(SR.GetResourceString(SR.ID6197));
context.Reject(
error: Errors.InvalidRequest,
description: SR.GetResourceString(SR.ID2141),
uri: SR.FormatID8000(SR.ID2141));
return default;
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for attaching the principal
/// extracted from the identity token hint to the event context.
/// </summary>
public class AttachPrincipal : IOpenIddictServerHandler<HandleAuthorizationRequestContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<HandleAuthorizationRequestContext>()
.UseSingletonHandler<AttachPrincipal>()
.SetOrder(int.MinValue + 100_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleAuthorizationRequestContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var notification = context.Transaction.GetProperty<ValidateAuthorizationRequestContext>(
typeof(ValidateAuthorizationRequestContext).FullName!) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0007));
context.IdentityTokenHintPrincipal ??= notification.IdentityTokenHintPrincipal;
return default;
}
}
/// <summary>
/// Contains the logic responsible for inferring the redirect URL
/// used to send the response back to the client application.
@ -1650,7 +1803,7 @@ public static partial class OpenIddictServerHandlers
// Note: at this stage, the validated redirect URI property may be null (e.g if an error
// is returned from the ExtractAuthorizationRequest/ValidateAuthorizationRequest events).
if (notification is not null && !notification.IsRejected)
if (notification is { IsRejected: false })
{
context.RedirectUri = notification.RedirectUri;
}

388
src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs

@ -6,7 +6,11 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using static OpenIddict.Server.OpenIddictServerHandlers.Authentication;
namespace OpenIddict.Server;
@ -29,7 +33,16 @@ public static partial class OpenIddictServerHandlers
* Logout request validation:
*/
ValidatePostLogoutRedirectUriParameter.Descriptor,
ValidateClientId.Descriptor,
ValidateClientPostLogoutRedirectUri.Descriptor,
ValidateEndpointPermissions.Descriptor,
ValidateToken.Descriptor,
ValidateAuthorizedParty.Descriptor,
/*
* Logout request handling:
*/
AttachPrincipal.Descriptor,
/*
* Logout response processing:
@ -362,7 +375,64 @@ public static partial class OpenIddictServerHandlers
}
/// <summary>
/// Contains the logic responsible for rejecting logout requests that use an invalid redirect_uri.
/// Contains the logic responsible for rejecting logout requests
/// that use an invalid client_id, if one was explicitly specified.
/// Note: this handler is not used when the degraded mode is enabled.
/// </summary>
public class ValidateClientId : IOpenIddictServerHandler<ValidateLogoutRequestContext>
{
private readonly IOpenIddictApplicationManager _applicationManager;
public ValidateClientId() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016));
public ValidateClientId(IOpenIddictApplicationManager applicationManager)
=> _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateLogoutRequestContext>()
.AddFilter<RequireDegradedModeDisabled>()
.UseScopedHandler<ValidateClientId>()
.SetOrder(ValidatePostLogoutRedirectUriParameter.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ValidateLogoutRequestContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// Note: support for the client_id parameter was only added in the second draft of the
// https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout specification
// and is optional. As such, the client identifier is only validated if it was specified.
if (string.IsNullOrEmpty(context.ClientId))
{
return;
}
var application = await _applicationManager.FindByClientIdAsync(context.ClientId);
if (application is null)
{
context.Logger.LogInformation(SR.GetResourceString(SR.ID6196), context.ClientId);
context.Reject(
error: Errors.InvalidRequest,
description: SR.FormatID2052(Parameters.ClientId),
uri: SR.FormatID8000(SR.ID2052));
return;
}
}
}
/// <summary>
/// Contains the logic responsible for rejecting logout
/// requests that use an invalid post_logout_redirect_uri.
/// Note: this handler is not used when the degraded mode is enabled.
/// </summary>
public class ValidateClientPostLogoutRedirectUri : IOpenIddictServerHandler<ValidateLogoutRequestContext>
@ -382,7 +452,7 @@ public static partial class OpenIddictServerHandlers
.AddFilter<RequireDegradedModeDisabled>()
.AddFilter<RequirePostLogoutRedirectUriParameter>()
.UseScopedHandler<ValidateClientPostLogoutRedirectUri>()
.SetOrder(ValidatePostLogoutRedirectUriParameter.Descriptor.Order + 1_000)
.SetOrder(ValidateClientId.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
@ -396,6 +466,43 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(!string.IsNullOrEmpty(context.PostLogoutRedirectUri), SR.FormatID4000(Parameters.PostLogoutRedirectUri));
// Note: support for the client_id parameter was only added in the second draft of the
// https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout specification
// and is optional. To support all client stacks, this handler supports two scenarios:
//
// * The client_id parameter is supported by the client and was explicitly sent:
// in this case, the post_logout_redirect_uris allowed for this client application
// are retrieved from the database: if one of them matches the specified address,
// the request is considered valid. Otherwise, it's automatically rejected.
//
// * The client_id parameter is not supported by the client or was not explicitly sent:
// in this case, all the applications matching the specified post_logout_redirect_uri
// are retrieved from the database: if one of them has been granted the correct endpoint
// permission, the request is considered valid. Otherwise, it's automatically rejected.
//
// Since the first method is more efficient, it's always used if a client_is was specified.
if (!string.IsNullOrEmpty(context.ClientId))
{
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));
var addresses = await _applicationManager.GetPostLogoutRedirectUrisAsync(application);
if (!addresses.Contains(context.PostLogoutRedirectUri, StringComparer.Ordinal))
{
context.Logger.LogInformation(SR.GetResourceString(SR.ID6128), context.PostLogoutRedirectUri);
context.Reject(
error: Errors.InvalidRequest,
description: SR.FormatID2052(Parameters.PostLogoutRedirectUri),
uri: SR.FormatID8000(SR.ID2052));
return;
}
return;
}
if (!await ValidatePostLogoutRedirectUriAsync(context.PostLogoutRedirectUri))
{
context.Logger.LogInformation(SR.GetResourceString(SR.ID6128), context.PostLogoutRedirectUri);
@ -427,6 +534,281 @@ public static partial class OpenIddictServerHandlers
}
}
/// <summary>
/// Contains the logic responsible for rejecting logout requests made by unauthorized applications.
/// Note: this handler is not used when the degraded mode is enabled or when endpoint permissions are disabled.
/// </summary>
public class ValidateEndpointPermissions : IOpenIddictServerHandler<ValidateLogoutRequestContext>
{
private readonly IOpenIddictApplicationManager _applicationManager;
public ValidateEndpointPermissions() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016));
public ValidateEndpointPermissions(IOpenIddictApplicationManager applicationManager)
=> _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateLogoutRequestContext>()
.AddFilter<RequireEndpointPermissionsEnabled>()
.AddFilter<RequireDegradedModeDisabled>()
.UseScopedHandler<ValidateEndpointPermissions>()
.SetOrder(ValidateClientPostLogoutRedirectUri.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ValidateLogoutRequestContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// Note: support for the client_id parameter was only added in the second draft of the
// https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout specification
// and is optional. As such, the client permissions are only validated if it was specified.
// If only post_logout_redirect_uri was specified, client permissions are expected to be
// enforced by the ValidateClientPostLogoutRedirectUri handler when finding matching clients.
if (string.IsNullOrEmpty(context.ClientId))
{
return;
}
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));
// Reject the request if the application is not allowed to use the logout endpoint.
if (!await _applicationManager.HasPermissionAsync(application, Permissions.Endpoints.Logout))
{
context.Logger.LogInformation(SR.GetResourceString(SR.ID6048), context.ClientId);
context.Reject(
error: Errors.UnauthorizedClient,
description: SR.GetResourceString(SR.ID2140),
uri: SR.FormatID8000(SR.ID2140));
return;
}
}
}
/// <summary>
/// Contains the logic responsible for rejecting logout requests that don't specify a valid id_token_hint.
/// </summary>
public class ValidateToken : IOpenIddictServerHandler<ValidateLogoutRequestContext>
{
private readonly IOpenIddictServerDispatcher _dispatcher;
public ValidateToken(IOpenIddictServerDispatcher dispatcher)
=> _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateLogoutRequestContext>()
.UseScopedHandler<ValidateToken>()
.SetOrder(ValidateEndpointPermissions.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ValidateLogoutRequestContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var notification = new ProcessAuthenticationContext(context.Transaction);
await _dispatcher.DispatchAsync(notification);
// Store the context object in the transaction so it can be later retrieved by handlers
// that want to access the authentication result without triggering a new authentication flow.
context.Transaction.SetProperty(typeof(ProcessAuthenticationContext).FullName!, notification);
if (notification.IsRequestHandled)
{
context.HandleRequest();
return;
}
else if (notification.IsRequestSkipped)
{
context.SkipRequest();
return;
}
else if (notification.IsRejected)
{
context.Reject(
error: notification.Error ?? Errors.InvalidRequest,
description: notification.ErrorDescription,
uri: notification.ErrorUri);
return;
}
// Attach the security principal extracted from the token to the validation context.
context.IdentityTokenHintPrincipal = notification.IdentityTokenPrincipal;
}
}
/// <summary>
/// Contains the logic responsible for rejecting logout requests that specify an identity
/// token hint that cannot be used by the client application sending the logout request.
/// </summary>
public class ValidateAuthorizedParty : IOpenIddictServerHandler<ValidateLogoutRequestContext>
{
private readonly IOpenIddictApplicationManager? _applicationManager;
public ValidateAuthorizedParty(IOpenIddictApplicationManager? applicationManager = null)
=> _applicationManager = applicationManager;
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateLogoutRequestContext>()
.UseScopedHandler<ValidateAuthorizedParty>(static provider =>
{
// Note: the application manager is only resolved if the degraded mode was not enabled to ensure
// invalid core configuration exceptions are not thrown even if the managers were registered.
var options = provider.GetRequiredService<IOptionsMonitor<OpenIddictServerOptions>>().CurrentValue;
return options.EnableDegradedMode ?
new ValidateAuthorizedParty() :
new ValidateAuthorizedParty(provider.GetService<IOpenIddictApplicationManager>() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)));
})
.SetOrder(ValidateToken.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ValidateLogoutRequestContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.IdentityTokenHintPrincipal is null)
{
return;
}
// This handler is responsible for ensuring that the specified identity token hint
// was issued to the same client that the one corresponding to the specified client_id
// or inferred from post_logout_redirect_uri. To achieve that, two approaches are used:
//
// * If an explicit client_id was received, the client_id is directly used to determine
// whether the specified id_token_hint lists it as an audience or as a presenter.
//
// * If no explicit client_id was set, all the client applications for which a matching
// post_logout_redirect_uri exists are iterated to determine whether the specified
// id_token_hint principal lists one of them as a valid audience or presenter.
//
// Since the first method is more efficient, it's always used if a client_is was specified.
if (!string.IsNullOrEmpty(context.ClientId))
{
// If an explicit client_id was specified, it must be listed either as
// an audience or as a presenter for the request to be considered valid.
if (!context.IdentityTokenHintPrincipal.HasAudience(context.ClientId) &&
!context.IdentityTokenHintPrincipal.HasPresenter(context.ClientId))
{
context.Logger.LogWarning(SR.GetResourceString(SR.ID6198));
context.Reject(
error: Errors.InvalidRequest,
description: SR.GetResourceString(SR.ID2141),
uri: SR.FormatID8000(SR.ID2141));
return;
}
return;
}
if (!context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.PostLogoutRedirectUri))
{
if (_applicationManager is null)
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0016));
}
if (!await ValidateAuthorizedParty(context.IdentityTokenHintPrincipal, context.PostLogoutRedirectUri))
{
context.Logger.LogWarning(SR.GetResourceString(SR.ID6198));
context.Reject(
error: Errors.InvalidRequest,
description: SR.GetResourceString(SR.ID2141),
uri: SR.FormatID8000(SR.ID2141));
return;
}
return;
}
async ValueTask<bool> ValidateAuthorizedParty(ClaimsPrincipal principal, string address)
{
// To be considered valid, one of the clients matching the specified post_logout_redirect_uri
// must be listed either as an audience or as a presenter in the identity token hint.
await foreach (var application in _applicationManager.FindByPostLogoutRedirectUriAsync(address))
{
var identifier = await _applicationManager.GetClientIdAsync(application);
if (!string.IsNullOrEmpty(identifier) && (principal.HasAudience(identifier) ||
principal.HasPresenter(identifier)))
{
return true;
}
}
return false;
}
}
}
/// <summary>
/// Contains the logic responsible for attaching the principal
/// extracted from the identity token hint to the event context.
/// </summary>
public class AttachPrincipal : IOpenIddictServerHandler<HandleLogoutRequestContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<HandleLogoutRequestContext>()
.UseSingletonHandler<AttachPrincipal>()
.SetOrder(int.MinValue + 100_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleLogoutRequestContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var notification = context.Transaction.GetProperty<ValidateLogoutRequestContext>(
typeof(ValidateLogoutRequestContext).FullName!) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0007));
context.IdentityTokenHintPrincipal ??= notification.IdentityTokenHintPrincipal;
return default;
}
}
/// <summary>
/// Contains the logic responsible for inferring the redirect URL
/// used to send the response back to the client application.
@ -461,7 +843,7 @@ public static partial class OpenIddictServerHandlers
// Note: at this stage, the validated redirect URI property may be null (e.g if
// an error is returned from the ExtractLogoutRequest/ValidateLogoutRequest events).
if (notification is not null && !notification.IsRejected)
if (notification is { IsRejected: false })
{
context.PostLogoutRedirectUri = notification.PostLogoutRedirectUri;
}

15
src/OpenIddict.Server/OpenIddictServerHandlers.cs

@ -181,7 +181,8 @@ public static partial class OpenIddictServerHandlers
context.ValidateAuthorizationCode) = context.EndpointType switch
{
// The authorization code grant requires sending a valid authorization code.
OpenIddictServerEndpointType.Token when context.Request.IsAuthorizationCodeGrantType() => (true, true, true),
OpenIddictServerEndpointType.Token when context.Request.IsAuthorizationCodeGrantType()
=> (true, true, true),
_ => (false, false, false)
};
@ -191,7 +192,8 @@ public static partial class OpenIddictServerHandlers
context.ValidateDeviceCode) = context.EndpointType switch
{
// The device code grant requires sending a valid device code.
OpenIddictServerEndpointType.Token when context.Request.IsDeviceCodeGrantType() => (true, true, true),
OpenIddictServerEndpointType.Token when context.Request.IsDeviceCodeGrantType()
=> (true, true, true),
_ => (false, false, false)
};
@ -202,7 +204,8 @@ public static partial class OpenIddictServerHandlers
{
// Tokens received by the introspection and revocation endpoints can be of any type.
// Additional token type filtering is made by the endpoint themselves, if needed.
OpenIddictServerEndpointType.Introspection or OpenIddictServerEndpointType.Revocation => (true, true, true),
OpenIddictServerEndpointType.Introspection or OpenIddictServerEndpointType.Revocation
=> (true, true, true),
_ => (false, false, false)
};
@ -213,7 +216,8 @@ public static partial class OpenIddictServerHandlers
{
// The identity token received by the authorization and logout
// endpoints are not required and serve as optional hints.
OpenIddictServerEndpointType.Authorization or OpenIddictServerEndpointType.Logout => (true, false, true),
OpenIddictServerEndpointType.Authorization or OpenIddictServerEndpointType.Logout
=> (true, false, true),
_ => (false, false, true)
};
@ -223,7 +227,8 @@ public static partial class OpenIddictServerHandlers
context.ValidateRefreshToken) = context.EndpointType switch
{
// The refresh token grant requires sending a valid refresh token.
OpenIddictServerEndpointType.Token when context.Request.IsRefreshTokenGrantType() => (true, true, true),
OpenIddictServerEndpointType.Token when context.Request.IsRefreshTokenGrantType()
=> (true, true, true),
_ => (false, false, false)
};

152
test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs

@ -10,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;
using static OpenIddict.Server.OpenIddictServerEvents;
using static OpenIddict.Server.OpenIddictServerHandlers.Protection;
namespace OpenIddict.Server.IntegrationTests;
@ -483,7 +484,7 @@ public abstract partial class OpenIddictServerIntegrationTests
}
[Fact]
public async Task ValidateAuthorizationRequest_RequestIsValidateWhenPkceIsNotRequiredAndCodeChallengeIsMissing()
public async Task ValidateAuthorizationRequest_RequestIsValidatedWhenPkceIsNotRequiredAndCodeChallengeIsMissing()
{
// Arrange
await using var server = await CreateServerAsync(options =>
@ -1818,6 +1819,155 @@ public abstract partial class OpenIddictServerIntegrationTests
Requirements.Features.ProofKeyForCodeExchange, It.IsAny<CancellationToken>()), Times.Never());
}
[Fact]
public async Task ValidateAuthorizationRequest_InvalidIdentityTokenHintCausesAnError()
{
// Arrange
await using var server = await CreateServerAsync(options => options.EnableDegradedMode());
await using var client = await server.CreateClientAsync();
// Act
var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest
{
ClientId = "Fabrikam",
IdTokenHint = "id_token",
RedirectUri = "http://www.fabrikam.com/path",
ResponseType = ResponseTypes.Token
});
// Assert
Assert.Equal(Errors.InvalidToken, response.Error);
Assert.Equal(SR.GetResourceString(SR.ID2009), response.ErrorDescription);
Assert.Equal(SR.FormatID8000(SR.ID2009), response.ErrorUri);
}
[Fact]
public async Task ValidateAuthorizationRequest_IdentityTokenHintCausesAnErrorWhenCallerIsNotAuthorized()
{
// Arrange
await using var server = await CreateServerAsync(options =>
{
options.EnableDegradedMode();
options.Configure(options => options.IgnoreEndpointPermissions = false);
options.AddEventHandler<ValidateTokenContext>(builder =>
{
builder.UseInlineHandler(context =>
{
Assert.Equal("id_token", context.Token);
Assert.Equal(new[] { TokenTypeHints.IdToken }, context.ValidTokenTypes);
context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer"))
.SetTokenType(TokenTypeHints.IdToken)
.SetPresenters("Contoso")
.SetClaim(Claims.Subject, "Bob le Bricoleur");
return default;
});
builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500);
});
});
await using var client = await server.CreateClientAsync();
// Act
var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest
{
ClientId = "Fabrikam",
IdTokenHint = "id_token",
RedirectUri = "http://www.fabrikam.com/path",
ResponseType = ResponseTypes.Token
});
// Assert
Assert.Equal(Errors.InvalidRequest, response.Error);
Assert.Equal(SR.GetResourceString(SR.ID2141), response.ErrorDescription);
Assert.Equal(SR.FormatID8000(SR.ID2141), response.ErrorUri);
}
[Fact]
public async Task ValidateAuthorizationRequest_RequestIsValidatedWhenIdentityTokenHintIsExpired()
{
// Arrange
var application = new OpenIddictApplication();
var manager = CreateApplicationManager(mock =>
{
mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny<CancellationToken>()))
.ReturnsAsync(application);
mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
mock.Setup(manager => manager.HasPermissionAsync(application,
Permissions.Endpoints.Authorization, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
});
await using var server = await CreateServerAsync(options =>
{
options.SetRevocationEndpointUris(Array.Empty<Uri>());
options.DisableAuthorizationStorage();
options.DisableTokenStorage();
options.DisableSlidingRefreshTokenExpiration();
options.Configure(options => options.IgnoreEndpointPermissions = false);
options.Services.AddSingleton(manager);
options.AddEventHandler<ValidateTokenContext>(builder =>
{
builder.UseInlineHandler(context =>
{
Assert.Equal("id_token", context.Token);
Assert.Equal(new[] { TokenTypeHints.IdToken }, context.ValidTokenTypes);
context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer"))
.SetTokenType(TokenTypeHints.IdToken)
.SetPresenters("Fabrikam")
.SetExpirationDate(new DateTimeOffset(2017, 1, 1, 0, 0, 0, TimeSpan.Zero))
.SetClaim(Claims.Subject, "Bob le Bricoleur");
return default;
});
builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500);
});
options.AddEventHandler<HandleAuthorizationRequestContext>(builder =>
builder.UseInlineHandler(context =>
{
Assert.Equal("Bob le Bricoleur", context.IdentityTokenHintPrincipal
?.FindFirst(Claims.Subject)?.Value);
context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer"))
.SetClaim(Claims.Subject, "Bob le Magnifique");
return default;
}));
});
await using var client = await server.CreateClientAsync();
// Act
var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest
{
ClientId = "Fabrikam",
IdTokenHint = "id_token",
RedirectUri = "http://www.fabrikam.com/path",
ResponseType = ResponseTypes.Token
});
// Assert
Assert.Null(response.Code);
Assert.NotNull(response.AccessToken);
Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny<CancellationToken>()), Times.AtLeastOnce());
Mock.Get(manager).Verify(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, Permissions.Endpoints.Authorization, It.IsAny<CancellationToken>()), Times.Once());
}
[Theory]
[InlineData("custom_error", null, null)]
[InlineData("custom_error", "custom_description", null)]

280
test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs

@ -4,10 +4,13 @@
* the license and the contributors participating to this project.
*/
using System.Collections.Immutable;
using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;
using static OpenIddict.Server.OpenIddictServerEvents;
using static OpenIddict.Server.OpenIddictServerHandlers.Protection;
namespace OpenIddict.Server.IntegrationTests;
@ -149,6 +152,39 @@ public abstract partial class OpenIddictServerIntegrationTests
Assert.Equal(SR.FormatID8000(message), response.ErrorUri);
}
[Fact]
public async Task ValidateLogoutRequest_RequestIsRejectedWhenClientCannotBeFound()
{
// Arrange
var manager = CreateApplicationManager(mock =>
{
mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny<CancellationToken>()))
.ReturnsAsync(value: null);
});
await using var server = await CreateServerAsync(options =>
{
options.Services.AddSingleton(manager);
});
await using var client = await server.CreateClientAsync();
// Act
var response = await client.PostAsync("/connect/logout", new OpenIddictRequest
{
ClientId = "Fabrikam",
PostLogoutRedirectUri = "http://www.fabrikam.com/path"
});
// Assert
Assert.Equal(Errors.InvalidRequest, response.Error);
Assert.Equal(SR.FormatID2052(Parameters.ClientId), response.ErrorDescription);
Assert.Equal(SR.FormatID8000(SR.ID2052), response.ErrorUri);
Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny<CancellationToken>()), Times.AtLeastOnce());
Mock.Get(manager).Verify(manager => manager.FindByPostLogoutRedirectUriAsync("http://www.fabrikam.com/path", It.IsAny<CancellationToken>()), Times.Never());
}
[Fact]
public async Task ValidateLogoutRequest_RequestIsRejectedWhenNoMatchingApplicationIsFound()
{
@ -287,6 +323,250 @@ public abstract partial class OpenIddictServerIntegrationTests
Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(applications[2], Permissions.Endpoints.Logout, It.IsAny<CancellationToken>()), Times.Never());
}
[Fact]
public async Task ValidateLogoutRequest_RequestIsRejectedWhenEndpointPermissionIsNotGranted()
{
// Arrange
var application = new OpenIddictApplication();
var manager = CreateApplicationManager(mock =>
{
mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny<CancellationToken>()))
.ReturnsAsync(application);
mock.Setup(manager => manager.GetPostLogoutRedirectUrisAsync(application, It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create("http://www.fabrikam.com/path"));
mock.Setup(manager => manager.HasPermissionAsync(application,
Permissions.Endpoints.Logout, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
});
await using var server = await CreateServerAsync(options =>
{
options.Services.AddSingleton(manager);
options.Configure(options => options.IgnoreEndpointPermissions = false);
});
await using var client = await server.CreateClientAsync();
// Act
var response = await client.PostAsync("/connect/logout", new OpenIddictRequest
{
ClientId = "Fabrikam"
});
// Assert
Assert.Equal(Errors.UnauthorizedClient, response.Error);
Assert.Equal(SR.GetResourceString(SR.ID2140), response.ErrorDescription);
Assert.Equal(SR.FormatID8000(SR.ID2140), response.ErrorUri);
Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny<CancellationToken>()), Times.AtLeastOnce());
Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application,
Permissions.Endpoints.Logout, It.IsAny<CancellationToken>()), Times.Once());
}
[Fact]
public async Task ValidateLogoutRequest_InvalidIdentityTokenHintCausesAnError()
{
// Arrange
await using var server = await CreateServerAsync(options => options.EnableDegradedMode());
await using var client = await server.CreateClientAsync();
// Act
var response = await client.PostAsync("/connect/logout", new OpenIddictRequest
{
ClientId = "Fabrikam",
IdTokenHint = "id_token"
});
// Assert
Assert.Equal(Errors.InvalidToken, response.Error);
Assert.Equal(SR.GetResourceString(SR.ID2009), response.ErrorDescription);
Assert.Equal(SR.FormatID8000(SR.ID2009), response.ErrorUri);
}
[Fact]
public async Task ValidateLogoutRequest_IdentityTokenHintCausesAnErrorWhenExplicitCallerIsNotAuthorized()
{
// Arrange
await using var server = await CreateServerAsync(options =>
{
options.EnableDegradedMode();
options.Configure(options => options.IgnoreEndpointPermissions = false);
options.AddEventHandler<ValidateTokenContext>(builder =>
{
builder.UseInlineHandler(context =>
{
Assert.Equal("id_token", context.Token);
Assert.Equal(new[] { TokenTypeHints.IdToken }, context.ValidTokenTypes);
context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer"))
.SetTokenType(TokenTypeHints.IdToken)
.SetPresenters("Contoso")
.SetClaim(Claims.Subject, "Bob le Bricoleur");
return default;
});
builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500);
});
});
await using var client = await server.CreateClientAsync();
// Act
var response = await client.PostAsync("/connect/logout", new OpenIddictRequest
{
ClientId = "Fabrikam",
IdTokenHint = "id_token"
});
// Assert
Assert.Equal(Errors.InvalidRequest, response.Error);
Assert.Equal(SR.GetResourceString(SR.ID2141), response.ErrorDescription);
Assert.Equal(SR.FormatID8000(SR.ID2141), response.ErrorUri);
}
[Fact]
public async Task ValidateLogoutRequest_IdentityTokenHintCausesAnErrorWhenInferredCallerIsNotAuthorized()
{
// Arrange
var application = new OpenIddictApplication();
var manager = CreateApplicationManager(mock =>
{
mock.Setup(manager => manager.FindByPostLogoutRedirectUriAsync("http://www.fabrikam.com/path", It.IsAny<CancellationToken>()))
.Returns(new[] { application }.ToAsyncEnumerable());
mock.Setup(manager => manager.HasPermissionAsync(application,
Permissions.Endpoints.Logout, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
mock.Setup(manager => manager.GetClientIdAsync(application, It.IsAny<CancellationToken>()))
.ReturnsAsync("Fabrikam");
});
await using var server = await CreateServerAsync(options =>
{
options.Services.AddSingleton(manager);
options.Configure(options => options.IgnoreEndpointPermissions = false);
options.AddEventHandler<ValidateTokenContext>(builder =>
{
builder.UseInlineHandler(context =>
{
Assert.Equal("id_token", context.Token);
Assert.Equal(new[] { TokenTypeHints.IdToken }, context.ValidTokenTypes);
context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer"))
.SetTokenType(TokenTypeHints.IdToken)
.SetPresenters("Contoso")
.SetClaim(Claims.Subject, "Bob le Bricoleur");
return default;
});
builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500);
});
});
await using var client = await server.CreateClientAsync();
// Act
var response = await client.PostAsync("/connect/logout", new OpenIddictRequest
{
IdTokenHint = "id_token",
PostLogoutRedirectUri = "http://www.fabrikam.com/path"
});
// Assert
Assert.Equal(Errors.InvalidRequest, response.Error);
Assert.Equal(SR.GetResourceString(SR.ID2141), response.ErrorDescription);
Assert.Equal(SR.FormatID8000(SR.ID2141), response.ErrorUri);
Mock.Get(manager).Verify(manager => manager.FindByPostLogoutRedirectUriAsync("http://www.fabrikam.com/path", It.IsAny<CancellationToken>()), Times.AtLeast(2));
}
[Fact]
public async Task ValidateLogoutRequest_RequestIsValidatedWhenIdentityTokenHintIsExpired()
{
// Arrange
var application = new OpenIddictApplication();
var manager = CreateApplicationManager(mock =>
{
mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny<CancellationToken>()))
.ReturnsAsync(application);
mock.Setup(manager => manager.GetPostLogoutRedirectUrisAsync(application, It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create("http://www.fabrikam.com/path"));
mock.Setup(manager => manager.HasPermissionAsync(application,
Permissions.Endpoints.Logout, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
});
await using var server = await CreateServerAsync(options =>
{
options.Services.AddSingleton(manager);
options.SetLogoutEndpointUris("/signout");
options.Configure(options => options.IgnoreEndpointPermissions = false);
options.AddEventHandler<ValidateTokenContext>(builder =>
{
builder.UseInlineHandler(context =>
{
Assert.Equal("id_token", context.Token);
Assert.Equal(new[] { TokenTypeHints.IdToken }, context.ValidTokenTypes);
context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer"))
.SetTokenType(TokenTypeHints.IdToken)
.SetPresenters("Fabrikam")
.SetExpirationDate(new DateTimeOffset(2017, 1, 1, 0, 0, 0, TimeSpan.Zero))
.SetClaim(Claims.Subject, "Bob le Bricoleur");
return default;
});
builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500);
});
options.AddEventHandler<HandleLogoutRequestContext>(builder =>
builder.UseInlineHandler(context =>
{
Assert.Equal("Bob le Bricoleur", context.IdentityTokenHintPrincipal
?.FindFirst(Claims.Subject)?.Value);
context.SignOut();
return default;
}));
});
await using var client = await server.CreateClientAsync();
// Act
var response = await client.PostAsync("/signout", new OpenIddictRequest
{
ClientId = "Fabrikam",
IdTokenHint = "id_token",
PostLogoutRedirectUri = "http://www.fabrikam.com/path",
State = "af0ifjsldkj"
});
// Assert
Assert.Equal("af0ifjsldkj", response.State);
Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny<CancellationToken>()), Times.AtLeastOnce());
Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, Permissions.Endpoints.Logout, It.IsAny<CancellationToken>()), Times.Once());
}
[Theory]
[InlineData("custom_error", null, null)]
[InlineData("custom_error", "custom_description", null)]

Loading…
Cancel
Save