Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bug] TokenAcquisitionalways fails with MicrosoftIdentityWebChallengeUserException when called from a DelegatingHandler, but only on an Azure Web App. #516

Closed
2 of 9 tasks
henriksen opened this issue Aug 28, 2020 · 23 comments
Assignees
Labels
Milestone

Comments

@henriksen
Copy link

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
    • 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)
    • 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.

@jmprieur jmprieur added this to the [6] Support new scenarios milestone Aug 28, 2020
@jmprieur
Copy link
Collaborator

Thanks @henriksen

Do you have details about the MicrosoftIdentityWebChallengeUserException exception and the MsalUiRequiredException inner exception?

We'll look at your repo. Thanks for sharing

@henriksen
Copy link
Author

henriksen commented Aug 28, 2020

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"

@jmprieur
Copy link
Collaborator

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.

@jmprieur
Copy link
Collaborator

@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()

@henriksen
Copy link
Author

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.

@henriksen
Copy link
Author

a possible idea (to try) might be to inject MicrosoftIdentityConsentAndConditionalAccessHandler in the constructor of you delegating handler

I'll try that, thanks!

@jmprieur
Copy link
Collaborator

I don't know. maybe the execution context is different.
@javiercn would you know?

@henriksen
Copy link
Author

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

@jmprieur
Copy link
Collaborator

Thanks for trying, @henriksen
That's definitively one for @javiercn, then

@jmprieur jmprieur added the question Further information is requested label Aug 28, 2020
@mlinschulte
Copy link

mlinschulte commented Aug 29, 2020

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 jmprieur self-assigned this Aug 31, 2020
@javiercn
Copy link

@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.

@aqua-cloud-gmbh
Copy link

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:

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.

@henriksen
Copy link
Author

@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.

@EdAlexander
Copy link

same issue here... @henriksen can you give more details on your workaround? much appriciated.

@henriksen
Copy link
Author

@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.

@EdAlexander
Copy link

EdAlexander commented Sep 11, 2020 via email

@jmprieur jmprieur added documentation Improvements or additions to documentation enhancement New feature or request and removed question Further information is requested labels Nov 30, 2020
@jmprieur
Copy link
Collaborator

jmprieur commented May 25, 2021

See also #1131
cc: @jennyf19

@jmprieur jmprieur removed the documentation Improvements or additions to documentation label May 28, 2021
@jennyf19
Copy link
Collaborator

@henriksen Is this still repro'ing with the latest version of Id.Web? wondering as we did a fix for anonymous controllers.

@DavidStrickland0
Copy link

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."

@jmprieur
Copy link
Collaborator

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

@DavidStrickland0
Copy link

worked fine locally in private. Failed inPrivate once I was up on azure. Moved it out of the delgating Handlerr and it was fine

@DavidStrickland0
Copy link

DavidStrickland0 commented Mar 29, 2022

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.

@jennyf19
Copy link
Collaborator

jennyf19 commented Dec 5, 2024

closing, recommendation is to use IDownstreamApi in v2. Please reopen or create a new issue if needed.

@jennyf19 jennyf19 closed this as completed Dec 5, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

7 participants