Browse Source

Stop using the token endpoint URI as the client assertion audience and use the new "client-authentication+jwt" JSON Web Token type

pull/2341/head
Kévin Chalet 8 months ago
parent
commit
d95b32221c
  1. 4
      src/OpenIddict.Abstractions/OpenIddictConstants.cs
  2. 3
      src/OpenIddict.Abstractions/OpenIddictResources.resx
  3. 11
      src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs
  4. 27
      src/OpenIddict.Client/OpenIddictClientHandlers.cs
  5. 14
      src/OpenIddict.Client/OpenIddictClientOptions.cs
  6. 14
      src/OpenIddict.Client/OpenIddictClientRegistration.cs
  7. 8
      src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs
  8. 8
      src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs
  9. 8
      src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs
  10. 57
      src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs
  11. 9
      src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs
  12. 177
      src/OpenIddict.Server/OpenIddictServerHandlers.cs
  13. 2
      src/OpenIddict.Server/OpenIddictServerOptions.cs
  14. 7
      src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs
  15. 8
      src/OpenIddict.Validation/OpenIddictValidationHandlers.cs
  16. 2
      src/OpenIddict.Validation/OpenIddictValidationOptions.cs

4
src/OpenIddict.Abstractions/OpenIddictConstants.cs

@ -261,7 +261,9 @@ public static class OpenIddictConstants
public static class JsonWebTokenTypes public static class JsonWebTokenTypes
{ {
public const string AccessToken = "at+jwt"; public const string AccessToken = "at+jwt";
public const string Jwt = "JWT"; public const string AuthorizationGrant = "authorization-grant+jwt";
public const string ClientAuthentication = "client-authentication+jwt";
public const string GenericJsonWebToken = "JWT";
public static class Prefixes public static class Prefixes
{ {

3
src/OpenIddict.Abstractions/OpenIddictResources.resx

@ -1796,6 +1796,9 @@ Alternatively, any value respecting the '[region]-[subregion]-[identifier]' patt
<data name="ID0495" xml:space="preserve"> <data name="ID0495" xml:space="preserve">
<value>The '{0}' parameter cannot contain values that are not valid absolute URIs containing no fragment component.</value> <value>The '{0}' parameter cannot contain values that are not valid absolute URIs containing no fragment component.</value>
</data> </data>
<data name="ID0496" xml:space="preserve">
<value>The issuer cannot be retrieved from the server options or inferred from the current request or is not a valid value.</value>
</data>
<data name="ID2000" xml:space="preserve"> <data name="ID2000" xml:space="preserve">
<value>The security token is missing.</value> <value>The security token is missing.</value>
</data> </data>

11
src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs

@ -80,7 +80,8 @@ public static partial class OpenIddictClientHandlers
// //
// See https://datatracker.ietf.org/doc/html/draft-bradley-oauth-jwt-encoded-state-09#section-4.3 // See https://datatracker.ietf.org/doc/html/draft-bradley-oauth-jwt-encoded-state-09#section-4.3
// for more information. // for more information.
if (context.ValidTokenTypes.Count > 1 && context.ValidTokenTypes.Contains(TokenTypeIdentifiers.Private.StateToken)) if (context.ValidTokenTypes.Count is > 1 &&
context.ValidTokenTypes.Contains(TokenTypeIdentifiers.Private.StateToken))
{ {
throw new InvalidOperationException(SR.GetResourceString(SR.ID0308)); throw new InvalidOperationException(SR.GetResourceString(SR.ID0308));
} }
@ -1109,10 +1110,12 @@ public static partial class OpenIddictClientHandlers
{ {
null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0025)), null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0025)),
// For client assertions, use the generic "JWT" type. // Note: OpenIddict 7.0 and higher no uses the generic "JWT" value for client assertions
TokenTypeIdentifiers.Private.ClientAssertion => JsonWebTokenTypes.Jwt, // but uses the new standard "client-authentication+jwt" type instead, as defined in the
// https://www.ietf.org/archive/id/draft-ietf-oauth-rfc7523bis-01.html#name-updates-to-rfc-7523
// specification.
TokenTypeIdentifiers.Private.ClientAssertion => JsonWebTokenTypes.ClientAuthentication,
// For state tokens, use its private representation.
TokenTypeIdentifiers.Private.StateToken => JsonWebTokenTypes.Private.StateToken, TokenTypeIdentifiers.Private.StateToken => JsonWebTokenTypes.Private.StateToken,
string value => value string value => value

27
src/OpenIddict.Client/OpenIddictClientHandlers.cs

@ -2646,21 +2646,20 @@ public static partial class OpenIddictClientHandlers
principal.SetExpirationDate(principal.GetCreationDate() + lifetime.Value); principal.SetExpirationDate(principal.GetCreationDate() + lifetime.Value);
} }
// Use the URI of the token endpoint as the audience, as recommended by the specifications. // Important: the initial OpenID Connect and Assertion Framework for OAuth 2.0 Client Authentication
// Applications that need to use a different value can register a custom event handler. // specifications initially encouraged using the token endpoint URI as the client assertion audience.
// Unfortunately, it was determined in 2025 that using the token endpoint URI could allow a malicious
// identity provider to trick a legitimate client into using attacker-controlled values as audiences,
// including token endpoint URIs or issuer identifiers used by other authorization servers, which could
// result in impersonation attacks if the same set of credentials were used to generate the assertions
// for all the client registrations (which is not a recommended pattern in OpenIddict). To mitigate that,
// OpenIddict no longer allows uses the token endpoint URI and always uses the issuer identity instead.
// Unlike the token endpoint URI, the issuer returned by the authorization server in its configuration
// document is always validated and must exactly match the value expected by the client application.
// //
// See https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication // For more information, see https://www.ietf.org/archive/id/draft-ietf-oauth-rfc7523bis-01.html#name-updates-to-rfc-7521
// and https://datatracker.ietf.org/doc/html/rfc7523#section-3 for more information. // and https://openid.net/wp-content/uploads/2025/01/OIDF-Responsible-Disclosure-Notice-on-Security-Vulnerability-for-private_key_jwt.pdf.
if (!string.IsNullOrEmpty(context.TokenEndpoint?.OriginalString)) principal.SetAudiences(context.Registration.Issuer.OriginalString);
{
principal.SetAudiences(context.TokenEndpoint.OriginalString);
}
// If the token endpoint URI is not available, use the issuer URI as the audience.
else
{
principal.SetAudiences(context.Registration.Issuer.OriginalString);
}
// Use the client_id as both the subject and the issuer, as required by the specifications. // Use the client_id as both the subject and the issuer, as required by the specifications.
// //

14
src/OpenIddict.Client/OpenIddictClientOptions.cs

@ -113,6 +113,20 @@ public sealed class OpenIddictClientOptions
ClockSkew = TimeSpan.Zero, ClockSkew = TimeSpan.Zero,
NameClaimType = Claims.Name, NameClaimType = Claims.Name,
RoleClaimType = Claims.Role, RoleClaimType = Claims.Role,
// Note: unlike IdentityModel, this custom validator deliberately uses case-insensitive comparisons.
TypeValidator = static (type, token, parameters) =>
{
if (parameters.ValidTypes is not null && parameters.ValidTypes.Any() &&
!parameters.ValidTypes.Contains(type, StringComparer.OrdinalIgnoreCase))
{
throw new SecurityTokenInvalidTypeException(SR.GetResourceString(SR.ID0271))
{
InvalidType = type
};
}
return type;
},
// Note: audience and lifetime are manually validated by OpenIddict itself. // Note: audience and lifetime are manually validated by OpenIddict itself.
ValidateAudience = false, ValidateAudience = false,
ValidateLifetime = false ValidateLifetime = false

14
src/OpenIddict.Client/OpenIddictClientRegistration.cs

@ -190,6 +190,20 @@ public sealed class OpenIddictClientRegistration
ClockSkew = TimeSpan.Zero, ClockSkew = TimeSpan.Zero,
NameClaimType = Claims.Name, NameClaimType = Claims.Name,
RoleClaimType = Claims.Role, RoleClaimType = Claims.Role,
// Note: unlike IdentityModel, this custom validator deliberately uses case-insensitive comparisons.
TypeValidator = static (type, token, parameters) =>
{
if (parameters.ValidTypes is not null && parameters.ValidTypes.Any() &&
!parameters.ValidTypes.Contains(type, StringComparer.OrdinalIgnoreCase))
{
throw new SecurityTokenInvalidTypeException(SR.GetResourceString(SR.ID0271))
{
InvalidType = type
};
}
return type;
},
// Note: audience and lifetime are manually validated by OpenIddict itself. // Note: audience and lifetime are manually validated by OpenIddict itself.
ValidateAudience = false, ValidateAudience = false,
ValidateLifetime = false ValidateLifetime = false

8
src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs

@ -900,7 +900,7 @@ public static partial class OpenIddictServerHandlers
// Prevent response_type=none from being used with any other value. // Prevent response_type=none from being used with any other value.
// See https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#none for more information. // See https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#none for more information.
var types = context.Request.GetResponseTypes().ToHashSet(StringComparer.Ordinal); var types = context.Request.GetResponseTypes().ToHashSet(StringComparer.Ordinal);
if (types.Count > 1 && types.Contains(ResponseTypes.None)) if (types.Count is > 1 && types.Contains(ResponseTypes.None))
{ {
context.Logger.LogInformation(6212, SR.GetResourceString(SR.ID6212), context.Request.ResponseType); context.Logger.LogInformation(6212, SR.GetResourceString(SR.ID6212), context.Request.ResponseType);
@ -2396,8 +2396,8 @@ public static partial class OpenIddictServerHandlers
{ {
{ IsAbsoluteUri: true } uri => uri.AbsoluteUri, { IsAbsoluteUri: true } uri => uri.AbsoluteUri,
// At this stage, throw an exception if the issuer cannot be retrieved or is not valid. // Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0023)) _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
}; };
} }
@ -2992,7 +2992,7 @@ public static partial class OpenIddictServerHandlers
// Prevent response_type=none from being used with any other value. // Prevent response_type=none from being used with any other value.
// See https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#none for more information. // See https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#none for more information.
var types = context.Request.GetResponseTypes().ToHashSet(StringComparer.Ordinal); var types = context.Request.GetResponseTypes().ToHashSet(StringComparer.Ordinal);
if (types.Count > 1 && types.Contains(ResponseTypes.None)) if (types.Count is > 1 && types.Contains(ResponseTypes.None))
{ {
context.Logger.LogInformation(6260, SR.GetResourceString(SR.ID6260), context.Request.ResponseType); context.Logger.LogInformation(6260, SR.GetResourceString(SR.ID6260), context.Request.ResponseType);

8
src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs

@ -343,7 +343,13 @@ public static partial class OpenIddictServerHandlers
throw new ArgumentNullException(nameof(context)); throw new ArgumentNullException(nameof(context));
} }
context.Issuer = context.Options.Issuer ?? context.BaseUri; context.Issuer = (context.Options.Issuer ?? context.BaseUri) switch
{
{ IsAbsoluteUri: true } uri => uri,
// Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
};
return default; return default;
} }

8
src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs

@ -737,7 +737,13 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(context.GenericTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); Debug.Assert(context.GenericTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006));
context.Issuer = context.Options.Issuer ?? context.BaseUri; context.Issuer = (context.Options.Issuer ?? context.BaseUri) switch
{
{ IsAbsoluteUri: true } uri => uri,
// Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
};
context.TokenId = context.GenericTokenPrincipal.GetClaim(Claims.JwtId); context.TokenId = context.GenericTokenPrincipal.GetClaim(Claims.JwtId);
context.Subject = context.GenericTokenPrincipal.GetClaim(Claims.Subject); context.Subject = context.GenericTokenPrincipal.GetClaim(Claims.Subject);

57
src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs

@ -95,7 +95,7 @@ public static partial class OpenIddictServerHandlers
// To simplify the token validation parameters selection logic, an exception is thrown // To simplify the token validation parameters selection logic, an exception is thrown
// if multiple token types are considered valid and contain tokens issued by the // if multiple token types are considered valid and contain tokens issued by the
// authorization server and tokens issued by the client (e.g client assertions). // authorization server and tokens issued by the client (e.g client assertions).
if (context.ValidTokenTypes.Count > 1 && if (context.ValidTokenTypes.Count is > 1 &&
context.ValidTokenTypes.Contains(TokenTypeIdentifiers.Private.ClientAssertion)) context.ValidTokenTypes.Contains(TokenTypeIdentifiers.Private.ClientAssertion))
{ {
throw new InvalidOperationException(SR.GetResourceString(SR.ID0308)); throw new InvalidOperationException(SR.GetResourceString(SR.ID0308));
@ -117,9 +117,34 @@ public static partial class OpenIddictServerHandlers
// Note: the audience/issuer/lifetime are manually validated by OpenIddict itself. // Note: the audience/issuer/lifetime are manually validated by OpenIddict itself.
var parameters = new TokenValidationParameters var parameters = new TokenValidationParameters
{ {
TypeValidator = static (type, token, parameters) =>
{
// Note: unlike IdentityModel, this custom validator deliberately uses case-insensitive comparisons.
if (parameters.ValidTypes is not null && parameters.ValidTypes.Any() &&
!parameters.ValidTypes.Contains(type, StringComparer.OrdinalIgnoreCase))
{
throw new SecurityTokenInvalidTypeException(SR.GetResourceString(SR.ID0271))
{
InvalidType = type
};
}
return type;
},
ValidateAudience = false, ValidateAudience = false,
ValidateIssuer = false, ValidateIssuer = false,
ValidateLifetime = false ValidateLifetime = false,
// Note: OpenIddict 7.0 and higher no uses the generic "JWT" value for client assertions and
// requires using the new standard "client-authentication+jwt" type instead, as defined in the
// https://www.ietf.org/archive/id/draft-ietf-oauth-rfc7523bis-01.html#name-updates-to-rfc-7523
// draft. The longer "application/client-authentication+jwt" form is also considered valid.
ValidTypes =
[
JsonWebTokenTypes.ClientAuthentication,
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.ClientAuthentication
]
}; };
// Only provide a signing key resolver if the degraded mode was not enabled. // Only provide a signing key resolver if the degraded mode was not enabled.
@ -204,8 +229,8 @@ public static partial class OpenIddictServerHandlers
// For identity tokens, both "JWT" and "application/jwt" are valid. // For identity tokens, both "JWT" and "application/jwt" are valid.
TokenTypeIdentifiers.IdentityToken => TokenTypeIdentifiers.IdentityToken =>
[ [
JsonWebTokenTypes.Jwt, JsonWebTokenTypes.GenericJsonWebToken,
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.Jwt JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.GenericJsonWebToken
], ],
// For authorization codes, only the short "oi_auc+jwt" form is valid. // For authorization codes, only the short "oi_auc+jwt" form is valid.
@ -529,23 +554,23 @@ public static partial class OpenIddictServerHandlers
// the token type (resolved from "typ" or "token_usage") as a special private claim. // the token type (resolved from "typ" or "token_usage") as a special private claim.
context.Principal = new ClaimsPrincipal(result.ClaimsIdentity).SetTokenType(result.TokenType switch context.Principal = new ClaimsPrincipal(result.ClaimsIdentity).SetTokenType(result.TokenType switch
{ {
// Client assertions are typically created by client libraries with either a missing "typ" header
// or a generic value like "JWT". Since the type defined by the client cannot be used as-is,
// validation is bypassed and tokens used as client assertions are assumed to be client assertions.
_ when context.ValidTokenTypes.Count is 1 &&
context.ValidTokenTypes.Contains(TokenTypeIdentifiers.Private.ClientAssertion)
=> TokenTypeIdentifiers.Private.ClientAssertion,
null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0025)), null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0025)),
// Both at+jwt and application/at+jwt are supported for access tokens. // Both "at+jwt" and "application/at+jwt" are supported for access tokens.
JsonWebTokenTypes.AccessToken or JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.AccessToken JsonWebTokenTypes.AccessToken or
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.AccessToken
=> TokenTypeIdentifiers.AccessToken, => TokenTypeIdentifiers.AccessToken,
// Both JWT and application/JWT are supported for identity tokens. // Both "JWT" and "application/jwt" are supported for identity tokens.
JsonWebTokenTypes.Jwt or JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.Jwt JsonWebTokenTypes.GenericJsonWebToken or
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.GenericJsonWebToken
=> TokenTypeIdentifiers.IdentityToken, => TokenTypeIdentifiers.IdentityToken,
// Both "client-authentication+jwt" and "application/client-authentication+jwt" for client assertions.
JsonWebTokenTypes.ClientAuthentication or
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.ClientAuthentication
=> TokenTypeIdentifiers.Private.ClientAssertion,
JsonWebTokenTypes.Private.AuthorizationCode => TokenTypeIdentifiers.Private.AuthorizationCode, JsonWebTokenTypes.Private.AuthorizationCode => TokenTypeIdentifiers.Private.AuthorizationCode,
JsonWebTokenTypes.Private.DeviceCode => TokenTypeIdentifiers.Private.DeviceCode, JsonWebTokenTypes.Private.DeviceCode => TokenTypeIdentifiers.Private.DeviceCode,
JsonWebTokenTypes.Private.RefreshToken => TokenTypeIdentifiers.RefreshToken, JsonWebTokenTypes.Private.RefreshToken => TokenTypeIdentifiers.RefreshToken,
@ -1629,7 +1654,7 @@ public static partial class OpenIddictServerHandlers
TokenTypeIdentifiers.AccessToken => JsonWebTokenTypes.AccessToken, TokenTypeIdentifiers.AccessToken => JsonWebTokenTypes.AccessToken,
TokenTypeIdentifiers.Private.AuthorizationCode => JsonWebTokenTypes.Private.AuthorizationCode, TokenTypeIdentifiers.Private.AuthorizationCode => JsonWebTokenTypes.Private.AuthorizationCode,
TokenTypeIdentifiers.Private.DeviceCode => JsonWebTokenTypes.Private.DeviceCode, TokenTypeIdentifiers.Private.DeviceCode => JsonWebTokenTypes.Private.DeviceCode,
TokenTypeIdentifiers.IdentityToken => JsonWebTokenTypes.Jwt, TokenTypeIdentifiers.IdentityToken => JsonWebTokenTypes.GenericJsonWebToken,
TokenTypeIdentifiers.RefreshToken => JsonWebTokenTypes.Private.RefreshToken, TokenTypeIdentifiers.RefreshToken => JsonWebTokenTypes.Private.RefreshToken,
TokenTypeIdentifiers.Private.RequestToken => JsonWebTokenTypes.Private.RequestToken, TokenTypeIdentifiers.Private.RequestToken => JsonWebTokenTypes.Private.RequestToken,
TokenTypeIdentifiers.Private.UserCode => JsonWebTokenTypes.Private.UserCode, TokenTypeIdentifiers.Private.UserCode => JsonWebTokenTypes.Private.UserCode,

9
src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs

@ -499,7 +499,14 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(context.AccessTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); Debug.Assert(context.AccessTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006));
context.Issuer = context.Options.Issuer ?? context.BaseUri; context.Issuer = (context.Options.Issuer ?? context.BaseUri) switch
{
{ IsAbsoluteUri: true } uri => uri,
// Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
};
context.Subject = context.AccessTokenPrincipal.GetClaim(Claims.Subject); context.Subject = context.AccessTokenPrincipal.GetClaim(Claims.Subject);
// The following claims are all optional and should be excluded when // The following claims are all optional and should be excluded when

177
src/OpenIddict.Server/OpenIddictServerHandlers.cs

@ -758,7 +758,7 @@ public static partial class OpenIddictServerHandlers
return default; return default;
} }
// Client assertions MUST contain at least one "aud" claim. For more information, // Client assertions MUST contain an "aud" claim. For more information,
// see https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication // see https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
// and https://datatracker.ietf.org/doc/html/rfc7523#section-3. // and https://datatracker.ietf.org/doc/html/rfc7523#section-3.
if (!context.ClientAssertionPrincipal.HasClaim(Claims.Audience)) if (!context.ClientAssertionPrincipal.HasClaim(Claims.Audience))
@ -789,19 +789,16 @@ public static partial class OpenIddictServerHandlers
static bool ValidateClaimGroup(string name, List<Claim> values) => name switch static bool ValidateClaimGroup(string name, List<Claim> values) => name switch
{ {
// The following claims MUST be represented as unique strings. // The following claims MUST be represented as unique strings.
Claims.AuthorizedParty or Claims.Issuer or Claims.JwtId or Claims.Subject //
// 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, the
// "aud" claim present in client assertions MUST always be represented as a single string.
//
// See https://www.ietf.org/archive/id/draft-ietf-oauth-rfc7523bis-01.html#section-4 for more information.
Claims.Audience or Claims.AuthorizedParty or Claims.Issuer or Claims.JwtId or Claims.Subject
=> values is [{ ValueType: ClaimValueTypes.String }], => values is [{ ValueType: ClaimValueTypes.String }],
// The following claims MUST be represented as unique strings or array of strings.
Claims.Audience
=> values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String) ||
// Note: a unique claim using the special JSON_ARRAY claim value type is allowed
// if the individual elements of the parsed JSON array are all string values.
(values is [{ ValueType: JsonClaimValueTypes.JsonArray, Value: string value }] &&
JsonSerializer.Deserialize(value, OpenIddictSerializer.Default.JsonElement)
is { ValueKind: JsonValueKind.Array } element &&
OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String)),
// The following claims MUST be represented as unique numeric dates. // The following claims MUST be represented as unique numeric dates.
Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore
=> values is [{ ValueType: ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or => values is [{ ValueType: ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or
@ -916,10 +913,15 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(context.ClientAssertionPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); Debug.Assert(context.ClientAssertionPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006));
// Ensure at least one non-empty audience was specified (note: in // Important: client assertions with multiple audiences was initially deliberately supported by
// the most common case, a single audience is generally specified). // the OpenID Connect and Assertion Framework for OAuth 2.0 Client Authentication specifications.
var audiences = context.ClientAssertionPrincipal.GetClaims(Claims.Audience); // Since 2025, using multiple audiences is no longer allowed for security reasons: as such, a single
if (!audiences.Any(static audience => !string.IsNullOrEmpty(audience))) // 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( context.Reject(
error: Errors.InvalidGrant, error: Errors.InvalidGrant,
@ -929,8 +931,14 @@ public static partial class OpenIddictServerHandlers
return default; return default;
} }
// Ensure at least one of the audiences points to the current authorization server. // Throw an exception if the issuer cannot be retrieved or is not valid.
if (!ValidateAudiences(audiences)) 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( context.Reject(
error: Errors.InvalidGrant, error: Errors.InvalidGrant,
@ -942,75 +950,6 @@ public static partial class OpenIddictServerHandlers
return default; return default;
bool ValidateAudiences(ImmutableArray<string> audiences)
{
foreach (var audience in audiences)
{
// Ignore the iterated audience if it's not a valid absolute URI.
if (!Uri.TryCreate(audience, UriKind.Absolute, out Uri? uri) || OpenIddictHelpers.IsImplicitFileUri(uri))
{
continue;
}
// Consider the audience valid if it matches the issuer value assigned to the current instance.
//
// See https://datatracker.ietf.org/doc/html/rfc7523#section-3 for more information.
if (context.Options.Issuer is not null && UriEquals(uri, context.Options.Issuer))
{
return true;
}
// At this point, ignore the rest of the validation logic if the current base URI is not known.
if (context.BaseUri is null)
{
continue;
}
// Consider the audience valid if it matches the current base URI, unless an explicit issuer was set.
if (context.Options.Issuer is null && UriEquals(uri, context.BaseUri))
{
return true;
}
// Consider the audience valid if it matches one of the URIs assigned to the token
// endpoint, independently of whether the request is a token request or not.
if (MatchesAnyUri(uri, context.Options.TokenEndpointUris))
{
return true;
}
// Consider the audience valid if it matches one of the URIs
// assigned to the endpoint that received the request.
switch (context.EndpointType)
{
case OpenIddictServerEndpointType.DeviceAuthorization
when MatchesAnyUri(uri, context.Options.DeviceAuthorizationEndpointUris):
case OpenIddictServerEndpointType.Introspection
when MatchesAnyUri(uri, context.Options.IntrospectionEndpointUris):
case OpenIddictServerEndpointType.PushedAuthorization
when MatchesAnyUri(uri, context.Options.PushedAuthorizationEndpointUris):
case OpenIddictServerEndpointType.Revocation
when MatchesAnyUri(uri, context.Options.RevocationEndpointUris):
return true;
}
}
return false;
}
bool MatchesAnyUri(Uri uri, List<Uri> uris)
{
for (var index = 0; index < uris.Count; index++)
{
if (UriEquals(uri, OpenIddictHelpers.CreateAbsoluteUri(context.BaseUri, uris[index])))
{
return true;
}
}
return false;
}
static bool UriEquals(Uri left, Uri right) static bool UriEquals(Uri left, Uri right)
{ {
if (string.Equals(left.AbsolutePath, right.AbsolutePath, StringComparison.Ordinal)) if (string.Equals(left.AbsolutePath, right.AbsolutePath, StringComparison.Ordinal))
@ -3541,7 +3480,13 @@ public static partial class OpenIddictServerHandlers
} }
// Use the server identity as the token issuer. // Use the server identity as the token issuer.
principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri); principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri) switch
{
{ IsAbsoluteUri: true } uri => uri.AbsoluteUri,
// Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
});
// Set the audiences based on the resource claims stored in the principal. // Set the audiences based on the resource claims stored in the principal.
principal.SetAudiences(context.Principal.GetResources()); principal.SetAudiences(context.Principal.GetResources());
@ -3665,7 +3610,13 @@ public static partial class OpenIddictServerHandlers
} }
// Use the server identity as the token issuer. // Use the server identity as the token issuer.
principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri); principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri) switch
{
{ IsAbsoluteUri: true } uri => uri.AbsoluteUri,
// Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
});
// Attach the redirect_uri to allow for later comparison when // Attach the redirect_uri to allow for later comparison when
// receiving a grant_type=authorization_code token request. // receiving a grant_type=authorization_code token request.
@ -3791,7 +3742,13 @@ public static partial class OpenIddictServerHandlers
} }
// Use the server identity as the token issuer. // Use the server identity as the token issuer.
principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri); principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri) switch
{
{ IsAbsoluteUri: true } uri => uri.AbsoluteUri,
// Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
});
// Restore the device code internal token identifier from the principal // Restore the device code internal token identifier from the principal
// resolved from the user code used in the end-user verification request. // resolved from the user code used in the end-user verification request.
@ -4048,7 +4005,13 @@ public static partial class OpenIddictServerHandlers
} }
// Use the server identity as the token issuer. // Use the server identity as the token issuer.
principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri); principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri) switch
{
{ IsAbsoluteUri: true } uri => uri.AbsoluteUri,
// Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
});
// Set the audiences based on the resource claims stored in the principal. // Set the audiences based on the resource claims stored in the principal.
principal.SetAudiences(context.Principal.GetResources()); principal.SetAudiences(context.Principal.GetResources());
@ -4170,7 +4133,13 @@ public static partial class OpenIddictServerHandlers
} }
// Use the server identity as the token issuer. // Use the server identity as the token issuer.
principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri); principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri) switch
{
{ IsAbsoluteUri: true } uri => uri.AbsoluteUri,
// Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
});
// Store the type of the request token. // Store the type of the request token.
principal.SetClaim(Claims.Private.RequestTokenType, context.EndpointType switch principal.SetClaim(Claims.Private.RequestTokenType, context.EndpointType switch
@ -4319,7 +4288,13 @@ public static partial class OpenIddictServerHandlers
} }
// Use the server identity as the token issuer. // Use the server identity as the token issuer.
principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri); principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri) switch
{
{ IsAbsoluteUri: true } uri => uri.AbsoluteUri,
// Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
});
context.RefreshTokenPrincipal = principal; context.RefreshTokenPrincipal = principal;
} }
@ -4452,7 +4427,13 @@ public static partial class OpenIddictServerHandlers
} }
// Use the server identity as the token issuer. // Use the server identity as the token issuer.
principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri); principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri) switch
{
{ IsAbsoluteUri: true } uri => uri.AbsoluteUri,
// Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
});
// If available, use the client_id as both the audience and the authorized party. // If available, use the client_id as both the audience and the authorized party.
// See https://openid.net/specs/openid-connect-core-1_0.html#IDToken for more information. // See https://openid.net/specs/openid-connect-core-1_0.html#IDToken for more information.
@ -4579,7 +4560,13 @@ public static partial class OpenIddictServerHandlers
} }
// Use the server identity as the token issuer. // Use the server identity as the token issuer.
principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri); principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri) switch
{
{ IsAbsoluteUri: true } uri => uri.AbsoluteUri,
// Throw an exception if the issuer cannot be retrieved or is not valid.
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
});
// Store the client_id as a public client_id claim. // Store the client_id as a public client_id claim.
principal.SetClaim(Claims.ClientId, context.Request.ClientId); principal.SetClaim(Claims.ClientId, context.Request.ClientId);

2
src/OpenIddict.Server/OpenIddictServerOptions.cs

@ -148,7 +148,7 @@ public sealed class OpenIddictServerOptions
type = usage switch type = usage switch
{ {
"access_token" => JsonWebTokenTypes.AccessToken, "access_token" => JsonWebTokenTypes.AccessToken,
"id_token" => JsonWebTokenTypes.Jwt, "id_token" => JsonWebTokenTypes.GenericJsonWebToken,
_ => throw new NotSupportedException(SR.GetResourceString(SR.ID0269)) _ => throw new NotSupportedException(SR.GetResourceString(SR.ID0269))
}; };

7
src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs

@ -1047,8 +1047,11 @@ public static partial class OpenIddictValidationHandlers
{ {
null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0025)), null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0025)),
// For client assertions, use the generic "JWT" type. // Note: OpenIddict 7.0 and higher no uses the generic "JWT" value for client assertions
TokenTypeIdentifiers.Private.ClientAssertion => JsonWebTokenTypes.Jwt, // but uses the new standard "client-authentication+jwt" type instead, as defined in the
// https://www.ietf.org/archive/id/draft-ietf-oauth-rfc7523bis-01.html#name-updates-to-rfc-7523
// specification.
TokenTypeIdentifiers.Private.ClientAssertion => JsonWebTokenTypes.ClientAuthentication,
string value => value string value => value
}; };

8
src/OpenIddict.Validation/OpenIddictValidationHandlers.cs

@ -437,8 +437,12 @@ public static partial class OpenIddictValidationHandlers
principal.SetExpirationDate(principal.GetCreationDate() + lifetime.Value); principal.SetExpirationDate(principal.GetCreationDate() + lifetime.Value);
} }
// Use the issuer URI as the audience. Applications that need to // Important: the initial Assertion Framework for OAuth 2.0 Client Authentication specifications
// use a different value can register a custom event handler. // initially encouraged supporting using the token endpoint URI as the client assertion audience,
// even for introspection requests. It was determined in 2025 that doing so may result in
// impersonation attacks as the token endpoint URI is not a guarded value. To mitigate that,
// OpenIddict always uses the issuer identity as the client assertion audience, as recommended
// by the https://www.ietf.org/archive/id/draft-ietf-oauth-rfc7523bis-01.html#section-2 draft.
principal.SetAudiences(context.Configuration.Issuer.OriginalString); principal.SetAudiences(context.Configuration.Issuer.OriginalString);
// Use the client_id as both the subject and the issuer, as required by the specifications. // Use the client_id as both the subject and the issuer, as required by the specifications.

2
src/OpenIddict.Validation/OpenIddictValidationOptions.cs

@ -179,7 +179,7 @@ public sealed class OpenIddictValidationOptions
type = usage switch type = usage switch
{ {
"access_token" => JsonWebTokenTypes.AccessToken, "access_token" => JsonWebTokenTypes.AccessToken,
"id_token" => JsonWebTokenTypes.Jwt, "id_token" => JsonWebTokenTypes.GenericJsonWebToken,
_ => throw new NotSupportedException(SR.GetResourceString(SR.ID0269)) _ => throw new NotSupportedException(SR.GetResourceString(SR.ID0269))
}; };

Loading…
Cancel
Save