Issue #46 — Document local auth setup for Mux Protocol frontend.
This document describes how the client-side authentication system works in development, how to run the app locally with auth enabled, and how to extend or replace the auth layer when a real backend is available.
The Mux Protocol frontend uses a hybrid session model:
| Layer | Mechanism |
|---|---|
| Session storage (client rehydration) | sessionStorage (key: mux_auth_user) |
| Server-verified session (backend mode) | HttpOnly mux_auth_token cookie, set by /api/auth/login from the backend login response, verified on every protected request via GET {backend}/auth/session |
| Route protection (server) | Next.js middleware — see src/lib/auth/routeAccess.ts |
| Route protection (client) | useSessionGuard hook redirects unauthenticated users |
| Auth state | React context (AuthContext) — isLoading, isAuthenticated, user |
- Backend configured (
NEXT_PUBLIC_API_URL/ aliases set): a protected route requires the HttpOnlymux_auth_tokencookie and a liveGET {backend}/auth/sessioncheck confirming it is still valid. The client-setmux_auth_sessionmarker cookie is not trusted on its own — this closes the "anyone can forgemux_auth_session=1" gap./api/auth/loginproxies credentials to{backend}/auth/loginand, on success, stores the backend-issued token in themux_auth_tokencookie via a serverSet-Cookieheader withHttpOnly; SameSite=Lax; Path=/(plusSecurewhenNODE_ENV=production) — seesetSessionCookie()insrc/app/api/auth/login/route.ts(#627)./api/auth/refreshproxies to{backend}/auth/refresh, forwarding the caller'sAuthorizationheader and session cookie, and rotatesmux_auth_tokenfrom the response (#626).signOut()callsPOST /api/auth/logout, which clears the cookie and best-effort notifies{backend}/auth/logout. - Mock mode (no backend, non-production only):
/api/auth/loginaccepts any well-formed credentials and returns a mock user plus asessionblock (accessToken/refreshToken/expiresIn); the middleware accepts themux_auth_sessionmarker cookie sopnpm dev/ CI work without a live auth server. In a production build with no backend,/api/auth/loginand/api/auth/refreshreturn503 backend_unavailable— there is no mock sign-in or mock refresh in production (#625).
Any session block in the login response is persisted to sessionStorage
(tab-scoped, cleared on close — never localStorage, never a NEXT_PUBLIC_*
var) by signIn. src/lib/api.js then attaches
Authorization: Bearer <accessToken> to outgoing requests and silently calls
/api/auth/refresh once on a 401. signOut clears this store.
Full SSO / OAuth (Clerk, Better Auth, …) is a later change; this model is provider-agnostic and does not add any SaaS dependency.
- Node.js ≥ 18
npm install(orpnpm install/yarn)
npm run dev
# or
pnpm devThe app starts at http://localhost:3000.
- Navigate to
http://localhost:3000/login. - Enter any valid-format email and a password of at least 6 characters.
- You will be redirected to
/dashboard(or thecallbackUrlquery param).
Note: In local development the
authenticateUserfunction insrc/app/login/page.tsxis a stub. It does not validate credentials against a real database. Replace it with afetchcall to your auth endpoint before deploying to production.
User visits /login
│
▼
LoginPage renders
│
├─ isLoading=true → show spinner (auth rehydrating from sessionStorage)
│
└─ isLoading=false
│
├─ isAuthenticated=true → redirect to callbackUrl / /dashboard
│
└─ isAuthenticated=false → show login form
│
▼
User submits form
│
▼
authenticateUser(email, password) ← replace with real API
│
├─ success → signIn(user) → redirect to callbackUrl
│
└─ failure → show inline error message
| File | Purpose |
|---|---|
src/context/AuthContext.tsx |
React context — AuthProvider, useAuth, signIn, signOut |
src/app/login/page.tsx |
Login page scaffold with form, validation, and redirect logic |
src/middleware.ts |
Next.js middleware — server-side cookie check for protected routes |
src/hooks/useSessionGuard.ts |
Client-side redirect hook for protected pages |
import { useAuth } from "@/context/AuthContext";
const { signIn } = useAuth();
// Call after successful authentication:
signIn({ name: "Jane Doe", email: "jane@example.com", role: "developer" });
// Optional second arg: session TTL in ms (default: 8 hours)
signIn(user, 4 * 60 * 60 * 1000); // 4-hour session// Optional third arg: bearer-token block from the login response (#628)
signIn(user, undefined, { accessToken, refreshToken, expiresIn });What signIn does:
- Writes a
SessionRecord(user +expiresAt) tosessionStorage(client UI state only). - Writes a non-
HttpOnlymux_auth_session=1marker cookie (SameSite=Lax, plus; Secureon HTTPS) — used only by the middleware's non-production presence-check fallback. - If a token block is passed, persists it via
src/lib/session.js(sessionStorage) sosrc/lib/api.jscan authorize requests (#628). - Updates
userstate inAuthContext→isAuthenticatedbecomestrue.
The authoritative session token — the HttpOnly mux_auth_session cookie
the middleware verifies in production — is set by POST /api/auth/login
server-side, not by signIn. The browser keeps the HttpOnly value; the
client-side marker write is ignored when an HttpOnly cookie of the same
name already exists.
const { signOut } = useAuth();
signOut();What signOut does:
- Removes the
mux_auth_userkey fromsessionStorage. - Clears the client-side marker cookie (
max-age=0). - Clears the bearer-token session (
src/lib/session.js). - Fires
POST /api/auth/logout(fire-and-forget) so the server clears theHttpOnlymux_auth_tokencookie — JS cannot delete it directly. - Sets
usertonull→isAuthenticatedbecomesfalse.
On every page load, AuthProvider runs a useEffect that:
- Reads
mux_auth_userfromsessionStorage. - Checks
expiresAt > Date.now(). - If valid: restores
userstate and re-syncs the cookie. - If expired or corrupt: clears storage and cookie, stays unauthenticated.
- Sets
isLoading = falsewhen done.
isLoadingistrueduring this window. Components that depend on auth state (e.g.DashboardLayout) should render a skeleton whileisLoadingistrueto avoid a flash of unauthenticated content.
src/middleware.ts delegates to evaluateAccess() in
src/lib/auth/routeAccess.ts on every request to a protected prefix. When
access is denied the user is redirected to /login?callbackUrl=<original-path>;
a rejected mux_auth_token is also cleared from the browser on that redirect.
// src/lib/auth/routeAccess.ts
export const PROTECTED_PREFIXES = ["/dashboard", "/demo/dashboard"];/demo/dashboard renders the same full dashboard shell as /dashboard
(sourced from local mock data), so it sits behind the same gate — the
developer console must never be publicly reachable with mock wallets and
fake analytics in a production build.
Add new protected route prefixes to PROTECTED_PREFIXES in
src/lib/auth/routeAccess.ts and to the config.matcher list at the
bottom of src/middleware.ts as the app grows.
DashboardLayout wraps its children in AuthGuard for the real
/dashboard/* tree (requireAuth defaults to true; the demo tree passes
requireAuth={false}). AuthGuard shows a skeleton while the session
rehydrates and redirects to /login if there is no in-memory session.
useSessionGuard() can also be used at the top of any protected page to
handle the case where the middleware cookie passes but the in-memory session
is stale:
"use client";
import { useSessionGuard } from "@/hooks/useSessionGuard";
export default function DashboardPage() {
useSessionGuard(); // redirects to "/" if not authenticated
return <div>...</div>;
}When a backend auth endpoint is available, replace the authenticateUser
function in src/app/login/page.tsx:
// Before (stub):
async function authenticateUser(email: string, _password: string) {
await new Promise((r) => setTimeout(r, 400));
return { name: "...", email, role: "developer" };
}
// After (real API):
async function authenticateUser(email: string, password: string) {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("Invalid credentials");
return res.json(); // { name, email, role }
}The rest of the login page (validation, error handling, redirect) requires no changes.
No environment variables are required for local development in mock mode.
To run against a real backend (which also enables server-verified sessions,
#621), set the API base URL in .env.local:
# Base URL for the Mux backend API. When set, /api/auth/login proxies to
# {NEXT_PUBLIC_API_URL}/auth/login and the middleware verifies sessions via
# {NEXT_PUBLIC_API_URL}/auth/session on every protected request.
NEXT_PUBLIC_API_URL=http://localhost:4000The backend is expected to expose POST /auth/login (returning a user plus
an opaque session token / accessToken / sessionToken),
POST /auth/refresh (rotating the token), GET /auth/session (200 when the
token is valid), and POST /auth/logout. No custody secrets are ever placed
in NEXT_PUBLIC_* or localStorage; the session token lives only in an
HttpOnly cookie (bearer tokens, when returned, live only in tab-scoped
sessionStorage).
Tests for the login page and auth context live in:
src/app/login/__tests__/LoginPage.test.tsx
src/context/__tests__/AuthContext.test.ts
src/lib/auth/__tests__/sessionToken.test.ts # JWT sign/verify (#622)
src/lib/auth/__tests__/routeAccess.test.ts # access-decision logic (#621)
src/__tests__/middleware.test.ts # route protection + callbackUrl (#652)
src/app/api/auth/login/__tests__/route.test.ts # sets the session cookie
src/app/api/auth/logout/route.test.ts # clears the session cookie
src/components/layouts/__tests__/AuthGuard.test.tsx
src/components/layouts/__tests__/DashboardLayout.test.tsx # AuthGuard wiring (#623)
Run tests with:
npm test
# or
pnpm testSee src/app/login/__tests__/LoginPage.test.tsx for examples of how to test
the login form, validation, and redirect behaviour.