diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 3e25c9a9..a1683353 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -38,6 +38,7 @@ namespace OpenIddict.Server NormalizeUserCode.Descriptor, ValidateReferenceTokenIdentifier.Descriptor, ValidateIdentityModelToken.Descriptor, + NormalizeScopeClaims.Descriptor, MapInternalClaims.Descriptor, RestoreReferenceTokenProperties.Descriptor, ValidatePrincipal.Descriptor, @@ -508,6 +509,56 @@ namespace OpenIddict.Server } } + /// + /// Contains the logic responsible of normalizing the scope claims stored in the tokens. + /// + public class NormalizeScopeClaims : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateIdentityModelToken.Descriptor.Order + 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Principal == null) + { + return default; + } + + // Note: in previous OpenIddict versions, scopes were represented as a JSON array + // and deserialized as multiple claims. In OpenIddict 3.0, the public "scope" claim + // is formatted as a unique space-separated string containing all the granted scopes. + // To ensure access tokens generated by previous versions are still correctly handled, + // both formats (unique space-separated string or multiple scope claims) must be supported. + // To achieve that, all the "scope" claims are combined into a single one containg all the values. + // Visit https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-04 for more information. + var scopes = context.Principal.GetClaims(Claims.Scope); + if (scopes.Length > 1) + { + context.Principal.SetClaim(Claims.Scope, string.Join(" ", scopes)); + } + + return default; + } + } + /// /// Contains the logic responsible of mapping internal claims used by OpenIddict. /// @@ -519,7 +570,7 @@ namespace OpenIddict.Server public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ValidateIdentityModelToken.Descriptor.Order + 1_000) + .SetOrder(NormalizeScopeClaims.Descriptor.Order + 1_000) .Build(); /// @@ -601,24 +652,14 @@ namespace OpenIddict.Server } // In OpenIddict 3.0, the scopes granted to an application are stored in "oi_scp". - // - // Note: in previous OpenIddict versions, scopes were represented as a JSON array - // and deserialized as multiple claims. In OpenIddict 3.0, the public "scope" claim - // is formatted as a unique space-separated string containing all the granted scopes. - // To ensure access tokens generated by previous versions are still correctly handled, - // both formats (unique space-separated string or multiple scope claims) must be supported. - // Visit https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-04 for more information. + // If no such claim exists, try to infer them from the standard "scope" JWT claim, + // which is guaranteed to be a unique space-separated claim containing all the values. if (!context.Principal.HasScope()) { - var scopes = context.Principal.GetClaims(Claims.Scope); - if (scopes.Length == 1) - { - scopes = scopes[0].Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries).ToImmutableArray(); - } - - if (scopes.Any()) + var scope = context.Principal.GetClaim(Claims.Scope); + if (!string.IsNullOrEmpty(scope)) { - context.Principal.SetScopes(scopes); + context.Principal.SetScopes(scope.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries)); } } diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs index 1c512947..f0eacf2b 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs @@ -34,6 +34,7 @@ namespace OpenIddict.Validation ValidateReferenceTokenIdentifier.Descriptor, ValidateIdentityModelToken.Descriptor, IntrospectToken.Descriptor, + NormalizeScopeClaims.Descriptor, MapInternalClaims.Descriptor, RestoreReferenceTokenProperties.Descriptor, ValidatePrincipal.Descriptor, @@ -349,6 +350,56 @@ namespace OpenIddict.Validation } } + /// + /// Contains the logic responsible of normalizing the scope claims stored in the tokens. + /// + public class NormalizeScopeClaims : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(IntrospectToken.Descriptor.Order + 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Principal == null) + { + return default; + } + + // Note: in previous OpenIddict versions, scopes were represented as a JSON array + // and deserialized as multiple claims. In OpenIddict 3.0, the public "scope" claim + // is formatted as a unique space-separated string containing all the granted scopes. + // To ensure access tokens generated by previous versions are still correctly handled, + // both formats (unique space-separated string or multiple scope claims) must be supported. + // To achieve that, all the "scope" claims are combined into a single one containg all the values. + // Visit https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-04 for more information. + var scopes = context.Principal.GetClaims(Claims.Scope); + if (scopes.Length > 1) + { + context.Principal.SetClaim(Claims.Scope, string.Join(" ", scopes)); + } + + return default; + } + } + /// /// Contains the logic responsible of mapping internal claims used by OpenIddict. /// @@ -360,7 +411,7 @@ namespace OpenIddict.Validation public static OpenIddictValidationHandlerDescriptor Descriptor { get; } = OpenIddictValidationHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(IntrospectToken.Descriptor.Order + 1_000) + .SetOrder(NormalizeScopeClaims.Descriptor.Order + 1_000) .Build(); /// @@ -442,24 +493,14 @@ namespace OpenIddict.Validation } // In OpenIddict 3.0, the scopes granted to an application are stored in "oi_scp". - // - // Note: in previous OpenIddict versions, scopes were represented as a JSON array - // and deserialized as multiple claims. In OpenIddict 3.0, the public "scope" claim - // is formatted as a unique space-separated string containing all the granted scopes. - // To ensure access tokens generated by previous versions are still correctly handled, - // both formats (unique space-separated string or multiple scope claims) must be supported. - // Visit https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-04 for more information. + // If no such claim exists, try to infer them from the standard "scope" JWT claim, + // which is guaranteed to be a unique space-separated claim containing all the values. if (!context.Principal.HasScope()) { - var scopes = context.Principal.GetClaims(Claims.Scope); - if (scopes.Length == 1) - { - scopes = scopes[0].Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries).ToImmutableArray(); - } - - if (scopes.Any()) + var scope = context.Principal.GetClaim(Claims.Scope); + if (!string.IsNullOrEmpty(scope)) { - context.Principal.SetScopes(scopes); + context.Principal.SetScopes(scope.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries)); } } diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index 5dc48b38..86e3d06a 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -532,6 +532,55 @@ namespace OpenIddict.Server.FunctionalTests Assert.Equal(new[] { "Fabrikam", "Contoso" }, (string[]) response[Claims.Private.Audience]); } + [Fact] + public async Task ProcessAuthentication_MultiplePublicScopesAreNormalizedToSingleClaim() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.SetUserinfoEndpointUris("/authenticate"); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("access_token", context.Token); + Assert.Equal(TokenTypeHints.AccessToken, context.TokenType); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeHints.AccessToken) + .SetClaim(Claims.Subject, "Bob le Magnifique") + .SetClaims(Claims.Scope, ImmutableArray.Create(Scopes.OpenId, Scopes.Profile)); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/authenticate", new OpenIddictRequest + { + AccessToken = "access_token" + }); + + // Assert + Assert.Equal("Bob le Magnifique", (string) response[Claims.Subject]); + Assert.Equal("openid profile", (string) response[Claims.Scope]); + } + [Fact] public async Task ProcessAuthentication_SinglePublicScopeIsMappedToPrivateClaims() { @@ -578,7 +627,6 @@ namespace OpenIddict.Server.FunctionalTests // Assert Assert.Equal("Bob le Magnifique", (string) response[Claims.Subject]); - Assert.Equal("openid profile", (string) response[Claims.Scope]); Assert.Equal(new[] { Scopes.OpenId, Scopes.Profile }, (string[]) response[Claims.Private.Scope]); } @@ -628,7 +676,6 @@ namespace OpenIddict.Server.FunctionalTests // Assert Assert.Equal("Bob le Magnifique", (string) response[Claims.Subject]); - Assert.Equal(new[] { Scopes.OpenId, Scopes.Profile }, (string[]) response[Claims.Scope]); Assert.Equal(new[] { Scopes.OpenId, Scopes.Profile }, (string[]) response[Claims.Private.Scope]); }