dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.59k stars 10.06k forks source link

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

Open BenJags opened 3 weeks ago

BenJags commented 3 weeks ago

Is there an existing issue for this?

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 commented 2 weeks ago

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.

BenJags commented 2 weeks ago

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.

halter73 commented 2 weeks ago

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.

BenJags commented 2 weeks ago

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.

halter73 commented 1 week ago

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