Skip to content

Route auth guarding — implementation plan

Goal

Every route except /login and /health requires a valid Directus session before its loader does any work. A signed-out visit redirects to /login and, after signing in, lands back on the page the user actually wanted. A session that dies mid-visit (refresh-token failure) is distinguishable on the login page from a first-time, never-signed-in visit.

What's already there

  • app/lib/session.server.ts's getActiveSession(request) returns ActiveSession | null and already handles the refresh-then-redirect case: if the access token is expired and the refresh token is rejected, it throws redirect("/login", ...) after clearing the cookie. Nothing calls getActiveSession today — every loader and action in the app runs unguarded.
  • app/routes/login.tsx's loader redirects a signed-in visitor to /; its action redirects to / on success. Neither reads a return destination.
  • app/routes.ts lists nine routes. login and logout are self-evidently exempt; health is an infra health check and stays public. The remaining six all need a guard:
  • home.tsx — loader only, currently listProjects() unconditionally.
  • create.tsx — no loader today, only an action; the bare GET /create page render is currently unguarded because there's nothing to guard it.
  • project.tsx, add-map.tsx, project-map.tsx — each has a synchronous loader({ params }) that calls getProject/addMap equivalents before any session check.
  • avesmaps-features.tsx, avesmaps-feature-detail.tsx — these are resource routes: they return Response.json(...) and are called via useFetcher/fetch from within an already-rendered map view (see their own "Resource route" header comments), never navigated to directly. Redirecting one of these to the /login HTML page would hand the fetcher an HTML body where it expects JSON — a 302 is the wrong signal here.

Decisions

Decision Choice Why
Where the guard lives New functions in session.server.ts, next to getActiveSession Keeps all session/redirect logic in the one module that already owns the refresh-redirect behavior, instead of a new file
Page routes vs resource routes Page loaders throw a redirect to /login; the two avesmaps-* resource loaders throw a 401 Response instead A redirect is meaningless to a fetch()/useFetcher caller expecting JSON; a 401 is inspectable by the caller without corrupting the response shape
Return-to destination Carried as a returnTo search param on the /login redirect, validated as a relative same-origin path before use anywhere (login loader redirect, login action redirect) Confirmed with user — user should land back where they were; an unvalidated returnTo is an open-redirect vector (OWASP), so it's restricted to /... paths only, never an absolute or protocol-relative URL
Expired-session signal An expired=1 search param added to the redirect already thrown by refreshActiveSession on refresh failure Confirmed with user — reuses the existing redirect call site instead of adding a second code path; login.tsx reads the flag to change its copy
create.tsx Gains a loader (it has none today) whose only job is the session check A page with no loader today still renders on GET with no guard at all; an action-only guard (out of scope per user) wouldn't cover the initial page view
Actions Not touched in this task Confirmed with user — loaders only for now; guarding actions is a follow-up

Prerequisites

None — builds directly on the existing getActiveSession/refreshActiveSession pair and the route files already in the repo.


1. session.server.ts: add the reusable guards and return-to handling

Goal: One place decides what "not signed in" means for a page loader versus a resource loader, and what a safe return-to destination looks like.

Scope

  • In: A sanitizeReturnTo(value: string | null): string helper — accepts only a path starting with a single / (rejects empty, //..., \..., and anything containing : before the first /, which rules out https://... and javascript:...); falls back to "/" for anything else.
  • In: A requireSession(request: Request): Promise<ActiveSession> — calls getActiveSession; if it returns null, throws redirect(\/login?returnTo=${sanitizeReturnTo(...)}`)` built from the request's own URL (path + search).
  • In: A requireSessionForResource(request: Request): Promise<ActiveSession> — calls getActiveSession; if it returns null, throws new Response("Unauthorized", { status: 401 }) instead of redirecting.
  • In: refreshActiveSession's existing failure branch appends ?expired=1 to the /login redirect it already throws.
  • Out: No change to getActiveSession's success path or ActiveSession's shape — callers still get { values, setCookie? } and are still responsible for attaching setCookie to their response when present.

Acceptance criteria

  • sanitizeReturnTo(null), sanitizeReturnTo(""), sanitizeReturnTo("//evil.com"), sanitizeReturnTo("https://evil.com") all resolve to "/"; sanitizeReturnTo("/project/abc") resolves to /project/abc unchanged.
  • requireSession resolves with ActiveSession when signed in, and throws a redirect Response to a /login?returnTo=... URL when not.
  • requireSessionForResource resolves with ActiveSession when signed in, and throws a 401 Response (not a redirect) when not.
  • The refresh-failure redirect in refreshActiveSession now includes expired=1 in its target URL; its cookie-clearing behavior is unchanged.
  • pnpm typecheck passes for this file in isolation (other files still fail until later tasks land — expected).

Affected areas

Open questions

  • None.

2. login.tsx: honor returnTo and expired

Goal: Sign-in sends the user back where they came from, and shows a distinct message when they arrived because their session expired rather than because they'd never signed in.

Scope

  • In: Loader reads returnTo from the query string, sanitizes it via sanitizeReturnTo, and — for an already-signed-in visitor — redirects there instead of the hardcoded /.
  • In: Loader also reads expired from the query string and returns it (e.g. { expired: boolean }) for the component to render a distinct message ("Your session has expired. Please sign in again.") instead of the default description, only when expired is present.
  • In: The form carries the sanitized returnTo forward as a hidden field (or the action re-reads and re-sanitizes it from the submitted URL/search params — either way it must not trust an unsanitized value at the point of the final redirect).
  • In: Action redirects to the sanitized returnTo on success instead of the hardcoded /.
  • Out: No change to the credential-checking logic in auth.server.ts — signIn itself is untouched.

Acceptance criteria

  • Visiting /login?returnTo=/project/abc while already signed in redirects to /project/abc, not /.
  • Visiting /login?returnTo=https://evil.com (or any absolute/ protocol-relative value) never redirects off-origin — it falls back to /.
  • Submitting valid credentials from /login?returnTo=/project/abc redirects to /project/abc after setting the session cookie.
  • Submitting valid credentials from plain /login (no returnTo) redirects to /, matching current behavior.
  • Visiting /login?expired=1 renders a visibly different message than plain /login (e.g. "Your session has expired…" vs. "Sign in").
  • pnpm typecheck passes for this file.

Affected areas

Open questions

  • None.

3. Guard the page loaders

Goal: home, create, project, add-map, and project-map never run their existing loader logic for a signed-out request.

Scope

  • In: Each loader below is made (or kept) async and calls requireSession as its first statement, before any existing lookup:
  • home.tsx — before listProjects().
  • create.tsx — new loader added (there is none today); it only calls requireSession and returns null.
  • project.tsx — before getProject.
  • add-map.tsx — before getProject.
  • project-map.tsx — before getProject.
  • Out: action functions in these same files (create.tsx, project.tsx, add-map.tsx, project-map.tsx) are not touched — confirmed out of scope.
  • Out: No change to the existing 404 behavior (throw new Response("Not Found", { status: 404 })) for a missing project/map — the session check runs first, but a signed-in user hitting a bad id still gets a 404, not an auth error.

Acceptance criteria

  • A signed-out GET to /, /create, /project/:id, /project/:id/map/new, or /project/:id/map/:mapId redirects to /login?returnTo=<the original path+query> without calling listProjects/getProject.
  • A signed-in GET to each of the above still returns the same data shape as before this change (loaderData types unaffected apart from create.tsx gaining a loader that returns null).
  • Visiting a nonexistent project/map id while signed in still throws the existing 404, not a redirect.
  • pnpm typecheck passes across all five files.

Affected areas

Open questions

  • None.

4. Guard the avesmaps resource routes

Goal: The two dataset resource routes never serve data to a signed-out caller, without breaking their JSON-consuming useFetcher callers.

Scope

  • In: avesmaps-features.tsx's loader calls requireSessionForResource before building the Response.json(...) payload.
  • In: avesmaps-feature-detail.tsx's loader calls requireSessionForResource before getAvesmapsFeatureDetail.
  • Out: No change to their existing 404-for-missing-id behavior.
  • Out: No change to the client components that call these routes (map.tsx, avesmaps-feature-detail-panel.tsx) — a signed-in user's fetcher calls are unaffected; a signed-out user should never reach the map view that issues these fetches in the first place, since it's already behind the task 3 guard.

Acceptance criteria

  • A signed-out request to /avesmaps/features or /avesmaps/features/:id returns HTTP 401, not a redirect and not the dataset payload.
  • A signed-in request to both routes is byte-for-byte unchanged from current behavior.
  • pnpm typecheck passes for both files.

Affected areas

Open questions

  • None.

5. Full-repo verification

Goal: Confirm the guarded app still type-checks, formats clean, and behaves correctly end to end.

Scope

  • In: Run pnpm typecheck and pnpm format across the whole repo.
  • In: Manual check with pnpm dev: visit a protected route signed out, land on /login with returnTo set, sign in, confirm landing back on the original page with the session cookie set. Then let (or force, e.g. by editing the cookie) a refresh failure occur and confirm the distinct expired-session message appears on /login.

Acceptance criteria

  • pnpm typecheck passes with no errors anywhere in the repo.
  • pnpm format produces no diff.
  • Manual return-to flow (above) behaves as described.
  • Manual expired-session flow (above) shows the distinct message.

Affected areas

  • None (verification only).

Open questions

  • None.