asp.net-coreauthenticationblazorrendering

Enable interactive render mode on Blazor templates accounts pages


Question:

I'm working on a Blazor project where the template for all account pages is set to RenderMode.Server. I want to make some of these account pages interactive, so I tried to enable the interactive render mode.

However, I've encountered multiple issues when doing so:

  1. Infinite Loop: If I globally enable the interactive render mode by removing the filter on account pages, I run into infinite loops.
  2. Null Exceptions: When enabling the interactive mode on a specific page, I encounter null exceptions.
  3. Response Already Started Error: After bypassing the null exceptions with checks, I get the following error:
    System.InvalidOperationException: OnStarting cannot be set because the response has already started.
       at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.ThrowResponseAlreadyStartedException(String value)
    

What is the correct approach to enable interactive render mode for account pages in Blazor without running into these issues? How can I handle authentication and redirection properly to avoid these errors?

Any guidance or examples would be greatly appreciated!

Here is my current example page (ExternalLogin.razor):

@page "/Account/ExternalLogin"

@rendermode @(new InteractiveServerRenderMode(prerender: false))

@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@inject IUserStore<ApplicationUser> UserStore
@inject IEmailSender<ApplicationUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<ExternalLogin> Logger

<PageTitle>Register</PageTitle>

<StatusMessage Message="@message" />
<h1>Register</h1>
<h2>Associate your @ProviderDisplayName account.</h2>
<hr />

<div class="alert alert-info">
    You've successfully authenticated with <strong>@ProviderDisplayName</strong>.
    Please enter an email address for this site below and select the account type, then click the Register button to finish logging in.
</div>

<div class="row">
    <div class="col-md-4">
        <RadzenTemplateForm TItem="InputModel" Data=@Input Submit=@OnValidSubmitAsync>
            <RadzenFieldset Text="Register">
                <div class="row mb-5">
                    <div class="col-md-4" style="align-self: center;">
                        <RadzenLabel Text="Email" Component="Email" />
                    </div>
                    <div class="col">
                        <RadzenTextBox style="display: block" Name="Input.Email" @bind-Value=@Input.Email class="w-100" Placeholder="Please enter your email" />
                        <RadzenRequiredValidator Component="Input.Email" Text="Email is required" Popup="true" Style="position: absolute" />
                        <RadzenEmailValidator Component="Input.Email" Text="Provide a valid email address" Popup="true" Style="position: absolute" />
                    </div>
                </div>
                <div class="row mb-5">
                    <div class="col-md-4" style="align-self: center;">
                        <RadzenLabel Text="Account Type" Component="AccountType" />
                    </div>
                    <div class="col">
                        <RadzenSelectBar @bind-Value=@Input.AccountType TValue="string" class="mb-5">
                            <Items>
                                <RadzenSelectBarItem Icon="person" Text="Customer" Value="RoleConstants.CustomerRoleName" IconColor="Colors.Info" />
                                <RadzenSelectBarItem Icon="cleaning_services" Text="Cleaner" Value="RoleConstants.StaffRoleName" IconColor="@Colors.Success" />
                            </Items>
                        </RadzenSelectBar>
                     </div>
                </div>
            </RadzenFieldset>
            <RadzenButton ButtonType="ButtonType.Submit" Size="ButtonSize.Large" Icon="save" Text="Register" />
        </RadzenTemplateForm>
    </div>
</div>

@code {
    public const string LoginCallbackAction = "LoginCallback";

    private string? message;
    private ExternalLoginInfo externalLoginInfo = default!;

    [CascadingParameter]
    private HttpContext HttpContext { get; set; } = default!;

    [SupplyParameterFromForm]
    private InputModel Input { get; set; } = new();

    [SupplyParameterFromQuery]
    private string? RemoteError { get; set; }

    [SupplyParameterFromQuery]
    private string? ReturnUrl { get; set; }

    [SupplyParameterFromQuery]
    private string? Action { get; set; }

    private string? ProviderDisplayName => externalLoginInfo?.ProviderDisplayName;

    protected override async Task OnInitializedAsync()
    {
        if (RemoteError is not null)
        {
            RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext);
        }

        var info = await SignInManager.GetExternalLoginInfoAsync();
        if (info is null)
        {
            RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
        }

        externalLoginInfo = info;

        if (HttpMethods.IsGet(HttpContext.Request.Method))
        {
            if (Action == LoginCallbackAction)
            {
                await OnLoginCallbackAsync();
                return;
            }

            // We should only reach this page via the login callback, so redirect back to
            // the login page if we get here some other way.
            RedirectManager.RedirectTo("Account/Login");
        }
    }
    
    protected override void OnParametersSet()
    {
        if (HttpContext is null)
        {
            // If this code runs, we're currently rendering in interactive mode, so there is no HttpContext.
            // The identity pages need to set cookies, so they require an HttpContext. To achieve this we
            // must transition back from interactive mode to a server-rendered page.
            NavigationManager.Refresh(forceReload: true);
        }
    }

    private async Task OnLoginCallbackAsync()
    {
        // Sign in the user with this external login provider if the user already has a login.
        var result = await SignInManager.ExternalLoginSignInAsync(
            externalLoginInfo.LoginProvider,
            externalLoginInfo.ProviderKey,
            isPersistent: false,
            bypassTwoFactor: true);

        if (result.Succeeded)
        {
            Logger.LogInformation(
                "{Name} logged in with {LoginProvider} provider.",
                externalLoginInfo.Principal.Identity?.Name,
                externalLoginInfo.LoginProvider);
            RedirectManager.RedirectTo(ReturnUrl);
        }
        else if (result.IsLockedOut)
        {
            RedirectManager.RedirectTo("Account/Lockout");
        }

        // If the user does not have an account, then ask the user to create an account.
        if (externalLoginInfo.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
        {
            Input.Email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? "";
        }
    }

    private async Task OnValidSubmitAsync()
    {
        var emailStore = GetEmailStore();
        var user = CreateUser();

        await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

        var result = await UserManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await UserManager.AddLoginAsync(user, externalLoginInfo);
            if (result.Succeeded)
            {
                Logger.LogInformation("User created an account using {Name} provider.", externalLoginInfo.LoginProvider);

                var userId = await UserManager.GetUserIdAsync(user);
                var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));

                var callbackUrl = NavigationManager.GetUriWithQueryParameters(
                    NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
                    new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
                await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));

                // If account confirmation is required, we need to show the link if we don't have a real email sender
                if (UserManager.Options.SignIn.RequireConfirmedAccount)
                {
                    RedirectManager.RedirectTo("Account/RegisterConfirmation", new() { ["email"] = Input.Email });
                }

                await SignInManager.SignInAsync(user, isPersistent: false, externalLoginInfo.LoginProvider);
                RedirectManager.RedirectTo(ReturnUrl);
            }
        }

        message = $"Error: {string.Join(",", result.Errors.Select(error => error.Description))}";
    }

    private ApplicationUser CreateUser()
    {
        try
        {
            return Activator.CreateInstance<ApplicationUser>();
        }
        catch
        {
            throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
                $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor");
        }
    }

    private IUserEmailStore<ApplicationUser> GetEmailStore()
    {
        if (!UserManager.SupportsUserEmail)
        {
            throw new NotSupportedException("The default UI requires a user store with email support.");
        }
        return (IUserEmailStore<ApplicationUser>)UserStore;
    }

    private sealed class InputModel
    {
        [Required]
        [EmailAddress]
        public string? Email { get; set; }

        [Required]
        public string AccountType { get; set; } = RoleConstants.CustomerRoleName;
    }
}

Issues:

  1. When I enable the interactive mode globally by removing the filter on account pages, I get infinite loops.
  2. When I enable it

on this specific page, I get null exceptions. Adding null checks to bypass those leads to the "response already started" error.

What I've Tried:

Looking at other questions and answers but they all seem to resolve it as "fixed" by disabling it on account pages which is not a fix in my case its accepting the bug as unfixable.

What I was expecting: The page to become interactive and work as others do

Edit with solution

Based on the accepted answer i found a working solution. i had to split the SSR logic and the interactive logic using the accepted answers approach. Specifically the main part i had to keep in the parent SSR was var info = await SignInManager.GetExternalLoginInfoAsync(); I also had to replace any instance of RedirectManager with standard Navigation manager in the interactive component

Below is my working solution

ExternalLogin.razor

@page "/Account/ExternalLogin"

@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@inject IUserStore<ApplicationUser> UserStore
@inject IEmailSender<ApplicationUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<ExternalLogin> Logger

<PageTitle>Register</PageTitle>

<StatusMessage Message="@message" />
<h1>Register</h1>
<h2>Associate your @ProviderDisplayName account.</h2>
<hr />

<div class="alert alert-info">
    You've successfully authenticated with <strong>@ProviderDisplayName</strong>.
    Please enter an email address for this site below and select the account type, then click the Register button to finish logging in.
</div>

<ExternalLoginForm  Input="@Input" 
                    ReturnUrl="@ReturnUrl" 
                    ProviderKey="@externalLoginInfo.ProviderKey" 
                    LoginProvider="@externalLoginInfo.LoginProvider"
                    ProviderDisplayName="@externalLoginInfo.ProviderDisplayName"
                   @rendermode=InteractiveServer />

@code {
    public const string LoginCallbackAction = "LoginCallback";

    private string? message;
    private ExternalLoginInfo externalLoginInfo = default!;

    [CascadingParameter]
    private HttpContext HttpContext { get; set; } = default!;

    [SupplyParameterFromForm]
    private ExternalLoginInputModel Input { get; set; } = new();

    [SupplyParameterFromQuery]
    private string? RemoteError { get; set; }

    [SupplyParameterFromQuery]
    private string? ReturnUrl { get; set; }

    [SupplyParameterFromQuery]
    private string? Action { get; set; }


    private string? ProviderDisplayName => externalLoginInfo?.ProviderDisplayName;

    protected override async Task OnInitializedAsync()
    {
        if (RemoteError is not null)
        {
            RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext);
        }

        var info = await SignInManager.GetExternalLoginInfoAsync();
        if (info is null)
        {
            RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
        }

        externalLoginInfo = info;

        if (HttpMethods.IsGet(HttpContext.Request.Method))
        {
            if (Action == LoginCallbackAction)
            {
                await OnLoginCallbackAsync();
                return;
            }

            // We should only reach this page via the login callback, so redirect back to
            // the login page if we get here some other way.
            RedirectManager.RedirectTo("Account/Login");
        }
    }

    private async Task OnLoginCallbackAsync()
    {
        // Sign in the user with this external login provider if the user already has a login.
        var result = await SignInManager.ExternalLoginSignInAsync(
            externalLoginInfo.LoginProvider,
            externalLoginInfo.ProviderKey,
            isPersistent: false,
            bypassTwoFactor: true);

        if (result.Succeeded)
        {
            Logger.LogInformation(
                "{Name} logged in with {LoginProvider} provider.",
                externalLoginInfo.Principal.Identity?.Name,
                externalLoginInfo.LoginProvider);
            RedirectManager.RedirectTo(ReturnUrl);
        }
        else if (result.IsLockedOut)
        {
            RedirectManager.RedirectTo("Account/Lockout");
        }

        // If the user does not have an account, then ask the user to create an account.
        if (externalLoginInfo.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
        {
            Input.Email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? "";
        }
    }

}

ExternalLoginForm.razor

@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@inject IUserStore<ApplicationUser> UserStore
@inject IEmailSender<ApplicationUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<ExternalLogin> Logger

<div class="row">
    <div class="col-md-4">

        <RadzenTemplateForm TItem="ExternalLoginInputModel" Data=@Input Submit=@OnValidSubmitAsync>
            <RadzenFieldset Text="Register">
                <div class="row mb-5">
                    <div class="col-md-4" style="align-self: center;">
                        <RadzenLabel Text="Email" Component="Email" />
                    </div>
                    <div class="col">
                        <RadzenTextBox style="display: block" Name="Input.Email" @bind-Value=@Input.Email class="w-100" Placeholder="Please enter your email" />
                        <RadzenRequiredValidator Component="Input.Email" Text="Email is required" Popup="true" Style="position: absolute" />
                        <RadzenEmailValidator Component="Input.Email" Text="Provide a valid email address" Popup="true" Style="position: absolute" />
                    </div>
                </div>
                <div class="row mb-5">
                    <div class="col-md-4" style="align-self: center;">
                        <RadzenLabel Text="Account Type" Component="AccountType" />
                    </div>
                    <div class="col">
                        <RadzenSelectBar @bind-Value=@Input.AccountType TValue="string" class="mb-5">
                            <Items>
                                <RadzenSelectBarItem Icon="person" Text="Customer" Value="RoleConstants.CustomerRoleName" IconColor="Colors.Info" />
                                <RadzenSelectBarItem Icon="cleaning_services" Text="Cleaner" Value="RoleConstants.StaffRoleName" IconColor="@Colors.Success" />
                            </Items>
                        </RadzenSelectBar>

                    </div>
                </div>
            </RadzenFieldset>

            <RadzenButton ButtonType="ButtonType.Submit" Size="ButtonSize.Large" Icon="save" Text="Register" />
        </RadzenTemplateForm>
    </div>
</div>

@code {

    [Parameter]
    public ExternalLoginInputModel Input { get; set; }

    [Parameter]
    public string ReturnUrl { get; set; }

    [Parameter]
    public string LoginProvider { get; set; }

    [Parameter]
    public string ProviderKey { get; set; }

    [Parameter]
    public string ProviderDisplayName { get; set; }


    private async Task OnValidSubmitAsync()
    {
        var emailStore = GetEmailStore();
        var user = CreateUser();

        await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

        var result = await UserManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await UserManager.AddLoginAsync(user, new UserLoginInfo(LoginProvider, ProviderKey, ProviderDisplayName));

            user.Roles.Add(Input.AccountType);
            await UserManager.AddToRoleAsync(user, Input.AccountType);

            if (result.Succeeded)
            {
                Logger.LogInformation("User created an account using {Name} provider.", LoginProvider);

                var userId = await UserManager.GetUserIdAsync(user);
                var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));

                var callbackUrl = NavigationManager.GetUriWithQueryParameters(
                    NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
                    new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
                await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));

                // If account confirmation is required, we need to show the link if we don't have a real email sender
                if (UserManager.Options.SignIn.RequireConfirmedAccount)
                {
                    NavigationManager.NavigateTo($"Account/RegisterConfirmation?email={Input.Email}");
                }
                else
                {
                    await SignInManager.SignInAsync(user, isPersistent: false, LoginProvider);
                    NavigationManager.NavigateTo(ReturnUrl);
                }
            }
        }
    }

    private ApplicationUser CreateUser()
    {
        try
        {
            return Activator.CreateInstance<ApplicationUser>();
        }
        catch
        {
            throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
                $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor");
        }
    }

    private IUserEmailStore<ApplicationUser> GetEmailStore()
    {
        if (!UserManager.SupportsUserEmail)
        {
            throw new NotSupportedException("The default UI requires a user store with email support.");
        }
        return (IUserEmailStore<ApplicationUser>)UserStore;
    }

}

Solution

  • You can not use interactive rendermode in account pages. The template use global filter to SSR for them because they need httpcontext to work. (such as SignInManager, SigninAsync .etc)

    HttpContext is only available in SSR. When in Interactive mode the page is actually using websocket not Http.

    You may try a workaround to put interactive control in a custom component, then

    <CustomComponent @rendermode=InteractiveServer></CustomComponent>