asp.net-coreasp.net-identityprogressive-web-appssmartphoneantiforgerytoken

ASP.NET Core 8.0 Razor Pages and Progressive Web-App (Add to home screen on mobile) does not work properly (with ASP.NET Identity; Antiforgery)


I have an ASP.NET Core Razor Pages Webpage which can be browsed to on smartphones (Android and IPhones).

I use ASP.NET Core Identity to secure access to the site.

My users want to add icons to the homescreens of their phones in order to access the site quickly (using the "add icon to homescreen"-feature of mobile versions of webbrowsers, which apparently "installs" a progressive web application, if its configured with a manifest). I can't get this to work properly. When accessing the site using the homescreen-button, the browser does not seem to load the local website data, especially not the cookies. Missing the ASP.NET Identity Cookies, the server redirects to the login page. If I enter credentials and post the form, the server responds with 400 Bad Request.

Excerpt from Logfile:

2024-12-10 15:00:42.8269|1|INFO|Microsoft.AspNetCore.Mvc.ViewFeatures.Filters.AutoValidateAntiforgeryTokenAuthorizationFilter|Antiforgery token validation failed. The provided antiforgery token was meant for a different claims-based user than the current user. Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException: The provided antiforgery token was meant for a different claims-based user than the current user.
   at Microsoft.AspNetCore.Antiforgery.DefaultAntiforgery.ValidateTokens(HttpContext httpContext, AntiforgeryTokenSet antiforgeryTokenSet)
   at Microsoft.AspNetCore.Antiforgery.DefaultAntiforgery.ValidateRequestAsync(HttpContext httpContext)
   at Microsoft.AspNetCore.Mvc.ViewFeatures.Filters.ValidateAntiforgeryTokenAuthorizationFilter.OnAuthorizationAsync(AuthorizationFilterContext context)

The rejection of the antiforgery-token happens only in the context described above, nowhere else.

I have this problem for a long time now. Things I tried to resolve the issue:

  1. Added a signed TLS-certificate to the IIS server (because I suspected the browser would not use local website data for websites, it doesn't trust).
  2. Added a manifest.json to configure Progressive Web Application installation.
  3. Added and used WebEssentials.AspNetCore.PWA.

Nothing of the above helped me with the issue.

The manifest:

{
  "short_name": "short name",
  "name": "name",
  "start_url": "/",
  "scope": "/",
  "display": "browser",
  "orientation": "portrait",
  "dir": "ltr",
  "lang": "de-DE",
  "theme_color": "#6FC2FF",
  "background_color": "#FFFFFF",
  "icons": [
    {
      "src": "/icons/rageguy512.png",
      "type": "image/png",
      "sizes": "512x512",
      "purpose": "any"
    },
    {
      "src": "/icons/rageguy192.png",
      "type": "image/png",
      "sizes": "192x192",
      "purpose": "any"
    }
  ]
}

Reference in my _Layout.cshtml:

<link rel="manifest" href="/manifest.json">

Program.cs

using WebEssentials.AspNetCore.Pwa;
// ...
builder.Services.AddProgressiveWebApp(new PwaOptions()
{
     Strategy = ServiceWorkerStrategy.Minimal,
});
// ...

Controller (LoginAsync()), which throws the 400 mentioned above:

using ***.Model.Identity;
using ***.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;



namespace ***.Areas.Identity.Controllers
{
    [Route("[Controller]/[Action]")]
    [Area("Identity")]
    [AutoValidateAntiforgeryToken]
    public class AccountController : Controller
    {
        private readonly SignInManager<ApplicationUser> _signInManager;

        private readonly IUserStore<ApplicationUser> _userStore;

        private readonly UserManager<ApplicationUser> _userManager;

        private readonly ReturnUrlService _returnUrlService;



        public AccountController(SignInManager<ApplicationUser> signInManager,
                                 IUserStore<ApplicationUser> userStore,
                                 UserManager<ApplicationUser> userManager,
                                 ReturnUrlService returnUrlService)
        {
            _signInManager = signInManager;
            _userStore = userStore;
            _userManager = userManager;
            _returnUrlService = returnUrlService;
        }



        [TempData]
        public string? ErrorMessage { get; set; }



        [HttpPost]
        [AllowAnonymous]
        public async Task<IActionResult> LoginAsync([FromForm] LoginInputModel Input)
        {
            if (ModelState.IsValid)
            {
                ApplicationUser? user = await _signInManager.UserManager.FindByIdAsync(Input.Id!);

                if (user == null)
                    return RedirectToPage("/Account/Login");

                var result = await _signInManager.PasswordSignInAsync(user, Input.Password!, true, lockoutOnFailure: true);

                if (result.Succeeded)
                {
                    return LocalRedirect(_returnUrlService.UrlUnescaped());
                }
                if (result.IsLockedOut)
                {
                    return RedirectToPage("/Account/Lockout");
                }
                else
                {
                    TempData.Set("ErrorMessage", "Login-Versuch ungültig!");
                    return RedirectToPage("/Account/Login");
                }
            }

            TempData.Set("ErrorMessage", "Fehler!");
            return RedirectToPage("/Account/Login");
        }



        public class LoginInputModel
        {
            [Required, StringLength(200, ErrorMessage = "Maximal 200 Zeichen.")]
            [Display(Name = "Benutzer")]
            public string? Id { get; set; }

            [Required(ErrorMessage = "Es muss ein Passwort angegeben werden.")]
            [DataType(DataType.Password)]
            [Display(Name = "Passwort")]
            public string? Password { get; set; }
        }



        [HttpPost]
        [Authorize(Policy = "TimeRecordingPolicy")]
        public async Task<IActionResult> LogoutAsync()
        {
            await _signInManager.SignOutAsync();

            return RedirectToPage("/Account/Login");
        }
    }
}

Any help appreciated.

ADDENDUM:

To clarify: the problem is twofold:

  1. The PWA does not seem to persist the Identity-cookie when it is closed. So, if the user closes the window and reopens it, the authenticated session is lost.
  2. User cannot login, because login-request is denied because of rejected antiforgery-token.

Solution

  • I found the issue: Apparently, cookies with samesite-attribute set to strict aren't handled very well in the context of progressive web applications. I configured the ASP.NET Identity Cookies to have samesite=lax, and problems disappeared.

    Program.cs exerpt:

    builder.Services.ConfigureApplicationCookie(o =>
    {
        o.LoginPath = "/Identity/Account/Login";
        o.LogoutPath = "/Identity/Account/Logout";
        o.AccessDeniedPath = "/Identity/Account/AccessDenied";
        o.Cookie.MaxAge = TimeSpan.FromDays(6);
        o.Cookie.HttpOnly = true;
    
        o.Cookie.SameSite = SameSiteMode.Lax; // <=====
    
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        o.ExpireTimeSpan = TimeSpan.FromDays(6);
        o.SlidingExpiration = true;
    });