AnnR
AnnR

Reputation: 83

Blazor Web App - Microsoft Identity - calling protected API

I have a .net core API project - simple test project giving back the weather forecast data. I have it registered in Microsoft Entra Id and can test it using Postman so I know this is working.

I have a Blazor Web App (.net 8) set up to use Microsoft Identity to register and then log users in. This is my program.cs file (part of it)

// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddMicrosoftIdentityConsentHandler();

// set the client secret for now
var clientSecret = "xxxxxxxxx"; // come back to this later and fix - maybe use Azure Key Vault

builder.Services.AddCascadingAuthenticationState();
//builder.Services.AddTokenAcquisition();

// This is where you wire up to events to detect when a user Log in
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApp(options =>
    {
        builder.Configuration.Bind("AzureAd", options);
        options.ClientSecret = clientSecret;
        options.Events = new OpenIdConnectEvents
        {
            OnRedirectToIdentityProvider = async ctxt =>
            {
                // Invoked before redirecting to the identity provider to authenticate. 
                // This can be used to set ProtocolMessage.State
                // that will be persisted through the authentication process. 
                // The ProtocolMessage can also be used to add or customize
                // parameters sent to the identity provider.
                ctxt.ProtocolMessage.PostLogoutRedirectUri = ctxt.Request.Scheme + "://" + ctxt.Request.Host + "/";
                await Task.Yield();
            },
            OnAuthenticationFailed = async ctxt =>
            {
                // They tried to log in but it failed
                await Task.Yield();
            },
            OnSignedOutCallbackRedirect = async ctxt =>
            {
                ctxt.HttpContext.Response.Redirect(ctxt.Options.SignedOutRedirectUri);
                ctxt.HandleResponse();
                await Task.Yield();
            },
            OnTicketReceived = async ctxt =>
            {
                if (ctxt.Principal != null)
                {
                    if (ctxt.Principal.Identity is ClaimsIdentity identity)
                    {
                        var colClaims = await ctxt.Principal.Claims.ToDynamicListAsync();
                        var IdentityProvider = colClaims.FirstOrDefault(
                            c => c.Type == "http://schemas.microsoft.com/identity/claims/identityprovider")?.Value;
                        var Objectidentifier = colClaims.FirstOrDefault(
                            c => c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
                        var EmailAddress = colClaims.FirstOrDefault(
                            c => c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress")?.Value;
                        var FirstName = colClaims.FirstOrDefault(
                            c => c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname")?.Value;
                        var LastName = colClaims.FirstOrDefault(
                            c => c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname")?.Value;
                        var AzureB2CFlow = colClaims.FirstOrDefault(
                            c => c.Type == "http://schemas.microsoft.com/claims/authnclassreference")?.Value;
                        var auth_time = colClaims.FirstOrDefault(
                            c => c.Type == "auth_time")?.Value;
                        var DisplayName = colClaims.FirstOrDefault(
                            c => c.Type == "name")?.Value;
                        var idp_access_token = colClaims.FirstOrDefault(
                            c => c.Type == "idp_access_token")?.Value;
                    }
                }
                await Task.Yield();
            },
            OnTokenValidated = async context =>
            {
                var idToken = context.SecurityToken as JwtSecurityToken;

                if (idToken != null)
                {
                    // Exchange ID token for an access token
                    var accessToken = await context.HttpContext.GetTokenAsync("access_token");

                    // Now you have the access token, you can store it or use it as per your requirement.
                    // For example, you can store it in the user s claims for later use.
                    var claimsIdentity = context.Principal.Identity as ClaimsIdentity;
                    claimsIdentity?.AddClaim(new Claim("id_token", idToken.RawData));
                }
                await Task.Yield();
            }
        };
    })
    .EnableTokenAcquisitionToCallDownstreamApi(new string[] { "api://xxxxxx/Access.Read" }) 
    .AddInMemoryTokenCaches(); 
    

I want to call my protected API (it's in the same Tenant). If I include the client secret in appsettings, user secrets or as i've done in the code above - just hardcoded it - I get this error

MsalServiceException: A configuration issue is preventing authentication - check the error message from the server for details. You can modify the configuration in the application registration portal. See https://aka.ms/msal-net-invalid-client for details. Original exception: AADSTS700025: Client is public so neither 'client_assertion' nor 'client_secret' should be presented.

If I don't include the secret - I get this error

MsalClientException: One client credential type required either: ClientSecret, Certificate, ClientAssertion or AppTokenProvider must be defined when creating a Confidential Client. Only specify one. See https://aka.ms/msal-net-client-credentials.

I've been at this the last couple of weeks. Microsoft's documentation isn't up to date yet for .net 8. I am losing the will to live at this stage. I'm tied to Microsoft for my project to can't change to another external provider.

When I comment out EnableTokenAcquisitionToCallDownstreamApi the user can log in but the Id token doesn't contain any scope information so I can't call my API.

Can anyone help me?

Upvotes: 1

Views: 586

Answers (1)

AnnR
AnnR

Reputation: 83

Thanks for Dasari for pointing me in the right direction. In Visual Studio - when you add Microsoft Identity under Connected Services - it adds the redirect URL under SPA. I've spent nearly 3 weeks trying to fix my code. Went into Azure and deleted the redirect under SPA and added it under the Web Application. I cannot thank you enough Dasari for the guidance. Just in case anyone else tearing their hair out like me - here is a sample of how I got the access_token I needed to call my protected API.

Both my api and web app are registered in the same tenent.

Program.cs as follows

using BlazorWebApp.Components;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.UI;
using BlazorWebApp;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.Identity.Client;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http.Extensions;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddMicrosoftIdentityConsentHandler();

//// Retrieve the client secret from the environment variable
var clientSecret = "xxxxxx"; // come back to this later and fix - maybe use Azure Key Vault

builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();

// This is where you wire up to events to detect when a user Log in
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApp(options =>
    {
        builder.Configuration.Bind("AzureAd", options);
        options.ClientSecret = clientSecret;
        options.Events = new OpenIdConnectEvents
        {
            OnRedirectToIdentityProvider = async ctxt =>
            {
                // Invoked before redirecting to the identity provider to authenticate. 
                // This can be used to set ProtocolMessage.State
                // that will be persisted through the authentication process. 
                // The ProtocolMessage can also be used to add or customize
                // parameters sent to the identity provider.
                ctxt.ProtocolMessage.PostLogoutRedirectUri = ctxt.Request.Scheme + "://" + ctxt.Request.Host + "/";
                await Task.Yield();
            },
            OnAuthenticationFailed = async ctxt =>
            {
                // They tried to log in but it failed
                await Task.Yield();
            },
            OnSignedOutCallbackRedirect = async ctxt =>
            {
                ctxt.HttpContext.Response.Redirect(ctxt.Options.SignedOutRedirectUri);
                ctxt.HandleResponse();
                await Task.Yield();
            }
        };
    })
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddInMemoryTokenCaches();


builder.Services.AddControllersWithViews()
    .AddMicrosoftIdentityUI();

builder.Services.AddScoped<MyApiService>();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.UseAntiforgery();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.UseExceptionHandler(new ExceptionHandlerOptions
{
    ExceptionHandler = async ctx => {
        var feature = ctx.Features.Get<IExceptionHandlerFeature>();
        if (feature?.Error is MsalUiRequiredException
            or { InnerException: MsalUiRequiredException }
           )
        {
            ctx.Response.Cookies.Delete($"{CookieAuthenticationDefaults.CookiePrefix}{CookieAuthenticationDefaults.AuthenticationScheme}");
            ctx.Response.Redirect(ctx.Request.GetEncodedPathAndQuery());
        }
    }
});

app.Run();

Then I added in a class "MyApiService" to call my protected API

using System.Net.Http.Headers;
using Microsoft.Identity.Abstractions;
using Newtonsoft.Json;

namespace BlazorWebApp;

public class MyApiService
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<MyApiService> _logger;
    private readonly IAuthorizationHeaderProvider _authorizationHeaderProvider;

    public MyApiService(IHttpClientFactory httpClientFactory, ILogger<MyApiService> logger,
        IAuthorizationHeaderProvider authorizationHeaderProvider)
    {
        _httpClient = httpClientFactory.CreateClient();
        _logger = logger;
        _authorizationHeaderProvider = authorizationHeaderProvider;
    }

    public async Task<List<WeatherForecast>> GetWeatherForecastsAsync()
    {
        try
        {
            // Set up the API request
            _httpClient.BaseAddress = new Uri("https://localhost:8181");
            _httpClient.DefaultRequestHeaders.Clear();
            _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            // Execute the API call asynchronously
            HttpResponseMessage response = await Task.Run(async () =>
            {
                // Create the authorization header
                //string[] scopes = ["Access.Read"];
                string[] scopes = ["api://XXXXXXXX/Access.Read"];
                string authorizationHeader = await _authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync(scopes);
                string accessToken = authorizationHeader.Replace("Bearer ", "");

                // Set the authorization header
                _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

                // Make the API call to retrieve weather forecasts
                return await _httpClient.GetAsync("/weatherforecast");
            });

            if (response.IsSuccessStatusCode)
            {
                string apiResponse = await response.Content.ReadAsStringAsync();
                List<WeatherForecast> forecasts = JsonConvert.DeserializeObject<List<WeatherForecast>>(apiResponse);
                return forecasts;
            }
            else
            {
                _logger.LogError("API call failed with status code: {StatusCode}", response.StatusCode);
                return null;
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error calling the API");
            return null;
        }
    }

Note how the scope is defined - it needs the full api url. Obviously this is only a test program and all the hardcoded stuff needs to be coded properly. But its working - woohoo :)

Not sure how to give the corrected answer to Dasari - if you can tell me how, I'll do it. It was definitely your comment that got me over the hurdle. Thanks again.

Upvotes: 0

Related Questions