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)]