Tutorial 22 of 36 · for anyone with a page only signed-in visitors should see · Markdown

Protect a route

You'll put a page behind a sign-in, turn an anonymous visit into a redirect and read who the visitor is from a loader. The check is one file and it runs before any of your routes do.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Session and identity#

A session is the visitor's scratch space: a cart, a draft, a preference. Every visitor has one, signed in or not. You declare its shape in app/schemas/session.ts and a body reads and writes it as a plain object.

An identity is who the visitor is: a subject and a map of claims. A body reads identity.subject and identity.claims.<name>; it reads null when nobody is signed in. You never assign to it. It arrives on the session when a login finishes.

Don't keep a user_id in the session yourself, since any code path could set it. Read identity, which only the login flow sets.

Write the check in middleware#

app/middleware.ts at the top of the app runs before every request that is not a static file: pages, the action route and handlers alike. What it returns decides the request.

app/middleware.ts
import type { MiddlewareCtx, MiddlewareResult } from "@generated/client";

export async function middleware({ request, identity }: MiddlewareCtx): Promise<MiddlewareResult> {
  if (!request.path.startsWith("/account")) return {};
  if (identity) return {};
  return { redirect: `/sign-in?next=${request.path}` };
}

An empty object lets the request continue. redirect answers with a location, rewrite serves another path under the same URL and status with a body answers outright.

Put the check in middleware instead of in each loader. A route you add under /account next month is then covered the day you add it. The action route is covered too, so a protected page's actions cannot be called by a visitor who was refused the page.

Reading the identity in a loader#

app/routes/account/page.loader.ts
import type { Ctx } from "@snapfire/fsr";

export async function load({ identity, services }: Ctx<"/account">) {
  return {
    name: identity?.claims.name ?? "",
    orders: await services.shopping.ordersFor({ subject: identity?.subject ?? "" }),
  };
}

The host carries the identity onto outbound service calls on its own, so a service that needs to know who is asking is already told. Passing the subject as an argument, as above, is for a service whose method takes it as data rather than as who the caller is.

Prerendering#

identity is per request, so a route whose loader reads it renders per request. A page behind a sign-in can't be prerendered and the build won't try.

Configure the login#

Add an [auth] section to the configuration:

config/app.toml
[auth]
provider = "file"
login = "/sign-in"

That mounts three routes the framework owns, the way it owns the action route. GET /auth/login starts the flow, taking return_to from the query, else the Referer's path, else /. The target is always a path on this origin. /auth/callback finishes it, as a form post, a JSON post or a GET carrying the provider's query. A success is a 303 to where the flow began. A refusal is a 303 back to the login page with error=denied. A callback with no flow in progress gets a 400. POST /auth/logout verifies _csrf and clears identity and custody.

You write the login page yourself. Auth never renders anything, so [auth] login names a route you wrote. /sign-in is an ordinary page with an ordinary form.

The file provider is DevProvider over config/auth.toml beside app.toml: a list of accounts with passwords and optional claims. It exists so the application can be written against identity from the first day and swap in the real provider later without a body changing, because a body only ever sees identity.

Log out#

Signing out is a form that posts to /auth/logout with the CSRF token. Put it on the account page, which reads csrf_token like any other prop:

app/routes/account/page.tsx
import type { AccountProps } from "@generated/client";

export default function Account({ name, orders, csrf_token }: AccountProps & { csrf_token?: string }) {
  return (
    <section>
      <h1>Signed in as {name}</h1>
      <p>{orders.length} orders</p>
      <form method="post" action="/auth/logout">
        <input type="hidden" name="_csrf" value={csrf_token ?? ""} />
        <button type="submit">Sign out</button>
      </form>
    </section>
  );
}

Clearing the identity clears the tokens held beside it, since the two cells persist together. Only the login and logout routes change the identity. A body can't set or clear it.

Next up: 360. Add light and dark themes.