Reputation: 83
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
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