# 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](https://rask.sh/docs/guides/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](https://rask.sh/docs/guides/authentication/) registers the scheme, backs it with your own `User` and a session row
> per device, and `rask new` writes the `/login`, `/register` and `/logout` pages 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):**

```csharp
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:

```csharp
[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`](https://rask.sh/docs/guides/data/#the-current-user--current) comes from
the `ClaimTypes.NameIdentifier` claim, as a `Guid`, and in an app with [multi-tenancy](https://rask.sh/docs/guides/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:

```csharp
[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`. 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* `MapRask`:**

```csharp
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 `UseAuthentication` runs *after* `MapRask`, `HttpContext.User` is empty when the
> session is seeded and every `[Authorize]` page challenges. Keep it before `MapRask`.

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-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? The `wasm-hosted` template 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 against `IAuth` from `Rask.Auth.Client`.

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

```csharp
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`:

```csharp
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):

```csharp
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`):

```csharp
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.)
