Ralms
Ralms

Reputation: 532

Blazor Standalone WASM Unable to get Access Token with MSAL

After fighting this for 2 days, around 6h invested, I finally decided to ask for help.

I have a standalone Blazor WASM app with MSAL Authentication, after the login is successful and it tries to get an Access Token I get the error:

blazor.webassembly.js:1 info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[1]
      Authorization was successful.
blazor.webassembly.js:1 crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
      Unhandled exception rendering component: An exception occurred executing JS interop: The JSON value could not be converted to System.DateTimeOffset. Path: $.token.expires | LineNumber: 0 | BytePositionInLine: 73.. See InnerException for more details.
Microsoft.JSInterop.JSException: An exception occurred executing JS interop: The JSON value could not be converted to System.DateTimeOffset. Path: $.token.expires | LineNumber: 0 | BytePositionInLine: 73.. See InnerException for more details.
 ---> System.Text.Json.JsonException: The JSON value could not be converted to System.DateTimeOffset. Path: $.token.expires | LineNumber: 0 | BytePositionInLine: 73.
 ---> System.InvalidOperationException: Cannot get the value of a token type 'Null' as a string.
   at System.Text.Json.Utf8JsonReader.TryGetDateTimeOffset(DateTimeOffset& value)
   at System.Text.Json.Utf8JsonReader.GetDateTimeOffset()

This error only shows after I login.

My setup is running on .NET 5.0, the Authentication provider is an Azure B2C tenant, I have the redirect URIs configured correctly as "Single-page application" and permissions granted to "offline_access" and "openid".

Here is my Program.cs

public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("#app");

            builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

            // Authenticate requests to Function API
            builder.Services.AddScoped<APIFunctionAuthorizationMessageHandler>();
            
            //builder.Services.AddHttpClient("MyAPI", 
            //    client => client.BaseAddress = new Uri("<https://my_api_uri>"))
            //  .AddHttpMessageHandler<APIFunctionAuthorizationMessageHandler>();

            builder.Services.AddMudServices();

            builder.Services.AddMsalAuthentication(options =>
            {
                // Configure your authentication provider options here.
                // For more information, see https://aka.ms/blazor-standalone-auth
                builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);

                options.ProviderOptions.LoginMode = "redirect";
                options.ProviderOptions.DefaultAccessTokenScopes.Add("openid");
                options.ProviderOptions.DefaultAccessTokenScopes.Add("offline_access");
            });

            await builder.Build().RunAsync();
        }
    }

I've intentionally commented out the HTTPClient link to the AuthorizationMessageHandler. The "AzureAD" configuration has the Authority, ClientId and ValidateAuthority which is set to true.

public class APIFunctionAuthorizationMessageHandler : AuthorizationMessageHandler
    {
        public APIFunctionAuthorizationMessageHandler(IAccessTokenProvider provider,
        NavigationManager navigationManager)
        : base(provider, navigationManager)
        {
            ConfigureHandler(
                authorizedUrls: new[] { "<https://my_api_uri>" });
                //scopes: new[] { "FunctionAPI.Read" });
        }
    }

I've tried defining the scopes such as openid or custom API scope and now without. No difference.

Then to cause the exception, all I'm doing is something as simple as:

@code {
    private string AccessTokenValue;

    protected override async Task OnInitializedAsync()
    {
        var accessTokenResult = await TokenProvider.RequestAccessToken();
        AccessTokenValue = string.Empty;

        if (accessTokenResult.TryGetToken(out var token))
        {
            AccessTokenValue = token.Value;
        }
    }
}

The final objective is to use something like this:

   try {
      var httpClient = ClientFactory.CreateClient("MyAPI");
      var resp = await httpClient.GetFromJsonAsync<APIResponse>("api/Function1");
      FunctionResponse = resp.Value;
      Console.WriteLine("Fetched " + FunctionResponse);
   }
   catch (AccessTokenNotAvailableException exception)
   {
      exception.Redirect();
   }

But the same error is returned, to what it seems before this even runs. This code is OnInitializedAsync() of the Blazor Component also.

Any ideas or suggestions are welcome. I'm stuck and getting a bit desperate.

I suspect that the access token is not being requested or returned from Azure AD B2C, but that assume that is the AuthorizationMessageHandler job.

Any welcome is greatly appreciated.

Thanks.

Upvotes: 7

Views: 7140

Answers (2)

Greg Grater
Greg Grater

Reputation: 69

I too struggled to find quality examples. Here's how I solved calling 1 or multiple API's from a Webassembly (Hosted or Standalone) application.

Most MSFT examples only deal with one Api and therefore use the options.ProviderOptions.DefaultAccessTokenScopes option when registering Msal through AddMsalAuthentication. This will lock your token's into a single audience which doesn't work when you have multiple api's to call.

Instead, derive from the AuthorizationMessageHandler class a handler for each api endpoint, set both the authorizedUrl and scopes in the ConfigureHandler, register named HttpClient's for each endpoint in the DI container and use the IHttpClientFactory to generate HttpClient's.

Scenario: Let's say I have a WebAssembly app (hosted or stand alone) that calls multiple protected api's including the microsoft graph api.

First, I must create a class for each api deriving from AuthorizationRequestMessageHandler :

Api 1:

// This message handler handles calls to the api at the endpoint  "https://localhost:7040".  It will generate tokens with the right audience and scope
// "aud": "api://aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
// "scp": "access_as_user",
public class ApiOneAuthorizationRequestMessageHandler : AuthorizationMessageHandler
{
    // ILogger if you want..
    private readonly ILogger<ApiOneAuthorizationRequestMessageHandler> logger = default!;
    public ApiOneAuthorizationRequestMessageHandler(IAccessTokenProvider provider,
        NavigationManager navigationManager,
        ILoggerFactory loggerFactory
        )
        : base(provider, navigationManager)
    {
        logger = loggerFactory.CreateLogger<ApiOneAuthorizationRequestMessageHandler>() ?? throw new ArgumentNullException(nameof(logger));

        logger.LogDebug($"Setting up {nameof(ApiOneAuthorizationRequestMessageHandler)} to authorize the base url: {"https://localhost:7090/"}");
        ConfigureHandler(
           authorizedUrls: new[] { "https://localhost:7040" },
           scopes: new[] { "api://aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/access_as_user" });
    }
}

Api 2:

// This message handler handles calls to the api at the endpoint  "https://localhost:7090".  Check out the scope and audience through https://jwt.io
// "aud": "api://bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
// "scp": "access_as_user",
public class ApiTwoAuthorizationRequestMessageHandler : AuthorizationMessageHandler
{
    public ApiTwoAuthorizationRequestMessageHandler(IAccessTokenProvider provider,
        NavigationManager navigationManager
        )
        : base(provider, navigationManager)
    {
        ConfigureHandler(
           authorizedUrls: new[] { "https://localhost:7090" },
           scopes: new[] { "api://bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/access_as_user" });
    }
}

MS Graph Api:

// This message handler handles calls to Microsoft graph.
// "aud": "00000003-0000-0000-c000-000000000000"
// "scp": "Calendars.ReadWrite email MailboxSettings.Read openid profile User.Read",
public class GraphApiAuthorizationRequestMessageHandler : AuthorizationMessageHandler
{
    public GraphApiAuthorizationRequestMessageHandler(IAccessTokenProvider provider,
        NavigationManager navigationManager
        )
        : base(provider, navigationManager)
    {
        ConfigureHandler(
           authorizedUrls: new[] { "https://graph.microsoft.com" },
           scopes: new[] { "User.Read", "MailboxSettings.Read", "Calendars.ReadWrite" });
    }
}

Now, register a named HttpClient for each endpoint using the endpoints AuthorizationMessageHandler from above. Do this in Program.cs:

HttpClient named "ProductsApi"

//register the AuthorizationRequestMessageHandler
builder.Services.AddScoped<ApiOneAuthorizationRequestMessageHandler>();
//register the named HttpClient 
builder.Services.AddHttpClient("ProductsApi",
    httpClient => httpClient.BaseAddress = new Uri("https://localhost:7040"))
    .AddHttpMessageHandler<ApiOneAuthorizationRequestMessageHandler>();

HttpClient named "MarketingApi":

builder.Services.AddScoped<ApiTwoAuthorizationRequestMessageHandler>();
builder.Services.AddHttpClient("MarketingApi",
    httpClient => httpClient.BaseAddress = new Uri("https://localhost:7090"))
    .AddHttpMessageHandler<ApiTwoAuthorizationRequestMessageHandler>();

HttpClient named "MSGraphApi"

builder.Services.AddScoped<GraphApiAuthorizationRequestMessageHandler>();
builder.Services.AddHttpClient("MSGraphApi",
    httpClient => httpClient.BaseAddress = new Uri("https://graph.microsoft.com"))
    .AddHttpMessageHandler<GraphApiAuthorizationRequestMessageHandler>();

After your named HttpClient's are registered, register Msal with your AzureAd appsettings into Program.cs.

Msal registration without customer User Claims:

builder.Services.AddMsalAuthentication(options =>
{
    builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
});

If you're following Microsoft Doc for custom User Account Claims through the GraphApi, your Add Msal should look like this:

Msal registration with custom User claims:

builder.Services.AddMsalAuthentication<RemoteAuthenticationState, RemoteUserAccount>(options =>
{
    builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
})
.AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, RemoteUserAccount, GraphUserAccountFactory>();

To use the GraphServiceClient, a GraphClientFactory is required. It will need to use the IHttpClientFactory to create the correct named HttpClient (e.g. MSGraphApi).

GraphClientFactory:

public class GraphClientFactory
{
    private readonly IAccessTokenProviderAccessor accessor;
    private readonly IHttpClientFactory httpClientFactory;
    private readonly ILogger<GraphClientFactory> logger;
    private GraphServiceClient graphClient;

    public GraphClientFactory(IAccessTokenProviderAccessor accessor,
        IHttpClientFactory httpClientFactory,
        ILogger<GraphClientFactory> logger)
    {
        this.accessor = accessor;
        this.httpClientFactory = httpClientFactory;
        this.logger = logger;
    }

    public GraphServiceClient GetAuthenticatedClient()
    {
        HttpClient httpClient;

        if (graphClient == null)
        {
            httpClient = httpClientFactory.CreateClient("MSGraphApi");

            graphClient = new GraphServiceClient(httpClient)
            {
                AuthenticationProvider = new GraphAuthProvider(accessor)
            };
        }

        return graphClient;
    }
}

You'll also need to register the GraphClientFactory in Program.cs.

builder.Services.AddScoped<GraphClientFactory>();

To access the Marketing Api, inject IHttpClientFactory and create a named HttpClient.

@inject IHttpClientFactory httpClientFactory

<h3>Example Component</h3>

@code {

    protected override async Task OnInitializedAsync()
    {
        try {
            var httpClient = httpClientFactory.CreateClient("MarketingApi");
            var resp = await httpClient.GetFromJsonAsync<APIResponse>("api/Function1");
            FunctionResponse = resp.Value;
            Console.WriteLine("Fetched " + FunctionResponse);
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }
    }
}

Now, with access to the MarketingApi, you can also access your calendar using the Graph Api by using the component described on this MSFT's Tutorial page:

Step 4 - Show Calendar Events

Accessing the ProductsApi is much the same as accessing the MarketingApi.

I hope this helps folks access Api's with the correct access token in Blazor Webassembly.

Upvotes: 3

Ralms
Ralms

Reputation: 532

Found the issue.

After doing some debugging on the JavaScript side, file AuthenticationService.js, method "async getTokenCore(e)" on line 171 after prettified, I've confirmed that in fact the Access Token was not being returned and only the IdToken.

From reading this document regarding requesting Access Token to Azure AD B2C, it mentioned that depending on the scopes you define, it will change what it returns back to you.

The Scope "openid" tells it you need an IdToken, then "offline_access" tells it you need a refresh token and lastly there is a nifty trick where you can define the scope to the App Id and it will return an Access token. More details here: https://learn.microsoft.com/en-us/azure/active-directory-b2c/access-tokens#openid-connect-scopes

So I've changed my code in Program.cs, builder.Services.AddMsalAuthentication step.

Now it looks like this:

builder.Services.AddMsalAuthentication(options =>
            {
                // Configure your authentication provider options here.
                // For more information, see https://aka.ms/blazor-standalone-auth
                builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);

                options.ProviderOptions.LoginMode = "redirect";
                options.ProviderOptions.DefaultAccessTokenScopes.Add("00000000-0000-0000-0000-000000000000");
                //options.ProviderOptions.DefaultAccessTokenScopes.Add("openid");
                //options.ProviderOptions.DefaultAccessTokenScopes.Add("offline_access");
            });

Instead of "00000000-0000-0000-0000-000000000000", I've set the actual App ID I'm using on this Blazor App.

Now the error is not happening and the Access Token returned.

Thanks.

Upvotes: 12

Related Questions