diff --git a/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs b/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs index 1de4fc67..fa1b5ee6 100644 --- a/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs +++ b/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs @@ -450,6 +450,19 @@ public interface IOpenIddictApplicationManager /// ValueTask ValidateClientSecretAsync(object application, string secret, CancellationToken cancellationToken = default); + /// + /// Validates the post_logout_redirect_uri to ensure it's associated with an application. + /// + /// The application. + /// The address that should be compared to one of the post_logout_redirect_uri stored in the database. + /// The that can be used to abort the operation. + /// Note: if no client_id parameter is specified in logout requests, this method may not be called. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns a boolean indicating whether the post_logout_redirect_uri was valid. + /// + ValueTask ValidatePostLogoutRedirectUriAsync(object application, string address, CancellationToken cancellationToken = default); + /// /// Validates the redirect_uri to ensure it's associated with an application. /// diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 879e7cd1..224c56cf 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -2359,6 +2359,9 @@ This may indicate that the hashed entry is corrupted or malformed. The post-logout redirection request was successfully validated. + + Client validation failed because '{PostLogoutRedirectUri}' was not a valid post_logout_redirect_uri for {Client}. + https://documentation.openiddict.com/errors/{0} diff --git a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs index 5170dbaa..462edd6e 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs @@ -1289,6 +1289,44 @@ public class OpenIddictApplicationManager : IOpenIddictApplication return true; } + /// + /// Validates the post_logout_redirect_uri to ensure it's associated with an application. + /// + /// The application. + /// The address that should be compared to one of the post_logout_redirect_uri stored in the database. + /// The that can be used to abort the operation. + /// Note: if no client_id parameter is specified in logout requests, this method may not be called. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns a boolean indicating whether the post_logout_redirect_uri was valid. + /// + public virtual async ValueTask ValidatePostLogoutRedirectUriAsync( + TApplication application, string address, CancellationToken cancellationToken = default) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (string.IsNullOrEmpty(address)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0143), nameof(address)); + } + + foreach (var uri in await Store.GetPostLogoutRedirectUrisAsync(application, cancellationToken)) + { + // Note: the post_logout_redirect_uri must be compared using case-sensitive "Simple String Comparison". + if (string.Equals(uri, address, StringComparison.Ordinal)) + { + return true; + } + } + + Logger.LogInformation(SR.GetResourceString(SR.ID6202), address, await GetClientIdAsync(application, cancellationToken)); + + return false; + } + /// /// Validates the redirect_uri to ensure it's associated with an application. /// @@ -1681,6 +1719,10 @@ public class OpenIddictApplicationManager : IOpenIddictApplication ValueTask IOpenIddictApplicationManager.ValidateClientSecretAsync(object application, string secret, CancellationToken cancellationToken) => ValidateClientSecretAsync((TApplication) application, secret, cancellationToken); + /// + ValueTask IOpenIddictApplicationManager.ValidatePostLogoutRedirectUriAsync(object application, string address, CancellationToken cancellationToken) + => ValidatePostLogoutRedirectUriAsync((TApplication) application, address, cancellationToken); + /// ValueTask IOpenIddictApplicationManager.ValidateRedirectUriAsync(object application, string address, CancellationToken cancellationToken) => ValidateRedirectUriAsync((TApplication) application, address, cancellationToken); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs index cad74579..96e295ef 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs @@ -486,8 +486,7 @@ public static partial class OpenIddictServerHandlers 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)) + if (!await _applicationManager.ValidatePostLogoutRedirectUriAsync(application, context.PostLogoutRedirectUri)) { context.Logger.LogInformation(SR.GetResourceString(SR.ID6128), context.PostLogoutRedirectUri); diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs index 07f2f182..912a7dc9 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs @@ -216,6 +216,81 @@ public abstract partial class OpenIddictServerIntegrationTests Mock.Get(manager).Verify(manager => manager.FindByPostLogoutRedirectUriAsync("http://www.fabrikam.com/path", It.IsAny()), Times.Once()); } + [Fact] + public async Task ValidateLogoutRequest_RequestIsRejectedWhenPostLogoutRedirectUriForExplicitClientIsInvalid() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidatePostLogoutRedirectUriAsync(application, "http://www.fabrikam.com/path", 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", + PostLogoutRedirectUri = "http://www.fabrikam.com/path" + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2052(Parameters.PostLogoutRedirectUri), 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.ValidatePostLogoutRedirectUriAsync(application, + "http://www.fabrikam.com/path", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidateLogoutRequest_RequestIsRejectedWhenPostLogoutRedirectUriForImplicitClientIsInvalid() + { + // Arrange + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByPostLogoutRedirectUriAsync("http://www.fabrikam.com/path", It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + }); + + 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 + { + PostLogoutRedirectUri = "http://www.fabrikam.com/path" + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2052(Parameters.PostLogoutRedirectUri), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2052), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByPostLogoutRedirectUriAsync( + "http://www.fabrikam.com/path", It.IsAny()), Times.Once()); + } + [Fact] public async Task ValidateLogoutRequest_RequestIsRejectedWhenNoMatchingApplicationIsGrantedEndpointPermission() { @@ -502,8 +577,8 @@ public abstract partial class OpenIddictServerIntegrationTests 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.ValidatePostLogoutRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); mock.Setup(manager => manager.HasPermissionAsync(application, Permissions.Endpoints.Logout, It.IsAny())) @@ -564,6 +639,7 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal("af0ifjsldkj", response.State); Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.ValidatePostLogoutRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny()), Times.Once()); Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, Permissions.Endpoints.Logout, It.IsAny()), Times.Once()); }