diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index 6ce389f54..d1abe08d3 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -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 { diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 5d37c9203..803a1fb07 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1796,6 +1796,9 @@ Alternatively, any value respecting the '[region]-[subregion]-[identifier]' patt The '{0}' parameter cannot contain values that are not valid absolute URIs containing no fragment component. + + The issuer cannot be retrieved from the server options or inferred from the current request or is not a valid value. + The security token is missing. diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs index f0dcbc882..af1705b67 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs @@ -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)); } @@ -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 diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index 8eed1c9e2..fa6bbb4f7 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -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. // diff --git a/src/OpenIddict.Client/OpenIddictClientOptions.cs b/src/OpenIddict.Client/OpenIddictClientOptions.cs index 5f41e6370..8f396b4e0 100644 --- a/src/OpenIddict.Client/OpenIddictClientOptions.cs +++ b/src/OpenIddict.Client/OpenIddictClientOptions.cs @@ -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 diff --git a/src/OpenIddict.Client/OpenIddictClientRegistration.cs b/src/OpenIddict.Client/OpenIddictClientRegistration.cs index 48e6c8d4f..b77572e47 100644 --- a/src/OpenIddict.Client/OpenIddictClientRegistration.cs +++ b/src/OpenIddict.Client/OpenIddictClientRegistration.cs @@ -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 diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs index 3e4aed39f..4d9509374 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs @@ -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); @@ -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)) }; } @@ -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); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs index 45e04bff6..bccd60b5c 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs @@ -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; } diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs index 3f7f600a8..fc717c8cb 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs @@ -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); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs index f3d4c338d..286dcb184 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs @@ -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)); @@ -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. @@ -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. @@ -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, @@ -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, diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs index db6660550..84a4949a5 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs @@ -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 diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 62957da99..b132de946 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -758,7 +758,7 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context) 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 // and https://datatracker.ietf.org/doc/html/rfc7523#section-3. if (!context.ClientAssertionPrincipal.HasClaim(Claims.Audience)) @@ -789,19 +789,16 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context) static bool ValidateClaimGroup(string name, List values) => name switch { // 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 }], - // 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. Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore => values is [{ ValueType: ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or @@ -916,10 +913,15 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context) Debug.Assert(context.ClientAssertionPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - // Ensure at least one non-empty audience was specified (note: in - // the most common case, a single audience is generally specified). - var audiences = context.ClientAssertionPrincipal.GetClaims(Claims.Audience); - if (!audiences.Any(static audience => !string.IsNullOrEmpty(audience))) + // 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, @@ -929,8 +931,14 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context) return default; } - // Ensure at least one of the audiences points to the current authorization server. - if (!ValidateAudiences(audiences)) + // 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, @@ -942,75 +950,6 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context) return default; - bool ValidateAudiences(ImmutableArray 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 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) { if (string.Equals(left.AbsolutePath, right.AbsolutePath, StringComparison.Ordinal)) @@ -3541,7 +3480,13 @@ public async ValueTask HandleAsync(ProcessSignInContext context) } // 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. principal.SetAudiences(context.Principal.GetResources()); @@ -3665,7 +3610,13 @@ public async ValueTask HandleAsync(ProcessSignInContext context) } // 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 // receiving a grant_type=authorization_code token request. @@ -3791,7 +3742,13 @@ public async ValueTask HandleAsync(ProcessSignInContext context) } // 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 // resolved from the user code used in the end-user verification request. @@ -4048,7 +4005,13 @@ public async ValueTask HandleAsync(ProcessSignInContext context) } // 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. principal.SetAudiences(context.Principal.GetResources()); @@ -4170,7 +4133,13 @@ public async ValueTask HandleAsync(ProcessSignInContext context) } // 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. principal.SetClaim(Claims.Private.RequestTokenType, context.EndpointType switch @@ -4319,7 +4288,13 @@ public async ValueTask HandleAsync(ProcessSignInContext context) } // 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; } @@ -4452,7 +4427,13 @@ public async ValueTask HandleAsync(ProcessSignInContext context) } // 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. // See https://openid.net/specs/openid-connect-core-1_0.html#IDToken for more information. @@ -4579,7 +4560,13 @@ public async ValueTask HandleAsync(ProcessSignInContext context) } // 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. principal.SetClaim(Claims.ClientId, context.Request.ClientId); diff --git a/src/OpenIddict.Server/OpenIddictServerOptions.cs b/src/OpenIddict.Server/OpenIddictServerOptions.cs index 445c4be9c..983b1681a 100644 --- a/src/OpenIddict.Server/OpenIddictServerOptions.cs +++ b/src/OpenIddict.Server/OpenIddictServerOptions.cs @@ -148,7 +148,7 @@ public sealed class OpenIddictServerOptions type = usage switch { "access_token" => JsonWebTokenTypes.AccessToken, - "id_token" => JsonWebTokenTypes.Jwt, + "id_token" => JsonWebTokenTypes.GenericJsonWebToken, _ => throw new NotSupportedException(SR.GetResourceString(SR.ID0269)) }; diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs index 91ba17c0a..57cca2a13 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs @@ -1047,8 +1047,11 @@ 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, string value => value }; diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs index 86eeeab96..6d5d89532 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs @@ -437,8 +437,12 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context) principal.SetExpirationDate(principal.GetCreationDate() + lifetime.Value); } - // Use the issuer URI as the audience. Applications that need to - // use a different value can register a custom event handler. + // Important: the initial Assertion Framework for OAuth 2.0 Client Authentication specifications + // 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); // Use the client_id as both the subject and the issuer, as required by the specifications. diff --git a/src/OpenIddict.Validation/OpenIddictValidationOptions.cs b/src/OpenIddict.Validation/OpenIddictValidationOptions.cs index c964f2bf8..687d12d38 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationOptions.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationOptions.cs @@ -179,7 +179,7 @@ public sealed class OpenIddictValidationOptions type = usage switch { "access_token" => JsonWebTokenTypes.AccessToken, - "id_token" => JsonWebTokenTypes.Jwt, + "id_token" => JsonWebTokenTypes.GenericJsonWebToken, _ => throw new NotSupportedException(SR.GetResourceString(SR.ID0269)) };