Skip to content

Commit adc34ec

Browse files
mehyaaraman-mggnaegi
authored
#913 Support RFC 8693 (OAuth 2.0 Token Exchange) for the "scope" claim in ScopesAuthorizer (#1478)
* ScopesAuthorizer refactoring Scopes can be a space separated list in a single claim. Include this possibility on allowed scopes check. * Refactoring * Refactored scope authorization logic to match the intended feature * Add unit tests for space-separated scope handling in ScopesAuthorizer * Add acceptance tests for space-separated scope handling in AuthorizationTests * Refactor ScopesAuthorizer * Fix AuthorizationTests acceptance tests for space-separated scopes handling * Added missing changes to AuthorizationTests * Update ScopesAuthorizer.cs * Refactor AuthorizationTests to simplify space-separated scope tests and remove redundant cases * Update src/Ocelot/Authorization/ScopesAuthorizer.cs * adding reference to rfc 8693, the scope claim is a json string * Improve code coverage * Code review by @raman-m * Update docs --------- Co-authored-by: Raman Maksimchuk, @raman-m <[email protected]> Co-authored-by: Guillaume Gnaegi, @ggnaegi <[email protected]>
1 parent 35dbc9d commit adc34ec

File tree

10 files changed

+268
-28
lines changed

10 files changed

+268
-28
lines changed

docs/features/authentication.rst

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,17 +236,26 @@ However, for the “my-service” service, authorization with the ``my-service``
236236
Allowed Scopes
237237
--------------
238238

239-
Option: ``AllowedScopes``
239+
| Option: ``AllowedScopes``
240+
| Middleware: :ref:`authorization-middleware`
240241
241242
To set up authorization by scopes from the ``AllowedScopes`` collection, after successful authentication by the middleware and after claims have been transformed,
242243
the authorization middleware in Ocelot retrieves all user claims (from the token) of the '``scope``' type and ensures that the user has at least one of the scopes in the list.
243244
This provides a way to restrict access to a route on a per-scope basis.
244245

245-
**Note**: Since version `24.1`_, specifying global *allowed scopes* is exclusively supported.
246-
Therefore, only a route-level scheme (i.e., the ``AuthenticationProviderKeys`` array) combined with a route-level ``AllowedScopes`` array can override the global ``AllowedScopes``.
247-
Sure, to enable authentication, the ``AllowAnonymous`` option must be set to ``false`` or left undefined.
246+
.. note::
247+
[#f5]_ Depending on the authentication provider, incoming tokens embed the '``scope``' claim value in the body either as an array or as a single space-separated string of multiple values.
248+
For instance, :ref:`authentication-identity-server` use an array, whereas most :ref:`authentication-jwt-tokens` providers generate a space-separated list of scopes, in accordance with `RFC 8693`_, as stated in section "`4.2. "scope" (Scopes) Claim`_".
249+
Since version `24.1`_, Ocelot supports `RFC 8693`_ (OAuth 2.0 Token Exchange) for the ``scope`` claim in the ``ScopesAuthorizer`` service, also known as the ``IScopesAuthorizer`` service in the DI container.
250+
251+
.. note::
252+
Starting with version `24.1`_, specifying global *allowed scopes* is exclusively supported.
253+
Be cautious when overriding the global ``AllowedScopes`` array with a route-level ``AllowedScopes`` array;
254+
a combination of the route scheme (``AuthenticationProviderKeys`` array) and its *allowed scopes* might be required, since new *allowed scopes* could belong to another authentication provider's security model.
248255
For more details, refer to the ":ref:`Configuration and AllowAnonymous <authentication-configuration>`" and ":ref:`Global Configuration <authentication-global-configuration>`" sections.
249256

257+
.. _authentication-jwt-tokens:
258+
250259
JWT Tokens
251260
----------
252261

@@ -380,12 +389,20 @@ Please open a "`Show and tell <https://github.com/ThreeMammals/Ocelot/discussion
380389
.. [#f2] The ":ref:`Multiple Authentication Schemes <authentication-multiple>`" feature was requested in issues `740`_, `1580`_ and delivered as a part of `23.0`_ release.
381390
.. [#f3] The global ":ref:`Configuration and AllowAnonymous <authentication-configuration>`" feature for static routes was requested in issues `842`_ and `1414`_, implemented in pull request `2114`_, and officially released in version `24.1`_.
382391
.. [#f4] The ":ref:`Global Configuration <authentication-global-configuration>`" feature for dynamic routes was requested in issues `585`_ and `2316`_, implemented in pull request `2336`_, and released in version `24.1`_.
392+
.. [#f5] The ":ref:`authentication-allowed-scopes`" feature fully supports `RFC 8693`_ (OAuth 2.0 Token Exchange) for the ``scope`` claim in the ``ScopesAuthorizer`` service, which is part of the :ref:`authorization-middleware`.
393+
Refer to section `4.2. "scope" (Scopes) Claim`_.
394+
This enhancement was requested in bug `913`_, fixed in pull request `1478`_, and the patch was rolled out as part of the `24.1`_ release.
395+
396+
.. _RFC 8693: https://datatracker.ietf.org/doc/html/rfc8693
397+
.. _4.2. "scope" (Scopes) Claim: https://datatracker.ietf.org/doc/html/rfc8693#name-scope-scopes-claim
383398

384399
.. _446: https://github.com/ThreeMammals/Ocelot/issues/446
385400
.. _585: https://github.com/ThreeMammals/Ocelot/issues/585
386401
.. _740: https://github.com/ThreeMammals/Ocelot/issues/740
387402
.. _842: https://github.com/ThreeMammals/Ocelot/issues/842
403+
.. _913: https://github.com/ThreeMammals/Ocelot/issues/913
388404
.. _1414: https://github.com/ThreeMammals/Ocelot/issues/1414
405+
.. _1478: https://github.com/ThreeMammals/Ocelot/pull/1478
389406
.. _1580: https://github.com/ThreeMammals/Ocelot/issues/1580
390407
.. _2114: https://github.com/ThreeMammals/Ocelot/pull/2114
391408
.. _2316: https://github.com/ThreeMammals/Ocelot/issues/2316

src/Ocelot/Authorization/AuthorizationMiddleware.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public async Task Invoke(HttpContext context)
3838
return;
3939
}
4040

41-
if (!authorized.Data)
41+
if (!authorized.Data) // TODO: Looks like this is never called due to the current ScopesAuthorizer design :D Definitely a good reason to refactor
4242
{
4343
var error = new UnauthorizedError($"{context.User.Identity.Name} unable to access route {route.Name()}");
4444
#if DEBUG

src/Ocelot/Authorization/IScopesAuthorizer.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,21 @@ namespace Ocelot.Authorization;
55

66
public interface IScopesAuthorizer
77
{
8+
/// <summary>
9+
/// Checks that the <paramref name="claimsPrincipal"/> and its <see cref="ClaimsPrincipal.Claims"/> collection
10+
/// contain at least one <see cref="ScopeClaim"/> value present in the <paramref name="routeAllowedScopes"/> list.
11+
/// </summary>
12+
/// <remarks>
13+
/// Supports the RFC 8693 standard, allowing scope claim values as whitespace-separated strings.<br/>
14+
/// RFC 8693 Docs: <see href="https://datatracker.ietf.org/doc/html/rfc8693">OAuth 2.0 Token Exchange</see> | <see href="https://datatracker.ietf.org/doc/html/rfc8693#name-scope-scopes-claim">4.2. "scope" (Scopes) Claim</see>.
15+
/// </remarks>
16+
/// <exception cref="ScopeNotAuthorizedError">If not authorized.</exception>
17+
/// <param name="claimsPrincipal">Claims object from the current authentication provider's token.</param>
18+
/// <param name="routeAllowedScopes">List of allowed scopes for the route.</param>
19+
/// <returns><see langword="true"/> if any token scope claim value is in the allowed scopes; otherwise, <see langword="false"/>.</returns>
820
Response<bool> Authorize(ClaimsPrincipal claimsPrincipal, List<string> routeAllowedScopes);
21+
22+
/// <summary>Gets the claim type for <c>scope</c>.</summary>
23+
/// <value>A <see cref="string"/> representing the <c>scope</c>.</value>
24+
string ScopeClaim { get; }
925
}
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
using Ocelot.Errors;
1+
using Microsoft.AspNetCore.Http;
2+
using Ocelot.Errors;
23

34
namespace Ocelot.Authorization;
45

56
public class ScopeNotAuthorizedError : Error
67
{
78
public ScopeNotAuthorizedError(string message)
8-
: base(message, OcelotErrorCode.ScopeNotAuthorizedError, 403)
9+
: base(message, OcelotErrorCode.ScopeNotAuthorizedError, StatusCodes.Status403Forbidden)
910
{
1011
}
1112
}

src/Ocelot/Authorization/ScopesAuthorizer.cs

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,54 @@
1-
using Ocelot.Infrastructure.Claims;
2-
using Ocelot.Responses;
1+
using Ocelot.Infrastructure.Claims;
2+
using Ocelot.Infrastructure.Extensions;
3+
using Ocelot.Responses;
34
using System.Security.Claims;
45

56
namespace Ocelot.Authorization;
67

78
public class ScopesAuthorizer : IScopesAuthorizer
89
{
9-
private readonly IClaimsParser _claimsParser;
1010
public const string Scope = "scope";
11+
public const char SpaceChar = (char)32;
12+
13+
private readonly IClaimsParser _claimsParser;
1114

1215
public ScopesAuthorizer(IClaimsParser claimsParser)
1316
{
1417
_claimsParser = claimsParser;
1518
}
1619

20+
/// <inheritdoc/>
21+
public string ScopeClaim => Scope;
22+
23+
/// <inheritdoc/>
1724
public Response<bool> Authorize(ClaimsPrincipal claimsPrincipal, List<string> routeAllowedScopes)
1825
{
1926
if (routeAllowedScopes == null || routeAllowedScopes.Count == 0)
2027
{
2128
return new OkResponse<bool>(true);
2229
}
2330

24-
var values = _claimsParser.GetValuesByClaimType(claimsPrincipal.Claims, Scope);
25-
31+
var values = _claimsParser.GetValuesByClaimType(claimsPrincipal.Claims, ScopeClaim);
2632
if (values.IsError)
2733
{
2834
return new ErrorResponse<bool>(values.Errors);
2935
}
3036

31-
var userScopes = values.Data;
37+
IList<string> userScopes = values.Data;
3238

33-
var matchesScopes = routeAllowedScopes.Intersect(userScopes);
39+
// There should not be more than one scope claim that has space-separated value by design
40+
// Some providers use array value some space-separated value but not both
41+
// https://datatracker.ietf.org/doc/html/rfc8693#name-scope-scopes-claim
42+
if (userScopes.Count == 1 && userScopes[0].Contains(SpaceChar))
43+
{
44+
userScopes = userScopes[0].Split(SpaceChar, StringSplitOptions.RemoveEmptyEntries);
45+
}
3446

47+
var matchesScopes = routeAllowedScopes.Intersect(userScopes);
3548
if (!matchesScopes.Any())
3649
{
3750
return new ErrorResponse<bool>(
38-
new ScopeNotAuthorizedError($"no one user scope: '{string.Join(',', userScopes)}' match with some allowed scope: '{string.Join(',', routeAllowedScopes)}'"));
51+
new ScopeNotAuthorizedError($"no one user scope: '{userScopes.Csv()}' match with some allowed scope: '{routeAllowedScopes.Csv()}'"));
3952
}
4053

4154
return new OkResponse<bool>(true);

test/Ocelot.AcceptanceTests/Authentication/AuthenticationSteps.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,12 @@ public void WithJwtBearerAuthentication(IServiceCollection services, bool addOce
101101
app.MapGet("/connect", () => "Hello! Connected!");
102102
app.MapPost("/token", (AuthenticationTokenRequest model) =>
103103
{
104-
if (!apiScopes.Contains(model.Scope))
104+
// The signing server should be eligible to sign predefined claims as specified in its configuration.
105+
// If an unknown scope or claim is requested for inclusion in a JWT, the server should reject the request.
106+
// Therefore, the server configuration should be well-known to the client; otherwise, it poses a security risk.
107+
if (!apiScopes.Intersect(model.Scopes.Split(' ')).Any())
105108
{
106-
return Results.NotFound();
109+
return Results.BadRequest();
107110
}
108111
var token = GenerateToken(url, model);
109112
return Results.Json(token);
@@ -144,7 +147,7 @@ public AuthenticationTokenRequest GivenAuthTokenRequest(string scope,
144147
{
145148
Audience = ocelotClient?.BaseAddress.Authority, // Ocelot DNS is token audience
146149
ApiSecret = testName, // "secret",
147-
Scope = scope ?? OcelotScopes.Api,
150+
Scopes = scope ?? OcelotScopes.Api,
148151
Claims = claims is null ? new() : new(claims),
149152
UserId = testName,
150153
UserName = testName,
@@ -279,7 +282,7 @@ public static BearerToken GenerateToken(string issuerUrl, AuthenticationTokenReq
279282
new(JwtRegisteredClaimNames.Sub, auth.UserId),
280283
new(JwtRegisteredClaimNames.Email, auth.UserName),
281284
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
282-
new(ScopesAuthorizer.Scope, auth.Scope),
285+
new(ScopesAuthorizer.Scope, auth.Scopes),
283286
};
284287
claims.AddRange(roleClaims);
285288
claims.AddRange(userClaims);

test/Ocelot.AcceptanceTests/Authentication/AuthenticationTokenRequest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public string ApiSecret
2828
}
2929

3030
[JsonInclude]
31-
public string Scope { get; set; }
31+
public string Scopes { get; set; }
3232

3333
[JsonInclude]
3434
public List<KeyValuePair<string, string>> Claims { get; set; } = new();

test/Ocelot.AcceptanceTests/Authorization/AuthorizationTests.cs

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using Microsoft.AspNetCore.Authentication.JwtBearer;
2-
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
32
using Ocelot.AcceptanceTests.Authentication;
43
using Ocelot.Configuration.File;
54
using System.Runtime.CompilerServices;
@@ -271,10 +270,51 @@ public async Task Should_return_200_OK_with_global_allowed_scopes()
271270
await ThenTheResponseBodyAsync();
272271
}
273272

274-
private static void Void() { }
273+
#region PR 1478
274+
[Fact]
275+
[Trait("Bug", "913")] // https://github.com/ThreeMammals/Ocelot/issues/913
276+
[Trait("PR", "1478")] // https://github.com/ThreeMammals/Ocelot/pull/1478
277+
public async Task Should_return_200_OK_with_space_separated_scope_match()
278+
{
279+
var port = PortFinder.GetRandomPort();
280+
var route = GivenAuthRoute(port);
281+
route.AuthenticationOptions.AllowedScopes = ["api", "api.read", "api.write"];
282+
var configuration = GivenConfiguration(route);
283+
GivenThereIsAConfiguration(configuration);
284+
await GivenThereIsExternalJwtSigningService("api.read", "openid", "offline_access");
285+
GivenThereIsAServiceRunningOn(port);
286+
GivenOcelotIsRunning(WithJwtBearerAuthentication);
287+
await GivenIHaveATokenWithScope("api.read openid offline_access");
288+
GivenIHaveAddedATokenToMyRequest();
289+
await WhenIGetUrlOnTheApiGateway("/");
290+
ThenTheStatusCodeShouldBeOK();
291+
await ThenTheResponseBodyAsync();
292+
}
275293

276-
private async Task GivenIHaveATokenWithScope(string scope, [CallerMemberName] string testName = "")
277-
=> await GivenIHaveAToken(scope, null, JwtSigningServerUrl, null, testName);
278-
private async Task GivenIHaveATokenWithClaims(IEnumerable<KeyValuePair<string, string>> claims, [CallerMemberName] string testName = "")
279-
=> await GivenIHaveAToken(OcelotScopes.Api, claims, JwtSigningServerUrl, null, testName);
294+
[Fact]
295+
[Trait("Bug", "913")]
296+
[Trait("PR", "1478")]
297+
public async Task Should_return_403_Forbidden_with_space_separated_scope_no_match()
298+
{
299+
var port = PortFinder.GetRandomPort();
300+
var route = GivenAuthRoute(port);
301+
route.AuthenticationOptions.AllowedScopes = ["admin", "superuser"];
302+
var configuration = GivenConfiguration(route);
303+
await GivenThereIsExternalJwtSigningService("api.read", "api.write", "openid");
304+
GivenThereIsAServiceRunningOn(port);
305+
GivenThereIsAConfiguration(configuration);
306+
GivenOcelotIsRunning(WithJwtBearerAuthentication);
307+
await GivenIHaveATokenWithScope("api.read api.write openid");
308+
GivenIHaveAddedATokenToMyRequest();
309+
await WhenIGetUrlOnTheApiGateway("/");
310+
ThenTheStatusCodeShouldBe(HttpStatusCode.Forbidden);
311+
}
312+
#endregion PR 1478
313+
314+
private static void Void() { }
315+
private const string DefaultAudience = null;
316+
private Task<BearerToken> GivenIHaveATokenWithScope(string scope, [CallerMemberName] string testName = "")
317+
=> GivenIHaveAToken(scope, null, JwtSigningServerUrl, DefaultAudience, testName);
318+
private Task<BearerToken> GivenIHaveATokenWithClaims(IEnumerable<KeyValuePair<string, string>> claims, [CallerMemberName] string testName = "")
319+
=> GivenIHaveAToken(OcelotScopes.Api, claims, JwtSigningServerUrl, DefaultAudience, testName);
280320
}

test/Ocelot.UnitTests/Authorization/AuthorizationMiddlewareTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,14 @@ public AuthorizationMiddlewareTests()
3030
_loggerFactory.Setup(x => x.CreateLogger<AuthorizationMiddleware>()).Returns(_logger.Object);
3131
_next = context => Task.CompletedTask;
3232
_middleware = new AuthorizationMiddleware(_next, _claimsAuthorizer.Object, _scopesAuthorizer.Object, _loggerFactory.Object);
33+
34+
_logger.Setup(x => x.LogWarning(It.IsAny<Func<string>>()))
35+
.Callback<Func<string>>(_warnings.Add);
3336
}
3437

38+
private readonly List<Func<string>> _warnings = new();
39+
private List<string> GetWarnings() => _warnings.Select(w => w()).ToList();
40+
3541
[Fact]
3642
[Trait("Feat", "100")] // https://github.com/ThreeMammals/Ocelot/issues/100
3743
[Trait("PR", "104")] // https://github.com/ThreeMammals/Ocelot/pull/104
@@ -75,6 +81,39 @@ public async Task Should_call_authorization_service()
7581
ThenClaimsAuthorizerIsCalled();
7682
}
7783

84+
[Fact]
85+
[Trait("Feat", "100")] // https://github.com/ThreeMammals/Ocelot/issues/100
86+
[Trait("PR", "104")] // https://github.com/ThreeMammals/Ocelot/pull/104
87+
[Trait("Release", "1.4.5")] // https://github.com/ThreeMammals/Ocelot/releases/tag/1.4.5
88+
public async Task Invoke_RouteIsAuthenticated_WhenScopesAuthorizerError_ShouldUpsertErrors()
89+
{
90+
// Arrange
91+
var route = new DownstreamRouteBuilder()
92+
.WithAuthenticationOptions(new(new("authScheme")))
93+
.WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithOriginalValue("/test").Build())
94+
.Build();
95+
var response = new ErrorResponse<bool>(new ScopeNotAuthorizedError("No match"));
96+
GivenTheDownStreamRouteIs(new(), route);
97+
GivenScopesAuthorizerReturns(response);
98+
99+
// Act
100+
await _middleware.Invoke(_httpContext);
101+
102+
// Assert
103+
ThenScopesAuthorizerIsCalled();
104+
#if DEBUG
105+
_logger.Verify(x => x.LogWarning(It.IsAny<Func<string>>()), Times.Once);
106+
var warnings = GetWarnings();
107+
Assert.Contains($"The '/test' route encountered authorization errors due to user scopes:{Environment.NewLine}ScopeNotAuthorizedError: No match", warnings);
108+
#endif
109+
var errors = _httpContext.Items.Errors();
110+
Assert.NotEmpty(errors);
111+
Assert.Contains(response.Errors[0], errors);
112+
var actual = Assert.Single(errors);
113+
Assert.Same(response.Errors[0], actual);
114+
Assert.Equal("No match", actual.Message);
115+
}
116+
78117
private void GivenTheDownStreamRouteIs(List<PlaceholderNameAndValue> templatePlaceholderNameAndValues, DownstreamRoute downstreamRoute)
79118
{
80119
_httpContext.Items.UpsertTemplatePlaceholderNameAndValues(templatePlaceholderNameAndValues);

0 commit comments

Comments
 (0)