Open BenJags opened 3 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 theMapUniqueJsonKey
method, otherwise only thename
,given_name
andid_token
are mapped per default. This is the major difference to the first option. You must explicitly define some of the claims you require.
The difference from the docs is that a "role" is not unique, so we want to use MapJsonKey
rather than MapUniqueJsonKey
.
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.
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.
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.
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.
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);
+ }
+ }
}
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:
With the OnUserInformationReceived logging I can see the claims coming from Keycloak:
I have amended UserInfo as follows:
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:
User Info: