diff --git a/.gitignore b/.gitignore index 4918295c7..10a11b58d 100644 --- a/.gitignore +++ b/.gitignore @@ -228,4 +228,4 @@ artifacts *.Artifacts/ -reports \ No newline at end of file +reports diff --git a/identity-server/src/Directory.Build.props b/identity-server/src/Directory.Build.props index fa02668cc..cebbc617b 100644 --- a/identity-server/src/Directory.Build.props +++ b/identity-server/src/Directory.Build.props @@ -11,4 +11,4 @@ 7.0 - \ No newline at end of file + diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/LoggingOptions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/LoggingOptions.cs index 796416585..900d001f3 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/LoggingOptions.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/LoggingOptions.cs @@ -50,6 +50,9 @@ public class LoggingOptions public ICollection AuthorizeRequestSensitiveValuesFilter { get; set; } = new HashSet { + // Secrets and assertions may be passed to the authorize endpoint via PAR + OidcConstants.TokenRequest.ClientSecret, + OidcConstants.TokenRequest.ClientAssertion, OidcConstants.AuthorizeRequest.IdTokenHint, OidcConstants.AuthorizeRequest.Request }; @@ -58,11 +61,15 @@ public class LoggingOptions /// Gets or sets the collection of keys that will be used to redact sensitive values from a pushed authorization request log. /// /// Please be aware that initializing this property could expose sensitive information in your logs. + /// Note that pushed authorization parameters are eventually handled by the authorize request pipeline. + /// In most cases, changes to this collection should also be made to + /// public ICollection PushedAuthorizationSensitiveValuesFilter { get; set; } = new HashSet { OidcConstants.TokenRequest.ClientSecret, OidcConstants.TokenRequest.ClientAssertion, + OidcConstants.AuthorizeRequest.IdTokenHint, OidcConstants.AuthorizeRequest.Request }; diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/MtlsOptions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/MtlsOptions.cs index 72faeb89a..e6971e714 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/MtlsOptions.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/MtlsOptions.cs @@ -23,9 +23,18 @@ public class MutualTlsOptions /// /// Specifies a separate domain to run the MTLS endpoints on. - /// If the string does not contain any dots, a subdomain is assumed - e.g. main domain: identityserver.local, MTLS domain: mtls.identityserver.local - /// If the string contains dots, a completely separate domain is assumend, e.g. main domain: identity.app.com, MTLS domain: mtls.app.com. In this case you must set a static issuer name on the options. /// + /// If the string does not contain any dots, it is treated as a + /// subdomain. For example, if the non-mTLS endpoints are hosted at + /// example.com, configuring this option with the value "mtls" means that + /// mtls is required for requests to mtls.example.com. + /// + /// If the string contains dots, it is treated as a complete domain. + /// mTLS will be required for requests whose host name matches the + /// configured domain name completely, including the port number. + /// This allows for separate domains for the mTLS and non-mTLS endpoints. + /// For example, identity.example.com and mtls.example.com. + /// public string? DomainName { get; set; } /// diff --git a/identity-server/src/IdentityServer/Endpoints/Results/ProtectedResourceErrorResult.cs b/identity-server/src/IdentityServer/Endpoints/Results/ProtectedResourceErrorResult.cs index 5d2533b76..fd2e3f564 100644 --- a/identity-server/src/IdentityServer/Endpoints/Results/ProtectedResourceErrorResult.cs +++ b/identity-server/src/IdentityServer/Endpoints/Results/ProtectedResourceErrorResult.cs @@ -58,17 +58,25 @@ internal class ProtectedResourceErrorHttpWriter : IHttpResponseWriter { - context.Response.Headers.Append(HeaderNames.WWWAuthenticate, new StringValues(new[] { "Bearer realm=\"IdentityServer\"", errorString })); - } - else + """ + Bearer realm="IdentityServer" + """, + $""" + error="{error}" + """ + }; + + if (!errorDescription.IsMissing()) { - var errorDescriptionString = string.Format($"error_description=\"{errorDescription}\""); - context.Response.Headers.Append(HeaderNames.WWWAuthenticate, new StringValues(new[] { "Bearer realm=\"IdentityServer\"", errorString, errorDescriptionString })); + values.Add($""" + error_description="{errorDescription}" + """); } + context.Response.Headers.Append(HeaderNames.WWWAuthenticate, string.Join(",", values)); + return Task.CompletedTask; } } diff --git a/identity-server/src/IdentityServer/Hosting/MutualTlsEndpointMiddleware.cs b/identity-server/src/IdentityServer/Hosting/MutualTlsEndpointMiddleware.cs index cf425fb26..bb85a4bc1 100644 --- a/identity-server/src/IdentityServer/Hosting/MutualTlsEndpointMiddleware.cs +++ b/identity-server/src/IdentityServer/Hosting/MutualTlsEndpointMiddleware.cs @@ -35,64 +35,116 @@ public class MutualTlsEndpointMiddleware _sanitizedLogger = new SanitizedLogger(logger); } + internal enum MtlsEndpointType + { + None, + SeparateDomain, + Subdomain, + PathBased + } + + internal MtlsEndpointType DetermineMtlsEndpointType(HttpContext context, out PathString? subPath) + { + subPath = null; + + if (!_options.MutualTls.Enabled) + { + return MtlsEndpointType.None; + } + + if (_options.MutualTls.DomainName.IsPresent()) + { + if (_options.MutualTls.DomainName.Contains('.')) + { + var requestedHost = HostString.FromUriComponent(_options.MutualTls.DomainName); + // Separate domain + if (RequestedHostMatches(context.Request.Host, _options.MutualTls.DomainName)) + { + _sanitizedLogger.LogDebug("Requiring mTLS because the request's domain matches the configured mTLS domain name."); + return MtlsEndpointType.SeparateDomain; + } + } + else + { + // Subdomain + if (context.Request.Host.Host.StartsWith(_options.MutualTls.DomainName + ".", StringComparison.OrdinalIgnoreCase)) + { + _sanitizedLogger.LogDebug("Requiring mTLS because the request's subdomain matches the configured mTLS domain name."); + return MtlsEndpointType.Subdomain; + } + } + + _sanitizedLogger.LogDebug("Not requiring mTLS because this request's domain does not match the configured mTLS domain name."); + return MtlsEndpointType.None; + } + + // Check path-based MTLS + if (context.Request.Path.StartsWithSegments( + ProtocolRoutePaths.MtlsPathPrefix.EnsureLeadingSlash(), out var path)) + { + _sanitizedLogger.LogDebug("Requiring mTLS because the request's path begins with the configured mTLS path prefix."); + subPath = path; + return MtlsEndpointType.PathBased; + } + + return MtlsEndpointType.None; + } + /// public async Task Invoke(HttpContext context, IAuthenticationSchemeProvider schemes) { - if (_options.MutualTls.Enabled) + var mtlsConfigurationStyle = DetermineMtlsEndpointType(context, out var subPath); + + if (mtlsConfigurationStyle != MtlsEndpointType.None) { - // domain-based MTLS - if (_options.MutualTls.DomainName.IsPresent()) + var result = await TriggerCertificateAuthentication(context); + if (!result.Succeeded) { - // separate domain - if (_options.MutualTls.DomainName.Contains('.')) - { - if (context.Request.Host.Host.Equals(_options.MutualTls.DomainName, - StringComparison.OrdinalIgnoreCase)) - { - var result = await TriggerCertificateAuthentication(context); - if (!result.Succeeded) - { - return; - } - } - } - // sub-domain - else - { - if (context.Request.Host.Host.StartsWith(_options.MutualTls.DomainName + ".", StringComparison.OrdinalIgnoreCase)) - { - var result = await TriggerCertificateAuthentication(context); - if (!result.Succeeded) - { - return; - } - } - } + return; } - // path based MTLS - else if (context.Request.Path.StartsWithSegments(ProtocolRoutePaths.MtlsPathPrefix.EnsureLeadingSlash(), out var subPath)) + + // Additional processing for path-based MTLS + if (mtlsConfigurationStyle == MtlsEndpointType.PathBased && subPath.HasValue) { - var result = await TriggerCertificateAuthentication(context); + var path = ProtocolRoutePaths.ConnectPathPrefix + subPath.Value.ToString().EnsureLeadingSlash(); + path = path.EnsureLeadingSlash(); - if (result.Succeeded) - { - var path = ProtocolRoutePaths.ConnectPathPrefix + subPath.ToString().EnsureLeadingSlash(); - path = path.EnsureLeadingSlash(); - - _sanitizedLogger.LogDebug("Rewriting MTLS request from: {oldPath} to: {newPath}", - context.Request.Path.ToString(), path); - context.Request.Path = path; - } - else - { - return; - } + _sanitizedLogger.LogDebug("Rewriting MTLS request from: {oldPath} to: {newPath}", + context.Request.Path.ToString(), path); + context.Request.Path = path; } } await _next(context); } + + private bool RequestedHostMatches(HostString requestHost, string configuredDomain) + { + // Parse the configured domain which might contain a port + var configuredHostname = configuredDomain; + var configuredPort = 443; + + var colonIndex = configuredDomain.IndexOf(':'); + if (colonIndex >= 0) + { + configuredHostname = configuredDomain.Substring(0, colonIndex); + if (int.TryParse(configuredDomain.Substring(colonIndex + 1), out var port)) + { + configuredPort = port; + } + } + + // Compare hostnames (case-insensitive) + if (!string.Equals(requestHost.Host, configuredHostname, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var requestPort = requestHost.Port ?? 443; + return requestPort == configuredPort; + } + private async Task TriggerCertificateAuthentication(HttpContext context) { var x509AuthResult = await context.AuthenticateAsync(_options.MutualTls.ClientCertificateAuthenticationScheme); diff --git a/identity-server/src/IdentityServer/Services/Default/DefaultSessionCoordinationService.cs b/identity-server/src/IdentityServer/Services/Default/DefaultSessionCoordinationService.cs index 78cc9596e..33820f4b3 100644 --- a/identity-server/src/IdentityServer/Services/Default/DefaultSessionCoordinationService.cs +++ b/identity-server/src/IdentityServer/Services/Default/DefaultSessionCoordinationService.cs @@ -140,14 +140,17 @@ public class DefaultSessionCoordinationService : ISessionCoordinationService { var client = await ClientStore.FindClientByIdAsync(clientId); // i don't think we care if it's an enabled client at this point - var shouldCoordinate = - client.CoordinateLifetimeWithUserSession == true || - (Options.Authentication.CoordinateClientLifetimesWithUserSession && client.CoordinateLifetimeWithUserSession != false); - - if (shouldCoordinate) + if (client != null) { - // this implies they should also be contacted for backchannel logout below - clientsToCoordinate.Add(clientId); + var shouldCoordinate = + client.CoordinateLifetimeWithUserSession == true || + (Options.Authentication.CoordinateClientLifetimesWithUserSession && client.CoordinateLifetimeWithUserSession != false); + + if (shouldCoordinate) + { + // this implies they should also be contacted for backchannel logout below + clientsToCoordinate.Add(clientId); + } } } diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Authorize/PushedAuthorizationTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Authorize/PushedAuthorizationTests.cs index 9dbe07b22..cd467751c 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Authorize/PushedAuthorizationTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Authorize/PushedAuthorizationTests.cs @@ -15,6 +15,7 @@ public class PushedAuthorizationTests { private readonly IdentityServerPipeline _mockPipeline = new(); private Client _client; + private string clientSecret = Guid.NewGuid().ToString(); public PushedAuthorizationTests() { @@ -22,7 +23,7 @@ public class PushedAuthorizationTests ConfigureUsers(); ConfigureScopesAndResources(); - _mockPipeline.Initialize(); + _mockPipeline.Initialize(enableLogging: true); _mockPipeline.Options.Endpoints.EnablePushedAuthorizationEndpoint = true; } @@ -58,12 +59,32 @@ public class PushedAuthorizationTests authorization.State.ShouldBe(expectedState); } + [Fact] + public async Task sensitive_values_should_not_be_logged_on_bad_request_to_par_endpoint() + { + // Login + await _mockPipeline.LoginAsync("bob"); + _mockPipeline.BrowserClient.AllowAutoRedirect = false; + + // Push Authorization + var expectedCallback = _client.RedirectUris.First(); + var expectedState = "123_state"; + var (parJson, statusCode) = await _mockPipeline.PushAuthorizationRequestAsync( + clientSecret: clientSecret, + redirectUri: "bogus", // <-- Intentionally wrong, to provoke logging an error with raw request + state: expectedState + ); + + _mockPipeline.MockLogger.LogMessages.ShouldContain(msg => msg.Contains("\"client_secret\": \"***REDACTED***\"")); + _mockPipeline.MockLogger.LogMessages.ShouldNotContain(msg => msg.Contains(clientSecret)); + } + [Fact] public async Task using_pushed_authorization_when_it_is_globally_disabled_fails() { _mockPipeline.Options.Endpoints.EnablePushedAuthorizationEndpoint = false; - var (_, statusCode) = await _mockPipeline.PushAuthorizationRequestAsync(); + var (_, statusCode) = await _mockPipeline.PushAuthorizationRequestAsync(clientSecret: clientSecret); statusCode.ShouldBe(HttpStatusCode.NotFound); } @@ -113,7 +134,7 @@ public class PushedAuthorizationTests public async Task existing_pushed_authorization_request_uris_become_invalid_when_par_is_disabled() { // PAR is enabled when we push authorization... - var (parJson, statusCode) = await _mockPipeline.PushAuthorizationRequestAsync(); + var (parJson, statusCode) = await _mockPipeline.PushAuthorizationRequestAsync(clientSecret: clientSecret); statusCode.ShouldBe(HttpStatusCode.Created); parJson.ShouldNotBeNull(); @@ -141,7 +162,7 @@ public class PushedAuthorizationTests // Login await _mockPipeline.LoginAsync("bob"); - var (parJson, statusCode) = await _mockPipeline.PushAuthorizationRequestAsync(); + var (parJson, statusCode) = await _mockPipeline.PushAuthorizationRequestAsync(clientSecret: clientSecret); ; statusCode.ShouldBe(HttpStatusCode.Created); parJson.ShouldNotBeNull(); @@ -274,7 +295,7 @@ public class PushedAuthorizationTests ClientId = "client1", ClientSecrets = new [] { - new Secret("secret".Sha256()) + new Secret(clientSecret.Sha256()) }, AllowedGrantTypes = GrantTypes.Implicit, RequireConsent = false, diff --git a/identity-server/test/IdentityServer.UnitTests/Endpoints/Results/ProtectedResourceErrorResultTests.cs b/identity-server/test/IdentityServer.UnitTests/Endpoints/Results/ProtectedResourceErrorResultTests.cs new file mode 100644 index 000000000..9cff25ee7 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Endpoints/Results/ProtectedResourceErrorResultTests.cs @@ -0,0 +1,61 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Endpoints.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; + +namespace UnitTests.Endpoints.Results; + +public class ProtectedResourceErrorResultTests +{ + private readonly ProtectedResourceErrorHttpWriter writer = new(); + + [Fact] + public void WwwAuthenticate_header_with_error_and_description_should_be_a_single_line() + { + var context = new DefaultHttpContext(); + + writer.WriteHttpResponse( + new ProtectedResourceErrorResult("oops", "big oops"), + context + ); + + var wwwAuthHeader = context.Response.Headers[HeaderNames.WWWAuthenticate].ToString(); + wwwAuthHeader.ShouldBe( + """ + Bearer realm="IdentityServer",error="oops",error_description="big oops" + """); + } + + [Fact] + public void WwwAuthenticate_header_with_error_should_be_a_single_line() + { + var context = new DefaultHttpContext(); + + writer.WriteHttpResponse( + new ProtectedResourceErrorResult("oops"), + context + ); + + var wwwAuthHeader = context.Response.Headers[HeaderNames.WWWAuthenticate].ToString(); + wwwAuthHeader.ShouldBe( + """ + Bearer realm="IdentityServer",error="oops" + """); + } + + [Fact] + public void WwwAuthenticate_header_should_always_be_a_single_string_value() + { + var context = new DefaultHttpContext(); + + writer.WriteHttpResponse( + new ProtectedResourceErrorResult("oops", "big oops"), + context + ); + + var wwwAuthHeader = context.Response.Headers[HeaderNames.WWWAuthenticate]; + wwwAuthHeader.Count.ShouldBe(1); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Hosting/MutualTlsEndpointMiddlewareTests.cs b/identity-server/test/IdentityServer.UnitTests/Hosting/MutualTlsEndpointMiddlewareTests.cs index bc32c2942..3d21fd54f 100644 --- a/identity-server/test/IdentityServer.UnitTests/Hosting/MutualTlsEndpointMiddlewareTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Hosting/MutualTlsEndpointMiddlewareTests.cs @@ -382,6 +382,149 @@ public class MutualTlsEndpointMiddlewareTests context.Request.Path.ToString().ShouldStartWith("/"); } + [Fact] + internal void mtls_endpoint_type_when_mtls_disabled_should_be_none() + { + _options.MutualTls.Enabled = false; + var context = CreateContext(); + var result = _subject.DetermineMtlsEndpointType(context, out var subPath); + result.ShouldBe(MutualTlsEndpointMiddleware.MtlsEndpointType.None); + subPath.ShouldBeNull(); + } + + [Theory] + [InlineData("mtls.example.com", "mtls.example.com", MutualTlsEndpointMiddleware.MtlsEndpointType.SeparateDomain)] + [InlineData("mTLS.example.com", "mtls.example.com", MutualTlsEndpointMiddleware.MtlsEndpointType.SeparateDomain)] + [InlineData("mtls.example.com", "mTLS.example.com", MutualTlsEndpointMiddleware.MtlsEndpointType.SeparateDomain)] + [InlineData("mtls.example.com:443", "mtls.example.com", MutualTlsEndpointMiddleware.MtlsEndpointType.SeparateDomain)] + [InlineData("mtls.example.com:5001", "mtls.example.com", MutualTlsEndpointMiddleware.MtlsEndpointType.None)] + [InlineData("mtls.example.com", "mtls.example.com:443", MutualTlsEndpointMiddleware.MtlsEndpointType.SeparateDomain)] + [InlineData("mtls.example.com:443", "mtls.example.com:443", MutualTlsEndpointMiddleware.MtlsEndpointType.SeparateDomain)] + [InlineData("mtls.example.com:5001", "mtls.example.com:443", MutualTlsEndpointMiddleware.MtlsEndpointType.None)] + [InlineData("mtls.example.com", "mtls.example.com:5001", MutualTlsEndpointMiddleware.MtlsEndpointType.None)] + [InlineData("mtls.example.com:443", "mtls.example.com:5001", MutualTlsEndpointMiddleware.MtlsEndpointType.None)] + [InlineData("mtls.example.com:5001", "mtls.example.com:5001", MutualTlsEndpointMiddleware.MtlsEndpointType.SeparateDomain)] + internal void mtls_endpoint_type_separate_domain_should_be_detected(string requestedHost, string configuredDomainName, MutualTlsEndpointMiddleware.MtlsEndpointType expectedType) + { + // Arrange + _options.MutualTls.Enabled = true; + _options.MutualTls.DomainName = configuredDomainName; + var context = CreateContext(); + context.Request.Host = new HostString(requestedHost); + + // Act + var result = _subject.DetermineMtlsEndpointType(context, out var subPath); + + // Assert + result.ShouldBe(expectedType); + subPath.ShouldBeNull(); + } + + [Theory] + [InlineData("example.com", "mtls.example.com")] + [InlineData("example.com:443", "mtls.example.com")] + [InlineData("example.com:5001", "mtls.example.com")] + [InlineData("other.example.com", "mtls.example.com")] + [InlineData("other.example.com:443", "mtls.example.com")] + [InlineData("example.com", "mtls.example.com:443")] + [InlineData("example.com:443", "mtls.example.com:443")] + [InlineData("other.example.com", "mtls.example.com:5001")] + [InlineData("other.example.com:5001", "mtls.example.com:5001")] + internal void mtls_endpoint_type_separate_domain_should_not_match_different_domain(string requestedHost, string configuredDomainName) + { + // Arrange + _options.MutualTls.Enabled = true; + _options.MutualTls.DomainName = configuredDomainName; + var context = CreateContext(); + context.Request.Host = new HostString(requestedHost); + + // Act + var result = _subject.DetermineMtlsEndpointType(context, out var subPath); + + // Assert + result.ShouldBe(MutualTlsEndpointMiddleware.MtlsEndpointType.None); + subPath.ShouldBeNull(); + } + + [Theory] + [InlineData("mtls.example.com", "mtls")] + [InlineData("mtls.example.com", "mTLS")] + [InlineData("mTLS.example.com", "mtls")] + [InlineData("mtls.example.com:443", "mtls")] + [InlineData("mtls.example.com:5001", "mtls")] + internal void mtls_endpoint_type_subdomain_should_be_detected(string requestedHost, string configuredDomainName) + { + // Arrange + _options.MutualTls.Enabled = true; + _options.MutualTls.DomainName = configuredDomainName; + var context = CreateContext(); + context.Request.Host = new HostString(requestedHost); + + // Act + var result = _subject.DetermineMtlsEndpointType(context, out var subPath); + + // Assert + result.ShouldBe(MutualTlsEndpointMiddleware.MtlsEndpointType.Subdomain); + subPath.ShouldBeNull(); + } + + [Theory] + [InlineData("api.example.com", "mtls")] + [InlineData("api.example.com:443", "mtls")] + [InlineData("example.com", "mtls")] + [InlineData("example.com:5001", "mtls")] + internal void mtls_endpoint_type_subdomain_should_not_match_different_subdomain(string requestedHost, string configuredDomainName) + { + // Arrange + _options.MutualTls.Enabled = true; + _options.MutualTls.DomainName = configuredDomainName; + var context = CreateContext(); + context.Request.Host = new HostString(requestedHost); + + // Act + var result = _subject.DetermineMtlsEndpointType(context, out var subPath); + + // Assert + result.ShouldBe(MutualTlsEndpointMiddleware.MtlsEndpointType.None); + subPath.ShouldBeNull(); + } + + [Theory] + [InlineData("/connect/mtls/token")] + [InlineData("/connect/mTLS/token")] + internal void mtls_endpoint_type_path_based_should_be_detected(string requestedPath) + { + // Arrange + _options.MutualTls.Enabled = true; + var context = CreateContext(); + context.Request.Path = new PathString(requestedPath); + + // Act + var result = _subject.DetermineMtlsEndpointType(context, out var subPath); + + // Assert + result.ShouldBe(MutualTlsEndpointMiddleware.MtlsEndpointType.PathBased); + subPath.Value.ToString().ShouldBe("/token"); + } + + [Fact] + internal void mtls_endpoint_type_should_be_none_when_enabled_but_no_matching_configuration() + { + // Arrange + _options.MutualTls.Enabled = true; + _options.MutualTls.DomainName = "mtls.example.com"; + var context = CreateContext(); + context.Request.Host = new HostString("regular.example.com"); + context.Request.Path = new PathString("/connect/token"); + + // Act + var result = _subject.DetermineMtlsEndpointType(context, out var subPath); + + // Assert + result.ShouldBe(MutualTlsEndpointMiddleware.MtlsEndpointType.None); + subPath.ShouldBeNull(); + } + private async Task GetResponseBodyAsString(HttpContext context) { context.Response.Body.Seek(0, SeekOrigin.Begin); diff --git a/identity-server/test/IdentityServer.UnitTests/Services/Default/DefaultSessionCoordinationServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Services/Default/DefaultSessionCoordinationServiceTests.cs new file mode 100644 index 000000000..59f147431 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Services/Default/DefaultSessionCoordinationServiceTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.Logging.Abstractions; +using UnitTests.Endpoints.EndSession; + +namespace UnitTests.Services.Default; + +public class DefaultSessionCoordinationServiceTests +{ + public DefaultSessionCoordinationService Service; + + [Fact] + public async Task Handles_missing_client_null_reference() + { + var stubBackChannelLogoutClient = new StubBackChannelLogoutClient(); + Service = new DefaultSessionCoordinationService( + new IdentityServerOptions(), + new InMemoryPersistedGrantStore(), + new InMemoryClientStore([]), + stubBackChannelLogoutClient, + new NullLogger()); + + await Service.ProcessExpirationAsync(new UserSession + { + ClientIds = ["not_found"], + SessionId = "1", + SubjectId = "1" + }); + + stubBackChannelLogoutClient + .SendLogoutsWasCalled + .ShouldBeFalse(); + } +}