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

BlazorWebAppOidc AddOpenIdConnect with GetClaimsFromUserInfoEndpoint = true doesn't propogate role claims to client #58826

Open
1 task done
BenJags opened this issue Nov 7, 2024 · 5 comments
Labels
area-security Docs This issue tracks updating documentation

Comments

@BenJags
Copy link

BenJags commented Nov 7, 2024

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

I am using the Blazor 8 BlazorWebAppOidc sample to authenticate and authorize using OpenIdConnect with keycloak. I am seeing an issue where role claims from the userinfo endpoint do not propogate to the client. My setup is as follows:

builder.Services.AddAuthentication(MS_OIDC_SCHEME)
    .AddOpenIdConnect(MS_OIDC_SCHEME, oidcOptions =>
    {
        oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;

        oidcOptions.Authority = "https://host.docker.internal/keycloak/realms/Autostore/";

        oidcOptions.ClientId = "WMSServiceCalendar";

        oidcOptions.ResponseType = OpenIdConnectResponseType.Code;

        oidcOptions.MapInboundClaims = false;
        oidcOptions.TokenValidationParameters = new TokenValidationParameters
        {
            NameClaimType = JwtRegisteredClaimNames.Name,
            RoleClaimType = "role"
        };

        oidcOptions.UsePkce = true;
        oidcOptions.GetClaimsFromUserInfoEndpoint = true;

        oidcOptions.Events.OnUserInformationReceived = ctx =>
        {
            Console.WriteLine();
            Console.WriteLine("Claims from the ID token");
            foreach (var claim in ctx.Principal.Claims)
            {
                Console.WriteLine($"{claim.Type} - {claim.Value}");
            }
            Console.WriteLine();
            Console.WriteLine("Claims from the UserInfo endpoint");
            foreach (var property in ctx.User.RootElement.EnumerateObject())
            {
                Console.WriteLine($"{property.Name} - {property.Value}");
            }
            return Task.CompletedTask;
        };
    })
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
    {
        options.Events.OnSigningIn = ctx =>
        {
            Console.WriteLine();
            Console.WriteLine("Claims received by the Cookie handler");
            foreach (var claim in ctx.Principal.Claims)
            {
                Console.WriteLine($"{claim.Type} - {claim.Value}");
            }
            Console.WriteLine();

            return Task.CompletedTask;
        };
    });

With the OnUserInformationReceived logging I can see the claims coming from Keycloak:

Claims from the ID token
exp - 1730968528
iat - 1730968228
auth_time - 1730968228
jti - 1801bcab-dfd5-463f-9bf6-7cf84ded884a
iss - https://host.docker.internal/keycloak/realms/Autostore
aud - WMSServiceCalendar
sub - c5f3046c-b1b7-497d-b12e-a3f29afc5d11
typ - ID
azp - WMSServiceCalendar
nonce - 638665650239925001.M2YxOGZlNDMtMzkxYi00MDhkLWJmZTEtYzM1Y2QwNjI3NjAxODBkODhmZDAtMjE3OC00ZDYyLWEwZjktZGM0MjI5MzZkNjA1
sid - 654b19df-4dcf-405b-9d34-27bffd126968
at_hash - iCCj0tkd2HpoJZRclzph-w
acr - 1
email_verified - true
name - manager name
preferred_username - manager
given_name - manager
family_name - name

Claims from the UserInfo endpoint
sub - c5f3046c-b1b7-497d-b12e-a3f29afc5d11
resource_access - {"CommonClient":{"roles":["CommonClient:GrafanaViewer","CommonClient:MessagingAdmin","CommonClient:GrafanaEditor","CommonClient:GrafanaAdmin","CommonClient:UserManagement","CommonClient:Users"]}}
email_verified - True
role - ["WMSServiceCalendar:VehicleTypeDelete","WMSServiceCalendar:VehicleDelete","WMSServiceCalendar:VehicleExport","WMSServiceCalendar:VehicleTypeView","WMSServiceCalendar:VehicleView","WMSServiceCalendar:Users"]
name - manager name
preferred_username - manager
given_name - manager
family_name - name

Claims received by the Cookie handler
auth_time - 1730968228
jti - 1801bcab-dfd5-463f-9bf6-7cf84ded884a
sub - c5f3046c-b1b7-497d-b12e-a3f29afc5d11
typ - ID
sid - 654b19df-4dcf-405b-9d34-27bffd126968
email_verified - true
name - manager name
preferred_username - manager
given_name - manager
family_name - name
role - WMSServiceCalendar:VehicleTypeDelete
role - WMSServiceCalendar:VehicleDelete
role - WMSServiceCalendar:VehicleExport
role - WMSServiceCalendar:VehicleTypeView
role - WMSServiceCalendar:VehicleView
role - WMSServiceCalendar:Users

I have amended UserInfo as follows:

public sealed class UserInfo
{
    public required string UserId { get; init; }
    public required string Name { get; init; }
    public required string[] Roles { get; init; }

    public const string UserIdClaimType = "sub";
    public const string NameClaimType = "name";
    public const string RoleClaimType = "role";

    public static UserInfo FromClaimsPrincipal(ClaimsPrincipal principal) =>
        new()
        {
            UserId = GetRequiredClaim(principal, UserIdClaimType),
            Name = GetRequiredClaim(principal, NameClaimType),
            Roles = principal.FindAll(RoleClaimType).Select(c => c.Value).ToArray(),
        };

    public ClaimsPrincipal ToClaimsPrincipal() =>
        new(new ClaimsIdentity(
            Roles.Select(role => new Claim(RoleClaimType, role))
                .Concat([
                    new Claim(UserIdClaimType, UserId),
                    new Claim(NameClaimType, Name),
                ]),
            authenticationType: nameof(UserInfo),
            nameType: NameClaimType,
            roleType: RoleClaimType));

    private static string GetRequiredClaim(ClaimsPrincipal principal, string claimType) =>
        principal.FindFirst(claimType)?.Value ?? throw new InvalidOperationException($"Could not find required '{claimType}' claim.");
}

In my client I then have the following in my page:

@attribute [Authorize(Roles = "WMSServiceCalendar:Users")]

As a result of the issue I see the following error:

info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2] Authorization failed. These requirements were not met: RolesAuthorizationRequirement:User.IsInRole must be true for one of the following roles: (WMSServiceCalendar:Users)

Note: If I add the claims to the id token in keycloak then all works but that feels like it defeats the point of using GetClaimsFromUserInfoEndpoint?

Expected Behavior

Using GetClaimsFromUserInfoEndpoint = true should flow the claims from server to client side and allow the roles to be used during authorization

Steps To Reproduce

I am using the above setup with no further modifications to the sample application.

Exceptions (if any)

No response

.NET Version

8.0.403

Anything else?

ID Token:

{
  "exp": 1730967440,
  "iat": 1730967140,
  "jti": "aab08d01-1d37-4d87-9c73-7f9f95aa103c",
  "iss": "https://host.docker.internal/keycloak/realms/Autostore",
  "aud": "WMSServiceCalendar",
  "sub": "c5f3046c-b1b7-497d-b12e-a3f29afc5d11",
  "typ": "ID",
  "azp": "WMSServiceCalendar",
  "sid": "0bf27112-0271-4ce1-9d97-9e1d18cc9214",
  "acr": "1",
  "email_verified": true,
  "name": "manager name",
  "preferred_username": "manager",
  "given_name": "manager",
  "family_name": "name"
}

User Info:

{
  "sub": "c5f3046c-b1b7-497d-b12e-a3f29afc5d11",
  "email_verified": true,
  "role": [
    "WMSServiceCalendar:VehicleTypeDelete",
    "WMSServiceCalendar:VehicleDelete",
    "WMSServiceCalendar:VehicleExport",
    "WMSServiceCalendar:VehicleTypeView",
    "WMSServiceCalendar:VehicleView",
    "WMSServiceCalendar:Users"
  ],
  "name": "manager name",
  "preferred_username": "manager",
  "given_name": "manager",
  "family_name": "name"
}
@halter73
Copy link
Member

halter73 commented Nov 10, 2024

Does it work if you map the "role" json key to the "role" claim type using MapJsonKey?

builder.Services.AddAuthentication(MS_OIDC_SCHEME)
    .AddOpenIdConnect(MS_OIDC_SCHEME, oidcOptions =>
    {
        // ...
        oidcOptions.GetClaimsFromUserInfoEndpoint = true;
        oidcOptions.ClaimActions.MapJsonKey("role", "role");
        // ...

Another way to get the user claims is to use the OpenID Connect User Info API. The ASP.NET Core client app uses the GetClaimsFromUserInfoEndpoint property to configure this. One important difference from the first settings, is that you must specify the claims you require using the MapUniqueJsonKey method, otherwise only the name, given_name and email standard claims will be available in the client app. The claims included in the id_token are mapped per default. This is the major difference to the first option. You must explicitly define some of the claims you require.

https://learn.microsoft.com/aspnet/core/security/authentication/claims?view=aspnetcore-8.0#mapping-claims-using-openid-connect-authentication

The difference from the docs is that a "role" is not unique, so we want to use MapJsonKey rather than MapUniqueJsonKey.

@halter73 halter73 added the ✔️ Resolution: Answered Resolved because the question asked by the original author has been answered. label Nov 10, 2024
@halter73 halter73 added Needs: Author Feedback The author of this issue needs to respond in order for us to continue investigating this issue. and removed ✔️ Resolution: Answered Resolved because the question asked by the original author has been answered. Status: Resolved labels Nov 10, 2024
@BenJags
Copy link
Author

BenJags commented Nov 11, 2024

No, I have added the MapJsonKey call and the role claims show in my logging but they are still not available on the claimsprincipal client side or at the minimal api server side to able to use them for authorization. The only claims that are in the principal identity are the ones from the ID token.

@dotnet-policy-service dotnet-policy-service bot added Needs: Attention 👋 This issue needs the attention of a contributor, typically because the OP has provided an update. and removed Needs: Author Feedback The author of this issue needs to respond in order for us to continue investigating this issue. labels Nov 11, 2024
@halter73
Copy link
Member

It's really surprising to me that the claims you see in a minimal API are different than what you're logging in CookieAuthenticationOptions.Events.OnSigningIn. That event is called almost immediately before setting the cookie. Maybe you could check again in OnSignedIn to verify nothing else is messing with the ClaimsPrincipal.

What do you see if you add the following minimal API endpoint?

app.MapGet("/claims", (ClaimsPrincipal user) => user.Claims.Select(c => new { c.Type, c.Value }));

Since the minimal API is relying purely on the cookie authentication handler and not the Blazor AuthenticationStateProvider, it should match exactly what you see in the OnSigningIn and OnSignedIn callbacks. If it's not exactly the same, what's different? Just the roles? Is ClaimsIdentity.RoleClaimType "role" as expected in both the callbacks and the minimal endpoint?

The ClaimsIdentity should be created with the RoleClaimType you specify in ValidationParameters and the default cookie serializer should serialize and deserialize it.

If you cannot figure out what's going on based on inspecting the ClaimsPrincipal in the events and a minimal API, we will need a repro project hosted on GitHub to take a look at. I know that you're trying to make only minor changes to the BlazorWebAppOidc sample, but I'm not seeing the issue you are when I try it myself, and I cannot easily guess what the differences may be. Don't worry about the OIDC server bits. I can fake a userinfo response to match what you're seeing.

@halter73 halter73 added Needs: Repro Indicates that the team needs a repro project to continue the investigation on this issue and removed Needs: Attention 👋 This issue needs the attention of a contributor, typically because the OP has provided an update. labels Nov 12, 2024
@dotnet-policy-service dotnet-policy-service bot added the Needs: Author Feedback The author of this issue needs to respond in order for us to continue investigating this issue. label Nov 12, 2024
@BenJags
Copy link
Author

BenJags commented Nov 12, 2024

I've added the /claims endpoint which outputs the following:

[
  {
    "type": "exp",
    "value": "1731400359"
  },
  {
    "type": "iat",
    "value": "1731400059"
  },
  {
    "type": "auth_time",
    "value": "1731400051"
  },
  {
    "type": "jti",
    "value": "6a14407a-a1b1-416b-b9d7-cc4d784d7305"
  },
  {
    "type": "iss",
    "value": "https://host.docker.internal/keycloak/realms/Autostore"
  },
  {
    "type": "aud",
    "value": "WMSServiceCalendar"
  },
  {
    "type": "sub",
    "value": "c5f3046c-b1b7-497d-b12e-a3f29afc5d11"
  },
  {
    "type": "typ",
    "value": "ID"
  },
  {
    "type": "azp",
    "value": "WMSServiceCalendar"
  },
  {
    "type": "sid",
    "value": "bbc98c8b-a536-4255-a3d2-595750e90d8e"
  },
  {
    "type": "at_hash",
    "value": "5rH5HNNhgTmaE_Sg2pdaQw"
  },
  {
    "type": "acr",
    "value": "1"
  },
  {
    "type": "email_verified",
    "value": "true"
  },
  {
    "type": "name",
    "value": "manager name"
  },
  {
    "type": "preferred_username",
    "value": "manager"
  },
  {
    "type": "given_name",
    "value": "manager"
  },
  {
    "type": "family_name",
    "value": "name"
  }
]

The output of my logging in the OnSigningIn callback is:

Claims received by the Cookie handler
auth_time - 1731400580
jti - ef2430e9-bd4a-401f-a88f-8794f59d306e
sub - c5f3046c-b1b7-497d-b12e-a3f29afc5d11
typ - ID
sid - 3ad70098-979f-4c42-8958-b66cf2592fd2
email_verified - true
name - manager name
preferred_username - manager
given_name - manager
family_name - name
role - WMSServiceCalendar:VehicleTypeDelete
role - WMSServiceCalendar:VehicleDelete
role - WMSServiceCalendar:VehicleExport
role - WMSServiceCalendar:VehicleTypeView
role - WMSServiceCalendar:VehicleView
role - WMSServiceCalendar:Users

And adding logging to the /weather-forecast endpoint results in:

Claims received by the weather minimal api
exp - 1731400887
iat - 1731400587
auth_time - 1731400580
jti - 91fea84a-7258-41e4-ad21-a3b15bb74c38
iss - https://host.docker.internal/keycloak/realms/Autostore
aud - WMSServiceCalendar
sub - c5f3046c-b1b7-497d-b12e-a3f29afc5d11
typ - ID
azp - WMSServiceCalendar
sid - 3ad70098-979f-4c42-8958-b66cf2592fd2
at_hash - ZMCKk1NT_u0h_QH_hSNkzQ
acr - 1
email_verified - true
name - manager name
preferred_username - manager
given_name - manager
family_name - name

So, I am only missing the role claims. The ClaimsIdentity.RoleClaimType is set to role in both the callback and the minimal api identities.

I am at a loss the same as you. So I have uploaded the repo to the following location:

https://github.com/BenJags/BlazorWebAppOidc

Let me know if you need more details about the keycloak responses to be able to fake them.

@dotnet-policy-service dotnet-policy-service bot added Needs: Attention 👋 This issue needs the attention of a contributor, typically because the OP has provided an update. and removed Needs: Author Feedback The author of this issue needs to respond in order for us to continue investigating this issue. labels Nov 12, 2024
@halter73
Copy link
Member

halter73 commented Nov 22, 2024

Thanks for the repro. I tried it out myself with Keycloak, and I discovered that the issue is that the CookieOidcRefresher (which is part of the BlazorWebAppOidc sample) refreshes the cookie using claims from just the ID token and not the /userinfo endpoint.

This "refresher" is only supposed to have an effect as the access token nears expiration, so that the cookie always contains an unexpired access token, but it winds up reissuing a cookie just about every request is because Keycloak's default access token timeout is 5 minutes, and the CookieOidcRefresher tries to refresh the cookie whenever the access token is within 5 minutes of expiration.

If you don't need the access token (see here for how it can be used to make requests to another server using AddJwtBearer as explained in the BFF variant of the Blazor Web OIDC documentation), you can remove the call to ConfigureCookieOidcRefresh from Program.cs, and delete the CookieOidcRefresher.cs and CookieOidcServiceCollectionExtensions.cs files from your project.

Otherwise, if you decide to keep the cookie/token refreshing logic, I think the first step is to reduce this 5 minute interval in CookieOidcRefresher, to something smaller like 1 minute. As long as it doesn't take longer than that to process a request and there isn't too much clock drift, it should still be fine to use the access token with a smaller interval. Another option is to adjust Keycloak's "Access Token Lifespan” configuration under the "Realm settings" to something larger like 15 minutes. Or you could adjust both.

Keycloak's "Access Token Lifespan” configuration

Of course, even after we fix the refreshing logic to not run every request, we'd still want to get claims from the /userinfo endpoint while refreshing the cookie. You can do so by applying the following patch to your CookieOidcRefresher.cs file. I mostly copied the logic from OpenIdConnectHandler.GetUserInformationAsync, but left out bits like calling the OnUserInformationReceived event. Feel free to add that back if it's important.

diff --git a/BlazorWebAppOidc/CookieOidcRefresher.cs b/BlazorWebAppOidc/CookieOidcRefresher.cs
index c832924..af33cd7 100644
--- a/BlazorWebAppOidc/CookieOidcRefresher.cs
+++ b/BlazorWebAppOidc/CookieOidcRefresher.cs
@@ -1,6 +1,8 @@
 using System.Globalization;
 using System.IdentityModel.Tokens.Jwt;
+using System.Net.Http.Headers;
 using System.Security.Claims;
+using System.Text.Json;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.Authentication.OpenIdConnect;
@@ -86,6 +88,12 @@ internal sealed class CookieOidcRefresher(IOptionsMonitor<OpenIdConnectOptions>
             ValidatedIdToken = validatedIdToken,
         });
 
+        if (oidcOptions.GetClaimsFromUserInfoEndpoint && !string.IsNullOrEmpty(oidcConfiguration.UserInfoEndpoint))
+        {
+            await AddClaimsFromUserInfoEndpointAsync(oidcConfiguration.UserInfoEndpoint, message.AccessToken, oidcScheme,
+                validatedIdToken, validationResult.ClaimsIdentity, oidcOptions, validateContext.HttpContext.RequestAborted);
+        }
+
         validateContext.ShouldRenew = true;
         validateContext.ReplacePrincipal(new ClaimsPrincipal(validationResult.ClaimsIdentity));
 
@@ -99,4 +107,43 @@ internal sealed class CookieOidcRefresher(IOptionsMonitor<OpenIdConnectOptions>
             new() { Name = "expires_at", Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) },
         ]);
     }
+
+    private static async Task AddClaimsFromUserInfoEndpointAsync(string userInfoEndpoint, string accessToken, string oidcScheme,
+        JwtSecurityToken validatedIdToken, ClaimsIdentity identity, OpenIdConnectOptions options, CancellationToken cancellationToken)
+    {
+        var requestMessage = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint);
+        requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+        requestMessage.Version = options.Backchannel.DefaultRequestVersion;
+        var responseMessage = await options.Backchannel.SendAsync(requestMessage, cancellationToken);
+        responseMessage.EnsureSuccessStatusCode();
+        var userInfoResponse = await responseMessage.Content.ReadAsStringAsync(cancellationToken);
+
+        string userInfoJson;
+        var contentType = responseMessage.Content.Headers.ContentType;
+        if (contentType?.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) ?? false)
+        {
+            userInfoJson = userInfoResponse;
+        }
+        else if (contentType?.MediaType?.Equals("application/jwt", StringComparison.OrdinalIgnoreCase) ?? false)
+        {
+            var userInfoEndpointJwt = new JwtSecurityToken(userInfoResponse);
+            userInfoJson = userInfoEndpointJwt.Payload.SerializeToJson();
+        }
+        else
+        {
+            return;
+        }
+
+        using var user = JsonDocument.Parse(userInfoJson);
+        options.ProtocolValidator.ValidateUserInfoResponse(new OpenIdConnectProtocolValidationContext()
+        {
+            UserInfoEndpointResponse = userInfoResponse,
+            ValidatedIdToken = validatedIdToken,
+        });
+
+        foreach (var action in options.ClaimActions)
+        {
+            action.Run(user.RootElement, identity, options.ClaimsIssuer ?? oidcScheme);
+        }
+    }
 }

@halter73 halter73 added Docs This issue tracks updating documentation and removed Needs: Attention 👋 This issue needs the attention of a contributor, typically because the OP has provided an update. Needs: Repro Indicates that the team needs a repro project to continue the investigation on this issue labels Nov 22, 2024
@halter73 halter73 removed their assignment Nov 22, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-security Docs This issue tracks updating documentation
Projects
None yet
Development

No branches or pull requests

2 participants