You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

.NET 8 Blazor Interactive Server JWT客户端存储及预渲染异常解决

.NET 8 Blazor Interactive Server 本地存储JWT认证的JSInterop异常解决

问题场景

在.NET 8 Blazor Interactive Server项目中自定义SmartRecipesAuthenticationStateProvider重写AuthenticationStateProvider,通过ILocalStorageService从客户端本地存储读取JWT令牌初始化认证状态时,触发以下JSInterop异常:

InvalidOperationException: JavaScript interop calls cannot be issued at this time. This is because the component is being statically rendered. When prerendering is enabled, JavaScript interop calls can only be performed during the OnAfterRenderAsync lifecycle method

核心原因是:预渲染阶段(组件初始化流程中)无法执行JSInterop操作,但GetAuthenticationStateAsync会在预渲染过程中被调用,直接访问本地存储导致异常。以下是保留客户端JWT存储的可行解决方案:


方案1:禁用预渲染

直接关闭项目的预渲染功能,让组件直接以交互模式运行,JSInterop可正常调用。

修改App.razor

<!-- 移除Prerender="true"配置 -->
<Routes @rendermode="InteractiveServer" />

或全局配置(Program.cs)

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents(options =>
    {
        options.DisablePrerendering = true;
    });

优缺点:实现简单快捷,但会失去预渲染带来的首屏加载速度和SEO优化优势。


方案2:延迟认证状态加载(适配预渲染)

在预渲染阶段先返回匿名状态,等客户端完成首次渲染后,再从本地存储加载JWT并更新认证状态。

修改自定义AuthenticationStateProvider

public class SmartRecipesAuthenticationStateProvider : AuthenticationStateProvider
{
    private readonly HttpClient httpClient;
    private readonly ILocalStorageService localStorage;
    private bool _isInitialized;

    public SmartRecipesAuthenticationStateProvider(HttpClient httpClient, ILocalStorageService localStorage)
    {
        this.httpClient = httpClient;
        this.localStorage = localStorage;
    }

    // 保留原有ParseBase64WithoutPadding方法
    private byte[] ParseBase64WithoutPadding(string base64)
    {
        switch (base64.Length % 4) {
            case 2: base64 += "=="; break;
            case 3: base64 += "="; break;
        }
        return Convert.FromBase64String(base64);
    }

    // 保留原有ParseClaimFromJwt方法
    private List<Claim> ParseClaimFromJwt(string jwt)
    {
        List<Claim> claims = new();
        var payload = jwt.Split(".")[1];
        var jsonBytes = ParseBase64WithoutPadding(payload);
        var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);

        keyValuePairs!.TryGetValue(ClaimTypes.Role, out object? roles);

        if (roles is not null)
        {
            if (roles.ToString()!.Trim().StartsWith("["))
            {
                var parsedRoles = JsonSerializer.Deserialize<string[]>(roles.ToString()!);
                foreach (var role in parsedRoles!)
                {
                    claims.Add(new Claim(ClaimTypes.Role, role));
                }
            }
            else
            {
                claims.Add(new Claim(ClaimTypes.Role, roles.ToString()!));
            }
            keyValuePairs.Remove(ClaimTypes.Role);
        }
        claims.AddRange(keyValuePairs.Select(x => new Claim(x.Key, x.Value.ToString()!)));

        return claims;
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        // 预渲染阶段返回匿名状态
        if (!_isInitialized)
        {
            return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
        }

        var savedToken = await localStorage.GetItemAsync<string>("authToken");

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

        httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", savedToken);
        return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(ParseClaimFromJwt(savedToken), "jwt")));
    }

    // 添加初始化方法,在客户端首次渲染后调用
    public async Task InitializeAuthenticationStateAsync()
    {
        _isInitialized = true;
        var authState = await GetAuthenticationStateAsync();
        NotifyAuthenticationStateChanged(Task.FromResult(authState));
    }

    // 保留原有MarkUserAsLoggedIn方法
    public void MarkUserAsLoggedIn(string email)
    {
        var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Email, email) }, "apiauth"));
        var authState = Task.FromResult(new AuthenticationState(authenticatedUser));
        NotifyAuthenticationStateChanged(authState);
    }

    // 保留原有MarkUserAsLoggedOut方法
    public void MarkUserAsLoggedOut()
    {
        var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
        var authState = Task.FromResult(new AuthenticationState(anonymousUser));
        NotifyAuthenticationStateChanged(authState);
    }
}

在根组件触发初始化(MainLayout.razor)

@inject SmartRecipesAuthenticationStateProvider AuthStateProvider

@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await AuthStateProvider.InitializeAuthenticationStateAsync();
        }
    }
}

优缺点:保留预渲染优势,用户首屏会看到匿名状态,之后自动切换为登录状态,功能正常但存在短暂的状态切换体验。


方案3:使用Cookie存储JWT(推荐Server模式)

Blazor Interactive Server中,Cookie是更适配的服务端认证存储方案,无需JSInterop即可访问,彻底避免预渲染问题,同时HttpOnly Cookie还能提升安全性。

修改自定义AuthenticationStateProvider

public class SmartRecipesAuthenticationStateProvider : AuthenticationStateProvider
{
    private readonly HttpClient httpClient;
    private readonly IHttpContextAccessor httpContextAccessor;

    public SmartRecipesAuthenticationStateProvider(HttpClient httpClient, IHttpContextAccessor httpContextAccessor)
    {
        this.httpClient = httpClient;
        this.httpContextAccessor = httpContextAccessor;
    }

    // 保留原有ParseBase64WithoutPadding和ParseClaimFromJwt方法

    public override Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        var context = httpContextAccessor.HttpContext;
        var jwtToken = context?.Request.Cookies["authToken"];

        if (string.IsNullOrEmpty(jwtToken))
        {
            return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
        }

        httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", jwtToken);
        var claims = ParseClaimFromJwt(jwtToken);
        return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt"))));
    }

    // 调整登录方法,将JWT写入Cookie
    public void MarkUserAsLoggedIn(string jwtToken)
    {
        var context = httpContextAccessor.HttpContext;
        context?.Response.Cookies.Append("authToken", jwtToken, new CookieOptions
        {
            HttpOnly = true,
            Secure = true,
            SameSite = SameSiteMode.Strict,
            Expires = DateTimeOffset.UtcNow.AddHours(2)
        });

        var claims = ParseClaimFromJwt(jwtToken);
        var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt"));
        NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(authenticatedUser)));
    }

    // 调整登出方法,删除Cookie
    public void MarkUserAsLoggedOut()
    {
        var context = httpContextAccessor.HttpContext;
        context?.Response.Cookies.Delete("authToken");

        var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
        NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(anonymousUser)));
    }
}

注册IHttpContextAccessor(Program.cs)

builder.Services.AddHttpContextAccessor();

优缺点:完全规避JSInterop问题,符合Server模式的安全最佳实践(HttpOnly Cookie防止XSS攻击),是推荐方案,但需要调整后端登录逻辑,将JWT写入Cookie而非返回给客户端存储。


内容的提问来源于stack exchange,提问作者Erray1

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.28 09:54:59