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