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
Cookie + Server
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 your own
Userand a session row per device, andrask newwrites the/login,/registerand/logoutpages 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.Authowns the cookie scheme and configures it last, so with the battery still on, theAddCookie(...)below is overwritten byAuthOptionsand your cookie name, expiry and login path quietly do not take.app.Configure(c => c.Auth.Off())— or dropping theAddRaskAuthline — 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; SignIn 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).OnSubmit(Submit).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(ButtonType.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 Submit(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.SignIn(new ClaimsPrincipal(identity), returnUrl: ReturnUrl ?? "/");
}
}
public sealed class LoginModel
{
public string Username { get; set; } = "";
public string Password { get; set; } = "";
}
The claims are what the data layer reads. Current.UserId comes from
the ClaimTypes.NameIdentifier claim, as a Guid, and in an app with multi-tenancy the
tenant a read filters by comes from the Tenant.ClaimType (rask:tenant) claim. A hand-wired store that
signs in a principal without them gets a null user and, on a tenant-scoped table, a read that throws.
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.Role("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. TheAuthorizecomponent subscribes to that event and re-renders, re-running itsAuthorizeddelegate with the fresh principal — so readinguser.Identity!.Nameinside the slot always reflects the current user with no extra work. By contrast, a page that readsusers.Currentdirectly in its ownRenderwon'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 theAuthorizedslot (it first renders once the gate opens), or subscribe the page itself:OnMount() => users.Changed += StateHasChanged;.
Program.cs — wire cookie auth before MapRask:
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 = 8.Hours;
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 MapRask — populates HttpContext.User on GET and WS upgrade
app.UseAuthorization();
app.MapRask<App>();
app.Run();
Ordering matters. If
UseAuthenticationruns afterMapRask,HttpContext.Useris empty when the session is seeded and every[Authorize]page challenges. Keep it beforeMapRask.
Sign out from any event handler: await auth.SignOut(returnUrl: "/");
Cookie + WASM
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-hostedtemplate 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'sApiUserProvider, login page and protected/memberspage. 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? The
wasm-hostedtemplate serves its browser app from the same origin, so the cookie is simply the host's, as in the section above — one project, one pipeline. What it does not have yet is sign-in pages of its own: Rask.Auth's are server-rendered, so the browser app's UI for them is yours to write againstIAuthfromRask.Auth.Client.
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 EventHandler? Changed;
public Task EnsureLoaded() => Load();
public async Task Refresh()
{
IsLoading = true; Changed?.Invoke(this, EventArgs.Empty);
await Load();
}
private async Task Load()
{
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(this, EventArgs.Empty); }
}
}
// 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 SignIn is intentionally
unsupported — the cookie is set by the server):
public sealed class WasmLoginService(HttpClient http, IUserProvider users)
{
public async Task<bool> Login(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.Refresh();
Go.To(returnUrl ?? "/members");
return true;
}
public async Task Logout()
{
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.
Go.To("/login");
await users.Refresh();
}
}
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.Run<App>();
Wire the form to WasmLoginService.Login and the sign-out button to WasmLoginService.Logout.
(The built-in WasmAuthSignIn.SignOut also works, but doing it through your own service keeps the
navigate-before-refresh ordering explicit.)