diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx
index 2c59f27b..e285d247 100644
--- a/src/OpenIddict.Abstractions/OpenIddictResources.resx
+++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx
@@ -1733,6 +1733,12 @@ Alternatively, you can disable the token storage feature by calling 'services.Ad
The specified state token has already been redeemed.
+
+ This client application is not allowed to use the logout endpoint.
+
+
+ The client application is not allowed to use the specified identity token hint.
+
The '{0}' parameter shouldn't be null or empty at this point.
@@ -2329,6 +2335,15 @@ This may indicate that the hashed entry is corrupted or malformed.
The userinfo response returned by {Address} was successfully extracted: {Response}.
+
+ The logout request was rejected because the client application was not found: '{ClientId}'.
+
+
+ The authorization request was rejected because the identity token used as a hint was issued to a different client.
+
+
+ The logout request was rejected because the identity token used as a hint was issued to a different client.
+
https://documentation.openiddict.com/errors/{0}
diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerEvents.Authentication.cs
index d5a44cce..f4cc8266 100644
--- a/src/OpenIddict.Server/OpenIddictServerEvents.Authentication.cs
+++ b/src/OpenIddict.Server/OpenIddictServerEvents.Authentication.cs
@@ -69,6 +69,12 @@ public static partial class OpenIddictServerEvents
///
public string? RedirectUri { get; private set; }
+ ///
+ /// Gets or sets the security principal extracted
+ /// from the identity token hint, if applicable.
+ ///
+ public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; }
+
///
/// Populates the property with the specified redirect_uri.
///
@@ -115,6 +121,12 @@ public static partial class OpenIddictServerEvents
set => Transaction.Request = value;
}
+ ///
+ /// Gets or sets the security principal extracted
+ /// from the identity token hint, if applicable.
+ ///
+ public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; }
+
///
/// Gets the additional parameters returned to the client application.
///
diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.Session.cs b/src/OpenIddict.Server/OpenIddictServerEvents.Session.cs
index 738b715b..ba6589e3 100644
--- a/src/OpenIddict.Server/OpenIddictServerEvents.Session.cs
+++ b/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;
}
+ ///
+ /// Gets the client_id specified by the client application, if available.
+ ///
+ public string? ClientId => Request?.ClientId;
+
///
/// Gets the post_logout_redirect_uri specified by the client application.
///
public string? PostLogoutRedirectUri { get; private set; }
+ ///
+ /// Gets or sets the security principal extracted
+ /// from the identity token hint, if applicable.
+ ///
+ public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; }
+
///
/// Populates the property with the specified redirect_uri.
///
@@ -106,6 +119,12 @@ public static partial class OpenIddictServerEvents
set => Transaction.Request = value;
}
+ ///
+ /// Gets or sets the security principal extracted
+ /// from the identity token hint, if applicable.
+ ///
+ public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; }
+
///
/// Gets a boolean indicating whether a sign-out should be triggered.
///
diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs
index f68fe969..dcc9454d 100644
--- a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs
+++ b/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
}
}
+ ///
+ /// Contains the logic responsible for rejecting authorization
+ /// requests that don't specify a valid id_token_hint.
+ ///
+ public class ValidateToken : IOpenIddictServerHandler
+ {
+ private readonly IOpenIddictServerDispatcher _dispatcher;
+
+ public ValidateToken(IOpenIddictServerDispatcher dispatcher)
+ => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .UseScopedHandler()
+ .SetOrder(ValidateProofKeyForCodeExchangeRequirement.Descriptor.Order + 1_000)
+ .SetType(OpenIddictServerHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ public class ValidateAuthorizedParty : IOpenIddictServerHandler
+ {
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .UseSingletonHandler()
+ .SetOrder(ValidateToken.Descriptor.Order + 1_000)
+ .SetType(OpenIddictServerHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Contains the logic responsible for attaching the principal
+ /// extracted from the identity token hint to the event context.
+ ///
+ public class AttachPrincipal : IOpenIddictServerHandler
+ {
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .UseSingletonHandler()
+ .SetOrder(int.MinValue + 100_000)
+ .SetType(OpenIddictServerHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ public ValueTask HandleAsync(HandleAuthorizationRequestContext context)
+ {
+ if (context is null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+
+ var notification = context.Transaction.GetProperty(
+ typeof(ValidateAuthorizationRequestContext).FullName!) ??
+ throw new InvalidOperationException(SR.GetResourceString(SR.ID0007));
+
+ context.IdentityTokenHintPrincipal ??= notification.IdentityTokenHintPrincipal;
+
+ return default;
+ }
+ }
+
///
/// 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;
}
diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs
index b028d00c..c2f18322 100644
--- a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs
+++ b/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
}
///
- /// 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.
+ ///
+ public class ValidateClientId : IOpenIddictServerHandler
+ {
+ private readonly IOpenIddictApplicationManager _applicationManager;
+
+ public ValidateClientId() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016));
+
+ public ValidateClientId(IOpenIddictApplicationManager applicationManager)
+ => _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager));
+
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .AddFilter()
+ .UseScopedHandler()
+ .SetOrder(ValidatePostLogoutRedirectUriParameter.Descriptor.Order + 1_000)
+ .SetType(OpenIddictServerHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ 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;
+ }
+ }
+ }
+
+ ///
+ /// 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.
///
public class ValidateClientPostLogoutRedirectUri : IOpenIddictServerHandler
@@ -382,7 +452,7 @@ public static partial class OpenIddictServerHandlers
.AddFilter()
.AddFilter()
.UseScopedHandler()
- .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
}
}
+ ///
+ /// 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.
+ ///
+ public class ValidateEndpointPermissions : IOpenIddictServerHandler
+ {
+ private readonly IOpenIddictApplicationManager _applicationManager;
+
+ public ValidateEndpointPermissions() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016));
+
+ public ValidateEndpointPermissions(IOpenIddictApplicationManager applicationManager)
+ => _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager));
+
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .AddFilter()
+ .AddFilter()
+ .UseScopedHandler()
+ .SetOrder(ValidateClientPostLogoutRedirectUri.Descriptor.Order + 1_000)
+ .SetType(OpenIddictServerHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ 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;
+ }
+ }
+ }
+
+ ///
+ /// Contains the logic responsible for rejecting logout requests that don't specify a valid id_token_hint.
+ ///
+ public class ValidateToken : IOpenIddictServerHandler
+ {
+ private readonly IOpenIddictServerDispatcher _dispatcher;
+
+ public ValidateToken(IOpenIddictServerDispatcher dispatcher)
+ => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .UseScopedHandler()
+ .SetOrder(ValidateEndpointPermissions.Descriptor.Order + 1_000)
+ .SetType(OpenIddictServerHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ public class ValidateAuthorizedParty : IOpenIddictServerHandler
+ {
+ private readonly IOpenIddictApplicationManager? _applicationManager;
+
+ public ValidateAuthorizedParty(IOpenIddictApplicationManager? applicationManager = null)
+ => _applicationManager = applicationManager;
+
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .UseScopedHandler(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>().CurrentValue;
+
+ return options.EnableDegradedMode ?
+ new ValidateAuthorizedParty() :
+ new ValidateAuthorizedParty(provider.GetService() ??
+ throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)));
+ })
+ .SetOrder(ValidateToken.Descriptor.Order + 1_000)
+ .SetType(OpenIddictServerHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ 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 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;
+ }
+ }
+ }
+
+ ///
+ /// Contains the logic responsible for attaching the principal
+ /// extracted from the identity token hint to the event context.
+ ///
+ public class AttachPrincipal : IOpenIddictServerHandler
+ {
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .UseSingletonHandler()
+ .SetOrder(int.MinValue + 100_000)
+ .SetType(OpenIddictServerHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ public ValueTask HandleAsync(HandleLogoutRequestContext context)
+ {
+ if (context is null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+
+ var notification = context.Transaction.GetProperty(
+ typeof(ValidateLogoutRequestContext).FullName!) ??
+ throw new InvalidOperationException(SR.GetResourceString(SR.ID0007));
+
+ context.IdentityTokenHintPrincipal ??= notification.IdentityTokenHintPrincipal;
+
+ return default;
+ }
+ }
+
///
/// 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;
}
diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs
index 11647735..436142c2 100644
--- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs
+++ b/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)
};
diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs
index 7eaf4cfa..812a92e3 100644
--- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs
+++ b/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()), 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(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()))
+ .ReturnsAsync(application);
+
+ mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny()))
+ .ReturnsAsync(true);
+
+ mock.Setup(manager => manager.HasPermissionAsync(application,
+ Permissions.Endpoints.Authorization, It.IsAny()))
+ .ReturnsAsync(true);
+ });
+
+ await using var server = await CreateServerAsync(options =>
+ {
+ options.SetRevocationEndpointUris(Array.Empty());
+ options.DisableAuthorizationStorage();
+ options.DisableTokenStorage();
+ options.DisableSlidingRefreshTokenExpiration();
+
+ options.Configure(options => options.IgnoreEndpointPermissions = false);
+
+ options.Services.AddSingleton(manager);
+
+ options.AddEventHandler(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(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()), Times.AtLeastOnce());
+ Mock.Get(manager).Verify(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny()), Times.Once());
+ Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, Permissions.Endpoints.Authorization, It.IsAny()), Times.Once());
+ }
+
[Theory]
[InlineData("custom_error", null, null)]
[InlineData("custom_error", "custom_description", null)]
diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs
index 498971d7..07f2f182 100644
--- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs
+++ b/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()))
+ .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()), Times.AtLeastOnce());
+ Mock.Get(manager).Verify(manager => manager.FindByPostLogoutRedirectUriAsync("http://www.fabrikam.com/path", It.IsAny()), 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()), 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()))
+ .ReturnsAsync(application);
+
+ mock.Setup(manager => manager.GetPostLogoutRedirectUrisAsync(application, It.IsAny()))
+ .ReturnsAsync(ImmutableArray.Create("http://www.fabrikam.com/path"));
+
+ mock.Setup(manager => manager.HasPermissionAsync(application,
+ Permissions.Endpoints.Logout, It.IsAny()))
+ .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()), Times.AtLeastOnce());
+ Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application,
+ Permissions.Endpoints.Logout, It.IsAny()), 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(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()))
+ .Returns(new[] { application }.ToAsyncEnumerable());
+
+ mock.Setup(manager => manager.HasPermissionAsync(application,
+ Permissions.Endpoints.Logout, It.IsAny()))
+ .ReturnsAsync(true);
+
+ mock.Setup(manager => manager.GetClientIdAsync(application, It.IsAny()))
+ .ReturnsAsync("Fabrikam");
+ });
+
+ await using var server = await CreateServerAsync(options =>
+ {
+ options.Services.AddSingleton(manager);
+
+ options.Configure(options => options.IgnoreEndpointPermissions = false);
+
+ options.AddEventHandler(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()), 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()))
+ .ReturnsAsync(application);
+
+ mock.Setup(manager => manager.GetPostLogoutRedirectUrisAsync(application, It.IsAny()))
+ .ReturnsAsync(ImmutableArray.Create("http://www.fabrikam.com/path"));
+
+ mock.Setup(manager => manager.HasPermissionAsync(application,
+ Permissions.Endpoints.Logout, It.IsAny()))
+ .ReturnsAsync(true);
+ });
+
+ await using var server = await CreateServerAsync(options =>
+ {
+ options.Services.AddSingleton(manager);
+
+ options.SetLogoutEndpointUris("/signout");
+
+ options.Configure(options => options.IgnoreEndpointPermissions = false);
+
+ options.AddEventHandler(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(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()), Times.AtLeastOnce());
+ Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, Permissions.Endpoints.Logout, It.IsAny()), Times.Once());
+ }
+
[Theory]
[InlineData("custom_error", null, null)]
[InlineData("custom_error", "custom_description", null)]