8

There is no real solution to this issue, only various workarounds until net7 comes around. In net7 we should once again be able to request scopes from multiple sources in one call according to https://github.com/dotnet/aspnetcore/pull/43954


I have a .net5 blazor webassembly application, set up with msal auth for azure.

services.AddMsalAuthentication(options =>
{
    configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
    options.ProviderOptions.DefaultAccessTokenScopes.Add("api://xxxxx/API.Access"); // API
    options.ProviderOptions.Cache.CacheLocation = "localStorage";
}); 

This works fine.

Additionally I need to get access to Microsoft graph. I've done this with the graph sdk and provided an authentication handler for the graph sdk

public class GraphAuthenticationProvider : IAuthenticationProvider
{
    private readonly NavigationManager _navigationManager;

    public GraphAuthenticationProvider(IAccessTokenProvider tokenProvider, NavigationManager navigationManager)

    {
        TokenProvider = tokenProvider;
        _navigationManager = navigationManager;
    }

    public IAccessTokenProvider TokenProvider { get; }

    public async Task AuthenticateRequestAsync(HttpRequestMessage request)
    {
        string[] scopes = new[] { "https://graph.microsoft.com/Mail.ReadWrite", "https://graph.microsoft.com/Mail.Send" };

        var result = await TokenProvider.RequestAccessToken(
            new AccessTokenRequestOptions()
            {
                Scopes = scopes,
                ReturnUrl = _navigationManager.Uri
            });

        if (result.TryGetToken(out var token))
        {
            request.Headers.Authorization ??= new AuthenticationHeaderValue(
                "Bearer", token.Value);
        }
        else
        {
            _navigationManager.NavigateTo(result.RedirectUrl);
        }
    }
}

This seems to be the way to do it according to the documentation I could find, though it seems to assume that you're trying to get additional scopes on the same resource. https://learn.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/additional-scenarios?view=aspnetcore-5.0#request-additional-access-tokens

There are two issues with this:

  • I can't find any way to do this with a popup, which means I am forced to navigate to a redirect which results in losing the program state. This can be worked around, but it seems that the popup version of consent should be possible?
  • When the authentication finishes and I get back to my own application, it tries to create a token for all scopes, both my api and the graph, which obviously fails with a more than one resource error, moving me to the login error page. Even though it actually did correctly get both tokens and I can simply navigate away from the error page and access both my api and graph.

I've been unable to find any documentation for blazor webassembly msal with multiple resources. Can someone explain what I'm doing wrong or point me to the correct documentation?

public class GraphAuthorizationMessageHandler : AuthorizationMessageHandler
{
    public GraphAuthorizationMessageHandler(IAccessTokenProvider provider, NavigationManager navigationManager)
        : base(provider, navigationManager)
    {
        ConfigureHandler(authorizedUrls: new[] { "https://graph.microsoft.com/" }, scopes: new[] { "https://graph.microsoft.com/Mail.ReadWrite", "https://graph.microsoft.com/Mail.Send" });
    }
}
services.AddScoped<CustomAuthorizationMessageHandler>();
services.AddScoped<GraphAuthenticationProvider>();

services.AddScoped<GraphHttpProvider>();
services.AddScoped<GraphAuthorizationMessageHandler>();

services.AddHttpClient<GraphHttpProvider>(
    client => client.BaseAddress = new Uri("https://graph.microsoft.com"))
    .AddHttpMessageHandler<GraphAuthorizationMessageHandler>();

services.AddScoped(sp =>
     new GraphServiceClient(
        sp.GetRequiredService<GraphAuthenticationProvider>(),
        sp.GetRequiredService<GraphHttpProvider>())
);

Edit: Related github issue https://github.com/dotnet/aspnetcore/issues/33241 - Seems that for now this functionality is bugged.

Archigo
  • 105
  • 6
  • Have you seen this tutorial? https://learn.microsoft.com/en-us/graph/tutorials/blazor – Brian Parker Jun 01 '21 at 15:59
  • 2
    @BrianParker This tutorial only has one resource, the graph api. How would I add my own api scope it, api://xxxxx/API.Access ? – Archigo Jun 02 '21 at 08:11
  • I know it's been a while, but any chance you figured this out? I've wasted a whole day on this... – BlueScreenOfTOM Feb 20 '22 at 03:08
  • Hi @BlueScreenOfTOM see below answer. I hope that helps – skerr4311 Feb 21 '22 at 05:52
  • @BlueScreenOfTOM If you can grant admin consent on the scopes in AD, then you can get it working elegantly. Otherwise you are out of luck. With admin consent granted, you can get scopes without a consent dialog. This means that you can remove the default access tokens scopes array, and simply get scopes when you need them. The scopes will be gotten silently and the user won't experience any hickup. Remember to only request scopes for one resource at a time. – Archigo Feb 21 '22 at 08:46
  • @skerr4311 I admit I have not tried this yet, but given that the user never has the opportunity to consent to the Scopes necessary when I call the graph API, I'm not sure how it will work? I am not able to Grant admin consent on the Scopes. – BlueScreenOfTOM Feb 21 '22 at 14:20
  • According to the linked git issue this problem is fixed in net7. In net6 we will have to use one of the workarounds mentioned in the answers. https://github.com/dotnet/aspnetcore/pull/43954 – Archigo Sep 21 '22 at 06:59

3 Answers3

3

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][1]

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.

  • Hi! Do you happen to still have a link to how to use the graph API? Looks like you never linked it. I believe that's the last step to solving my own issues with multiple resources ;) – Jason Rebelo Neves Aug 10 '22 at 06:23
  • This will still give you an authentication dialog for each api, unless you have admin consented scopes. However since there is no real solution except for waiting for net7, I will mark this as the answer if you add to the top a link to the pr about this being solved in net7 and that this is a workaround that does not directly solve the issue https://github.com/dotnet/aspnetcore/pull/43954 – Archigo Sep 21 '22 at 07:51
0

Work around - Not solution to the above unfortunately

Posting here in case it is useful to anyone else. Firstly, inject the following into the component you wish to use:

@inject IAccessTokenProvider TokenProvider

Once you have logged in then once authorized to your server you can make the following call (this is done silently in the background once a user authenticates to the server through the standard login process):

var tokenResult = await TokenProvider.RequestAccessToken(
new AccessTokenRequestOptions
{
    Scopes = new[] { "openid", "offline_access", "https://graph.microsoft.com/.default" }
});
if (tokenResult != null && tokenResult.TryGetToken(out var token))

{
    user.GraphToken = token.Value;

    var response = await Http.PostAsJsonAsync("UserController/graph", user);
    user = await response.Content.ReadFromJsonAsync<UserObj>();
}

In the scopes array above you can define the resource you wish to acquire a token for. Then just follow it with your graph call on server side:

public async Task<UserObj> GetMe(string accessToken)
    {
        UserObj response = new UserObj();
        string url = "https://graph.microsoft.com/v1.0/me/?$select=displayName,givenName,department,jobTitle,officeLocation,surname,preferredName,mail";

        HttpWebRequest requestObj = (HttpWebRequest)WebRequest.Create(url);
        requestObj.Method = "Get";
        requestObj.ContentType = "application/json";
        requestObj.Headers.Add("Authorization", "Bearer " + accessToken);

        HttpWebResponse responseObj = null;
        responseObj = (HttpWebResponse)await requestObj.GetResponseAsync();

        if (responseObj.StatusCode != HttpStatusCode.OK)
        {
            using (var stream = responseObj.GetResponseStream())
            using (var reader = new StreamReader(stream))
            {
                response.Error = String.Format("There was an issue: {0}", reader.ReadToEnd());
            }
        }
        else
        {
            using (var stream = responseObj.GetResponseStream())
            using (var reader = new StreamReader(stream))
            {
                string json = reader.ReadToEnd();
                response = JsonConvert.DeserializeObject<UserObj>(json);
            }
        }

        return response;
    }
skerr4311
  • 113
  • 6
  • Moving all the multi scope resource handling down to an api is a good workaround workaround, but not the solution to this question. As such i will not mark this as the correct answer. – Archigo Feb 21 '22 at 08:53
0

This is a workaround, not the solution to the question.

If you can grant admin consent on the scopes in AD, then you can get it working elegantly.

With admin consent granted, you can get scopes without a consent dialog. This means that you can remove the default access tokens scopes array, and simply get scopes when you need them.

The scopes will be gotten silently and the user won't experience any hickup. Remember to only request scopes for one resource at a time.

Archigo
  • 105
  • 6