Skip to content

Logout doesn't work in Blazor Web Application with global WASM interactivity (AntiforgeryValidationException) #58822

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

Open
1 task done
Andrzej-W opened this issue Nov 6, 2024 · 11 comments
Labels
area-blazor Includes: Blazor, Razor Components bug This issue describes a behavior which is not expected - a bug. feature-blazor-wasm-auth
Milestone

Comments

@Andrzej-W
Copy link

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

AntiforgeryValidationException after clicking logout when Blazor WASM interactive page is displayed in application with global interactivity.

Expected Behavior

Logout should work without exceptions.

Steps To Reproduce

  1. Create Blazor Web Application with global WASM interactivity.
    dotnet new blazor -n LogoutTest --interactivity WebAssembly --auth Individual --all-interactive True
  2. Run the application, register new user (apply DB migration), login as new user.
  3. (this step is not necessary) Open any page used to manage an account (they are not interactive). Click Logout - everything works as expected.
  4. Login again, open any WASM interactive page, for example Counter and click Logout. Exception!

Exceptions (if any)

      An unhandled exception has occurred while executing the request.
      Microsoft.AspNetCore.Http.BadHttpRequestException: Invalid anti-forgery token found when reading parameter "string returnUrl" from the request body as form.
       ---> Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException: The required antiforgery request token was not provided in either form field "__RequestVerificationToken" or header value "RequestVerificationToken".
         at Microsoft.AspNetCore.Antiforgery.DefaultAntiforgery.ValidateRequestAsync(HttpContext httpContext)
         at Microsoft.AspNetCore.Antiforgery.Internal.AntiforgeryMiddleware.InvokeAwaited(HttpContext context)
         --- End of inner exception stack trace ---
         at Microsoft.AspNetCore.Http.RequestDelegateFactory.Log.InvalidAntiforgeryToken(HttpContext httpContext, String parameterTypeName, String parameterName, Exception exception, Boolean shouldThrow)
         at Microsoft.AspNetCore.Http.RequestDelegateFactory.<HandleRequestBodyAndCompileRequestDelegateForForm>g__TryReadFormAsync|103_0(HttpContext httpContext, String parameterTypeName, String parameterName, Boolean throwOnBadRequest)
         at Microsoft.AspNetCore.Http.RequestDelegateFactory.<>c__DisplayClass103_2.<<HandleRequestBodyAndCompileRequestDelegateForForm>b__2>d.MoveNext()
      --- End of stack trace from previous location ---
         at Microsoft.AspNetCore.Antiforgery.Internal.AntiforgeryMiddleware.InvokeAwaited(HttpContext context)
         at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
         at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
         at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

.NET Version

9.0.100-rc.2.24474.11

Anything else?

Probably related issue #56687
Pinging @javiercn because he was active in related issue.

@Andrzej-W Andrzej-W added area-blazor Includes: Blazor, Razor Components feature-blazor-wasm-auth bug This issue describes a behavior which is not expected - a bug. labels Nov 6, 2024
@Andrzej-W
Copy link
Author

The problem is that html form does not contain __RequestVerificationToken hidden field. This bug is related to this issue #54533 and it looks it is NOT fixed. Original issue was reported by @SteveSandersonMS. Pinging @javiercn again because he was working on the fix in .NET 9 RC1.

@javiercn javiercn added this to the .NET 10 Planning milestone Nov 7, 2024
@hakenr
Copy link
Member

hakenr commented Dec 3, 2024

I can also reproduce the issue in several of our projects (InteractiveWebAssemblyRenderMode(prerender: false)).

The problem is that the __internal__AntiforgeryRequestToken is not present in the persistent component state rendered on the server, so it doesn't get restored to the WASM antiforgery state provider.

If I add <AntiforgeryToken /> to App.razor (using Static SSR), the hidden field with the token is correctly rendered in the server output.

However, it somehow doesn't make its way into the prerendered state:

_subscription = state.RegisterOnPersisting(() =>
{
state.PersistAsJson(PersistenceKey, GetAntiforgeryToken());
return Task.CompletedTask;
}, RenderMode.InteractiveAuto);

@hakenr
Copy link
Member

hakenr commented Dec 3, 2024

@javiercn I think I found the cause of the bug.

It's not the DefaultAntiforgeryStateProvider, but rather the ComponentStatePersistenceManager together with the ResourceCollectionProvider.

  1. The DefaultAntiforgeryStateProvider correctly registers itself to provide persistent component state.
  2. The ComponentStatePersistenceManager maintains _registeredCallbacks. The last two callbacks in this collection come from the ResourceCollectionProvider and the DefaultAntiforgeryStateProvider (EndpointAntiforgeryStateProvider).
  3. When state is collected in ComponentStatePersistenceManager.PauseAsync, it isn't expected that the _registeredCallbacks collection would change during the process:
    for (var i = 0; i < _registeredCallbacks.Count; i++)
    {
    var registration = _registeredCallbacks[i];
    if (!store.SupportsRenderMode(registration.RenderMode!))
    {
    // The callback does not have an associated render mode and we are in a multi-store scenario.
    // Otherwise, in a single store scenario, we just run the callback.
    // If the registration callback is null, it's because it was associated with a component and we couldn't infer
    // its render mode, which means is an SSR only component and we don't need to persist its state at all.
    continue;
    }
    var result = ExecuteCallback(registration.Callback, _logger);
    if (!result.IsCompletedSuccessfully)
    {
    pendingCallbackTasks ??= new();
    pendingCallbackTasks.Add(result);
    }
    }
  4. However, the ResourceCollectionProvider disposes of its subscription during the persistence process. This modifies the _registeredCallbacks collection before the for-loop in ComponentStatePersistenceManager.PauseAsync finishes (by removing its own registration). As a result, the loop condition i < _registeredCallbacks.Count ends prematurely, and the next callback in _registeredCallbacks doesn't get executed:
    registration = _state.RegisterOnPersisting(() =>
    {
    _state.PersistAsJson(ResourceCollectionUrlKey, _url);
    registration.Dispose();
    return Task.CompletedTask;
    }, RenderMode.InteractiveWebAssembly);

A workaround could involve influencing the order of registrations in _registeredCallbacks so that the ResourceCollectionProvider registration is always the last one. This ensures that its removal from the collection won't affect the other registrations.

The ultimate fix should be either:

  • Making the callback loop in PauseAsync resilient to changes in the collection.
  • Or preventing such changes by moving the subscription.Dispose() call from the callback to the regular Dispose method in ResourceCollectionProvider.

If we agree on the solution, I can prepare a PR to address this.

cc @jirikanda

@hakenr
Copy link
Member

hakenr commented Dec 3, 2024

Workaround

  1. Create WorkaroundEndpointAntiforgeryStateProvider, which registers a dummy persistence callback that's skipped due to the bug described above. The actual persistence callback will execute, passing the antiforgery token to the client.
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;

namespace Havit.NewProjectTemplate.Web.Server.Infrastructure.Antiforgery;

// Replicates EndpointAntiforgeryStateProvider (incl. base DefaultAntiforgeryStateProvider) and adds a workaround for the issue with the ResourceCollectionProvider
// https://github.com/dotnet/aspnetcore/issues/58822
public class WorkaroundEndpointAntiforgeryStateProvider : AntiforgeryStateProvider, IDisposable
{
	private const string PersistenceKey = $"__internal__{nameof(AntiforgeryRequestToken)}";
	private readonly PersistingComponentStateSubscription _subscription;
	private readonly PersistingComponentStateSubscription _subscriptionDummy;
	private readonly AntiforgeryRequestToken _currentToken;
	private readonly IAntiforgery _antiforgery;
	private readonly IHttpContextAccessor _httpContextAccessor;
	private readonly PersistentComponentState _state;

	public WorkaroundEndpointAntiforgeryStateProvider(
		IAntiforgery antiforgery,
		IHttpContextAccessor httpContextAccessor,
		PersistentComponentState state)
	{
		// This is a dummy subscription that does nothing. It's only here as the previous ResourceCollectionProvider
		// persising callback disposes its subscription, which modifies the ComponentStatePersistenceManager._registeredCallbacks
		// collection in while looping and causing next callback to be skipped.
		_subscriptionDummy = state.RegisterOnPersisting(() => Task.CompletedTask, RenderMode.InteractiveWebAssembly);

		// Automatically flow the Request token to server/wasm through
		// persistent component state. This guarantees that the antiforgery
		// token is available on the interactive components, even when they
		// don't have access to the request.
		_subscription = state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly);


		state.TryTakeFromJson(PersistenceKey, out _currentToken);
		_antiforgery = antiforgery;
		_httpContextAccessor = httpContextAccessor;
		_state = state;
	}

	private Task OnPersistingAsync()
	{
		_state.PersistAsJson(PersistenceKey, GetAntiforgeryToken());
		return Task.CompletedTask;
	}

	public override AntiforgeryRequestToken GetAntiforgeryToken()
	{
		if (_httpContextAccessor.HttpContext == null)
		{
			// We're in an interactive context. Use the token persisted during static rendering.
			return _currentToken;
		}


		// We already have a callback setup to generate the token when the response starts if needed.
		// If we need the tokens before we start streaming the response, we'll generate and store them;
		// otherwise we'll just retrieve them.
		// In case there are no tokens available, we are going to return null and no-op.
		var tokens = !_httpContextAccessor.HttpContext.Response.HasStarted ? _antiforgery.GetAndStoreTokens(_httpContextAccessor.HttpContext) : _antiforgery.GetTokens(_httpContextAccessor.HttpContext);
		if (tokens.RequestToken is null)
		{
			return null;
		}


		return new AntiforgeryRequestToken(tokens.RequestToken, tokens.FormFieldName);
	}


	/// <inheritdoc />
	public void Dispose()
	{
		_subscriptionDummy.Dispose();
		_subscription.Dispose();
	}
}
  1. Register WorkaroundEndpointAntiforgeryStateProvider to application services in Program.cs in your main Blazor project (not the .Client):
app.Services.AddRazorComponents()
	.AddInteractiveWebAssemblyComponents();
app.Services.AddScoped<AntiforgeryStateProvider, WorkaroundEndpointAntiforgeryStateProvider>();

UPDATE:
Due to the deterministic order of service resolutions here, the workaround should be quite reliable:

InitializeResourceCollection(httpContext);
if (handler != null && form != null)
{
httpContext.RequestServices.GetRequiredService<HttpContextFormDataProvider>()
.SetFormData(handler, new FormCollectionReadOnlyDictionary(form), form.Files);
}
if (httpContext.RequestServices.GetService<AntiforgeryStateProvider>() is EndpointAntiforgeryStateProvider antiforgery)
{
antiforgery.SetRequestContext(httpContext);
}

@alekswaleks
Copy link

Could someone correct this bug? :(

@alekswaleks
Copy link

@javiercn please help :(

@rogerjak
Copy link

rogerjak commented Dec 8, 2024

The workaround provided by @hakenr is working perfectly!

@MichelJansson
Copy link
Contributor

I've spent at least a full day troubleshooting this while trying to update to .NET9 which breaks my app here and there.

For some reason I did not have the luxury or receiving the nice AntiforgeryValidationException the OP was getting - all I got was 400 response and no message at all in the console, having me chasing a routing red herring...

My investigations lead me to the same conclusion as @hakenr (kudos for writing it up). But I went for a simpler workaround:

Alternative workaround

A simple workaround that does not replicate the underlying services, which also hopefully does not have any adverse effects should these antiforgery bugs be solved once and for all.

  1. Create a PersistAntiforgeryState.cs-component:
using Microsoft.AspNetCore.Components.Web;

namespace Microsoft.AspNetCore.Components.Forms;

public sealed class PersistAntiforgeryState : ComponentBase, IDisposable
{
    private const string PersistenceKey = $"__internal__{nameof(AntiforgeryRequestToken)}";
    private PersistingComponentStateSubscription _subscription;

    [Inject] PersistentComponentState PersistentState { get; set; }
    [Inject] AntiforgeryStateProvider AntiforgeryProvider { get; set; }

    protected override void OnInitialized()
    {
        _subscription = PersistentState.RegisterOnPersisting(() =>
        {
            PersistentState.PersistAsJson(PersistenceKey, AntiforgeryProvider.GetAntiforgeryToken());
            return Task.CompletedTask;
        }, RenderMode.InteractiveAuto);
    }

    public void Dispose() => _subscription.Dispose();
}
  1. Add the component to the App.razor somewhere:
<PersistAntiforgeryState /> @* Workaround for AntiforgeryToken bug like https://github.com/dotnet/aspnetcore/issues/58822 *@

<!DOCTYPE html>
<html lang="en">
<head>
...

@Andrzej-W
Copy link
Author

Workaround proposed by @MichelJansson works perfectly.

@danroth27
Copy link
Member

@lewing @javiercn Should we consider this issue for servicing?

@levinkata
Copy link

Workaround proposed by @MichelJansson works perfectly for me..

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components bug This issue describes a behavior which is not expected - a bug. feature-blazor-wasm-auth
Projects
None yet
Development

No branches or pull requests

8 participants