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)returnsActiveSession | nulland already handles the refresh-then-redirect case: if the access token is expired and the refresh token is rejected, it throwsredirect("/login", ...)after clearing the cookie. Nothing callsgetActiveSessiontoday — 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.
loginandlogoutare self-evidently exempt;healthis an infra health check and stays public. The remaining six all need a guard: home.tsx— loader only, currentlylistProjects()unconditionally.create.tsx— no loader today, only anaction; the bareGET /createpage render is currently unguarded because there's nothing to guard it.project.tsx,add-map.tsx,project-map.tsx— each has a synchronousloader({ params })that callsgetProject/addMapequivalents before any session check.avesmaps-features.tsx,avesmaps-feature-detail.tsx— these are resource routes: they returnResponse.json(...)and are called viauseFetcher/fetchfrom within an already-rendered map view (see their own "Resource route" header comments), never navigated to directly. Redirecting one of these to the/loginHTML 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): stringhelper — accepts only a path starting with a single/(rejects empty,//...,\..., and anything containing:before the first/, which rules outhttps://...andjavascript:...); falls back to"/"for anything else. - In: A
requireSession(request: Request): Promise<ActiveSession>— callsgetActiveSession; if it returnsnull, throwsredirect(\/login?returnTo=${sanitizeReturnTo(...)}`)` built from the request's own URL (path + search). - In: A
requireSessionForResource(request: Request): Promise<ActiveSession>— callsgetActiveSession; if it returnsnull, throwsnew Response("Unauthorized", { status: 401 })instead of redirecting. - In:
refreshActiveSession's existing failure branch appends?expired=1to the/loginredirect it already throws. - Out: No change to
getActiveSession's success path orActiveSession's shape — callers still get{ values, setCookie? }and are still responsible for attachingsetCookieto 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/abcunchanged. -
requireSessionresolves withActiveSessionwhen signed in, and throws a redirectResponseto a/login?returnTo=...URL when not. -
requireSessionForResourceresolves withActiveSessionwhen signed in, and throws a401 Response(not a redirect) when not. - The refresh-failure redirect in
refreshActiveSessionnow includesexpired=1in its target URL; its cookie-clearing behavior is unchanged. -
pnpm typecheckpasses 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
returnTofrom the query string, sanitizes it viasanitizeReturnTo, and — for an already-signed-in visitor — redirects there instead of the hardcoded/. - In: Loader also reads
expiredfrom 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 whenexpiredis present. - In: The form carries the sanitized
returnToforward 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
returnToon success instead of the hardcoded/. - Out: No change to the credential-checking logic in
auth.server.ts —
signInitself is untouched.
Acceptance criteria
- Visiting
/login?returnTo=/project/abcwhile 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/abcredirects to/project/abcafter setting the session cookie. - Submitting valid credentials from plain
/login(noreturnTo) redirects to/, matching current behavior. - Visiting
/login?expired=1renders a visibly different message than plain/login(e.g. "Your session has expired…" vs. "Sign in"). -
pnpm typecheckpasses 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)
asyncand callsrequireSessionas its first statement, before any existing lookup: - home.tsx — before
listProjects(). - create.tsx — new
loaderadded (there is none today); it only callsrequireSessionand returnsnull. - project.tsx — before
getProject. - add-map.tsx — before
getProject. - project-map.tsx — before
getProject. - Out:
actionfunctions 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
GETto/,/create,/project/:id,/project/:id/map/new, or/project/:id/map/:mapIdredirects to/login?returnTo=<the original path+query>without callinglistProjects/getProject. - A signed-in
GETto each of the above still returns the same data shape as before this change (loaderDatatypes unaffected apart fromcreate.tsxgaining aloaderthat returnsnull). - Visiting a nonexistent project/map id while signed in still throws the existing 404, not a redirect.
-
pnpm typecheckpasses across all five files.
Affected areas
- app/routes/home.tsx
- app/routes/create.tsx
- app/routes/project.tsx
- app/routes/add-map.tsx
- app/routes/project-map.tsx
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
loadercallsrequireSessionForResourcebefore building theResponse.json(...)payload. - In:
avesmaps-feature-detail.tsx's
loadercallsrequireSessionForResourcebeforegetAvesmapsFeatureDetail. - 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/featuresor/avesmaps/features/:idreturns 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 typecheckpasses 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 typecheckandpnpm formatacross the whole repo. - In: Manual check with
pnpm dev: visit a protected route signed out, land on/loginwithreturnToset, 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 typecheckpasses with no errors anywhere in the repo. -
pnpm formatproduces 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.