Merge pull request #2121 from DuendeSoftware/jmdc/7.2-to-7.3

Merge fixes from 7.2.x forward to 7.3.x
This commit is contained in:
Joe DeCock 2025-07-22 08:57:05 -05:00 committed by GitHub
commit b0b257570a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 410 additions and 67 deletions

2
.gitignore vendored
View file

@ -228,4 +228,4 @@ artifacts
*.Artifacts/
reports
reports

View file

@ -11,4 +11,4 @@
<MinVerMinimumMajorMinor>7.0</MinVerMinimumMajorMinor>
</PropertyGroup>
</Project>
</Project>

View file

@ -50,6 +50,9 @@ public class LoggingOptions
public ICollection<string> AuthorizeRequestSensitiveValuesFilter { get; set; } =
new HashSet<string>
{
// 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.
/// </summary>
/// <remarks>Please be aware that initializing this property could expose sensitive information in your logs.</remarks>
/// <remarks>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 <see cref="AuthorizeRequestSensitiveValuesFilter"/>
/// </remarks>
public ICollection<string> PushedAuthorizationSensitiveValuesFilter { get; set; } =
new HashSet<string>
{
OidcConstants.TokenRequest.ClientSecret,
OidcConstants.TokenRequest.ClientAssertion,
OidcConstants.AuthorizeRequest.IdTokenHint,
OidcConstants.AuthorizeRequest.Request
};

View file

@ -23,9 +23,18 @@ public class MutualTlsOptions
/// <summary>
/// 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.
/// </summary>
/// <remarks>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.
/// </remarks>
public string? DomainName { get; set; }
/// <summary>

View file

@ -58,17 +58,25 @@ internal class ProtectedResourceErrorHttpWriter : IHttpResponseWriter<ProtectedR
errorDescription = "The access token expired";
}
var errorString = string.Format($"error=\"{error}\"");
if (errorDescription.IsMissing())
var values = new List<string>
{
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;
}
}

View file

@ -35,64 +35,116 @@ public class MutualTlsEndpointMiddleware
_sanitizedLogger = new SanitizedLogger<MutualTlsEndpointMiddleware>(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;
}
/// <inheritdoc />
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<AuthenticateResult> TriggerCertificateAuthentication(HttpContext context)
{
var x509AuthResult = await context.AuthenticateAsync(_options.MutualTls.ClientCertificateAuthenticationScheme);

View file

@ -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);
}
}
}

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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<string> GetResponseBodyAsString(HttpContext context)
{
context.Response.Body.Seek(0, SeekOrigin.Begin);

View file

@ -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<DefaultSessionCoordinationService>());
await Service.ProcessExpirationAsync(new UserSession
{
ClientIds = ["not_found"],
SessionId = "1",
SubjectId = "1"
});
stubBackChannelLogoutClient
.SendLogoutsWasCalled
.ShouldBeFalse();
}
}