-1

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" @[email protected] 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 @[email protected] 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:

  • Global Interactive Mode: Causes infinite loops.
  • Page-Specific Interactive Mode: Causes null exceptions and "response already started" error.

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" @[email protected] 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 @[email protected] 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;
    }

}
3
  • All of these forms are designed to "post" to the "Server" endpoints so disabling prerender will not generate forms with antiforgery tokens. Commented Jul 7 at 21:31
  • [Polite] Why? Authentication, as it looks like you've configured it, is a server side activity. Authentication information is written to the HttpRequest pipeline to the returned page. Making the page interactive doesn't make the authentication process interactive. Commented Jul 8 at 12:11
  • @MrCakaShaunCurtis i want interactive feedback and the ability to reuse the same ui style and behaviour as the rest of the application. the fact its authentication information might in this context present a challenge but i can handle authentication information on any other interactive page outside the accounts so didn't see a reason i cant or should not here. Im not trying to make authentication itself interactive (not even sure what that means) im trying to make the fields the user files out interactive
    – ace90210
    Commented Jul 8 at 20:31

1 Answer 1

1

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>
1
  • 1
    Thanks, this pointed me in the right direction it was quite abit of work taking this idea and resolving the issue but it was the piece of the puzzle. It was abit of a "duh" moment when i read it as i was aware of this feature i just didnt think to use it to split SRR the page logic and the interactive logic. I will be updating my question with my working solution
    – ace90210
    Commented Jul 8 at 20:34

Not the answer you're looking for? Browse other questions tagged or ask your own question.