microsoft-identity-web
microsoft-identity-web copied to clipboard
[Bug] TokenAcquisitionalways fails with MicrosoftIdentityWebChallengeUserException when called from a DelegatingHandler, but only on an Azure Web App.
Which version of Microsoft Identity Web are you using? Microsoft Identity Web 0.3.0-preview
Where is the issue?
- Web app
- [ ] Sign-in users
- [X] Sign-in users and call web APIs
- Web API
- [ ] Protected web APIs (validating tokens)
- [ ] Protected web APIs (validating scopes)
- [ ] Protected web APIs call downstream web APIs
- Token cache serialization
- [ ] In-memory caches
- [ ] Session caches
- [ ] Distributed caches
- Other (please describe)
- [X] Calling protected web APIs from a Server side Blazor app.
Is this a new or an existing app? New Blazor part of existing ASP.NET Core MVC app, but reproducible in a small, plain Blazor app.
Repro
This is a wierd one. We have existing typed HttpClients and are using a DelegatingHandler to call .GetAccessTokenForUserAsync and add the auth token from AAD on all requests. This works fine when injecting the client into, and using it from a MVC controller, but it fails with a MicrosoftIdentityWebChallengeUserException every time when used from Blazor Server side and running on an Azure Web App, causing the page to go in an endless loop with the application page redirecting to AAD and the AAD authorization page redirecting back. Running locally, it works fine in both cases.
If the token acquisition is included in the actual typed client, and not in the delegating handler, it works fine in all cases. It's just when called from a DelegatingHandler the call fails.
TestClient.cs - two versions of the typed client,
// This works every time, everywhere.
public interface ITestClientWithAuthIncluded
{
Task<string> GetProfileAsync();
}
public class TestClientWithAuthIncluded : ITestClientWithAuthIncluded
{
private readonly HttpClient _httpClient;
private readonly ITokenAcquisition _tokenAcquisition;
public TestClientWithAuthIncluded(HttpClient httpClient, ITokenAcquisition tokenAcquisition)
{
_httpClient = httpClient;
_tokenAcquisition = tokenAcquisition;
}
public async Task<string> GetProfileAsync()
{
var uri = "https://graph.microsoft.com/v1.0/me";
var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { "User.Read" }).ConfigureAwait(false);
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var responseString = await _httpClient.GetStringAsync(uri);
return responseString;
}
}
// This does not work in Blazor on Azure.
public interface ITestClientWithoutAuth
{
Task<string> GetProfileAsync();
}
public class TestClientWithoutAuth : ITestClientWithoutAuth
{
private readonly HttpClient _httpClient;
public TestClientWithoutAuth(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> GetProfileAsync()
{
var uri = "https://graph.microsoft.com/v1.0/me";
var responseString = await _httpClient.GetStringAsync(uri);
return responseString;
}
}
AuthHandler.cs
internal class AuthHandler : DelegatingHandler
{
private readonly ITokenAcquisition _tokenAcquisition;
public AuthHandler(ITokenAcquisition tokenAcquisition)
{
_tokenAcquisition = tokenAcquisition;
}
public string Scope { get; set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] {Scope}).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
return await base.SendAsync(request, cancellationToken);
}
}
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
IdentityModelEventSource.ShowPII = true;
services.AddMicrosoftIdentityWebAppAuthentication(Configuration)
.EnableTokenAcquisitionToCallDownstreamApi()
.AddInMemoryTokenCaches();
services.AddHttpClient<ITestClientWithAuthIncluded, TestClientWithAuthIncluded>();
services.AddTransient<AuthHandler>();
services.AddHttpClient<ITestClientWithoutAuth, TestClientWithoutAuth>()
.AddHttpMessageHandler(c =>
{
var handler = c.GetService<AuthHandler>();
handler.Scope = "User.Read";
return handler;
});
services.AddControllersWithViews(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
})
.AddMicrosoftIdentityUI();
services.AddRazorPages();
services.AddServerSideBlazor()
.AddMicrosoftIdentityConsentHandler();
}
index.razor
@page "/"
@using Microsoft.Identity.Web
@inject MicrosoftIdentityConsentAndConditionalAccessHandler ConsentHandler
@inject ITestClientWithAuthIncluded TestClientWithAuth
@inject ITestClientWithoutAuth TestClientWithoutAuth
<h1>Hello, world!</h1>
<p>
@Content
</p>
@code {
private string Content;
protected override async Task OnInitializedAsync()
{
try
{
// This will work every time
//Content = await TestClientWithAuth.GetProfileAsync();
// This always thows MicrosoftIdentityWebChallengeUserException when running on Azure, works fine locally (throws once and then it's okay).
Content = await TestClientWithoutAuth.GetProfileAsync();
}
catch (Exception ex)
{
Content = ex.ToString();
ConsentHandler.HandleException(ex);
}
}
}
For comparison, this works fine in all cases.
HomeController.cs
[Authorize]
[AuthorizeForScopes(Scopes = new string[] { "User.Read" })]
public class HomeController : Controller
{
private readonly ITestClientWithoutAuth _testClient;
public HomeController(ITestClientWithoutAuth testClient)
{
_testClient = testClient;
}
[Route("Home/Index")]
public async Task<IActionResult> Index()
{
var result = await _testClient.GetProfileAsync();
return Ok(result);
}
}
Expected behavior
When using the HTTP client, it should throw MicrosoftIdentityWebChallengeUserException once, redirect to AAD, authorize, reload the page and the HTTP client should get a token.
Actual behavior
The .GetAccessTokenForUserAsync call throws a MicrosoftIdentityWebChallengeUserException every time it is called in a delegating handler from a Blazor page, when running on Azure.
Possible solution No idea. Told ya it was an interesting one...
Additional context / logs / screenshots
I have a minimal repo here: https://github.com/henriksen/BlazorAuthRepo that includes the code mentioned above and consistently reproduces the error in our environment. It is based on the standard Blazor template and modified for Microsoft.Identity.Web 0.3.0. Add the correct tenantId and clientId in appSettings.json, it also expects a AzureAd:ClientSecret as a user secret (or in the appSettings.json file).
The sample repo uses Graph API for simplicity, but we're seeing the same problem calling our own APIs using our own defined scopes.
The app is set up to run on server, if changed to ServerPrerendered the Blazor page will flash the correct data once (from the pre-render), try to refresh, get the exception, redirect and then enter the infinite loop.
Thanks @henriksen
Do you have details about the MicrosoftIdentityWebChallengeUserException exception and the MsalUiRequiredException inner exception?
We'll look at your repo. Thanks for sharing
Yes, of course!
Users: MSAL UI error. Unable to get user list: "Microsoft.Identity.Web.MicrosoftIdentityWebChallengeUserException: IDW10502: An MsalUiRequiredException was thrown due to a challenge for the user. See https://aka.ms/ms-id-web/ca_incremental-consent.
---> MSAL.NetCore.4.17.1.0.MsalUiRequiredException:
ErrorCode: user_null
Microsoft.Identity.Client.MsalUiRequiredException: No account or login hint was passed to the AcquireTokenSilent call.
at Microsoft.Identity.Client.AcquireTokenSilentParameterBuilder.Validate()
at Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder`1.ValidateAndCalculateApiId()
at Microsoft.Identity.Web.TokenAcquisition.GetAccessTokenForWebAppWithAccountFromCacheAsync(IConfidentialClientApplication application, IAccount account, IEnumerable`1 scopes, String authority, String userFlow)
at Microsoft.Identity.Web.TokenAcquisition.GetAccessTokenForWebAppWithAccountFromCacheAsync(IConfidentialClientApplication application, ClaimsPrincipal claimsPrincipal, IEnumerable`1 scopes, String authority, String userFlow)
at Microsoft.Identity.Web.TokenAcquisition.GetAccessTokenForUserAsync(IEnumerable`1 scopes, String tenant, String userFlow, ClaimsPrincipal user)
StatusCode: 0
ResponseBody:
Headers:
--- End of inner exception stack trace ---
at Microsoft.Identity.Web.TokenAcquisition.GetAccessTokenForUserAsync(IEnumerable`1 scopes, String tenant, String userFlow, ClaimsPrincipal user)
at Web.Security.AuthHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) in /home/vsts/work/1/s/src/Web/Security/AuthHandler.cs:line 29
at Microsoft.Extensions.Http.Logging.LoggingScopeHttpMessageHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
at System.Net.Http.HttpClient.FinishSendAsyncUnbuffered(Task`1 sendTask, HttpRequestMessage request, CancellationTokenSource cts, Boolean disposeCts)
at Client.UserClient.QueryAsync(Query query, CancellationToken cancellationToken)
at Web.Pages.Users.Users.LoadData(String searchText) in /home/vsts/work/1/s/src/Web/Pages/Users/Users.razor:line 83"
Oh, ok, @henriksen. So Microsoft.Identity.Web, in this particular context cannot find the user. (because the HttpContext is not available in Blazor, and probably the NavigationManager either)
You probably want to get it from wherever you can and pass it as an optional argument of GetAccessTokenForUserAsync(). There is a user parameter for these kind of situations where developers have to help.
@henriksen: a possible idea (to try) might be to inject MicrosoftIdentityConsentAndConditionalAccessHandler in the constructor of you delegating handler, and use the .User member when calling GetAccessTokenForUserAsync()
But why does it work fine locally? Or fine when I call it in the TestClient directly. It's only when it's in the handler running in Azure it doesn't work.
a possible idea (to try) might be to inject
MicrosoftIdentityConsentAndConditionalAccessHandlerin the constructor of you delegating handler
I'll try that, thanks!
I don't know. maybe the execution context is different. @javiercn would you know?
Looks like the MicrosoftIdentityConsentAndConditionalAccessHandler couldn't find a user either.
Tried
var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(
new[] {Scope},
user: _consentAndConditionalAccess?.User
).ConfigureAwait(false);
System.NullReferenceException: Object reference not set to an instance of an object. at Microsoft.Identity.Web.MicrosoftIdentityConsentAndConditionalAccessHandler.get_User() at BlazorTest.AuthHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) in C:\dev\BlazorTest\AuthHandler.cs:line 30 at Microsoft.Extensions.Http.Logging.LoggingScopeHttpMessageHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) at System.Net.Http.HttpClient.FinishSendAsyncUnbuffered(Task`1 sendTask, HttpRequestMessage request, CancellationTokenSource cts, Boolean disposeCts) at System.Net.Http.HttpClient.GetStringAsyncCore(Task`1 getTask) at BlazorTest.TestClientWithoutAuth.GetProfileAsync() in C:\dev\BlazorTest\TestClient.cs:line 60 at BlazorTest.Pages.Index.OnInitializedAsync() in C:\dev\BlazorTest\Pages\Index.razor:line 22
Thanks for trying, @henriksen That's definitively one for @javiercn, then
Interested in this bug as well. I can confirm that in a server side blazor app the user is not available in HttpContext reliably. Instead you might use AuthenticationStateProvider. You can add it via DI as follows:
public SampleService(ITokenAcquisition tokenAcquisition, HttpClient http, AuthenticationStateProvider provider)
{
_Http = http;
_TokenAcquisition = tokenAcquisition;
_provider = provider;
}
later
var authState = await _provider.GetAuthenticationStateAsync();
var authUser = authState.User;
var accessToken = await _TokenAcquisition.GetAccessTokenForUserAsync(new[] { Scope }, user: authUser).ConfigureAwait(false);
Anyway, no luck so far. accessToken crashes in my code although authUser is available.
Happy for any clues.
@jmprieur let me ask this question to some folks, I'm not an expert in HttpClientFactory, the dev who worked on it is no longer on the team, and is now part of dotnet/runtime, so I'll need to familiarize myself a bit with it before I can understand if there's something going on.
Just as an info: a valid HTTPContext seems to be available in _Host.cshtml of a Blazor Server App (see also first link below)
For @henriksen and others who are interested in how to connect an Azure ADB2C with a Blazor server and a .Net Core Web api using the same ADB2C for auth, perhaps the two links below are helping you until this bug is fixed:
- ASP.NET Core Blazor Server additional security scenarios
- How to obtain “B2C JWT Access Token” from the signed in User?
Instead of getting an access_token, id_token seems to work fine for authenticating against the web api. I am using "Microsoft.AspNetCore.Authentication.AzureADB2C.UI" on both sides (Blazor Server and API) as it is initialized by default in current Visual Studio when you create a Blazor Server/Web Api project.
@andagon I don't think those would work in my case. Firstly, I'm not using B2C, but secondly, from my understanding the problem here is when getting a new access token for a downstream API. Having the original id-token or access-token from login wouldn't work, since the audience for those tokens are the Blazor app. I need to go to AAD, present one of those tokens and say "can I have an access token for this downstream service". This is the part that fails for me in the DelegatingHandler.
I've got a workaround now, using custom typed clients that do the token acquisition in the actual client, but that means I can't use the already generated clients that relied on the DelegatingHandler.
same issue here... @henriksen can you give more details on your workaround? much appriciated.
@EdAlexander The workaround is basically using ITokenAccessor anywhere but in a DelegateHandler 😄 What I did was make a new Typed Client that just reuse the DTOs and Query objects from the generated clients and then does the token aquisition and HTTP calls itself.
I have an example here: https://gist.github.com/henriksen/fe8846ffb4a4373a95403597b285ed18
The BaseService does the generic heavy lifting and the UserService specifies the path to call and passes parameters in and results out. Hope you find it useful.
Very nice work Glenn! Hopefully the default tooling catches up in Blazor and things will get back to simple.
EAC Partners/317.762.3331
COVID-19: As always, EAC Partners is available to help your staff whether they are working at home or in the office. Remote assistance for your employees can be performed over the phone, through Microsoft Teams and/or Quick Assist on Windows 10.Together we can keep your workforce efficient through this health emergency.
From: Glenn F. Henriksenmailto:[email protected] Sent: Friday, September 11, 2020 12:42 PM To: AzureAD/microsoft-identity-webmailto:[email protected] Cc: Edward Alexandermailto:[email protected]; Mentionmailto:[email protected] Subject: Re: [AzureAD/microsoft-identity-web] [Bug] TokenAcquisitionalways fails with MicrosoftIdentityWebChallengeUserException when called from a DelegatingHandler, but only on an Azure Web App. (#516)
@EdAlexanderhttps://github.com/EdAlexander The workaround is basically using ITokenAccessor anywhere but in a DelegateHandler 😄 What I did was make a new Typed Client that just reuse the DTOs and Query objects from the generated clients and then does the token aquisition and HTTP calls itself. I have an example here: https://gist.github.com/henriksen/fe8846ffb4a4373a95403597b285ed18 The BaseService does the generic heavy lifting and the UserService specifies the path to call and passes parameters in and results out. Hope you find it useful.
— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHubhttps://github.com/AzureAD/microsoft-identity-web/issues/516#issuecomment-691199977, or unsubscribehttps://github.com/notifications/unsubscribe-auth/ADI6FERKMOLOKK37TPISNITSFJHPNANCNFSM4QOBBHDQ.
See also https://github.com/AzureAD/microsoft-identity-web/issues/1131 cc: @jennyf19
@henriksen Is this still repro'ing with the latest version of Id.Web? wondering as we did a fix for anonymous controllers.
Think I just Hit this. App was fine locally using a DelegatingHandler with GetAccessTokenForUserAsync(scope). But once I deployed to Azure it Failed with the much feared "No account or login hint was passed to the AcquireTokenSilent call."
I think you can debug it locally by using an In-private browser session. See https://github.com/AzureAD/microsoft-identity-web/wiki/Managing-incremental-consent-and-conditional-access
worked fine locally in private. Failed inPrivate once I was up on azure. Moved it out of the delgating Handlerr and it was fine
One additional Note if we use Redis as a distributed Cache its fine. But relying completely on InMemoryCache from withen a Delegating Handler calling HandleException() just seems to cause an endless loop of "No account or login hint was passed to the AcquireTokenSilent call." Followed by a screen refresh followed by another "No account or login hint was passed to the AcquireTokenSilent call."
Wouldnt be to suprised to find out Azure has something to prevent Stateful Delegating Handlers.