Blazor WebAssembly standalone - Role-based and Policy-based authorization

Introduction

I was working on a demo for Blazor WASM security last week and I came across with Role-based and Policy-based authorization. This post is my effort to talk about these all at once and together as part of my Blazor series. This is my third post on Blazor WASM standalone, if you have not read my previous posts I suggest to start from there:

The goal is to add Role-based and Policy-based authorization for razor pages on Blazor WASM standalone app. Before we talk about authorization on Blazor WebAssembly standalone app, let’s refresh on basics:

Blazor WebAssembly is a single-page app framework for building interactive client-side web apps with .NET. Blazor WebAssembly uses open web standards without plugins or code transpilation and works in all modern web browsers, including mobile browsers.

Blazor WebAssembly apps run on the browser(client). Since client-side code can be modified by a user, Blazor WebAssembly app can’t enforce authorization access rules. Authorization is only used to determine what to show on UI.

The assumption is that you’ve already setup the IdentityServer and Blazor WebAssembly app instances.

Sample code

Find the sample code for this post on Blazor Adventures.

Pre-requirements and versions

Role-based Authorization

To implement Role-based authorization, first thing is to make sure roles are part of the access token issued by IdentityServer (I’m using a custom ProfileService in my sample to add roles). Although a user can have one or multi roles assigned. There are some slight differences on implementation when dealing with each case.

Starting with the cases when user has only one role. Here is how the access token looks like:

{
  "alg": "RS256",
  "kid": "17AFB0669F63DDC55FACF222AB92BCC3",
  "typ": "at+jwt"
}.{
  "nbf": 1615266707,
  "exp": 1615270307,
  "iss": "https://localhost:5001",
  "aud": "https://localhost:5001/resources",
  "client_id": "wasmappauth-client",
  "sub": "818727",
  "auth_time": 1615266514,
  "idp": "local",
  "role": "Editor",
  "jti": "33998A3E47628B97E80F3C2C8999FB2C",
  "sid": "C39B4FC65D273AB585EF133C921EB1C8",
  "iat": 1615266707,
  "scope": [
    "openid",
    "profile"
  ],
  "amr": [
    "pwd"
  ]
}.[Signature]

And on Blazor WASM app the current logged-in user has one claim with type as role and value equals Editor.

Second is to make sure the current logged-in user’s role is set with role claim. The default value for RoleClaim on RemoteAuthenticationUserOptions is null. You need to set it to role when adding OIDC authentication on Blazor WASM app’s startup:

public class Program
{
    public static async Task Main(string[] args)
    {
        ...

        builder.Services.AddOidcAuthentication(options =>
        {
            ...

            options.UserOptions.RoleClaim = "role";
        });
    }
}

Third is to use the role to add authorization. The AuthorizeView supports Role-based authorization. Set the Roles property to the role value. For example, in code bellow only the user with role equals Editor is authorized:

<AuthorizeView Roles="Editor">
    <Authorized>
        Hello @context.User.Identity.Name!
        You are Editor!
    </Authorized>
    <NotAuthorized>
        User is not authenticated or Editor!
    </NotAuthorized>
</AuthorizeView>

Try to login with user (alice/alice). Role-based access page should show:

Hello Alice Smith! You are Editor!
User is not authenticated or Admin!

Role-based Authorization - Multiple role value

The behaviour of Blazor WASM app is different when user has multi roles. Here is an access token for this user with multiple roles:

{
  "alg": "RS256",
  "kid": "17AFB0669F63DDC55FACF222AB92BCC3",
  "typ": "at+jwt"
}.{
  "nbf": 1615268634,
  "exp": 1615272234,
  "iss": "https://localhost:5001",
  "aud": "https://localhost:5001/resources",
  "client_id": "wasmappauth-client",
  "sub": "88421113",
  "auth_time": 1615268621,
  "idp": "local",
  "role": [
    "Editor",
    "Admin"
  ],
  "jti": "F4832E1CC70E533297E245E80749EB9C",
  "sid": "4BD239255D895823E6711438BE32F5CF",
  "iat": 1615268634,
  "scope": [
    "openid",
    "profile"
  ],
  "amr": [
    "pwd"
  ]
}.[Signature]

As you see the role value is a list ["Editor","Admin"]. This is not matching Editor neither Admin and causing the Role-based authorization to be failed.

To fix this we need to add a custom AccountClaimsPrincipalFactory. The AccountClaimsPrincipalFactory is to remove the role claim with value as array and add multiple role claims, one for each role value in the array. Here is how the code may look like:

public class AccountClaimsPrincipalFactoryEx : AccountClaimsPrincipalFactory<RemoteUserAccount>
{
    public AccountClaimsPrincipalFactoryEx(IAccessTokenProviderAccessor accessor) : base(accessor)
    {
    }

    public async override ValueTask<ClaimsPrincipal> CreateUserAsync(RemoteUserAccount account, RemoteAuthenticationUserOptions options)
    {
        var user = await base.CreateUserAsync(account, options);

        if (!user.Identity.IsAuthenticated)
        {
            return user;
        }

        var identity = (ClaimsIdentity)user.Identity;
        var roleClaims = identity.FindAll(identity.RoleClaimType);

        if (roleClaims == null || !roleClaims.Any())
        {
            return user;
        }

        var rolesElem = account.AdditionalProperties[identity.RoleClaimType];

        if (rolesElem is JsonElement roles)
        {
            if (roles.ValueKind == JsonValueKind.Array)
            {
                identity.RemoveClaim(identity.FindFirst(options.RoleClaim));
                foreach (var role in roles.EnumerateArray())
                {
                    identity.AddClaim(new Claim(options.RoleClaim, role.GetString()));
                }
            }
        }

        return user;
    }
}

Next step is to add the AccountClaimsPrincipalFactoryEx to the OIDC authentication:

public class Program
{
    public static async Task Main(string[] args)
    {
        ...

        builder.Services.AddOidcAuthentication(options =>
        {
            ...
        }).AddAccountClaimsPrincipalFactory<AccountClaimsPrincipalFactoryEx>();
    }
}

To make sure this change is applied, check the user’s claims on Current's user claims page. It should be like this:

role: Editor
role: Admin

Login with user (bob/bob). Role-based access page should show:

Hello Bob Smith! You are Editor!
Hello Bob Smith! You are Admin!

Policy-based Authorization

There is not much complications for Policy-based authorization. All we need to do is to define the policy and later use it on the razor page.

  1. Define the policy on Blazor WASM standalone app’s startup using AddAuthorizationCore extension. Example below is to add a policy checking if user is logged-in and also if user has a role claim with value equal Admin:
public class Program
  {
      public static async Task Main(string[] args)
      {
          ...

          builder.Services.AddAuthorizationCore(config =>
          {
              config.AddPolicy("delete-access",
                    new AuthorizationPolicyBuilder().
                        RequireAuthenticatedUser().
                        RequireRole("Admin").
                        Build()
                    );  
          });

          ...
      }
  }
}

Read more on Policy-based authorization in ASP.NET Core.

  1. AuthorizeView supports Policy-based authorization. To add set the Policy property. Here is an example using the policy defined above:
<AuthorizeView Policy="delete-access">
    <Authorized>
        Hello @context.User.Identity.Name 
        You have delete access!
    </Authorized>
    <NotAuthorized>
        User is not authenticated or has no delete access!
    </NotAuthorized>
</AuthorizeView>

Summary

By now you know the basics of Role-based and Policy-based authorization for Blazor WASM standalone.

Sample code

Find the sample code for this post on my Blazor Adventures repo.