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();
+ }
+}