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:
- Blazor WebAssembly authentication and authorization with IdentityServer4
- Blazor WebAssembly - Calling a protected API using access token
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.
- 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 equalAdmin
:
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.
AuthorizeView
supports Policy-based authorization. To add set thePolicy
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.