You're reading the Rask v1.0.0 guides.View source
All guides

Cookie authentication

Cookie-based login and session for the Rask Server (WS) host and for a WASM SPA backed by your own API host.

‹ Back to Authentication

The lowest-friction, most secure option for the Server (WS) host — the token lives in an HttpOnly cookie and never reaches JavaScript.

You probably do not need this page. Cookie sign-in is what a Rask app does by default: the accounts battery registers the scheme, backs it with ASP.NET Core Identity, and routes /login, /register and /logout for you. What follows is how to wire the same thing by hand against your own credential store — an existing users table, an internal directory, anything that can answer "is this password right" and hand back claims.

Turn the battery off first. Rask.Auth owns the cookie scheme and configures it last, so with the battery still on, the AddCookie(...) below is overwritten by AuthOptions and your cookie name, expiry and login path quietly do not take. app.Configure(c => c.Auth.Off()) — or dropping the AddRaskAuth line — is what makes this page's wiring yours.

A credential store (demo — swap for your real one):


public interface ICredentialStore
{
    IReadOnlyList<Claim>? Validate(string username, string password);
}

public sealed class DemoCredentialStore : ICredentialStore
{
    public IReadOnlyList<Claim>? Validate(string username, string password) =>
        (username, password) switch
        {
            ("alice", "password") => [new Claim(ClaimTypes.Name, "alice"), new Claim(ClaimTypes.Role, "user")],
            ("root",  "password") => [new Claim(ClaimTypes.Name, "root"),  new Claim(ClaimTypes.Role, "admin")],
            _ => null
        };
}

The login page — a normal Rask page; SignInAsync runs inside the form's submit handler:


[Route("login")]
[AllowAnonymous]
public sealed partial class LoginPage(IAuthSignIn auth, ICredentialStore creds) : Component
{
    private readonly LoginModel _model = new();
    private string? _error;

    [QueryParam] public string? ReturnUrl { get; set; }

    protected override Component? Render() =>
        Div.Class("mx-auto").Style("max-width:24rem")[
            H1["Sign in"],
            _error is null ? null : Div.Class("rounded-lg px-4 py-3 text-sm bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200")[_error],
            Form.Model(_model).OnValidSubmitAsync(SubmitAsync).Class("flex flex-col gap-3")[
                Input.Bind(() => _model.Username).Id("username").Class("w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-violet-500 focus:outline-none dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"),
                Input.Bind(() => _model.Password).Id("password").Type(InputType.Password).Class("w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-violet-500 focus:outline-none dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"),
                Button.Type("submit").Class("inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium no-underline transition disabled:cursor-default disabled:opacity-50 bg-violet-600 text-white hover:bg-violet-500")["Sign in"]
            ]
        ];

    private async Task SubmitAsync(LoginModel m)
    {
        var claims = creds.Validate(m.Username, m.Password);
        if (claims is null) { _error = "Invalid username or password."; return; }

        var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
        await auth.SignInAsync(new ClaimsPrincipal(identity), returnUrl: ReturnUrl ?? "/");
    }
}

public sealed class LoginModel
{
    public string Username { get; set; } = "";
    public string Password { get; set; } = "";
}

A protected page redirects to /login?returnUrl=/secure for anonymous users (handled by the route guard). Gate the content with the Authorize component; the Authorized slot is a delegate handed the freshly-authenticated principal, so the greeting reads the name inline — no child component, no subscription:


[Route("secure")]
[Authorize]
public sealed partial class SecurePage : Component
{
    protected override Component? Render() =>
        Authorize.Authorizing(P["Signing you in…"]).NotAuthorized(P["Please sign in."]).Authorized(user => Div[      // ← receives the current principal, re-runs on sign-in/out
                H1[$"Hello, {user.Identity!.Name}"],
                Authorize.Roles(["admin"]).NotAuthorized(P["You have standard access."])[
                    Div.Class("rounded-lg px-4 py-3 text-sm bg-amber-50 text-amber-900 dark:bg-amber-950 dark:text-amber-200")["🔑 Admin tools"]]
            ]);
}

Reactivity. Sign-in on the Server completes over a WS reconnect that re-seeds the principal and fires IUserProvider.Changed. The Authorize component subscribes to that event and re-renders, re-running its Authorized delegate with the fresh principal — so reading user.Identity!.Name inside the slot always reflects the current user with no extra work. By contrast, a page that reads users.Current directly in its own Render won't re-execute (it didn't subscribe), so a greeting built there can go stale after a mid-session sign-in. If you must read the principal outside the slot, either move that markup into a child component placed in the Authorized slot (it first renders once the gate opens), or subscribe the page itself: OnMount() => users.Changed += StateHasChanged;.

Program.cs — wire cookie auth before UseRask:


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(o =>
    {
        o.Cookie.Name = "rask.auth";
        o.Cookie.HttpOnly = true;
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        o.Cookie.SameSite = SameSiteMode.Lax;
        o.ExpireTimeSpan = TimeSpan.FromHours(8);
        o.SlidingExpiration = true;
        o.LoginPath = "/login";
    });
builder.Services.AddSingleton<ICredentialStore, DemoCredentialStore>();
builder.Services.AddRask(); // no auth config on AddRask — it's all on AddCookie above

var app = builder.Build();

app.UseAuthentication();   // ⚠️ MUST precede UseRask — populates HttpContext.User on GET and WS upgrade
app.UseAuthorization();
app.UseRask<App>();
app.Run();

Ordering matters. If UseAuthentication runs after UseRask, HttpContext.User is empty when the session is seeded and every [Authorize] page challenges. Keep it before UseRask.

Sign out from any event handler: await auth.SignOutAsync(returnUrl: "/");


The WASM client has no server pipeline of its own, so the API host owns the cookie. The client hydrates its principal from /api/me.

By hand, and unexercised. No template scaffolds this two-project shape — the wasm-hosted template that did was removed — so the pieces below are yours to write: the host's /api/login + /api/me + /auth/logout, and the browser client's ApiUserProvider, login page and protected /members page. A pair of sample projects used to hold exactly this arrangement, kept building and covered by a browser E2E; they are gone, so this page is now the whole of it and nothing verifies it end to end. Read it as a design, not as tested code.

Building something new? A server app with --wasm needs none of this: one project, one pipeline, and the cookie is simply the host's, as in the section above.

On the API host (MyApp.Server / your ASP.NET server):


builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
builder.Services.AddSingleton<ICredentialStore, DemoCredentialStore>();
// ... build app ...
app.UseAuthentication();
app.UseAuthorization();

app.MapPost("/api/login", async (HttpContext ctx, LoginDto dto, ICredentialStore creds) =>
{
    var claims = creds.Validate(dto.Username, dto.Password);
    if (claims is null) return Results.Unauthorized();
    var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
    await ctx.SignInAsync(new ClaimsPrincipal(identity));   // sets the HttpOnly cookie
    return Results.Ok(new MeDto(dto.Username, claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToArray()));
});

app.MapGet("/api/me", (HttpContext ctx) =>
    ctx.User.Identity?.IsAuthenticated == true
        ? Results.Ok(new MeDto(ctx.User.Identity!.Name!, ctx.User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray()))
        : Results.NoContent());

app.MapPost("/auth/logout", async (HttpContext ctx) => { await ctx.SignOutAsync(); return Results.Ok(); });

public sealed record LoginDto(string Username, string Password);
public sealed record MeDto(string Name, string[] Roles);

The client IUserProvider bootstraps from /api/me:


public sealed class ApiUserProvider(HttpClient http) : IUserProvider
{
    private ClaimsPrincipal _current = new(new ClaimsIdentity());
    public ClaimsPrincipal Current => _current;
    public bool IsLoading { get; private set; }
    public event Action? Changed;

    public Task EnsureLoadedAsync() => LoadAsync();

    public async Task RefreshAsync()
    {
        IsLoading = true; Changed?.Invoke();
        await LoadAsync();
    }

    private async Task LoadAsync()
    {
        try
        {
            var me = await http.GetFromJsonAsync("api/me", AuthJson.Default.MeDto);
            _current = me is { Name: { } name }
                ? new ClaimsPrincipal(new ClaimsIdentity(
                    [new Claim(ClaimTypes.Name, name), .. me.Roles.Select(r => new Claim(ClaimTypes.Role, r))], "api"))
                : new ClaimsPrincipal(new ClaimsIdentity());
        }
        catch (HttpRequestException) { _current = new ClaimsPrincipal(new ClaimsIdentity()); }
        finally { IsLoading = false; Changed?.Invoke(); }
    }
}

// Source-generated JSON keeps the WASM trim-clean (zero IL warnings).
[JsonSerializable(typeof(MeDto))]
[JsonSerializable(typeof(LoginDto))]
public partial class AuthJson : JsonSerializerContext { }

Client login posts credentials, then refreshes the provider (WASM SignInAsync is intentionally unsupported — the cookie is set by the server):


public sealed class WasmLoginService(HttpClient http, IUserProvider users, Navigator nav)
{
    public async Task<bool> LoginAsync(string username, string password, string? returnUrl)
    {
        var resp = await http.PostAsJsonAsync("api/login", new LoginDto(username, password), AuthJson.Default.LoginDto);
        if (!resp.IsSuccessStatusCode) return false;
        await users.RefreshAsync();
        nav.NavigateTo(returnUrl ?? "/members");
        return true;
    }

    public async Task LogoutAsync()
    {
        await http.PostAsync("auth/logout", null);
        // Navigate first (still in the click-handler scope), then clear the principal — refreshing first
        // closes the Authorize gate and unmounts the calling component before the navigation runs.
        nav.NavigateTo("/login");
        await users.RefreshAsync();
    }
}

Program.cs (client) — note WasmHostBuilder.BaseAddress (not Blazor's HostEnvironment):


var host = WasmHostBuilder.CreateDefault();
host.Services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(WasmHostBuilder.BaseAddress) });
host.Services.AddSingleton<ApiUserProvider>();
host.Services.AddSingleton<IUserProvider>(sp => sp.GetRequiredService<ApiUserProvider>()); // overrides the anonymous default
host.Services.AddSingleton<WasmLoginService>();
await host.RunAsync<App>();

Wire the form to WasmLoginService.LoginAsync and the sign-out button to WasmLoginService.LogoutAsync. (The built-in WasmAuthSignIn.SignOutAsync also works, but doing it through your own service keeps the navigate-before-refresh ordering explicit.)