Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AllowedScopes feature is not functioning as anticipated during authentication with the OpenIddict server #2177

Open
hosamyousof opened this issue Oct 16, 2024 · 22 comments · May be fixed by #1431
Labels
accepted Bug or feature would be accepted as a PR or is being worked on Authentication Ocelot feature: Authentication Authorization Ocelot feature: Authorization Autumn'24 Autumn 2024 release high High priority proposal Proposal for a new functionality in Ocelot
Milestone

Comments

@hosamyousof
Copy link

hosamyousof commented Oct 16, 2024

Expected Behavior

The quote from the documentation: Authentication | Allowed Scopes

If you add scopes to AllowedScopes, Ocelot will get all the user claims (from the token) of the type scope and make sure that the user has at least one of the scopes in the list.

Actual Behavior

Validating one scope is not working and return 403 Forbidden.
The user access token has permission to two scopes 'InfraAPIs AdminAPIs' and I want to validate the InfraAPIs scope only in the rule in ocelot.json like:

{
  "DownstreamPathTemplate": "/log/{url}",
  "DownstreamScheme": "https",
  "DownstreamHostAndPorts": [
    {
      "Host": "localhost", "Port": 5004
    }
  ],
  "UpstreamPathTemplate": "/log/{url}",
  "UpstreamHttpMethod": [ "Get", "Post" ],
  "AuthenticationOptions": {
    "AuthenticationProviderKey": "OpenIddict.Validation.AspNetCore",
    "AllowedScopes": ["InfraAPIs"]
  }

Error log message from ocelot:

warn: Ocelot.Responder.Middleware.ResponderMiddleware[0]
      requestId: 0HN7E54RBBKV6:00000003, previousRequestId: No PreviousRequestId, message: 'Error Code: ScopeNotAuthorizedError Message: no one user scope: 'InfraAPIs AdminAPIs' match with some allowed scope: 'InfraAPIs' errors found in ResponderMiddleware. Setting error response for request path:/log/App/UpdateAPIAppLastConnectionDate, request method: POST'

OpenIddict validation handler configuration in program.cs:

// Add services to the container.
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});

// Register the OpenIddict validation components.
builder.Services.AddOpenIddict()
    .AddValidation(options =>
    {
        // Note: the validation handler uses OpenID Connect discovery
        // to retrieve the address of the introspection endpoint.
        options.SetIssuer("https://localhost:5001/");
        //options.AddAudiences("rs_SubscriptionAPI");

        //either user clientid and client secret or AddEncryptionKey to be used for validation

        // Configure the validation handler to use introspection and register the client
        // credentials used when communicating with the remote introspection endpoint.
        //options.UseIntrospection()
        //        .SetClientId("rs_SubscriptionAPI")
        //        .SetClientSecret("SubscriptionAPISecret");

        // Register the encryption credentials. This sample uses a symmetric
        // encryption key that is shared between the server and the Api2 sample
        // (that performs local token validation instead of using introspection).
        //
        // Note: in a real world application, this encryption key should be
        // stored in a safe place (e.g in Azure KeyVault, stored as a secret).
        options.AddEncryptionKey(new SymmetricSecurityKey(
            Convert.FromBase64String("<EncryptionKey>")));

        // Register the System.Net.Http integration.
        options.UseSystemNetHttp();

        // Register the ASP.NET Core host.
        options.UseAspNetCore();
    });

If I set all the scopes it's working but I want only to set one scope for the rule in ocelot.json.

Steps to Reproduce the Problem

Use the provided configuration and make call to endpoint.

Specifications

  • Version: 23.3.4
  • Platform: .NET 8
  • Subsystem: windows
@hosamyousof
Copy link
Author

@raman-m can you please help resolve this?

@raman-m raman-m added Authentication Ocelot feature: Authentication Authorization Ocelot feature: Authorization question Initially seen a question could become a new feature or bug or closed ;) labels Oct 17, 2024
@raman-m
Copy link
Member

raman-m commented Oct 17, 2024

OpenIddict -> https://github.com/openiddict/openiddict-core

We do not use this Auth provider and have not tested it, so we cannot assist with its integration. Our core services are thoroughly tested with the IdentityServer4 provider only. It is possible that our core auth services do not support the models of your Auth provider.

no one user scope: 'InfraAPIs AdminAPIs' match with some allowed scope: 'InfraAPIs'

This error is generated by this line of code:

new ScopeNotAuthorizedError($"no one user scope: '{string.Join(',', userScopes)}' match with some allowed scope: '{string.Join(',', routeAllowedScopes)}'"));

This implies that the matchesScopes collection is empty following the intersection. It would be beneficial to debug the following line accordingly:
var values = _claimsParser.GetValuesByClaimType(claimsPrincipal.Claims, Scope);

It appears that the _claimsParser service is parsing the data from the OpenIddict token incorrectly. Could you debug it?

@raman-m
Copy link
Member

raman-m commented Oct 18, 2024

@hosamyousof Hosam, why have you been silent?

@hosamyousof
Copy link
Author

Thanks @raman-m,
I will try to debug it and get back to you.

@hosamyousof
Copy link
Author

Hi @raman-m,
After debugging, I found that the issue is caused by the userScopes being returned as a single space-separated string. This is why the routeAllowedScopes.Intersect(userScopes) results in an empty list.

Please refer to the following screenshots for more details. Could you confirm if it's appropriate to split the userScopes string by spaces to get the correct scopes and resolve this issue?
image

image

@hosamyousof
Copy link
Author

hosamyousof commented Oct 19, 2024

Proposed fix

Replace this: var userScopes = values.Data;
with:

var userScopes = new List<string>();
foreach (var item in values.Data)
{
    userScopes.AddRange(item.Split(' '));
}

@raman-m
Copy link
Member

raman-m commented Oct 19, 2024

Could you confirm if it's appropriate to split the userScopes string by spaces to get the correct scopes and resolve this issue?

Yes, it is! It is appropriate. For your authentication provider, you need to write a custom parser for the claim value that contains the roles as whitespace-separated values, which are provided by the authentication provider. Following this, the intersection should not be empty and will contain the exact role required.

@raman-m
Copy link
Member

raman-m commented Oct 19, 2024

Proposed fix

Replace this: var userScopes = values.Data; with:

var userScopes = new List<string>();
foreach (var item in values.Data)
{
    userScopes.AddRange(item.Split(' '));
}

The code may function as intended, but it seems incomplete. A more robust solution would be to replace the IScopesAuthorizer and/or IClaimsAuthorizer services in the DI-container with new parsing logic. This approach is a direct solution based on the DI-container. However, I suspect there is more to explore within Ocelot's features... see the next comment...

@raman-m
Copy link
Member

raman-m commented Oct 19, 2024

First... We have ClaimsAuthorizer which is a part of AuthorizationMiddleware:

public class ClaimsAuthorizer : IClaimsAuthorizer

used in
private readonly IClaimsAuthorizer _claimsAuthorizer;


Second... Also, ClaimsParser has another GetValue method:

public Response<string> GetValue(IEnumerable<Claim> claims, string key, string delimiter, int index)

Seems this method is exactly what you need, 😃 right?
So, you have multiple values claim type (with whitespace-separated roles), and you need to call it somehow with these params:
_claimsParser.GetValue(claimsPrincipal.Claims, Scope, " ", 0)
I think it should work. 😉


Lastly... The problem is that in AuthorizationMiddleware we have authentication step first, and then authorization step;
We authenticate first by _scopesAuthorizer.Authorize here

var authorized = _scopesAuthorizer.Authorize(httpContext.User, downstreamRoute.AuthenticationOptions.AllowedScopes);

after successful authentication by scopes we must authorize by roles aka RouteClaimsRequirement here
var authorized = _claimsAuthorizer.Authorize(httpContext.User, downstreamRoute.RouteClaimsRequirement, httpContext.Items.TemplatePlaceholderNameAndValues());

In the given user scenario, the authentication stage fails due to the whitespace-separated value of the scope claim from the OpenIddict token. It appears that the IClaimsParser service, which functions correctly, should not be replaced. Instead, the IScopesAuthorizer service should be replaced, specifically by rewriting the Authorize method. Would you agree? However, replacing it with a customized IClaimsParser service could also be effective.

@raman-m
Copy link
Member

raman-m commented Oct 19, 2024

Hosam, did you search our repository for the same issue? It appears not, right? 😄 We have open PRs that address this OpenIddict issue:

I will prioritize your issue by linking it to PR #1431. Additionally, I encourage you to contribute to the completion of #1431, okay?

@raman-m raman-m changed the title AllowedScopes not working as expected with integrating with OpenIddict Auth Server AllowedScopes feature is not functioning as anticipated during authentication with the OpenIddict server Oct 19, 2024
@raman-m raman-m added proposal Proposal for a new functionality in Ocelot accepted Bug or feature would be accepted as a PR or is being worked on high High priority Oct'24 October 2024 release and removed question Initially seen a question could become a new feature or bug or closed ;) labels Oct 19, 2024
@raman-m raman-m added this to the October'24 milestone Oct 19, 2024
@raman-m
Copy link
Member

raman-m commented Oct 19, 2024

Discussed in

P.S. To locate all links related to the problem area, search for ScopesAuthorizer within the repository.

@hosamyousof
Copy link
Author

Hosam, did you search our repository for the same issue? It appears not, right? 😄 We have open PRs that address this OpenIddict issue:

I will prioritize your issue by linking it to PR #1431. Additionally, I encourage you to contribute to the completion of #1431, okay?

Thanks Raman for your great support. Yes, I did search quickly, but I didn't find anything directly related to the AllowedScopes with OpenIddict server 😅.

Sure, I can contribute to complete the PR.

@hosamyousof
Copy link
Author

Second... Also, ClaimsParser has another GetValue method:

public Response<string> GetValue(IEnumerable<Claim> claims, string key, string delimiter, int index)

Seems this method is exactly what you need, 😃 right?
So, you have multiple values claim type (with whitespace-separated roles), and you need to call it somehow with these params:
_claimsParser.GetValue(claimsPrincipal.Claims, Scope, " ", 0)
I think it should work. 😉

_claimsParser.GetValue(claimsPrincipal.Claims, Scope, " ", 0) is returning the claim in specific index after splitting, so, it's not enough. I suggest creating another overload of GetValuesByClaimType like Response<List<string>> GetValuesByClaimType(IEnumerable<Claim> claims, string claimType, string delimiter) and write the custom parser to get all the scopes and split them by the provided delimiter. Do you agree?

Lastly... The problem is that in AuthorizationMiddleware we have authentication step first, and then authorization step; We authenticate first by _scopesAuthorizer.Authorize here

var authorized = _scopesAuthorizer.Authorize(httpContext.User, downstreamRoute.AuthenticationOptions.AllowedScopes);

after successful authentication by scopes we must authorize by roles aka RouteClaimsRequirement here

var authorized = _claimsAuthorizer.Authorize(httpContext.User, downstreamRoute.RouteClaimsRequirement, httpContext.Items.TemplatePlaceholderNameAndValues());

In the given user scenario, the authentication stage fails due to the whitespace-separated value of the scope claim from the OpenIddict token. It appears that the IClaimsParser service, which functions correctly, should not be replaced. Instead, the IScopesAuthorizer service should be replaced, specifically by rewriting the Authorize method. Would you agree? However, replacing it with a customized IClaimsParser service could also be effective.

Yes, I agree. 😊

@hosamyousof
Copy link
Author

@raman-m do you have any comments to the following fix?

Adding another overload of GetValuesByClaimType() to the IClaimsParser

        public Response<List<string>> GetValuesByClaimType(IEnumerable<Claim> claims, string claimType, string delimiter)
        {
            var values = claims.Where(x => x.Type == claimType)
                .SelectMany(x => x.Value.Split(delimiter.ToCharArray()))
                .ToList();

            return new OkResponse<List<string>>(values);
        }

Then modify the Authorize() to call the new GetValuesByClaimType() and pass the dilimiters like:
var values = _claimsParser.GetValuesByClaimType(claimsPrincipal.Claims, Scope, ", ");
instead of:
var values = _claimsParser.GetValuesByClaimType(claimsPrincipal.Claims, Scope);

@hosamyousof
Copy link
Author

@raman-m should I create a PR?

@raman-m
Copy link
Member

raman-m commented Oct 21, 2024

Did you noticed the linked PR #1431 ? Why not to reuse this PR?
I believe this PR should fix your issue, right?

Would you like to complete and deliver it? The issue of owning the PR (pushing commits) can be resolved by forking it to my account and adding you as a collaborator. Alternatively, we could request the author to add you, but it appears that the author is no longer interested in the PR. Don't you like this idea?

@raman-m
Copy link
Member

raman-m commented Oct 22, 2024

@raman-m should I create a PR?

And what changes will be included? One extra method which will be called from authorization middleware during authentication step?
Do you understand that will be breaking change for our product?

@hosamyousof
Copy link
Author

Did you noticed the linked PR #1431 ? Why not to reuse this PR? I believe this PR should fix your issue, right?

Would you like to complete and deliver it? The issue of owning the PR (pushing commits) can be resolved by forking it to my account and adding you as a collaborator. Alternatively, we could request the author to add you, but it appears that the author is no longer interested in the PR. Don't you like this idea?

I'm not sure if the PR #1431 will solve the issue because there are a lot of changes.

@raman-m
Copy link
Member

raman-m commented Oct 23, 2024

I'm not sure if the PR #1431 will solve the issue because there are a lot of changes.

As a Senior Software Engineer (Team Lead) at OXO E-Shops and Technical Lead at SARsatX 😄, you should recognize that the mentioned PR could be further developed to ultimately support token claims from OpenIddict during the authentication phase. However, it appears you have no intention of contributing.

In response to your direct question:

@raman-m should I create a PR?

As mentioned, the linked PR should resolve the issue. I will ensure to double-check and incorporate additional parser logic when finalizing this PR.
You can address the issue in your environment by replacing services in the DI-container with customized behavior tailored to your requirements when constructing the Ocelot app pipeline.

@raman-m raman-m added Autumn'24 Autumn 2024 release and removed Oct'24 October 2024 release labels Oct 26, 2024
@raman-m raman-m modified the milestones: October'24, Autumn'24 Oct 26, 2024
@hosamyousof
Copy link
Author

I'm not sure if the PR #1431 will solve the issue because there are a lot of changes.

As a Senior Software Engineer (Team Lead) at OXO E-Shops and Technical Lead at SARsatX 😄, you should recognize that the mentioned PR could be further developed to ultimately support token claims from OpenIddict during the authentication phase. However, it appears you have no intention of contributing.

I think there is a misunderstanding 😒. I'm willing to contribute, but I'm really very busy.

As mentioned, the linked PR should resolve the issue. I will ensure to double-check and incorporate additional parser logic when finalizing this PR. You can address the issue in your environment by replacing services in the DI-container with customized behavior tailored to your requirements when constructing the Ocelot app pipeline.

Thanks for the hint!. That's what I've already done as a temporary solution, I replaced the currently registered ScopesAuthorizer with a custom version called OpenIddictScopesAuthorizer. This custom implementation modifies the Authorize() method to address the specific issue, and the problem is now resolved.

@raman-m
Copy link
Member

raman-m commented Oct 28, 2024

I replaced the currently registered ScopesAuthorizer with a custom version called OpenIddictScopesAuthorizer.

Could you show your code here plz?

@hosamyousof
Copy link
Author

Could you show your code here plz?

Sure!

Here is the replacement of ScopesAuthorizer with the new custom OpenIddictScopesAuthorizer in the program.cs

// Ocelot configuration
builder.Services.AddOcelot(builder.Configuration);

// Remove the existing ScopesAuthorizer registration
var descriptor = builder.Services.SingleOrDefault(
    d => d.ServiceType == typeof(IScopesAuthorizer) && d.ImplementationType == typeof(ScopesAuthorizer));

if (descriptor != null)
{
    builder.Services.Remove(descriptor);
}
// add OpenIddictScopesAuthorizer instead of ScopesAuthorizer
builder.Services.TryAddSingleton<IScopesAuthorizer, OpenIddictScopesAuthorizer>();

Here is the OpenIddictScopesAuthorizer with the new implementation of Authorize() method

    public class OpenIddictScopesAuthorizer : IScopesAuthorizer
    {
        private readonly IClaimsParser _claimsParser;
        private const string Scope = "scope";

        public OpenIddictScopesAuthorizer(IClaimsParser claimsParser)
        {
            _claimsParser = claimsParser;
        }

        public Response<bool> Authorize(ClaimsPrincipal claimsPrincipal, List<string> routeAllowedScopes)
        {
            if (routeAllowedScopes == null || routeAllowedScopes.Count == 0)
            {
                return new OkResponse<bool>(true);
            }

            var values = _claimsParser.GetValuesByClaimType(claimsPrincipal.Claims, Scope);

            if (values.IsError)
            {
                return new ErrorResponse<bool>(values.Errors);
            }

            //var userScopes = values.Data;
            var userScopes = new List<string>();
            foreach (var item in values.Data)
            {
                userScopes.AddRange(item.Split(' '));
            }

            var matchesScopes = routeAllowedScopes.Intersect(userScopes);

            if (!matchesScopes.Any())
            {
                return new ErrorResponse<bool>(
                    new ScopeNotAuthorizedError($"no one user scope: '{string.Join(',', userScopes)}' match with some allowed scope: '{string.Join(',', routeAllowedScopes)}'"));
            }

            return new OkResponse<bool>(true);
        }
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
accepted Bug or feature would be accepted as a PR or is being worked on Authentication Ocelot feature: Authentication Authorization Ocelot feature: Authorization Autumn'24 Autumn 2024 release high High priority proposal Proposal for a new functionality in Ocelot
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants