Reputation: 153
We have a NET Core 3.1 web application where users are authenticated in Azure AD with the Microsoft.Identity.Web package. We are calling the Microsoft Graph SDK on behalf of the signed in users as described here.
After logging in to the application everything works fine - all calls to Microsoft Graph SDK succeed. However, after about 30-60 minutes (we haven't timed it exactly) users remain authenticated in our application but calls to the Microsoft Graph SDK fail with the following error:
IDW10502: An MsalUiRequiredException was thrown due to a challenge for the user.
And the inner exception has:
Microsoft.Identity.Client.MsalUiRequiredException: No account or login hint was passed to the AcquireTokenSilent call.
Users then need to log out of the application and log back in, after which calls to MS Graph succeed again for 30-60 minutes.
Can anyone shed any light on this?
Our Setup
In appsettings.json :
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "<our-domain-name>",
"TenantId": "<our-tenant-id>",
"ClientId": "<our-client-id>",
"CallbackPath": "/signin-oidc",
"SignedOutCallbackPath ": "/signout-callback-oidc",
"ClientSecret": "<secret>"
},
"GraphBeta": {
"BaseUrl": "https://graph.microsoft.com/beta",
"Scopes": "User.Read Sites.Read.All Files.Read.All Sites.ReadWrite.All Files.ReadWrite.All",
"DefaultScope": "https://graph.microsoft.com/.default"
}
In Startup.cs :
services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp( Configuration.GetSection("AzureAd"))
.EnableTokenAcquisitionToCallDownstreamApi(new string[] { "User.Read","Sites.Read.All","Files.Read.All","Sites.ReadWrite.All","Files.ReadWrite.All" })
.AddMicrosoftGraph(Configuration.GetSection("GraphBeta"))
.AddDistributedTokenCaches();
services.AddDistributedSqlServerCache(options =>
{
options.ConnectionString = Configuration.GetConnectionString("AzureConnection");
options.SchemaName = "dbo";
options.TableName = "TokenCache";
});
The GraphServiceClient is injected into our controllers :
readonly ITokenAcquisition tokenAcquisition;
private readonly GraphServiceClient graphServiceClient;
public SearchController(IConfiguration configuration, ITokenAcquisition _tokenAcquisition,
GraphServiceClient _graphServiceClient) : base(configuration)
{
tokenAcquisition = _tokenAcquisition;
graphServiceClient = _graphServiceClient;
}
And the relevant actions decorated with AuthorizeForScopes :
[AuthorizeForScopes(ScopeKeySection = "GraphBeta:Scopes")]
public async Task<IActionResult> Results(string queryString, int pageNumber, int pageSize)
{
...
}
And then calls are made to MS Graph, for example:
return await graphClient.Search.Query(requests).Request().PostAsync();
We are using
Upvotes: 6
Views: 10945
Reputation: 1878
Posting here in the hope I might save someone some time. If your application is an MVC application in the traditional server-side rendering style, then dealing with this (expected) behaviour is a two step process:
[AuthorizeForScopes]
attribute to your controller(s) and make sure it includes the scopes you need authorisation for. For me that was https://apps.azureiotcentral.com/user_impersonation
. For those calling Microsoft Graph, it'll be user.read
. NOTE: You can also use this attribute in the form [AuthorizeForScopes(ScopeKeySection = "Path:to:scopes:in:appsettings")]
. AuthorizeForScopes is an exception filter that doesn't do anything unless it sees exceptions telling it that the user needs to log in again. When it catches an exception containing a MsalUiRequiredException it will then send the user off to login.microsoftonline.com (or your corporate AAD tenant) to log in again. Generally this won't actually produce a login form as various cookie-based magic will silently happen, and the user will then be quietly redirected back to your page where a repeat request to your controller will succeed.Microsoft.Identity.Web.MicrosoftIdentityWebChallengeUserException
exceptions to bubble all the way up and out of your controller actions. In my case, I'd added exception handling code around my calls to ITokenAquisition.GetAccessTokenForUserAsync(...) that was not allowing these exceptions out, so the AuthorizeForScopes attribute couldn't work its magic.Exception handling code like this will do the trick:
try
{
// Custom code that ultimately ends up calling
// ITokenAcquisition.GetAccessTokenForUserAsync(...)
accessToken = await this.GetBearerTokenAsync();
}
catch (MicrosoftIdentityWebChallengeUserException)
{
// If a new challenge to the user (i.e. log in again, or apply
// authentication cookie) is required, then the call to GetBearerTokenAsync
// will throw an MsalUiRequiredException inside a
// MicrosoftIdentityWebChallengeUserException. We should re-throw this, so
// it can be caught by the [AuthorizeForScopes] exception handling attribute
// added to our application's base AuthenticatedController class.
throw;
}
catch (Exception ex)
{
// Handle other exceptions gracefully
exceptionMessage = ex.Message;
}
Upvotes: 3
Reputation: 17139
Adding a solution/workaround here - I was getting this error only when debugging in VS, because the cookie would persist but not the server-side session state. So rapidly debugging the web application was causing this exception.
You can work around it by adding some exception-handling middleware that just clears the login cookie (which will re-execute the interactive login flow).
You could optionally wrap this in a development enviornment condition (env.IsDevelopment()
), but if this error were to happen in production, it will handle that gracefully as well.
// Program.cs (C# 10, .NET 6)
app.UseExceptionHandler(new ExceptionHandlerOptions
{
ExceptionHandler = async ctx => {
var feature = ctx.Features.Get<IExceptionHandlerFeature>();
if (feature?.Error is MsalUiRequiredException
or { InnerException: MsalUiRequiredException }
or { InnerException.InnerException: MsalUiRequiredException })
{
ctx.Response.Cookies.Delete($"{CookieAuthenticationDefaults.CookiePrefix}{CookieAuthenticationDefaults.AuthenticationScheme}");
ctx.Response.Redirect(ctx.Request.GetEncodedPathAndQuery());
}
}
});
Upvotes: 10
Reputation: 251
Not sure if you're still experiencing this issue, but I had this exact problem and found the AuthorizeForScopes attribute needs the exact scopes that I am using in that method. Any incorrect scopes there will result in a challenge, which means that MsalUiRequiredException gets thrown.
Try hardcoding the parameters in that attribute to whatever scopes are required here in your code:
return await graphClient.Search.Query(requests).Request().PostAsync();
I did this on my end and am no longer seeing the issue even after leaving the app for a few hours (without refreshing in the hopes that the token expires).
For example, I have a method here that uploads some files to a Shared Document Library, and these are my scopes (which appear to be working):
[AuthorizeForScopes(Scopes = new[] { "MyFiles.Read", "MyFiles.Write", "Sites.Search.All" })]
public async Task<IActionResult> Upload(IFormFile file)
Lastly, if there are still issues, go to your App Registration in Azure > Manifest > knownClientApplications and add your Client ID into it. I actually don't know if this does anything to solving the problem, but I did it before I figured out the scope stuff, and found it began to work intermittently.
Upvotes: 0
Reputation: 153
It would appear that for us this has been solved my enabling Multi-Factor Authentication on our tenant.
Users originally just used a username and password to log into their Azure AD accounts, but since enabling MFA the above errors have stopped occurring.
Unfortunately we can only speculate as to the reasons why, so we can't provide an explanation for why this solves the issue.
Upvotes: 0