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
4 changes: 3 additions & 1 deletion src/OpenIddict.Abstractions/OpenIddictConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,9 @@ public static class GrantTypes
public static class JsonWebTokenTypes
{
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
{
Expand Down
3 changes: 3 additions & 0 deletions src/OpenIddict.Abstractions/OpenIddictResources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1796,6 +1796,9 @@ Alternatively, any value respecting the '[region]-[subregion]-[identifier]' patt
<data name="ID0495" xml:space="preserve">
<value>The '{0}' parameter cannot contain values that are not valid absolute URIs containing no fragment component.</value>
</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">
<value>The security token is missing.</value>
</data>
Expand Down
11 changes: 7 additions & 4 deletions src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ public ValueTask HandleAsync(ValidateTokenContext context)
//
// See https://datatracker.ietf.org/doc/html/draft-bradley-oauth-jwt-encoded-state-09#section-4.3
// 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));
}
Expand Down Expand Up @@ -1109,10 +1110,12 @@ public ValueTask HandleAsync(GenerateTokenContext context)
{
null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0025)),

// For client assertions, use the generic "JWT" type.
TokenTypeIdentifiers.Private.ClientAssertion => JsonWebTokenTypes.Jwt,
// Note: OpenIddict 7.0 and higher no uses the generic "JWT" value for client assertions
// 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,

string value => value
Expand Down
27 changes: 13 additions & 14 deletions src/OpenIddict.Client/OpenIddictClientHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2646,21 +2646,20 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context)
principal.SetExpirationDate(principal.GetCreationDate() + lifetime.Value);
}

// Use the URI of the token endpoint as the audience, as recommended by the specifications.
// Applications that need to use a different value can register a custom event handler.
// Important: the initial OpenID Connect and Assertion Framework for OAuth 2.0 Client Authentication
// 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
// and https://datatracker.ietf.org/doc/html/rfc7523#section-3 for more information.
if (!string.IsNullOrEmpty(context.TokenEndpoint?.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);
}
// For more information, see https://www.ietf.org/archive/id/draft-ietf-oauth-rfc7523bis-01.html#name-updates-to-rfc-7521
// and https://openid.net/wp-content/uploads/2025/01/OIDF-Responsible-Disclosure-Notice-on-Security-Vulnerability-for-private_key_jwt.pdf.
principal.SetAudiences(context.Registration.Issuer.OriginalString);

// Use the client_id as both the subject and the issuer, as required by the specifications.
//
Expand Down
14 changes: 14 additions & 0 deletions src/OpenIddict.Client/OpenIddictClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ public sealed class OpenIddictClientOptions
ClockSkew = TimeSpan.Zero,
NameClaimType = Claims.Name,
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.
ValidateAudience = false,
ValidateLifetime = false
Expand Down
14 changes: 14 additions & 0 deletions src/OpenIddict.Client/OpenIddictClientRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,20 @@ public sealed class OpenIddictClientRegistration
ClockSkew = TimeSpan.Zero,
NameClaimType = Claims.Name,
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.
ValidateAudience = false,
ValidateLifetime = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -900,7 +900,7 @@ public ValueTask HandleAsync(ValidateAuthorizationRequestContext context)
// 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.
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);

Expand Down Expand Up @@ -2396,8 +2396,8 @@ public ValueTask HandleAsync(ApplyAuthorizationResponseContext context)
{
{ IsAbsoluteUri: true } uri => uri.AbsoluteUri,

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

Expand Down Expand Up @@ -2992,7 +2992,7 @@ public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context)
// 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.
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);

Expand Down
8 changes: 7 additions & 1 deletion src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,13 @@ public ValueTask HandleAsync(HandleConfigurationRequestContext 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,13 @@ public ValueTask HandleAsync(HandleIntrospectionRequestContext context)

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.Subject = context.GenericTokenPrincipal.GetClaim(Claims.Subject);
Expand Down
57 changes: 41 additions & 16 deletions src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public ValueTask HandleAsync(ValidateTokenContext context)
// 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
// 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))
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0308));
Expand All @@ -117,9 +117,34 @@ TokenValidationParameters GetClientTokenValidationParameters()
// Note: the audience/issuer/lifetime are manually validated by OpenIddict itself.
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,
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.
Expand Down Expand Up @@ -204,8 +229,8 @@ TokenValidationParameters GetServerTokenValidationParameters()
// For identity tokens, both "JWT" and "application/jwt" are valid.
TokenTypeIdentifiers.IdentityToken =>
[
JsonWebTokenTypes.Jwt,
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.Jwt
JsonWebTokenTypes.GenericJsonWebToken,
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.GenericJsonWebToken
],

// For authorization codes, only the short "oi_auc+jwt" form is valid.
Expand Down Expand Up @@ -529,23 +554,23 @@ 1 when context.ValidTokenTypes.Contains(TokenTypeIdentifiers.AccessToken)
// the token type (resolved from "typ" or "token_usage") as a special private claim.
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)),

// Both at+jwt and application/at+jwt are supported for access tokens.
JsonWebTokenTypes.AccessToken or JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.AccessToken
// Both "at+jwt" and "application/at+jwt" are supported for access tokens.
JsonWebTokenTypes.AccessToken or
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.AccessToken
=> TokenTypeIdentifiers.AccessToken,

// Both JWT and application/JWT are supported for identity tokens.
JsonWebTokenTypes.Jwt or JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.Jwt
// Both "JWT" and "application/jwt" are supported for identity tokens.
JsonWebTokenTypes.GenericJsonWebToken or
JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.GenericJsonWebToken
=> 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.DeviceCode => TokenTypeIdentifiers.Private.DeviceCode,
JsonWebTokenTypes.Private.RefreshToken => TokenTypeIdentifiers.RefreshToken,
Expand Down Expand Up @@ -1629,7 +1654,7 @@ TokenTypeIdentifiers.RefreshToken or TokenTypeIdentifiers.Private.U
TokenTypeIdentifiers.AccessToken => JsonWebTokenTypes.AccessToken,
TokenTypeIdentifiers.Private.AuthorizationCode => JsonWebTokenTypes.Private.AuthorizationCode,
TokenTypeIdentifiers.Private.DeviceCode => JsonWebTokenTypes.Private.DeviceCode,
TokenTypeIdentifiers.IdentityToken => JsonWebTokenTypes.Jwt,
TokenTypeIdentifiers.IdentityToken => JsonWebTokenTypes.GenericJsonWebToken,
TokenTypeIdentifiers.RefreshToken => JsonWebTokenTypes.Private.RefreshToken,
TokenTypeIdentifiers.Private.RequestToken => JsonWebTokenTypes.Private.RequestToken,
TokenTypeIdentifiers.Private.UserCode => JsonWebTokenTypes.Private.UserCode,
Expand Down
9 changes: 8 additions & 1 deletion src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,14 @@ public ValueTask HandleAsync(HandleUserInfoRequestContext context)

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);

// The following claims are all optional and should be excluded when
Expand Down
Loading