Selthien
Selthien

Reputation: 1258

Keycloak with ASP.NET Core MVC app, claims never contain roles

I have a keycloak server deployed to production and I am trying to get roles to work properly within my ASP.NET Core MVC app. The token from keycloak is returning the roles. I am able to be redirected and authenticated from keycloak perfectly fine. I just can't seem to get any roles to work.

Startup.cs:

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
    options.LoginPath = "/Account/Login";
})
.AddOpenIdConnect(options =>
{
    options.Authority = "https://MyServer/auth/realms/ATG";
    options.MetadataAddress = "https://MyServer/realms/ATG/.well-known/openid-configuration";
    options.ClientId = "ATG.ad.yaskawa.com";
    options.ClientSecret = "tpdyzbDOADYdsCUaoFz9bTJNqRsOsrcQ";
    options.ResponseType = "code";
    options.SaveTokens = true;
    
    options.Scope.Add("openid");
    options.CallbackPath = "/signin-oidc"; // Update callback path
    options.SignedOutCallbackPath = "/signout-callback-oidc"; // Update signout callback path
    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = "preferred_username",
        RoleClaimType = "roles"
    };
});
builder.Services.AddTransient<IClaimsTransformation, CustomRoleClaimsTransformation>();
//Fix Telerik camelCase to PascalCase
builder.Services.AddControllersWithViews().AddJsonOptions(options =>
                options.JsonSerializerOptions.PropertyNamingPolicy = null); ;
ConfigurationManager configuration = builder.Configuration;

IdentityModelEventSource.ShowPII = true;
var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Home/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages();

app.Run();

Token:

{
  "exp": 1720548651,
  "iat": 1720548351,
  "auth_time": 1720548351,
  "jti": "b9aca179-91c9-4528-834e-29f5d33e0308",
  "iss": "https://MyServer/realms/ATG",
  "aud": "account",
  "sub": "1ab9a926-d2a9-4941-9a01-ffe07d500abd",
  "typ": "Bearer",
  "azp": "ATG.ad.yaskawa.com",
  "sid": "fd0c995d-3ec7-4bec-8a0c-18449b393ee8",
  "acr": "1",
  "scope": "email profile",
  "email_verified": true,
  "roles": [
    "Admin",
    "view-profile"
  ],
  "name": "Eric Obermuller",
  "preferred_username": "[email protected]",
  "given_name": "Eric",
  "family_name": "O",
  "email": "[email protected]"
}

So from here I am really not sure what to do. I tried using a custom role transformation but nothing about the "roles" property is coming into principal.identity

public class CustomRoleClaimsTransformation : IClaimsTransformation
{
    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        var identity = (ClaimsIdentity)principal.Identity;

        // Ensure roles are being extracted and mapped correctly
        var roles = identity.FindAll("roles").Select(c => c.Value).ToList();

        foreach (var role in roles)
        {
            identity.AddClaim(new Claim(ClaimTypes.Role, role));
        }

        return Task.FromResult(principal);
    }
}

Upvotes: 1

Views: 475

Answers (1)

R10t--
R10t--

Reputation: 829

After about 8 hours of searching... I figured it out on my own!

I am VERY certain that this is a bug with the Microsoft Authentication library, and this workaround should not be required. I raised this as a bug on aspnetcore

The issue is that the AddOpenIdConnect doesn't seem to be properly creating the user Claims from the access token. It can parse "some" things from the token, but it fails miserably at gathering all of the claims for some unknown reason...

What I had to do was implement a custom token decoder in the OnTokenValidated event and manually add the claims to the user's identity.

options.Events.OnTokenValidated = async ctx =>
{
    // For some reason, the access token's claims are not getting added to the user in C#
    // So this method hooks into the TokenValidation and adds it manually...
    // This definitely seems like a bug to me.

    // First, let's just get the access token and read it as a JWT
    var token = ctx.TokenEndpointResponse.AccessToken;
    var handler = new JwtSecurityTokenHandler();
    var parsedJwt = handler.ReadJwtToken(token);
    
    // For some reason, this is not enough.
    // The `role` claim is just being set to "role" as the claim type.
    // But Microsoft requires using their enum, `ClaimTypes.Role` if you want to use the claims with the `[Authorize(Roles = "...")]` Annotation.
    // So, we need to convert any "role" claims in the JWT to the actual Microsoft enum for them to be properly picked up...
    // So convert them here I guess...
    var updatedClaims = parsedJwt.Claims.ToList().Select(c =>
    {
        return c.Type == "role" ? new Claim(ClaimTypes.Role, c.Value) : c;
    });
    
    
    // Finally, use the new claims list and add a new `Identity` that contains them.
    ctx.Principal.AddIdentity(new ClaimsIdentity(updatedClaims));
};

With this implementation, I was successfully able to lock down a route using the annotation:

[Route("auth")]
[HttpGet]
[Authorize(Roles = "admin")]
public IActionResult Auth()
{
    return Ok("Authorized.");
}

And this properly adhered to my Keycloak roles listing.

Upvotes: 0

Related Questions