diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 30ec5114..9503c937 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1578,6 +1578,15 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId Multiple claims of the same type are present in the identity or principal. + + The '{0}' claim present in the specified principal is malformed or isn't of the expected type. + + + The specified principal contains an authenticated identity, which is not valid for this operation. Make sure that 'ClaimsPrincipal.Identity.AuthenticationType' is null and that 'ClaimsPrincipal.Identity.IsAuthenticated' returns 'false'. + + + The specified principal contains a subject claim, which is not valid for this operation. + The security token is missing. diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index cc0df1b7..4174cc69 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -332,7 +332,7 @@ public static partial class OpenIddictClientHandlers } if (context.Registration is null && string.IsNullOrEmpty(context.RegistrationId) && - context.Issuer is null && string.IsNullOrEmpty(context.ProviderName) && + context.Issuer is null && string.IsNullOrEmpty(context.ProviderName) && context.Options.Registrations.Count is not 1) { throw context.Options.Registrations.Count is 0 ? @@ -1618,14 +1618,10 @@ public static partial class OpenIddictClientHandlers Debug.Assert(context.FrontchannelIdentityTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); foreach (var group in context.FrontchannelIdentityTokenPrincipal.Claims - .GroupBy(claim => claim.Type) - .ToDictionary(group => group.Key, group => group.ToList())) + .GroupBy(static claim => claim.Type) + .ToDictionary(static group => group.Key, group => group.ToList()) + .Where(static group => !ValidateClaimGroup(group.Key, group.Value))) { - if (ValidateClaimGroup(group)) - { - continue; - } - context.Reject( error: Errors.InvalidRequest, description: SR.FormatID2121(group.Key), @@ -1696,28 +1692,22 @@ public static partial class OpenIddictClientHandlers return default; - static bool ValidateClaimGroup(KeyValuePair> claims) => claims switch + static bool ValidateClaimGroup(string name, List values) => name switch { - // The following JWT claims MUST be represented as unique strings. - { - Key: Claims.AuthenticationContextReference or Claims.AuthorizedParty or - Claims.Issuer or Claims.Nonce or Claims.Subject, - Value: List values - } => values.Count is 1 && values[0].ValueType is ClaimValueTypes.String, + // The following claims MUST be represented as unique strings. + Claims.AuthenticationContextReference or Claims.AuthorizedParty or + Claims.Issuer or Claims.Nonce or Claims.Subject + => values is [{ ValueType: ClaimValueTypes.String }], - // The following JWT claims MUST be represented as unique strings or array of strings. - { - Key: Claims.Audience or Claims.AuthenticationMethodReference, - Value: List values - } => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), + // The following claims MUST be represented as unique strings or array of strings. + Claims.Audience or Claims.AuthenticationMethodReference + => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), - // The following JWT claims MUST be represented as unique numeric dates. - { - Key: Claims.AuthenticationTime or Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore, - Value: List values - } => values.Count is 1 && values[0].ValueType is ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or - ClaimValueTypes.Integer64 or ClaimValueTypes.Double or - ClaimValueTypes.UInteger32 or ClaimValueTypes.UInteger64, + // The following claims MUST be represented as unique numeric dates. + Claims.AuthenticationTime or Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore + => values is [{ ValueType: ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or + ClaimValueTypes.Integer64 or ClaimValueTypes.Double or + ClaimValueTypes.UInteger32 or ClaimValueTypes.UInteger64 }], // Claims that are not in the well-known list can be of any type. _ => true @@ -2954,14 +2944,10 @@ public static partial class OpenIddictClientHandlers Debug.Assert(context.BackchannelIdentityTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); foreach (var group in context.BackchannelIdentityTokenPrincipal.Claims - .GroupBy(claim => claim.Type) - .ToDictionary(group => group.Key, group => group.ToList())) + .GroupBy(static claim => claim.Type) + .ToDictionary(static group => group.Key, group => group.ToList()) + .Where(static group => !ValidateClaimGroup(group.Key, group.Value))) { - if (ValidateClaimGroup(group)) - { - continue; - } - context.Reject( error: Errors.InvalidRequest, description: SR.FormatID2125(group.Key), @@ -3032,28 +3018,22 @@ public static partial class OpenIddictClientHandlers return default; - static bool ValidateClaimGroup(KeyValuePair> claims) => claims switch + static bool ValidateClaimGroup(string name, List values) => name switch { - // The following JWT claims MUST be represented as unique strings. - { - Key: Claims.AuthenticationContextReference or Claims.AuthorizedParty or - Claims.Issuer or Claims.Nonce or Claims.Subject, - Value: List values - } => values.Count is 1 && values[0].ValueType is ClaimValueTypes.String, + // The following claims MUST be represented as unique strings. + Claims.AuthenticationContextReference or Claims.AuthorizedParty or + Claims.Issuer or Claims.Nonce or Claims.Subject + => values is [{ ValueType: ClaimValueTypes.String }], - // The following JWT claims MUST be represented as unique strings or array of strings. - { - Key: Claims.Audience or Claims.AuthenticationMethodReference, - Value: List values - } => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), + // The following claims MUST be represented as unique strings or array of strings. + Claims.Audience or Claims.AuthenticationMethodReference + => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), - // The following JWT claims MUST be represented as unique numeric dates. - { - Key: Claims.AuthenticationTime or Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore, - Value: List values - } => values.Count is 1 && values[0].ValueType is ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or - ClaimValueTypes.Integer64 or ClaimValueTypes.Double or - ClaimValueTypes.UInteger32 or ClaimValueTypes.UInteger64, + // The following claims MUST be represented as unique numeric dates. + Claims.AuthenticationTime or Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore + => values is [{ ValueType: ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or + ClaimValueTypes.Integer64 or ClaimValueTypes.Double or + ClaimValueTypes.UInteger32 or ClaimValueTypes.UInteger64 }], // Claims that are not in the well-known list can be of any type. _ => true @@ -3871,14 +3851,10 @@ public static partial class OpenIddictClientHandlers Debug.Assert(context.UserinfoTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); foreach (var group in context.UserinfoTokenPrincipal.Claims - .GroupBy(claim => claim.Type) - .ToDictionary(group => group.Key, group => group.ToList())) + .GroupBy(static claim => claim.Type) + .ToDictionary(static group => group.Key, group => group.ToList()) + .Where(static group => !ValidateClaimGroup(group.Key, group.Value))) { - if (ValidateClaimGroup(group)) - { - continue; - } - context.Reject( error: Errors.InvalidRequest, description: SR.FormatID2131(group.Key), @@ -3889,13 +3865,10 @@ public static partial class OpenIddictClientHandlers return default; - static bool ValidateClaimGroup(KeyValuePair> claims) => claims switch + static bool ValidateClaimGroup(string name, List values) => name switch { - // The following JWT claims MUST be represented as unique strings. - { - Key: Claims.Subject, - Value: List values - } => values.Count is 1 && values[0].ValueType is ClaimValueTypes.String, + // The following claims MUST be represented as unique strings. + Claims.Subject => values is [{ ValueType: ClaimValueTypes.String }], // Claims that are not in the well-known list can be of any type. _ => true @@ -4191,7 +4164,7 @@ public static partial class OpenIddictClientHandlers } if (context.Registration is null && string.IsNullOrEmpty(context.RegistrationId) && - context.Issuer is null && string.IsNullOrEmpty(context.ProviderName) && + context.Issuer is null && string.IsNullOrEmpty(context.ProviderName) && context.Options.Registrations.Count is not 1) { throw context.Options.Registrations.Count is 0 ? @@ -4199,6 +4172,45 @@ public static partial class OpenIddictClientHandlers new InvalidOperationException(SR.GetResourceString(SR.ID0305)); } + if (context.Principal is not { Identity: ClaimsIdentity }) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0011)); + } + + if (context.Principal.Identity.IsAuthenticated) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0425)); + } + + if (context.Principal.HasClaim(Claims.Subject)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0426)); + } + + foreach (var group in context.Principal.Claims + .GroupBy(static claim => claim.Type) + .ToDictionary(static group => group.Key, static group => group.ToList()) + .Where(static group => !ValidateClaimGroup(group.Key, group.Value))) + { + throw new InvalidOperationException(SR.FormatID0424(group.Key)); + } + + static bool ValidateClaimGroup(string name, List values) => name switch + { + // The following claims MUST be represented as unique strings or array of strings. + Claims.Private.Audience or Claims.Private.Resource or Claims.Private.Presenter + => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), + + // The following claims MUST be represented as unique integers. + Claims.Private.StateTokenLifetime + => values is [{ ValueType: ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or + ClaimValueTypes.Integer64 or ClaimValueTypes.UInteger32 or + ClaimValueTypes.UInteger64 }], + + // Claims that are not in the well-known list can be of any type. + _ => true + }; + return default; } } @@ -5842,7 +5854,7 @@ public static partial class OpenIddictClientHandlers } if (context.Registration is null && string.IsNullOrEmpty(context.RegistrationId) && - context.Issuer is null && string.IsNullOrEmpty(context.ProviderName) && + context.Issuer is null && string.IsNullOrEmpty(context.ProviderName) && context.Options.Registrations.Count is not 1) { throw context.Options.Registrations.Count is 0 ? @@ -5850,6 +5862,45 @@ public static partial class OpenIddictClientHandlers new InvalidOperationException(SR.GetResourceString(SR.ID0341)); } + if (context.Principal is not { Identity: ClaimsIdentity }) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0011)); + } + + if (context.Principal.Identity.IsAuthenticated) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0425)); + } + + if (context.Principal.HasClaim(Claims.Subject)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0426)); + } + + foreach (var group in context.Principal.Claims + .GroupBy(static claim => claim.Type) + .ToDictionary(static group => group.Key, static group => group.ToList()) + .Where(static group => !ValidateClaimGroup(group.Key, group.Value))) + { + throw new InvalidOperationException(SR.FormatID0424(group.Key)); + } + + static bool ValidateClaimGroup(string name, List values) => name switch + { + // The following claims MUST be represented as unique strings or array of strings. + Claims.Private.Audience or Claims.Private.Resource or Claims.Private.Presenter + => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), + + // The following claims MUST be represented as unique integers. + Claims.Private.StateTokenLifetime + => values is [{ ValueType: ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or + ClaimValueTypes.Integer64 or ClaimValueTypes.UInteger32 or + ClaimValueTypes.UInteger64 }], + + // Claims that are not in the well-known list can be of any type. + _ => true + }; + return default; } } diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index d8afa149..98971db6 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -623,14 +623,10 @@ public static partial class OpenIddictServerHandlers Debug.Assert(context.ClientAssertionPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); foreach (var group in context.ClientAssertionPrincipal.Claims - .GroupBy(claim => claim.Type) - .ToDictionary(group => group.Key, group => group.ToList())) + .GroupBy(static claim => claim.Type) + .ToDictionary(static group => group.Key, group => group.ToList()) + .Where(static group => !ValidateClaimGroup(group.Key, group.Value))) { - if (ValidateClaimGroup(group)) - { - continue; - } - context.Reject( error: Errors.InvalidRequest, description: SR.FormatID2171(group.Key), @@ -706,27 +702,20 @@ public static partial class OpenIddictServerHandlers return default; - static bool ValidateClaimGroup(KeyValuePair> claims) => claims switch + static bool ValidateClaimGroup(string name, List values) => name switch { - // The following JWT claims MUST be represented as unique strings. - { - Key: Claims.AuthorizedParty or Claims.Issuer or Claims.JwtId or Claims.Subject, - Value: List values - } => values.Count is 1 && values[0].ValueType is ClaimValueTypes.String, + // The following claims MUST be represented as unique strings. + Claims.AuthorizedParty or Claims.Issuer or Claims.JwtId or Claims.Subject + => values is [{ ValueType: ClaimValueTypes.String }], - // The following JWT claims MUST be represented as unique strings or array of strings. - { - Key: Claims.Audience, - Value: List values - } => values.TrueForAll(static value => value.ValueType is 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), - // The following JWT claims MUST be represented as unique numeric dates. - { - Key: Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore, - Value: List values - } => values.Count is 1 && values[0].ValueType is ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or - ClaimValueTypes.Integer64 or ClaimValueTypes.Double or - ClaimValueTypes.UInteger32 or ClaimValueTypes.UInteger64, + // 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 + ClaimValueTypes.Integer64 or ClaimValueTypes.Double or + ClaimValueTypes.UInteger32 or ClaimValueTypes.UInteger64 }], // Claims that are not in the well-known list can be of any type. _ => true @@ -2123,7 +2112,7 @@ public static partial class OpenIddictServerHandlers throw new InvalidOperationException(SR.GetResourceString(SR.ID0012)); } - if (!string.IsNullOrEmpty(context.Principal.GetClaim(Claims.Subject))) + if (context.Principal.HasClaim(Claims.Subject)) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0013)); } @@ -2142,7 +2131,47 @@ public static partial class OpenIddictServerHandlers } } + foreach (var group in context.Principal.Claims + .GroupBy(static claim => claim.Type) + .ToDictionary(static group => group.Key, static group => group.ToList()) + .Where(static group => !ValidateClaimGroup(group.Key, group.Value))) + { + throw new InvalidOperationException(SR.FormatID0424(group.Key)); + } + return default; + + static bool ValidateClaimGroup(string name, List values) => name switch + { + // The following claims MUST be represented as unique strings. + Claims.AuthenticationContextReference or Claims.Subject or + Claims.Private.AuthorizationId or Claims.Private.CreationDate or + Claims.Private.DeviceCodeId or Claims.Private.ExpirationDate or + Claims.Private.TokenId + => values is [{ ValueType: ClaimValueTypes.String }], + + // The following claims MUST be represented as unique strings or array of strings. + Claims.AuthenticationMethodReference or Claims.Private.Audience or + Claims.Private.Presenter or Claims.Private.Resource + => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), + + // The following claims MUST be represented as unique integers. + Claims.Private.AccessTokenLifetime or Claims.Private.AuthorizationCodeLifetime or + Claims.Private.DeviceCodeLifetime or Claims.Private.IdentityTokenLifetime or + Claims.Private.RefreshTokenLifetime or Claims.Private.RefreshTokenLifetime + => values is [{ ValueType: ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or + ClaimValueTypes.Integer64 or ClaimValueTypes.UInteger32 or + ClaimValueTypes.UInteger64 }], + + // The following claims MUST be represented as unique numeric dates. + Claims.AuthenticationTime + => values is [{ ValueType: ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or + ClaimValueTypes.Integer64 or ClaimValueTypes.Double or + ClaimValueTypes.UInteger32 or ClaimValueTypes.UInteger64 }], + + // Claims that are not in the well-known list can be of any type. + _ => true + }; } } diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index dcc2bc3d..d2747fc8 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -1449,6 +1449,40 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal(SR.GetResourceString(SR.ID0013), exception.Message); } + [Fact] + public async Task ProcessSignIn_InvalidClaimValueTypeCausesAnException() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetClaim(Claims.Subject, 42); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act and assert + var exception = await Assert.ThrowsAsync(delegate + { + return client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + }); + + Assert.Equal(SR.FormatID0424(Claims.Subject), exception.Message); + } + [Fact] public async Task ProcessSignIn_ScopeDefaultsToOpenId() {