dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.15k stars 9.92k forks source link

MSAL : Azure B2C Blazor wasm fails after 24 hours when the refresh token has been expired #48506

Closed kiranchandran closed 2 months ago

kiranchandran commented 1 year ago

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

builder.Services.AddMsalAuthentication(options =>
{
    options.ProviderOptions.LoginMode = "redirect";
    builder.Configuration.Bind("AzureAdB2C", options.ProviderOptions.Authentication);
    options.ProviderOptions.DefaultAccessTokenScopes.Add("SomeScope");
    options.ProviderOptions.Cache.StoreAuthStateInCookie = true;
    options.ProviderOptions.Cache.CacheLocation = "localStorage";
    options.UserOptions.RoleClaim = ClaimTypes.Role;
});

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.

image

image

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

mkArtakMSFT commented 1 year 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!

mkArtakMSFT commented 1 year ago

Ping @jmprieur

kiranchandran commented 1 year ago

Hi @jmprieur,

Is this something that you can help us?

Thanks

mkArtakMSFT commented 1 year ago

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!

hectormmg commented 1 year ago

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

kiranchandran commented 1 year ago

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.

ghost commented 1 year ago

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.

hectormmg commented 1 year ago

@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
};
kiranchandran commented 1 year ago

Hi @hectormmg,

We are using blazor and could you please let me know how exactly this can be configured?

Thanks

jonsaich commented 1 year ago

Any updates on this please?

We're also facing the same issue when the token expires.

jonsaich commented 1 year ago

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.

hjrb commented 9 months ago

when will this finally be fixed,? It causes massive issues for real world paying customers!

Coruscate5 commented 9 months ago

@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

kiranchandran commented 9 months ago

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.

hjrb commented 9 months ago

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.

That doesn't work

Schoof-T commented 9 months ago

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.

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.

hjrb commented 9 months ago

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.

paulcociuba commented 7 months ago

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

luisabreu commented 6 months ago

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.

hjrb commented 6 months ago

Your thinking is wrong. You have a bug - not ASP.NET. I appears that is the MS message here.

atomicfraser commented 4 months ago

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

luisabreu commented 4 months ago

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

atomicfraser commented 4 months ago

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);
        }
    }
}
halter73 commented 2 months ago

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.

kiranchandran commented 2 months ago

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.

tariqalsoahmed commented 2 months ago

#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() or NavigationManager.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 :(