Closed kiranchandran closed 2 months ago
@jmprieur do you know who can help with this one? This is related to msal.js library. Also, is there some other place the customer should report this issue in? Thanks!
Ping @jmprieur
Hi @jmprieur,
Is this something that you can help us?
Thanks
Discussed this with current @emlauber (her team owns MSAL.js). @emlauber feel free to unassign yourself if you find out that this is caused by Blazor, rather than MSAL.js (which is the current thinking). Thanks!
@kiranchandran @mkArtakMSFT It seems like MSAL.js is working as designed.
The iframe warning is not a breaking error, it is expected for some cases and handled by MSAL by throwing an interaction required error. Per MSAL JS docs, applications using MSAL need to add fallback code to catch interaction required errors and call acquireTokenRedirect/Popup.
From aspnetcore/src/Components/WebAssembly/Authentication.Msal/src/Interop/AuthenticationService.ts:
async getAccessToken(request?: AccessTokenRequestOptions): Promise<AccessTokenResult> {
try {
this.trace('getAccessToken', request);
const newToken = await this.getTokenCore(request?.scopes);
return {
status: AccessTokenResultStatus.Success,
token: newToken
};
} catch (e) {
return {
status: AccessTokenResultStatus.RequiresRedirect
};
}
}
I see getAccessToken
returns a RequiresRedirect
status, so I assume somewhere up the MsalAuthentication service there must be fallback logic, but I can't find it.
Here's a modified version of getTokenCore
that shows more or less what MSAL usage usually looks like:
Also from aspnetcore/src/Components/WebAssembly/Authentication.Msal/src/Interop/AuthenticationService.ts
async getTokenCore(scopes?: string[]): Promise<AccessToken | undefined> {
const account = this.getAccount();
if (!account) {
throw new Error('Failed to retrieve token, no account found.');
}
const silentRequest = {
redirectUri: this._settings.auth?.redirectUri,
account: account,
scopes: scopes || this._settings.defaultAccessTokenScopes
};
this.debug(`Provisioning a token silently for scopes '${silentRequest.scopes}'`)
this.trace('_msalApplication.acquireTokenSilent', silentRequest);
// const response = await this._msalApplication.acquireTokenSilent(silentRequest); < --- Current acquireTokenSilent call
### Expected usage
const response = await this._msalApplication.acquireTokenSilent(silentRequest).then(tokenResponse => {
// Do something with the tokenResponse
}).catch(error => {
if (error instanceof InteractionRequiredAuthError) {
// fallback to interaction when silent call fails
return msalInstance.acquireTokenRedirect(request);
}
### End expected usage
this.trace('_msalApplication.acquireTokenSilent-response', response);
if (response.scopes.length === 0 || response.accessToken === '') {
throw new Error('Scopes not granted.');
}
const result = {
value: response.accessToken,
grantedScopes: response.scopes,
expires: response.expiresOn
};
this.trace('getAccessToken-result', result);
return result;
}
My assumption is the current implementation was chosen so ASP.NET Core can delegate the decision of falling back to redirect to the developer, which makes sense. Are there any developer docs for the MsalAuthentication service I can look at to see if the usage pattern is correct?
Hi @mkArtakMSFT Thanks a lot for the follow up. Hi @hectormmg I am looking for an option within the framework to disable this iframe and always use the re-direct or popup. From the browser warning, it looks like at some point we need to get rid of this hidden iframe for security reasons.
As of now, as a temporary fix we have edited the AuthenticationService.js and loading it in the balzor from within the application. But I think we need to have an overriding feature from the framework itself.
We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.
@kiranchandran @mkArtakMSFT MSAL Browser does have a configuration option called CacheLookupPolicy
that can be configured per-request. Using CacheLookupPolicy.AccessTokenAndRefreshToken
will return the InteractionRequired
error before attempting iframe renewal. Example:
var silentRequest = {
scopes: ["SCOPE"],
account: currentAccount,
forceRefresh: false
cacheLookupPolicy: CacheLookupPolicy.AccessTokenAndRefreshToken // will default to CacheLookupPolicy.Default if omitted
};
Hi @hectormmg,
We are using blazor and could you please let me know how exactly this can be configured?
Thanks
Any updates on this please?
We're also facing the same issue when the token expires.
Just thought I'd come back and update you with a workaround.
The issues arises when calling an API with an expired access token. We have an implementation of AuthorizationMessageHandler
as we want to the access token to outgoing requests for a particular API.
Overriding the SendAsync method allows us to catch the AccessTokenNotAvailableException
and call its Redirect
method, which redirects the user as opposed to using the hidden iframe.
Not sure why this isn't done for you already and why you have to step in and call the Redirect
method manually, but it solves our problem.
when will this finally be fixed,? It causes massive issues for real world paying customers!
@jonsaich - is the middleware required even if the iframe policy allows it? We are working on the same issue - we'd like to basically get MSAL to keep reusing access/refresh until Interaction is absolutely necessary
Hi @Coruscate5 The issue is when MSAL tries to renew the token after expiry and it uses iframe and that is when the error occurs. (If iframe is allowed you will never face this issue) I am not sure how this middleware solves the issue coz this token renewal happens few minutes before the token expires from client side.
Just thought I'd come back and update you with a workaround.
The issues arises when calling an API with an expired access token. We have an implementation of
AuthorizationMessageHandler
as we want to the access token to outgoing requests for a particular API.Overriding the SendAsync method allows us to catch the
AccessTokenNotAvailableException
and call itsRedirect
method, which redirects the user as opposed to using the hidden iframe.Not sure why this isn't done for you already and why you have to step in and call the
Redirect
method manually, but it solves our problem.
That doesn't work
Just thought I'd come back and update you with a workaround. The issues arises when calling an API with an expired access token. We have an implementation of
AuthorizationMessageHandler
as we want to the access token to outgoing requests for a particular API. Overriding the SendAsync method allows us to catch theAccessTokenNotAvailableException
and call itsRedirect
method, which redirects the user as opposed to using the hidden iframe. Not sure why this isn't done for you already and why you have to step in and call theRedirect
method manually, but it solves our problem.That doesn't work
This worked for me pre .NET 8, after upgrading to .NET 8 this doesn't work any more.
You get correctly redirected to the login page ('authentication/login'), but I think that page also gets a 'AccessTokenNotAvailableException'. So the page is just stuck.
Users are now stuck on an error page, and even a full refresh doesn't solve this. Need to clear the site data completely.
I now get the following error:
crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100] Unhandled exception rendering component: net_http_message_not_success_statuscode_reason, 302, Token expired, redirecting to login.
Maybe my error is even something different.... In my client program.cs (maybe someone at MS would be smart enough go change Programs.cs to BlazorClientApp.cs and BlazorServerApp.cs - that would avoid haven two programs.cs which can cause confision)
builder.Services
.AddMsalAuthentication<RemoteAuthenticationState, CustomUserAccount>
(configure: options =>
{
// use redirect, popup are better but might be blocked!
options.ProviderOptions.LoginMode = "redirect"; // "popup";
var apiScope = .. // the scope of the server app registered on Azure
options.ProviderOptions.DefaultAccessTokenScopes.Add(apiScope);
// https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/environments?view=aspnetcore-8.0
if (!builder.HostEnvironment.IsProduction())
{
// enable caching token in localstorage
// TODO: re-enable for production once msal.js issue is fixed see
// without caching EVERY time the app get's access there is a authentication redirect which does NOTHING. That is very annoying and makes Blazor look like really bad
// with caching the cache invalidation does simply not work and after the token expired (e.g. 24) hours the user gets an nasty error message that access the above apiScope failed. MAYBE this the same error or yet another error
options.ProviderOptions.Cache.CacheLocation = "localStorage"; // cache MSAL authentication in localstorage to enhance start up speed
}
builder.Configuration.Bind(key: "AzureAd", instance: options.ProviderOptions.Authentication);
options.UserOptions.RoleClaim = "role";
}
)
.AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, CustomUserAccount, CustomUserFactory>();
....
with
public class CustomUserAccount : RemoteUserAccount
{
[JsonPropertyName(name: "groups")]
public string[]? Groups
{
get; set;
}
[JsonPropertyName(name: "roles")]
public string[]? Roles
{
get; set;
}
}
and
public class CustomUserFactory(IAccessTokenProviderAccessor accessor) : AccountClaimsPrincipalFactory<CustomUserAccount>(accessor: accessor)
{
public override async ValueTask<ClaimsPrincipal> CreateUserAsync(CustomUserAccount account, RemoteAuthenticationUserOptions options)
{
var user = await base.CreateUserAsync(account: account, options: options)??throw new Exception($"Failed to create user for account {account.ToString()}");
if (user.Identity?.IsAuthenticated != true)
{
return user;
}
var userIdentity = (ClaimsIdentity?)user.Identity;
if (account.Roles?.Length > 0)
{
foreach (var role in account.Roles)
{
userIdentity?.AddClaim(claim: new Claim(type: "role", value: role));
}
}
if (!(account.Groups?.Length > 0))
{
return user;
}
foreach (var group in account.Groups)
{
userIdentity?.AddClaim(claim: new Claim(type: "group", value: group));
}
return user;
}
}
The REALLY bad part is that the only way for a user to recover is to logout - something you don't want in an Enterprise application because nothing can be done without being authenticated - and then logon again. So we have the choice of getting a dummy authentication redirect every time we open the app or run into nasty error message and procedures that no user understands. This is a TRIVIAL basic feature that SIMPLY HAS TO work. I don't want any workaround. I want this to be fixed YESTERDAY.
I have not been able to reproduce this in .Net 8 with a Blazor WASM project and a WebAPI project that exposes the WeatherController.
I believe the difference is from the usage of the IHttpClientFactory used to generate the HttpClient instances that will make the requests to the WebAPI endpoints. In my Blazor WASM application I use the following code to register the service:
using Blazor_Wasm_B2c_API.Client;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using System.Security.Claims;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
//create a named Http Client factory
builder.Services.AddHttpClient("Blazor_Wasm_B2c_API.ServerAPI", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
.AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();
// Supply HttpClient instances that include access tokens when making requests to the server project
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("Blazor_Wasm_B2c_API.ServerAPI"));
//setup Azure B2C authentication
builder.Services.AddMsalAuthentication(options =>
{
//load the authentication options from the configuration
builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
//adding 'open_id' and 'offline_access' to the scopes requested
options.ProviderOptions.DefaultAccessTokenScopes.Add("openid");
options.ProviderOptions.DefaultAccessTokenScopes.Add("offline_access");
//get the scopes needed for accessing the API
options.ProviderOptions.DefaultAccessTokenScopes.Add(builder.Configuration.GetSection("ServerApi")["Scopes"]);
//from GitHub thread : https://github.com/dotnet/aspnetcore/issues/48506
//store authentication and refresh token in Local Storage instead of session storage
options.ProviderOptions.Cache.StoreAuthStateInCookie = true;
options.ProviderOptions.Cache.CacheLocation = "localStorage";
options.UserOptions.RoleClaim = ClaimTypes.Role;
});
The registration of the BaseAddressAuthorizationMessageHandler class through the call of the AddHttpMessageHandler
Unfortunately, this bug is still here and it's affecting our SPA which is running on .NET 8.0. I'm starting to think that I should really have gone with vuejs instead.
Your thinking is wrong. You have a bug - not ASP.NET. I appears that is the MS message here.
@paulcociuba
The registration of the BaseAddressAuthorizationMessageHandler class through the call of the AddHttpMessageHandler extension handler is all you need to append the needed token to access the API, and this will also handle the case where the refresh token is expired and needs to be renewed.
Please could you explain how the refresh token is renewed? I can't see any code that does this.
As far as I can tell, if AuthenticationService.ts returns a RequiresRedirect
result, this is thrown as an AccessTokenNotAvailableException
by AuthorizationMessageHandler
and that exception is not handled because, as your example shows, it is the outermost message handler in the HTTP client. I have stack traces which show exactly this.
Hello.
I'm adding the client log file from my Blazor App. It might help understand what's going, but the truth is that sometimes the UI gets stuck on the login-callback method. login_callback_error.log
I created another HttpMessageHandler
which wraps AuthorizationMessageHandler
and gracefully handles AccessTokenNotAvailableException
by refreshing the session via a popup:
public class RedirectingAuthorizationMessageHandler : DelegatingHandler
{
private static readonly SemaphoreSlim LoginSemaphore = new SemaphoreSlim(1, 1);
private static bool requiresLogin;
private readonly IJSRuntime jsRuntime;
public RedirectingAuthorizationMessageHandler(IJSRuntime jsRuntime)
{
this.jsRuntime = jsRuntime;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (InnerHandler is not AuthorizationMessageHandler)
throw new ArgumentException($"{nameof(InnerHandler)} must be of type {nameof(AuthorizationMessageHandler)} (was {InnerHandler?.GetType().Name})");
try
{
return await base.SendAsync(request, cancellationToken);
}
catch (AccessTokenNotAvailableException)
{
requiresLogin = true;
await LoginSemaphore.WaitAsync(cancellationToken);
if (requiresLogin)
{
try
{
await jsRuntime.InvokeVoidAsync("AuthenticationService.instance._msalApplication.loginPopup", cancellationToken);
requiresLogin = false;
}
catch (JSException e) when (e.Message.Contains("Popup"))
{
// Notify the user that they need to allow popups
throw;
}
}
LoginSemaphore.Release();
return await base.SendAsync(request, cancellationToken);
}
}
}
This appears to be a dupe of https://github.com/dotnet/aspnetcore/issues/48264 that got lost in the servicing milestone. 24 hours is the documented refresh token lifetimefor single page apps since the tokens are accessible from JS in the browser unlike traditional server-side apps which have a 90-day lifetime.
Refresh tokens sent to a redirect URI registered as spa expire after 24 hours. Additional refresh tokens acquired using the initial refresh token carry over that expiration time, so apps must be prepared to rerun the authorization code flow using an interactive authentication to get a new refresh token every 24 hours. Users don't have to enter their credentials and usually don't even see any related user experience, just a reload of your application. The browser must visit the sign-in page in a top-level frame to show the login session. This is due to privacy features in browsers that block third party cookies.
https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens#token-lifetime
The main regression here is that browsers have started to block third-party cookies by default even in iframes meaning a full-page navigation is required to refresh the token. Unlike a silent iframe-based refresh, Blazor cannot do a full-page navigation automatically to refresh the token because it would be too breaking to automatically force full reload of the page which would lose application state.
As many people on this issue have already helpfully noted, the application needs to call AccessTokenNotAvailableException.Redirect()
or NavigationManager.NavigateToLogin(accessTokenResult.InteractiveRequestUrl)
to handle AccessTokenResultStatus.RequiresRedirect
just like it did before browsers started blocking third-party cookies in iframes. It's just that now that these cookies are blocked, a full-page redirect is more often needed even when the user does not need to reenter credentials or otherwise interact with Azure B2C which could have previously been done silently in an iframe without reloading the app. Fortunately, as noted in the refresh token docs, "users don't have to enter their credentials and usually don't even see any related user experience, just a reload of your application."
https://github.com/dotnet/aspnetcore/issues/48264#issuecomment-2038558878 provides a more in-depth explanation of what's going on, and it links to a full repro project that demonstrates both AccessTokenNotAvailableException.Redirect()
or NavigationManager.NavigateToLogin(accessTokenResult.InteractiveRequestUrl)
.
If someone can provide a full repro project on GitHub where the AccessTokenResultStatus
is incorrect, or where AccessTokenNotAvailableException.Redirect()
/NavigationManager.NavigateToLogin(accessTokenResult.InteractiveRequestUrl)
does not work, please file a new issue with a link to the repro.
Hi @halter73 I do understand that there is a similar issue logged 12 days before, but you have closed that issue also without any resolution. Its very un-fortunate that you people are not having a solution even after an year. Please show this issue to someone who developed this AuthenticationService.ts, they can fix this quickly.
I understand that browser blocks the 3rd party cookie and the silent token renew fails. so what is the ideal solutions? We need a configuration in this js pluggin so that we can disable the silent renewal of token which is the issue here. Or if the silent renewal fails you have to automatically do the full redirect for getting the new token. As a framework I would except this to be done by the AuthenticationService.ts rather than each developer doing their own fix for the same problem.
@halter73 Instead of closing the issue blindly please review this issue correctly and give us a proper fix and we are waiting for more than an year.
#48264 (comment) provides a more in-depth explanation of what's going on, and it links to a full repro project that demonstrates both
AccessTokenNotAvailableException.Redirect()
orNavigationManager.NavigateToLogin(accessTokenResult.InteractiveRequestUrl)
.
The provided resolution https://github.com/halter73/BlazorHostedWasmAAD/compare/custom-delegating-handler essentially behave the same as MSAL sessionStorage.
localStorage should support long login session up to 3 months in B2C ... What's the benefit of enabling localStorage if users need a full redirect every 24 hours, essentially the same as sessionStorage :(
Is there an existing issue for this?
Describe the bug
We are using Azure B2C login with a Blazor wasm application. Using local storage to save the tokens and the goal is not to ask user credentials again if the user tries to access the same site till the refresh token expires. This works well.
But we are facing an issue if we try to access the application after a period of 24 hours of inactivity. This is the time when the refresh token expires(SPA with PKEC in azure B2C has 24 hour expiry for refresh token). After 24 hours the application is trying to login the user through a hidden iframe and for some security reason some of the browsers are throwing a warning and the login process fails.
I can see below message in the console
An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing.
I need to know is there any way to fix this. Does MSAL in Blazor provides any customization to disable this hidden iframe and enable the normal browser redirect?
Here is my configuration in program.cs
Here are some screenshots from the browser console which can help to understand the issue better.
In case if someone needs to re-create this issue without waiting for 24 hours, then you can replace the access token and refresh token to an already expired value and try this in Chrome Incognito or Mozilla. In normal chrome I am not getting this issue, but some other are getting the issue even in non incognito chrome window.
Expected Behavior
The application should have with some kind of a fallback strategy. Let say if the browser doesn't support hidden iframe redirect, then define a fallback mechanism to do a regular browser redirect.
Steps To Reproduce
Login to Azure B2C Use local storage to store the token cache in client side Try to load the application again after 24 hours(ie after the refresh token expires) See the browser logs
Note: In case if someone needs to re-create this issue without waiting for 24 hours, then you can replace the access token and refresh token to an already expired value and try this in Chrome Incognito or Mozilla. In normal chrome I am not getting this issue, but some other are getting the issue even in non incognito chrome window.
Exceptions (if any)
In the chrome console windows we can see a warning like
An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing.
In the redirect response we can see an error like
error=interaction_required&error_description= AADB2C90077 User does not have an existing session and request prompt parameter has a value of None
.NET Version
7
Anything else?
No response