Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 58 additions & 81 deletions src/OpenIddict.Server/OpenIddictServerHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ public static partial class OpenIddictServerHandlers
ValidateClientAssertion.Descriptor,
ValidateClientAssertionWellknownClaims.Descriptor,
ValidateClientAssertionIssuer.Descriptor,
ValidateClientAssertionAudience.Descriptor,
ValidateClientId.Descriptor,
ValidateClientType.Descriptor,
ValidateClientSecret.Descriptor,
Expand Down Expand Up @@ -632,10 +631,15 @@ public async ValueTask HandleAsync(ProcessAuthenticationContext context)
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
Expand All @@ -648,6 +652,31 @@ public async ValueTask HandleAsync(ProcessAuthenticationContext context)
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)
Expand Down Expand Up @@ -716,9 +745,11 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context)
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(
Expand All @@ -729,9 +760,11 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context)
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(
Expand All @@ -742,10 +775,12 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context)
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,
Expand All @@ -755,10 +790,12 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context)
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,
Expand Down Expand Up @@ -871,6 +908,7 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context)
/// <summary>
/// Contains the logic responsible for validating the audience contained in the client assertion principal.
/// </summary>
[Obsolete("This class is obsolete and will be removed in a future version.")]
public sealed class ValidateClientAssertionAudience : IOpenIddictServerHandler<ProcessAuthenticationContext>
{
/// <summary>
Expand All @@ -885,70 +923,7 @@ public sealed class ValidateClientAssertionAudience : IOpenIddictServerHandler<P
.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 '/';
}
}
public ValueTask HandleAsync(ProcessAuthenticationContext context) => default;
}

/// <summary>
Expand Down Expand Up @@ -977,7 +952,9 @@ public ValidateClientId(IOpenIddictApplicationManager? applicationManager = null
new ValidateClientId(provider.GetService<IOpenIddictApplicationManager>() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)));
})
#pragma warning disable CS0618
.SetOrder(ValidateClientAssertionAudience.Descriptor.Order + 1_000)
#pragma warning restore CS0618
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,71 @@ public async Task ProcessAuthentication_ValidAuthorizationCodeReturnsExpectedIde
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)]
Expand Down