Skip to content

Commit d95b322

Browse files
committed
Stop using the token endpoint URI as the client assertion audience and use the new "client-authentication+jwt" JSON Web Token type
1 parent c40383f commit d95b322

16 files changed

+216
-143
lines changed

src/OpenIddict.Abstractions/OpenIddictConstants.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,9 @@ public static class GrantTypes
261261
public static class JsonWebTokenTypes
262262
{
263263
public const string AccessToken = "at+jwt";
264-
public const string Jwt = "JWT";
264+
public const string AuthorizationGrant = "authorization-grant+jwt";
265+
public const string ClientAuthentication = "client-authentication+jwt";
266+
public const string GenericJsonWebToken = "JWT";
265267

266268
public static class Prefixes
267269
{

src/OpenIddict.Abstractions/OpenIddictResources.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,6 +1796,9 @@ Alternatively, any value respecting the '[region]-[subregion]-[identifier]' patt
17961796
<data name="ID0495" xml:space="preserve">
17971797
<value>The '{0}' parameter cannot contain values that are not valid absolute URIs containing no fragment component.</value>
17981798
</data>
1799+
<data name="ID0496" xml:space="preserve">
1800+
<value>The issuer cannot be retrieved from the server options or inferred from the current request or is not a valid value.</value>
1801+
</data>
17991802
<data name="ID2000" xml:space="preserve">
18001803
<value>The security token is missing.</value>
18011804
</data>

src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ public ValueTask HandleAsync(ValidateTokenContext context)
8080
//
8181
// See https://datatracker.ietf.org/doc/html/draft-bradley-oauth-jwt-encoded-state-09#section-4.3
8282
// for more information.
83-
if (context.ValidTokenTypes.Count > 1 && context.ValidTokenTypes.Contains(TokenTypeIdentifiers.Private.StateToken))
83+
if (context.ValidTokenTypes.Count is > 1 &&
84+
context.ValidTokenTypes.Contains(TokenTypeIdentifiers.Private.StateToken))
8485
{
8586
throw new InvalidOperationException(SR.GetResourceString(SR.ID0308));
8687
}
@@ -1109,10 +1110,12 @@ public ValueTask HandleAsync(GenerateTokenContext context)
11091110
{
11101111
null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0025)),
11111112

1112-
// For client assertions, use the generic "JWT" type.
1113-
TokenTypeIdentifiers.Private.ClientAssertion => JsonWebTokenTypes.Jwt,
1113+
// Note: OpenIddict 7.0 and higher no uses the generic "JWT" value for client assertions
1114+
// but uses the new standard "client-authentication+jwt" type instead, as defined in the
1115+
// https://www.ietf.org/archive/id/draft-ietf-oauth-rfc7523bis-01.html#name-updates-to-rfc-7523
1116+
// specification.
1117+
TokenTypeIdentifiers.Private.ClientAssertion => JsonWebTokenTypes.ClientAuthentication,
11141118

1115-
// For state tokens, use its private representation.
11161119
TokenTypeIdentifiers.Private.StateToken => JsonWebTokenTypes.Private.StateToken,
11171120

11181121
string value => value

src/OpenIddict.Client/OpenIddictClientHandlers.cs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2646,21 +2646,20 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context)
26462646
principal.SetExpirationDate(principal.GetCreationDate() + lifetime.Value);
26472647
}
26482648

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

26652664
// Use the client_id as both the subject and the issuer, as required by the specifications.
26662665
//

src/OpenIddict.Client/OpenIddictClientOptions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,20 @@ public sealed class OpenIddictClientOptions
113113
ClockSkew = TimeSpan.Zero,
114114
NameClaimType = Claims.Name,
115115
RoleClaimType = Claims.Role,
116+
// Note: unlike IdentityModel, this custom validator deliberately uses case-insensitive comparisons.
117+
TypeValidator = static (type, token, parameters) =>
118+
{
119+
if (parameters.ValidTypes is not null && parameters.ValidTypes.Any() &&
120+
!parameters.ValidTypes.Contains(type, StringComparer.OrdinalIgnoreCase))
121+
{
122+
throw new SecurityTokenInvalidTypeException(SR.GetResourceString(SR.ID0271))
123+
{
124+
InvalidType = type
125+
};
126+
}
127+
128+
return type;
129+
},
116130
// Note: audience and lifetime are manually validated by OpenIddict itself.
117131
ValidateAudience = false,
118132
ValidateLifetime = false

src/OpenIddict.Client/OpenIddictClientRegistration.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,20 @@ public sealed class OpenIddictClientRegistration
190190
ClockSkew = TimeSpan.Zero,
191191
NameClaimType = Claims.Name,
192192
RoleClaimType = Claims.Role,
193+
// Note: unlike IdentityModel, this custom validator deliberately uses case-insensitive comparisons.
194+
TypeValidator = static (type, token, parameters) =>
195+
{
196+
if (parameters.ValidTypes is not null && parameters.ValidTypes.Any() &&
197+
!parameters.ValidTypes.Contains(type, StringComparer.OrdinalIgnoreCase))
198+
{
199+
throw new SecurityTokenInvalidTypeException(SR.GetResourceString(SR.ID0271))
200+
{
201+
InvalidType = type
202+
};
203+
}
204+
205+
return type;
206+
},
193207
// Note: audience and lifetime are manually validated by OpenIddict itself.
194208
ValidateAudience = false,
195209
ValidateLifetime = false

src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -900,7 +900,7 @@ public ValueTask HandleAsync(ValidateAuthorizationRequestContext context)
900900
// Prevent response_type=none from being used with any other value.
901901
// See https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#none for more information.
902902
var types = context.Request.GetResponseTypes().ToHashSet(StringComparer.Ordinal);
903-
if (types.Count > 1 && types.Contains(ResponseTypes.None))
903+
if (types.Count is > 1 && types.Contains(ResponseTypes.None))
904904
{
905905
context.Logger.LogInformation(6212, SR.GetResourceString(SR.ID6212), context.Request.ResponseType);
906906

@@ -2396,8 +2396,8 @@ public ValueTask HandleAsync(ApplyAuthorizationResponseContext context)
23962396
{
23972397
{ IsAbsoluteUri: true } uri => uri.AbsoluteUri,
23982398

2399-
// At this stage, throw an exception if the issuer cannot be retrieved or is not valid.
2400-
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0023))
2399+
// Throw an exception if the issuer cannot be retrieved or is not valid.
2400+
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
24012401
};
24022402
}
24032403

@@ -2992,7 +2992,7 @@ public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context)
29922992
// Prevent response_type=none from being used with any other value.
29932993
// See https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#none for more information.
29942994
var types = context.Request.GetResponseTypes().ToHashSet(StringComparer.Ordinal);
2995-
if (types.Count > 1 && types.Contains(ResponseTypes.None))
2995+
if (types.Count is > 1 && types.Contains(ResponseTypes.None))
29962996
{
29972997
context.Logger.LogInformation(6260, SR.GetResourceString(SR.ID6260), context.Request.ResponseType);
29982998

src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,13 @@ public ValueTask HandleAsync(HandleConfigurationRequestContext context)
343343
throw new ArgumentNullException(nameof(context));
344344
}
345345

346-
context.Issuer = context.Options.Issuer ?? context.BaseUri;
346+
context.Issuer = (context.Options.Issuer ?? context.BaseUri) switch
347+
{
348+
{ IsAbsoluteUri: true } uri => uri,
349+
350+
// Throw an exception if the issuer cannot be retrieved or is not valid.
351+
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
352+
};
347353

348354
return default;
349355
}

src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,13 @@ public ValueTask HandleAsync(HandleIntrospectionRequestContext context)
737737

738738
Debug.Assert(context.GenericTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006));
739739

740-
context.Issuer = context.Options.Issuer ?? context.BaseUri;
740+
context.Issuer = (context.Options.Issuer ?? context.BaseUri) switch
741+
{
742+
{ IsAbsoluteUri: true } uri => uri,
743+
744+
// Throw an exception if the issuer cannot be retrieved or is not valid.
745+
_ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0496))
746+
};
741747

742748
context.TokenId = context.GenericTokenPrincipal.GetClaim(Claims.JwtId);
743749
context.Subject = context.GenericTokenPrincipal.GetClaim(Claims.Subject);

src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ public ValueTask HandleAsync(ValidateTokenContext context)
9595
// To simplify the token validation parameters selection logic, an exception is thrown
9696
// if multiple token types are considered valid and contain tokens issued by the
9797
// authorization server and tokens issued by the client (e.g client assertions).
98-
if (context.ValidTokenTypes.Count > 1 &&
98+
if (context.ValidTokenTypes.Count is > 1 &&
9999
context.ValidTokenTypes.Contains(TokenTypeIdentifiers.Private.ClientAssertion))
100100
{
101101
throw new InvalidOperationException(SR.GetResourceString(SR.ID0308));
@@ -117,9 +117,34 @@ TokenValidationParameters GetClientTokenValidationParameters()
117117
// Note: the audience/issuer/lifetime are manually validated by OpenIddict itself.
118118
var parameters = new TokenValidationParameters
119119
{
120+
TypeValidator = static (type, token, parameters) =>
121+
{
122+
// Note: unlike IdentityModel, this custom validator deliberately uses case-insensitive comparisons.
123+
if (parameters.ValidTypes is not null && parameters.ValidTypes.Any() &&
124+
!parameters.ValidTypes.Contains(type, StringComparer.OrdinalIgnoreCase))
125+
{
126+
throw new SecurityTokenInvalidTypeException(SR.GetResourceString(SR.ID0271))
127+
{
128+
InvalidType = type
129+
};
130+
}
131+
132+
return type;
133+
},
134+
120135
ValidateAudience = false,
121136
ValidateIssuer = false,
122-
ValidateLifetime = false
137+
ValidateLifetime = false,
138+
139+
// Note: OpenIddict 7.0 and higher no uses the generic "JWT" value for client assertions and
140+
// requires using the new standard "client-authentication+jwt" type instead, as defined in the
141+
// https://www.ietf.org/archive/id/draft-ietf-oauth-rfc7523bis-01.html#name-updates-to-rfc-7523
142+
// draft. The longer "application/client-authentication+jwt" form is also considered valid.
143+
ValidTypes =
144+
[
145+
JsonWebTokenTypes.ClientAuthentication,
146+
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.ClientAuthentication
147+
]
123148
};
124149

125150
// Only provide a signing key resolver if the degraded mode was not enabled.
@@ -204,8 +229,8 @@ TokenValidationParameters GetServerTokenValidationParameters()
204229
// For identity tokens, both "JWT" and "application/jwt" are valid.
205230
TokenTypeIdentifiers.IdentityToken =>
206231
[
207-
JsonWebTokenTypes.Jwt,
208-
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.Jwt
232+
JsonWebTokenTypes.GenericJsonWebToken,
233+
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.GenericJsonWebToken
209234
],
210235

211236
// For authorization codes, only the short "oi_auc+jwt" form is valid.
@@ -529,23 +554,23 @@ 1 when context.ValidTokenTypes.Contains(TokenTypeIdentifiers.AccessToken)
529554
// the token type (resolved from "typ" or "token_usage") as a special private claim.
530555
context.Principal = new ClaimsPrincipal(result.ClaimsIdentity).SetTokenType(result.TokenType switch
531556
{
532-
// Client assertions are typically created by client libraries with either a missing "typ" header
533-
// or a generic value like "JWT". Since the type defined by the client cannot be used as-is,
534-
// validation is bypassed and tokens used as client assertions are assumed to be client assertions.
535-
_ when context.ValidTokenTypes.Count is 1 &&
536-
context.ValidTokenTypes.Contains(TokenTypeIdentifiers.Private.ClientAssertion)
537-
=> TokenTypeIdentifiers.Private.ClientAssertion,
538-
539557
null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0025)),
540558

541-
// Both at+jwt and application/at+jwt are supported for access tokens.
542-
JsonWebTokenTypes.AccessToken or JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.AccessToken
559+
// Both "at+jwt" and "application/at+jwt" are supported for access tokens.
560+
JsonWebTokenTypes.AccessToken or
561+
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.AccessToken
543562
=> TokenTypeIdentifiers.AccessToken,
544563

545-
// Both JWT and application/JWT are supported for identity tokens.
546-
JsonWebTokenTypes.Jwt or JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.Jwt
564+
// Both "JWT" and "application/jwt" are supported for identity tokens.
565+
JsonWebTokenTypes.GenericJsonWebToken or
566+
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.GenericJsonWebToken
547567
=> TokenTypeIdentifiers.IdentityToken,
548568

569+
// Both "client-authentication+jwt" and "application/client-authentication+jwt" for client assertions.
570+
JsonWebTokenTypes.ClientAuthentication or
571+
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.ClientAuthentication
572+
=> TokenTypeIdentifiers.Private.ClientAssertion,
573+
549574
JsonWebTokenTypes.Private.AuthorizationCode => TokenTypeIdentifiers.Private.AuthorizationCode,
550575
JsonWebTokenTypes.Private.DeviceCode => TokenTypeIdentifiers.Private.DeviceCode,
551576
JsonWebTokenTypes.Private.RefreshToken => TokenTypeIdentifiers.RefreshToken,
@@ -1629,7 +1654,7 @@ TokenTypeIdentifiers.RefreshToken or TokenTypeIdentifiers.Private.U
16291654
TokenTypeIdentifiers.AccessToken => JsonWebTokenTypes.AccessToken,
16301655
TokenTypeIdentifiers.Private.AuthorizationCode => JsonWebTokenTypes.Private.AuthorizationCode,
16311656
TokenTypeIdentifiers.Private.DeviceCode => JsonWebTokenTypes.Private.DeviceCode,
1632-
TokenTypeIdentifiers.IdentityToken => JsonWebTokenTypes.Jwt,
1657+
TokenTypeIdentifiers.IdentityToken => JsonWebTokenTypes.GenericJsonWebToken,
16331658
TokenTypeIdentifiers.RefreshToken => JsonWebTokenTypes.Private.RefreshToken,
16341659
TokenTypeIdentifiers.Private.RequestToken => JsonWebTokenTypes.Private.RequestToken,
16351660
TokenTypeIdentifiers.Private.UserCode => JsonWebTokenTypes.Private.UserCode,

0 commit comments

Comments
 (0)