Featured image of post RBAC

RBAC

Role-based Access Control with Keycloak and Blazor.

RBAC, also known as Role-based Access Control, is there for a while. In a nutshell, idea is to assign roles to users or groups of users, then leverage those roles to authorize actions from within applications. So, once a user is authenticated, we are able to claim for his roles for a given application. Combining both authentication1 and authorization2, developers are able to fine-tune application behavior.

Deployment

Stack is composed by a Blazor WASM application bound to Keycloak to perform authentication and authorization. A PostgreSQL instance allows for Keycloak persistence. Stack can be locally deployed through docker compose.

structurizr-1-C4_D_compose_.svg

Keycloak setup

Leveraging docker compose, you can locally deploy a Keycloak instance at http://localhost:8089.
Admin credentials are default ones, ie user and bitnami.

Create a new OpenID Connect client, here rbac-client, from Clients | Create client. Client ID is used by the application to ask for scoped credentials.

Pay attention to enable both Client authentication and Implicit flow, and to fill in Valid redirect URIs and Web origins. All remaining fields can use their default values. You can refer to client export for the exhaustive setup.

Select this brand-new client and browse Client scopes | rbac-client-dedicated, then Add mapper | By configuration | User Client Role, here .NET Client role. It will allow from the application side to claim roles for a given user from within the received token. All application frameworks do not necessarily perform the same way, that’s why we named it with a .NET prefix. Important piece there is Token Claim Name, aka the the key you will used on the application side to claim for authorization.

Note that this client and client-scope setup can be exported to replicate configuration.

Define application roles through Roles | Create role, here spiker and architect. Those roles are usually defined per application, but may be defined for a set of application to enforce UX consistency. Naming and semantic will benefit from guidelines to streamline usage and management.

Previous operations have to be done per application.

Create a user through Users | Add user, here rvasseur, and add a password to it. We have to there because our Keycloak instance is not bound to an existing structure, such as GitHub or Azure Active Directory.

Create a group through Groups | Create group, here rangers.

Add members to the brand-new group.

And finally assign roles.

Note that this step is both tedious and error prone. Thus, it may highly benefit from rules and automation. Keycloak exposes a REST API that we could probably leverage to do so.

We now have an operational Keycloak setup we can exercise from our application.

Application setup

Code snippets has been extracted from the Blazor WebAssembly Standalone App template with Individual authentication enabled.

1
dotnet new blazorwasm --auth Individual -o XXX

Ensure you have proper authentication setup.

1
2
3
4
5
6
7
8
9
builder.Services.AddOidcAuthentication(o =>
    {
        // Retrieve settings
        builder.Configuration.Bind("Local", o.ProviderOptions);

        // Override poor default, ie http://schemas.microsoft.com/ws/2008/06/identity/claims/role
        // Must match `Token Claim Name` used when defining the `User Client Role`
        o.UserOptions.RoleClaim = roles;
    });

Modify appsettings.json according to Keycloak setup.

1
2
3
4
5
6
7
8
{
    "Local": {
        // Keycloak instance
        "Authority": "http://localhost:8089/realms/master",
        // Keycloak client ID
        "ClientId": "rbac-client"
    }
}

Authority must match Keycloak issuer properties that can be found from Realm Settings | OpenID endpoint Configuration.

Leveraging docker compose, you can locally deploy your blazor application instance at http://localhost:5000/.

Then, engage login ceremonial by clicking matching button. Application will redirect you to a Keycloak prompt you can fill in accordingly.

After completing the login sequence, authentication pipeline brings you back to the Application, now properly authenticated.

Once in place, you can browse token insights, eg by declaring a dedicated razor view.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@page "/User"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@using System.Security.Claims

<h1>@AuthenticatedUser?.Identity?.Name</h1>

<h2>Claims</h2>

@foreach (var claim in AuthenticatedUser?.Claims ?? Array.Empty<Claim>())
{
    <p class="claim">@(claim.Type): @claim.Value</p>
}

@code {
    [CascadingParameter]
    private Task<AuthenticationState> AuthenticationState { get; set; }

    public ClaimsPrincipal AuthenticatedUser { get; set; }

    protected override async Task OnInitializedAsync()
    {
        await base.OnInitializedAsync();
        var state = await AuthenticationState;
        AuthenticatedUser = state.User;
    }
}

You can also complement your existing razor view leveraging matching AuthorizeView syntax. API checks will be covered later but more or less sticks to the same pattern, using Authorize attribute and its derivates to protect methods from unexpected usage. Below you can see how one could smoothly combine freely available content with authenticated or authorized ones within the same view. Of course, you can also define multiple versions of the same view and act as the routing level to properly dispatch.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@page "/counter"

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @_currentCount</p>

@* This button is always available *@
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

<h1>Authenticated Counter</h1>

@* This button is available if user is authenticated *@
<AuthorizeView>
    <Authorized>
        <p>You are authenticated.</p>
        <button class="btn btn-primary" @onclick="IncrementCount">Click me when authenticated</button>
    </Authorized>
    <NotAuthorized>
        <p>You're not authenticated.</p>
    </NotAuthorized>
</AuthorizeView>

<h1>Authorized Counter</h1>

@* This button is available if user is authorized, here with 'spiker' role *@
<AuthorizeView Roles="spiker">
    <Authorized>
        <p>You have the 'spiker' role claim.</p>
        <button class="btn btn-primary" @onclick="IncrementCount">Click me when authorized</button>
    </Authorized>
    <NotAuthorized>
        <p>You're not authorized.</p>
    </NotAuthorized>
</AuthorizeView>


@code {
    private int _currentCount = 0;

    private void IncrementCount()
    {
        _currentCount++;
    }
}

You may encounter issue if a user is assigned with multiple roles within a single application as it is not properly handled by default on .NET side. In that case, a workaround to flatten roles can be applied on the application side to deal with it.

Closing

We see today how one could locally setup a compelling RBAC stack to play with. It allows for fast prototyping of usage for both product management and R&D. It also streamlines CI/CD pipeline by ensuring we leverage the same underlying tooling. Nothing will change at the end but the Authority endpoint. I do think it is what we call seamless scaling..

Authentication and authorization are cross-cutting concerns and thus must be dealt with accordingly. Ensure using either attribute, decorator or injection patterns to avoid mixing those dimensions within your code base, ie noising every single method with related plumbing. As for other cross-cutting concerns, this way of doing is not sustainable. Here, .NET framework provides both dependency injection helpers to enrich our host and built-in attributes and tags to decorate our code base with in a declarative fashion.

Annex

Docker compose

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
x-app:
  &default-glue
  networks:
    - rbac-nw
  extra_hosts:
    - "host.docker.internal:host-gateway"
    
services:

  blazoroidc:
    <<: *default-glue
    image: blazoroidc
    build:
      context: Pre.BlazorWasm
      dockerfile: Dockerfile
    ports:
      - "5000:80"
      - "5001:443"

  # https://github.com/bitnami/containers/blob/main/bitnami/keycloak/docker-compose.yml
  keycloak:
    <<: *default-glue
    image: docker.io/bitnami/keycloak:23.0.4
    # environment:
    # - KEYCLOAK_ADMIN_USER=user
    # - KEYCLOAK_ADMIN_PASSWORD=bitnami
    depends_on:
      - postgresql
    ports:
      - "8089:8080"
  
  postgresql:
    <<: *default-glue
    image: docker.io/bitnami/postgresql:11
    environment:
      # ALLOW_EMPTY_PASSWORD is recommended only for development.
      - ALLOW_EMPTY_PASSWORD=yes
      - POSTGRESQL_USERNAME=bn_keycloak
      - POSTGRESQL_DATABASE=bitnami_keycloak
    volumes:
      - 'postgresql_data:/bitnami/postgresql'

volumes:
  postgresql_data:
    driver: local

networks:
  rbac-nw:

Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# -------------------------------------
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

# -------------------------------------
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

COPY Pre.BlazorWasm.csproj .
RUN dotnet restore Pre.BlazorWasm.csproj

COPY . .
RUN dotnet build Pre.BlazorWasm.csproj -c Release -o /app/build

# -------------------------------------
FROM build AS publish
RUN dotnet publish Pre.BlazorWasm.csproj -c Release -o /app/publish /p:UseAppHost=false

# -------------------------------------
FROM nginx:alpine AS final
WORKDIR /usr/share/nginx/html

COPY --from=publish /app/publish/wwwroot .
COPY nginx.conf /etc/nginx/nginx.conf

Keycloak client export

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
{
  "clientId": "rbac-client",
  "name": "",
  "description": "",
  "rootUrl": "",
  "adminUrl": "",
  "baseUrl": "",
  "surrogateAuthRequired": false,
  "enabled": true,
  "alwaysDisplayInConsole": false,
  "clientAuthenticatorType": "client-secret",
  "secret": "yX3HxuUoPwNP4dOr47FAptP12mcCJsDL",
  "redirectUris": [
    "http://localhost:5000/authentication/logout-callback",
    "https://localhost:44371/authentication/logout-callback",
    "http://localhost:5000/authentication/login-callback",
    "https://localhost:44371/authentication/login-callback"
  ],
  "webOrigins": [
    "http://localhost:5000"
  ],
  "notBefore": 0,
  "bearerOnly": false,
  "consentRequired": false,
  "standardFlowEnabled": true,
  "implicitFlowEnabled": true,
  "directAccessGrantsEnabled": true,
  "serviceAccountsEnabled": false,
  "publicClient": false,
  "frontchannelLogout": true,
  "protocol": "openid-connect",
  "attributes": {
    "oidc.ciba.grant.enabled": "false",
    "client.secret.creation.time": "1705591172",
    "backchannel.logout.session.required": "true",
    "display.on.consent.screen": "false",
    "oauth2.device.authorization.grant.enabled": "false",
    "backchannel.logout.revoke.offline.tokens": "false"
  },
  "authenticationFlowBindingOverrides": {},
  "fullScopeAllowed": true,
  "nodeReRegistrationTimeout": -1,
  "protocolMappers": [
    {
      "name": ".NET client role",
      "protocol": "openid-connect",
      "protocolMapper": "oidc-usermodel-client-role-mapper",
      "consentRequired": false,
      "config": {
        "introspection.token.claim": "true",
        "multivalued": "true",
        "userinfo.token.claim": "true",
        "id.token.claim": "true",
        "access.token.claim": "true",
        "claim.name": "roles",
        "jsonType.label": "String",
        "usermodel.clientRoleMapping.clientId": "rbac-client"
      }
    }
  ],
  "defaultClientScopes": [
    "web-origins",
    "acr",
    "profile",
    "roles",
    "email"
  ],
  "optionalClientScopes": [
    "address",
    "phone",
    "offline_access",
    "microprofile-jwt"
  ],
  "access": {
    "view": true,
    "configure": true,
    "manage": true
  }
}

Multiple roles issue

1
2
builder.Services
       .AddScoped<AccountClaimsPrincipalFactory<RemoteUserAccount>, RolesAccountClaimsPrincipalFactory>();
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;

namespace Pre.BlazorWasm;

/// <summary>
/// Workaround multiple roles issue, see https://github.com/dotnet/AspNetCore.Docs/issues/17649#issuecomment-616253008
/// </summary>
internal sealed class RolesAccountClaimsPrincipalFactory : AccountClaimsPrincipalFactory<RemoteUserAccount>
{
    internal static readonly string RoleClaimName = "roles";

    public RolesAccountClaimsPrincipalFactory(IAccessTokenProviderAccessor accessor)
        : base(accessor)
    {
    }

    /// <summary>
    /// Flatten multiple roles by provisioning matching brand-new claims
    /// </summary>
    public override async ValueTask<ClaimsPrincipal> CreateUserAsync(RemoteUserAccount account,
                                                                     RemoteAuthenticationUserOptions options)
    {
        var user = await base.CreateUserAsync(account, options);
        // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
        if (account != null && account.AdditionalProperties.TryGetValue(RoleClaimName, out var property))
        {
            var roles = property as JsonElement?;
            if (roles?.ValueKind == JsonValueKind.Array)
            {
                if (user.Identity is ClaimsIdentity identity)
                {
                    foreach (var claim in roles.Value
                                               .EnumerateArray()
                                               .Select(e => e.GetString() ?? string.Empty)
                                               .Where(s => !string.IsNullOrWhiteSpace(s))
                                               .Select(s => new Claim(RoleClaimName, s)))
                    {
                        identity.AddClaim(claim);
                    }
                }
            }
        }

        return user;
    }
}

  1. Authentication is the process of proving that you’re who you say you are. This is achieved by verification of the identity of a person or device. It’s sometimes shortened to AuthN. MSDN ↩︎

  2. Authorization is the act of granting an authenticated party permission to do something. It specifies what data you’re allowed to access and what you can do with that data. Authorization is sometimes shortened to AuthZ. MSDN ↩︎

Licensed under CC BY-NC-SA 4.0
Last updated on Jan 29, 2024 00:00 UTC
Built with Hugo
Theme Stack designed by Jimmy