From 7984d2435ef47084192bde0541c260ffc224f9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Sun, 6 Sep 2026 11:09:41 +0200 Subject: [PATCH] Remove the flawed client assertion audience validation and use the generic logic --- .../OpenIddictServerHandlers.cs | 156 ++++++------------ .../OpenIddictServerIntegrationTests.cs | 65 ++++++++ 2 files changed, 120 insertions(+), 101 deletions(-) diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 43a2cc20..ae5d97a7 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -47,7 +47,6 @@ public static partial class OpenIddictServerHandlers ValidateClientAssertion.Descriptor, ValidateClientAssertionWellknownClaims.Descriptor, ValidateClientAssertionIssuer.Descriptor, - ValidateClientAssertionAudience.Descriptor, ValidateClientId.Descriptor, ValidateClientType.Descriptor, ValidateClientSecret.Descriptor, @@ -637,10 +636,15 @@ public static partial class OpenIddictServerHandlers return; } + // Throw an exception if the issuer cannot be retrieved or is not valid. + var issuer = context.Options.Issuer ?? context.BaseUri; + if (issuer is not { IsAbsoluteUri: true }) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0496)); + } + var notification = new ValidateTokenContext(context.Transaction) { - // Note: for client authentication assertions, audience validation is enforced by a specialized handler. - DisableAudienceValidation = true, DisablePresenterValidation = true, Token = context.ClientAssertion, TokenFormat = context.ClientAssertionType switch @@ -653,6 +657,31 @@ public static partial class OpenIddictServerHandlers ValidTokenTypes = { TokenTypeIdentifiers.Private.ClientAssertion } }; + // Note: for client assertions, the audience MUST be the issuer URI. + if (issuer is { AbsolutePath: "/", Query.Length: 0, Fragment.Length: 0 }) + { + // If the issuer URI doesn't contain any query/fragment, allow both http://www.fabrikam.com + // and http://www.fabrikam.com/ (the recommended URI representation) to be considered valid. + // See https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.3 for more information. + notification.ValidAudiences.Add(issuer.AbsoluteUri); // Uri.AbsoluteUri is normalized and always contains a trailing slash. + notification.ValidAudiences.Add(issuer.AbsoluteUri[..^1]); + } + + else if (issuer is { AbsolutePath.Length: 0, Query.Length: 0, Fragment.Length: 0 }) + { + // When properly normalized, Uri.AbsolutePath should never be empty and should at least + // contain a leading slash. While dangerous, System.Uri now offers a way to create a URI + // instance without applying the default canonicalization logic. To support such URIs, + // a special case is added here to add back the missing trailing slash when necessary. + notification.ValidAudiences.Add(issuer.AbsoluteUri); + notification.ValidAudiences.Add(issuer.AbsoluteUri + "/"); + } + + else + { + notification.ValidAudiences.Add(issuer.AbsoluteUri); + } + await _dispatcher.DispatchAsync(notification); if (notification.IsRequestHandled) @@ -721,9 +750,11 @@ public static partial class OpenIddictServerHandlers return ValueTask.CompletedTask; } - // Client assertions MUST contain an "iss" claim. For more information, - // see https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication - // and https://datatracker.ietf.org/doc/html/rfc7523#section-3. + // Client assertions MUST contain an issuer. + // + // For more information, see + // https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication and + // https://datatracker.ietf.org/doc/html/rfc7523#section-3. if (!context.ClientAssertionPrincipal.HasClaim(Claims.Issuer)) { context.Reject( @@ -734,9 +765,11 @@ public static partial class OpenIddictServerHandlers return ValueTask.CompletedTask; } - // Client assertions MUST contain a "sub" claim. For more information, - // see https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication - // and https://datatracker.ietf.org/doc/html/rfc7523#section-3. + // Client assertions MUST contain a subject. + // + // For more information, see + // https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication and + // https://datatracker.ietf.org/doc/html/rfc7523#section-3. if (!context.ClientAssertionPrincipal.HasClaim(Claims.Subject)) { context.Reject( @@ -747,10 +780,12 @@ public static partial class OpenIddictServerHandlers return ValueTask.CompletedTask; } - // Client assertions MUST contain an "aud" claim. For more information, - // see https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication - // and https://datatracker.ietf.org/doc/html/rfc7523#section-3. - if (!context.ClientAssertionPrincipal.HasClaim(Claims.Audience)) + // Client assertions MUST contain a unique audience. + // + // For more information, see + // https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication and + // https://datatracker.ietf.org/doc/html/rfc7523#section-3. + if (context.ClientAssertionPrincipal.GetAudiences() is not [_]) { context.Reject( error: Errors.InvalidRequest, @@ -760,10 +795,12 @@ public static partial class OpenIddictServerHandlers return ValueTask.CompletedTask; } - // Client assertions MUST contain contain a "exp" claim. For more information, - // see https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication - // and https://datatracker.ietf.org/doc/html/rfc7523#section-3. - if (!context.ClientAssertionPrincipal.HasClaim(Claims.ExpiresAt)) + // Client assertions MUST contain contain an expiration date. + // + // For more information, see + // https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication and + // https://datatracker.ietf.org/doc/html/rfc7523#section-3. + if (context.ClientAssertionPrincipal.GetExpirationDate() is null) { context.Reject( error: Errors.InvalidRequest, @@ -873,89 +910,6 @@ public static partial class OpenIddictServerHandlers } } - /// - /// Contains the logic responsible for validating the audience contained in the client assertion principal. - /// - public sealed class ValidateClientAssertionAudience : IOpenIddictServerHandler - { - /// - /// Gets the default descriptor definition assigned to this handler. - /// - public static OpenIddictServerHandlerDescriptor Descriptor { get; } - = OpenIddictServerHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseSingletonHandler() - .SetOrder(ValidateClientAssertionIssuer.Descriptor.Order + 1_000) - .SetType(OpenIddictServerHandlerType.BuiltIn) - .Build(); - - /// - public ValueTask HandleAsync(ProcessAuthenticationContext context) - { - ArgumentNullException.ThrowIfNull(context); - - Debug.Assert(context.ClientAssertionPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - - // Important: client assertions with multiple audiences was initially deliberately supported by - // the OpenID Connect and Assertion Framework for OAuth 2.0 Client Authentication specifications. - // Since 2025, using multiple audiences is no longer allowed for security reasons: as such, a single - // audience is allowed here and an exception is thrown if multiple claims are present in the principal. - // - // See https://www.ietf.org/archive/id/draft-ietf-oauth-rfc7523bis-01.html#section-4 for more information. - var audience = context.ClientAssertionPrincipal.GetClaim(Claims.Audience); - if (string.IsNullOrEmpty(audience) || - !Uri.TryCreate(audience, UriKind.Absolute, out Uri? uri) || OpenIddictHelpers.IsImplicitFileUri(uri)) - { - context.Reject( - error: Errors.InvalidGrant, - description: SR.FormatID2172(Claims.Audience), - uri: SR.FormatID8000(SR.ID2172)); - - return ValueTask.CompletedTask; - } - - // Throw an exception if the issuer cannot be retrieved or is not valid. - var issuer = context.Options.Issuer ?? context.BaseUri; - if (issuer is not { IsAbsoluteUri: true }) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0496)); - } - - if (!UriEquals(uri, issuer)) - { - context.Reject( - error: Errors.InvalidGrant, - description: SR.FormatID2173(Claims.Audience), - uri: SR.FormatID8000(SR.ID2173)); - - return ValueTask.CompletedTask; - } - - return ValueTask.CompletedTask; - - static bool UriEquals(Uri left, Uri right) - { - if (string.Equals(left.AbsolutePath, right.AbsolutePath, StringComparison.Ordinal)) - { - return true; - } - - // Consider the two URIs identical if they only differ by the trailing slash. - - if (left.AbsolutePath.Length == right.AbsolutePath.Length + 1 && - left.AbsolutePath.StartsWith(right.AbsolutePath, StringComparison.Ordinal) && - left.AbsolutePath[^1] is '/') - { - return true; - } - - return right.AbsolutePath.Length == left.AbsolutePath.Length + 1 && - right.AbsolutePath.StartsWith(left.AbsolutePath, StringComparison.Ordinal) && - right.AbsolutePath[^1] is '/'; - } - } - } - /// /// Contains the logic responsible for rejecting authentication demands that use an invalid client_id. /// @@ -967,7 +921,7 @@ public static partial class OpenIddictServerHandlers public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ValidateClientAssertionAudience.Descriptor.Order + 1_000) + .SetOrder(ValidateClientAssertionIssuer.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index fd3569a4..466d4240 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -838,6 +838,71 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal("Bob le Magnifique", (string?) response[Claims.Subject]); } + [Theory] + [InlineData("http://www.fabrikam.com/", new[] { "http://www.fabrikam.com", "http://www.fabrikam.com/" })] + [InlineData("http://www.fabrikam.com/issuer", new[] { "http://www.fabrikam.com/issuer" })] + [InlineData("http://www.fabrikam.com/issuer/", new[] { "http://www.fabrikam.com/issuer/" })] + public async Task ProcessAuthentication_ValidAudiencesAreAttachedToClientAssertionValidationContext(string issuer, string[] audiences) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.SetIssuer(issuer); + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetClaim(Claims.Subject, "Bob le Magnifique"); + + return ValueTask.CompletedTask; + })); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Single(context.ValidTokenTypes); + Assert.Contains(TokenTypeIdentifiers.Private.ClientAssertion, context.ValidTokenTypes); + + Assert.Equal(audiences.Length, context.ValidAudiences.Count); + foreach (var audience in audiences) + { + Assert.Contains(audience, context.ValidAudiences); + } + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetAudiences(issuer) + .SetClaim(Claims.Subject, "Fabrikam") + .SetClaim(Claims.Issuer, "Fabrikam") + .SetExpirationDate(TimeProvider.System.GetUtcNow() + TimeSpan.FromHours(1)) + .SetTokenType(TokenTypeIdentifiers.Private.ClientAssertion); + + return ValueTask.CompletedTask; + }); + + builder.SetOrder(int.MinValue); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientAssertion = "2YotnFZFEjr1zCsicMWpAA", + ClientAssertionType = ClientAssertionTypes.JwtBearer, + ClientId = "Fabrikam", + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }); + + // Assert + Assert.NotNull(response.AccessToken); + } + [Theory] [InlineData(OpenIddictServerEndpointType.DeviceAuthorization)] [InlineData(OpenIddictServerEndpointType.Introspection)]