2

I have a Blazor web app, where I am using static SSR and wasm (but not interactive server).

I of course have a server project and a wasm project.

I have setup auth with Appwrite and written of own auth providers. They work fine for a while, but after some time (several days) when I load the website, the server will still see me as logged in, however the client will see me as logged out.

I have a login component which shows a "manage profile" button when logged in, or sign in button when logged out. When in this state, the pre-render shows the manage profile, but when wasm hydrates it switched to a login button.

I am serving a minimal API, using the same auth, and this will allow actions to be taken by the logged in user. So, it appears that the issue is happening somewhere at the point of syncing auth state from server to client.

I have 2 debug pages setup to show login status as well as claims etc. the one which pre-renders shows what I would expect, whereas the wasm of course shows that I am logged out.

in my server project, I setup auth as:

public static IHostApplicationBuilder ConfigureAuthentication(this IHostApplicationBuilder builder)
{
    builder.Services.AddCascadingAuthenticationState();
    builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
        .AddCookie(x =>
        {
            x.ExpireTimeSpan = TimeSpan.FromDays(30);
            x.SlidingExpiration = true;
            x.LoginPath = "/login";
            x.LogoutPath = "/logout";
            x.AccessDeniedPath = "/access-denied";

            x.Events.OnRedirectToLogin = context =>
            {
                // Check if it's an API request (you can adjust this logic)
                if (context.Request.Path.StartsWithSegments("/api") ||
                    context.Request.Headers.Accept.ToString().Contains("application/json"))
                {
                    context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                    return Task.CompletedTask;
                }

                // For regular Blazor pages, keep the redirect behavior
                context.Response.Redirect(context.RedirectUri);
                return Task.CompletedTask;
            };

            x.Events.OnRedirectToLogout = context =>
            {
                // Check if it's an API request
                if (context.Request.Path.StartsWithSegments("/api") ||
                    context.Request.Headers.Accept.ToString().Contains("application/json"))
                {
                    // For API requests, return 200 OK but don't redirect
                    context.Response.StatusCode = StatusCodes.Status200OK;
                    return Task.CompletedTask;
                }

                // For regular requests, keep the redirect to the logout page
                // This will hit your Logout.razor WASM page
                context.Response.Redirect(context.RedirectUri);

                return Task.CompletedTask;
            };
        });
    builder.Services.AddHttpContextAccessor();

    builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();

    return builder;
}

and:

public static WebApplication ConfigureAuthentication(this WebApplication app)
{
    app.UseAuthentication();
    app.UseAuthorization();

    return app;
}

and then in my client (wasm) project:

public static WebAssemblyHostBuilder ConfigureAuthentication(this WebAssemblyHostBuilder builder)
{
    builder.Services.AddAuthorizationCore();
    builder.Services.AddCascadingAuthenticationState();
    builder.Services.AddAuthenticationStateDeserialization();

    return builder;
}

In the server, this is my custom auth state provider:

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using PinguApps.Appwrite.Server.Clients;
using PinguApps.Appwrite.Shared.Requests.Users;
using PinguApps.Appwrite.Shared.Responses;
using Wrevo.Common;
using Wrevo.UI.Client.Features.Extensions;
using ZiggyCreatures.Caching.Fusion;

namespace Wrevo.UI.Features.Pages.Account;

public class CustomAuthStateProvider : ServerAuthenticationStateProvider
{
    private readonly IServerAppwriteClient _appwriteClient;
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IFusionCache _cache;

    public CustomAuthStateProvider(IServerAppwriteClient appwriteClient,
        IHttpContextAccessor httpContextAccessor, IFusionCache cache)
    {
        _appwriteClient = appwriteClient;
        _httpContextAccessor = httpContextAccessor;
        _cache = cache;
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext is null)
        {
            return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
        }

        // Try to get the current user from cookie authentication
        var authenticateResult = await httpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        if (!authenticateResult.Succeeded)
        {
            return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
        }

        // Check for both session cookies
        var sessionId = authenticateResult.Principal.FindFirst(ClaimTypes.PrimarySid)?.Value;

        if (string.IsNullOrEmpty(sessionId))
        {
            return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
        }

        var authState = await _cache.GetOrSetAsync<List<SerializableClaim>?>(
            CacheKeys.Auth.SessionState(sessionId),
            async (context, _) =>
            {
                context.Options.IsFailSafeEnabled = false;

                var isSignedOut = await _cache.GetOrDefaultAsync(
                    CacheKeys.Auth.SignedOutSession(sessionId),
                    (string?)null);

                if (!string.IsNullOrEmpty(isSignedOut))
                {
                    context.Options.SetDurationZero();
                    return null;
                }

                var userId = authenticateResult.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;

                if (string.IsNullOrEmpty(userId))
                {
                    await SignOutAsync(sessionId);
                    context.Options.SetDurationZero();
                    return null;
                }

                // Validate user session
                var sessionsRequest = new ListUserSessionsRequest
                {
                    UserId = userId
                };

                var sessionsResult = await _appwriteClient.Users.ListUserSessions(sessionsRequest);

                if (!sessionsResult.Result.TryParseResult(out var sessionsList) || !sessionsList.Sessions.Any(x => x.Id == sessionId))
                {
                    await SignOutAsync(sessionId);
                    context.Options.SetDurationZero();
                    return null;
                }

                // Retrieve and validate existence of session cookie here

                var sessionCookie = httpContext.Request.Cookies["wrevo.session"];

                if (string.IsNullOrEmpty(sessionCookie))
                {
                    await SignOutAsync(sessionId);
                    context.Options.SetDurationZero();
                    return null;
                }

                var userRequest = new GetUserRequest
                {
                    UserId = userId
                };

                var userResult = await _appwriteClient.Users.GetUser(userRequest);

                if (!userResult.Result.TryParseResult(out var user) || !user.Status)
                {
                    await SignOutAsync(sessionId);
                    context.Options.SetDurationZero();
                    return null;
                }

                var claimsIdentity = GetClaimsForUser(user, sessionId, sessionCookie);

                var currentClaims = authenticateResult.Principal.Claims;
                var newClaims = claimsIdentity.Claims;

                var claimsMatch = currentClaims.Select(x => new { x.Type, x.Value }).SequenceEqual(newClaims.Select(x => new { x.Type, x.Value }));

                if (!claimsMatch)
                {
                    await SignIn(httpContext, claimsIdentity);
                }

                context.Options.SetDuration(CacheOptions.Auth.Session);

                return claimsIdentity.Claims
                    .Select(x => new SerializableClaim { Type = x.Type, Value = x.Value })
                    .ToList();
            },
            tags: CacheTags.Auth.SessionState);

        // Convert the result to AuthenticationState
        if (authState is not null)
        {
            var identity = new ClaimsIdentity(
                authState.Select(x => new Claim(x.Type, x.Value)),
                CookieAuthenticationDefaults.AuthenticationScheme);

            return new AuthenticationState(new ClaimsPrincipal(identity));
        }

        return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
    }

    private async Task SignOutAsync(string sessionId)
    {
        if (_httpContextAccessor.HttpContext is not null)
        {
            await _cache.SetAsync(
                CacheKeys.Auth.SignedOutSession(sessionId),
                "true",
                CacheOptions.Auth.Signout,
                CacheTags.Auth.SignedOutSession);
            await _cache.RemoveAsync(CacheKeys.Auth.SessionState(sessionId));

            var httpContext = _httpContextAccessor.HttpContext;
            if (httpContext is not null && !httpContext.Response.HasStarted)
            {
                await _httpContextAccessor.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
                _httpContextAccessor.HttpContext.Response.Cookies.Delete("wrevo.session");
                _httpContextAccessor.HttpContext.Response.Cookies.Delete("wrevo.sessionId");
            }
        }
    }

    public static ClaimsIdentity GetClaimsForUser(User user, string sessionId, string sessionSecret)
    {
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.Id),
            new(ClaimTypes.Name, user.Name),
            new(ClaimTypes.Email, user.Email),
            new(ClaimTypes.PrimarySid, sessionId),
            new(Claims.CreatedAt, user.CreatedAt.ToString("O")),
            new(Claims.SessionSecret, sessionSecret)
        };

        if (user.EmailVerification)
        {
            claims.Add(new(ClaimTypes.Role, "emailVerified"));
        }
        else
        {
            claims.Add(new(ClaimTypes.Role, "anonymous"));
        }

        foreach (var label in user.Labels)
        {
            claims.Add(new(ClaimTypes.Role, label));
        }

        return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
    }

    public static Task SignIn(HttpContext httpContext, ClaimsIdentity claimsIdentity)
    {
        var authProperties = new AuthenticationProperties
        {
            IsPersistent = true,
            ExpiresUtc = DateTimeOffset.UtcNow.Add(TimeSpan.FromDays(30))
        };

        return httpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            new ClaimsPrincipal(claimsIdentity),
            authProperties);
    }

    public async Task InvalidateCache(string sessionId)
    {
        await _cache.RemoveAsync(CacheKeys.Auth.SessionState(sessionId));
        NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
    }

    private class SerializableClaim
    {
        public string Type { get; set; } = string.Empty;
        public string Value { get; set; } = string.Empty;
    }
}

I am at a loss as to figuring out why sometimes, after a session has been active a few days or more, the auth state fails to serialize correctly down to wasm during hydration...

3
  • Can you clarify how you're serializing and passing the authentication state to the client during static SSR pre-rendering? Specifically, are you using <CascadingAuthenticationState> around your app and ensuring AuthenticationStateProvider.GetAuthenticationStateAsync() is called server-side and properly serialized for hydration? Commented Jun 5 at 13:19
  • I'm calling builder.Services.AddCascadingAuthenticationState(); in Program.cs on the server project covering the CascadingAuthenticationState. My app is also calling GetAuthenticationStateAsync in several different spots. Commented Jun 6 at 12:23
  • (and also have the following in program.cs: builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents() .AddAuthenticationStateSerialization(x => x.SerializeAllClaims = true);) Commented Jun 6 at 12:24

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.