Browse Source

Remove the flawed client assertion audience validation and use the generic logic

pull/2541/head
Kévin Chalet 3 days ago
parent
commit
7984d2435e
  1. 156
      src/OpenIddict.Server/OpenIddictServerHandlers.cs
  2. 65
      test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs

156
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
}
}
/// <summary>
/// Contains the logic responsible for validating the audience contained in the client assertion principal.
/// </summary>
public sealed class ValidateClientAssertionAudience : IOpenIddictServerHandler<ProcessAuthenticationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireClientAssertionPrincipal>()
.UseSingletonHandler<ValidateClientAssertionAudience>()
.SetOrder(ValidateClientAssertionIssuer.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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 '/';
}
}
}
/// <summary>
/// Contains the logic responsible for rejecting authentication demands that use an invalid client_id.
/// </summary>
@ -967,7 +921,7 @@ public static partial class OpenIddictServerHandlers
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.UseSingletonHandler<ValidateClientId>()
.SetOrder(ValidateClientAssertionAudience.Descriptor.Order + 1_000)
.SetOrder(ValidateClientAssertionIssuer.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();

65
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<HandleTokenRequestContext>(builder =>
builder.UseInlineHandler(context =>
{
context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer"))
.SetClaim(Claims.Subject, "Bob le Magnifique");
return ValueTask.CompletedTask;
}));
options.AddEventHandler<ValidateTokenContext>(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)]

Loading…
Cancel
Save