Blazor WebAssembly standalone - Calling a protected API using access token

Introduction

In this post, I want to talk about calling a protected API from ASP.NET Core Blazor WASM standalone app. This is the second post of my Blazor series, if you have not read my first post for Blazor WebAssembly authentication and authorization with IdentityServer4 I suggest to start from there.

blazor-api

The assumption here is that you’ve already setup the IdentityServer and Blazor WebAssembly app instances. We will be going through adding a protected API endpoint and calling it from the Blazor WASM standalone app using the access_token. The solution structure will be like this:

- BlazorSecurity.sln 
  - IdentityServer.csproj 
  - WasmAppAuth.csproj
  - Api.csproj
  - Models.csproj

Sample code

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

Pre-requirements and versions

API

This section includes all changes required on API project to have a protected endpoint available.

Setup API project:

Start with creating a web API project follow the official docs. After creation of API project, open launchSettings.json located under Properties folder and change it to add or adjust HTTPS urls.

{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:5006",
      "sslPort": 5016
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "launchUrl": "swagger",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "Api": {
      "commandName": "Project",
      "dotnetRunMessages": "true",
      "launchBrowser": true,
      "launchUrl": "swagger",
      "applicationUrl": "https://localhost:5016;",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

I suggest to use ASP.NET Core HTTPS development certificate to develop locally under HTTPS. If you need help to make it work read this post: ASP.NET Core HTTPS development certificate on Windows.

Run the API project. Now, you should be able to see https://localhost:5016/swagger/index.html. API project contains a WeatherForecastController by default, which is enough for the context of this post.

Add Protection the API using IdntityServer

To apply protection the to the API, add JWT bearer authentication handler. The Audience is the API’s unique ID and it will be used for API configurations on IdentityServer.

public class Startup
{  
    public void ConfigureServices(IServiceCollection services)
    {
        ...

        services.AddAuthentication("Bearer").AddJwtBearer("Bearer",
            options =>
            {
                options.Authority = "https://localhost:5001";
                options.Audience = "weatherapi"; 
            });
        ...
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        ...

        app.UseAuthentication();

        app.UseAuthorization();

        ...
    }
}

Note that UseAuthentication should come before UseAuthorization while configuring the API (see above sample code).

Add protection to an endpoint on API

To add protection to an endpoint inside API project, add [Authorize] attribute. If you need authorization on all endpoints, add the [Authorize] attribute on the controller class.

[ApiController]
[Route("weatherforecasts")]
public class WeatherForecastController : ControllerBase
{
    ...

    [HttpGet, Authorize]
    public IEnumerable<WeatherForecast> Get()
    {
        ...
    }
    ...
}

As result, to access weatherforecasts endpoint, we need to pass an access_token as Bearer on Authorization header when calling it. The access token should be:

  • Issued by IdentityServer hosted on https://localhost:5001.
  • Contains a value as weatherapi on aud property.

Here is an example:

{
  "nbf": 1614400591,
  "exp": 1614404191,
  "iss": "https://localhost:5001",
  "aud": [
    "weatherapi",
    "https://localhost:5001/resources"
  ],
  "client_id": "wasmappauth-client",
  "sub": "818727",
  "auth_time": 1614400583,
  "idp": "local",
  "jti": "A63C943BF63692E1923A37A44F42BDA4",
  "sid": "97BA5DCBFAA1E3E464E32B0A80167179",
  "iat": 1614400591,
  "scope": [
    "openid",
    "profile"
  ],
  "amr": [
    "pwd"
  ]
}

Enable Cross-Origin Requests (CORS) on the API

The Blazor WASM standalone app is a single page app (SPA) running on browser. To make requests from the browser to an endpoint with a different origin, the endpoint must enable cross-origin resource sharing (CORS). This step is to enable Cross-Origin Requests (CORS) on the API. Read more here

public class Startup
{        
    public void ConfigureServices(IServiceCollection services)
    {
        ...

        services.AddCors(options =>
        {
            options.AddPolicy("default", policy =>
            {
                policy.WithOrigins("https://localhost:5015") //The Blazor WASM app's URL.
                    .AllowAnyHeader()
                    .AllowAnyMethod();
            });
        });
        ...
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        ...

        app.UseCors("default");

        ...
    }
}

Add policy-based protection to an endpoint on API

Policy based authorization is the next level of protection for an API endpoint. Follow these steps:

  1. Add the policy on API configuration. Here is an example of a policy which requires a specific scope on the access_token.

    public class Startup
    {
        ...
    
        public void ConfigureServices(IServiceCollection services)
        {
            ...
    
            services.AddAuthorization(options =>
            {           
                options.AddPolicy("read-access", policy =>
                {
                    policy.RequireAuthenticatedUser();
                    policy.RequireClaim("scope", "weather.read");
                });
            });
        }
    }
    
    
  2. Change the endpoint to add policy on [Authorize] attribute:

    [ApiController]
    [Route("weatherforecasts")]
    public class WeatherForecastController : ControllerBase
    {
        ...
    
        [HttpGet, Authorize("read-access")]
        public IEnumerable<WeatherForecast> Get()
        {
            ...
        }
        ...
    }
    

The weatherforecasts endpoint requires a Bearer access_token that is:

  • Issued by IdentityServer hosted on https://localhost:5001

  • Contains a value as weatherapi on aud property

  • Contains a scope as weather.read. Here is an example:

    {
      "nbf": 1614400591,
      "exp": 1614404191,
      "iss": "https://localhost:5001",
      "aud": [
        "weatherapi",
        "https://localhost:5001/resources"
      ],
      "client_id": "wasmappauth-client",
      "sub": "818727",
      "auth_time": 1614400583,
      "idp": "local",
      "jti": "A63C943BF63692E1923A37A44F42BDA4",
      "sid": "97BA5DCBFAA1E3E464E32B0A80167179",
      "iat": 1614400591,
      "scope": [
        "openid",
        "profile",
        "weather.read"
      ],
      "amr": [
        "pwd"
      ]
    }
    

Verify the API

To verify non-protected endpoints, use swagger. I’ve added a verify endpoint to verify if the API is up and running.

[ApiController]
[Route("weatherforecasts")]
public class WeatherForecastController : ControllerBase
{
    ...

    [HttpGet]
    [Route("verify")]
    public string Verify()
    {
        return "API is up!";
    }
    ...
}

Run the API project, execute /weatherforecasts/verify on https://localhost:5016/swagger/index.html. It should return API is up!.

To call and verify a protected endpoint such as /weatherforecasts you need a REST client like postman to specify authorization. In our case, we will verify the protected endpoint API call by using Blazor WASM standalone client app later. So, leave this here for now.

IdentityServer

There are couple of changes required on IdentityServer:

  1. Add API resources and scopes for weather API. The API resource value should match API’s audience used on API setup. The API scopes are stands for access types you want to expose for the API. For now, I only have a simple read scope:

    public static class Config
    {
        ...
    
        public static IEnumerable<ApiScope> ApiScopes =>
            new ApiScope[]
            {
                new ApiScope("weather.read")
            };
    
        public static IEnumerable<ApiResource> ApiResources =>
            new ApiResource[]
            {
                new ApiResource("weatherapi", "Weather API") //API's Audience
                {
                    Scopes = { "weather.read" }
                }
            };
    
        ...
    }
    
  2. Add the API resources and scopes to the IdentityServer. In my example, I use in-memory configurations. It only requires adding AddInMemoryApiResources and AddInMemoryApiScopes to IdentityServer instance.

    public class Startup
    {
        ...
        public void ConfigureServices(IServiceCollection services)
        {
            ...
            var builder = services.AddIdentityServer(options =>
            {
                // see https://identityserver4.readthedocs.io/en/latest/topics/resources.html
                options.EmitStaticAudienceClaim = true;
            })
                .AddInMemoryIdentityResources(Config.IdentityResources)
                .AddInMemoryApiResources(Config.ApiResources)
                .AddInMemoryApiScopes(Config.ApiScopes)
                .AddInMemoryClients(Config.Clients)
                .AddTestUsers(TestUsers.Users);
            ...
        }
        ...
    }
    
  3. Change the Blazor WASM app config and add the API scope to list of allowed scopes. If client ask for this scope when requesting token, this scope will be added to the issued token by IdentityServer. If the issued token contains at least one of the weather API’s scopes, it will also have the weather API’s audience.

    public static class Config
    {        
        public static IEnumerable<Client> Clients =>
            new Client[]
            {
                new Client
                {
                    ClientId = "wasmappauth-client",
                    ...
    
                    AllowedScopes = {"openid", "profile", "offline_access", "weather.read" },
                    ...
                }
            };
    }
    

The Blazor WASM standalone app:

To call a protected endpoint, access_token should be passed as Bearer on Authorization header. There are multiple ways to call an API endpoint from Blazor WASM app. Using typed HTTPClient along with AuthorizationMessageHandler is one of suggested options which encapsulates the authorization header logic.

Add the custom AuthorizationMessageHandler on Blazor WASM standalone app:

  • Add a class which implements AuthorizationMessageHandler.
  • Configure the handler:
  • authorizedUrls: Add the API url to this list.
  • scopes: Add required scopes by API endpoint to this list. In our case it is weather.read. If the default access_token issued by IdentityServer doesn’t contain these scopes, the handler calls the IdentityServer’s token endpoint and requests for a new token.
public class CustomAuthorizationMessageHandler : AuthorizationMessageHandler
{
    public CustomAuthorizationMessageHandler(IAccessTokenProvider provider,
        NavigationManager navigationManager)
        : base(provider, navigationManager)
    {
        ConfigureHandler(
            authorizedUrls: new[] { "https://localhost:5016" },                
            scopes: new[] { "weather.read" });
    }
}

Add the typed HttpClient on Blazor WASM standalone app:

  • Add a new class which accepts HttpClient as constructor param.
  • Add a method to call each endpoint using the injected HttpClient.
  • Add error handling of your need and choice.
public class WeatherForecastHttpClient
{
    public HttpClient _httpClient;

    public WeatherForecastHttpClient(HttpClient client)
    {
        _httpClient = client;
    }

    public async Task<List<WeatherForecast>> GetWeatherForecastsAsync()
    {
        var response = await _httpClient.GetAsync("/weatherforecasts");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<List<WeatherForecast>>();
    }
}

Register new added types on Blazor WASM standalone app

  • Register CustomAuthorizationMessageHandler using AddScoped extension.
  • Register WeatherForecastHttpClient using AddHttpClient extension method.
  • Set BaseAddress to the API’s base address when adding the typed HttpClient.
  • Set CustomAuthorizationMessageHandler as a message handler for WeatherForecastHttpClient using AddHttpMessageHandler helper method.
public class Program
{
    public static async Task Main(string[] args)
    {
        ...

        builder.Services.AddScoped<CustomAuthorizationMessageHandler>();
        builder.Services.AddHttpClient<WeatherForecastHttpClient>(client =>
        {
            client.BaseAddress = new Uri("https://localhost:5016");
        }).AddHttpMessageHandler<CustomAuthorizationMessageHandler>();
        
        ...
    }
}

Call the protected endpoint on Blazor WASM standalone app

  • Modify FetchData razor page to be protected by adding [Authorize] on the top of page. This is to ensure access_token is accessible on this page.
  • Add an instance of WeatherForecastHttpClient on the top.
  • Call GetWeatherForecastsAsync method to fetch the data from API.
...

@page "/fetchdata"
@attribute [Authorize]

@inject WeatherForecastHttpClient Http

@using Microsoft.AspNetCore.Authorization;
@using WasmAppAuth.Services;

...

@code {
    private List<WeatherForecast> forecasts;

    protected override async Task OnInitializedAsync()
    {
        forecasts = await Service.GetWeatherForecastsAsync();
    }
}

Add to default scopes on Blazor WASM standalone app

You can add required scope for the API to the list of DefaultScopes. The DefaultScopes exists on the access_token issued on Login by IdentityServer. This is suggested if some of scopes are used on all API calls.

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

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

            options.ProviderOptions.DefaultScopes.Add("weather.read");
        });
    }
}

Verify the API call from Blazor WASM standalone app

Now its time to verify all changes we have made:

  • Change the solution to have multiple startup projects and run it. This is to run IdentityServer, API, and WasmAppAuth all together.

  • Use any of test users to login. (i.e. alice/alice)

  • Try Fetch Data from API from left side menu. There should be weather data listed with no error.

fetch-data

Summary

By doing this, you can call a protected endpoint from Blazor WASM standalone app. However, there are many other ways to extend and implement more complex security scenarios.

Sample code

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