Skip to content

feat(web): add frontend authentication with route protection - #217

Merged
mijinummi merged 1 commit into
MD-Creative-Production:mainfrom
Cybermaxi7:feat/frontend-authentication-107
Aug 20, 2026
Merged

feat(web): add frontend authentication with route protection#217
mijinummi merged 1 commit into
MD-Creative-Production:mainfrom
Cybermaxi7:feat/frontend-authentication-107

Conversation

@Cybermaxi7

Copy link
Copy Markdown
Contributor

Closes #107

Summary

Adds apps/web/lib/auth/ — login flow, session storage, and route protection for the dashboard.

Requirement Where
Login flow LoginForm + AuthProvider.login() + auth-api.login()
Token storage MemoryTokenStorage (default) and LocalStorageTokenStorage behind a TokenStorage interface
Route protection ProtectedRoute, with optional role gating
Logout supported AuthProvider.logout(), clears local state then calls the API

Token storage defaults to memory, not localStorage

This is the decision most worth your attention, and it is deliberate.

A bearer token in localStorage is readable by any script that manages to run on the page — so a single XSS becomes full account takeover of a security console. The default MemoryTokenStorage keeps the token in the JS heap, where it dies with the tab and is never reachable through document-level access.

The cost is real and I would rather state it than hide it: a page refresh signs the user out. Persisting safely across reloads needs the refresh token in an httpOnly cookie the frontend cannot read, which requires backend support that does not exist yet. LocalStorageTokenStorage is provided for teams that accept the trade, but it is opt-in rather than the default — and every access to it is guarded, because localStorage throws in private browsing and on quota exhaustion, and a failure to persist must never take down the login.

If you would rather ship persistence by default, it is a one-line change at the AuthProvider call site.

Three auth states, not two

AuthStatus is unknown | authenticated | unauthenticated.

On first render the app has not yet inspected storage. Collapsing that into "unauthenticated" is the standard bug that flashes the login screen at users who are in fact signed in, so ProtectedRoute renders a loading state for unknown.

It also keeps signed out and lacking permission distinct: the first should send you to sign in, the second should tell you that signing in again will not help. requiredRoles mirrors the backend Role enum in src/modules/rbac/roles.enum.ts (ADMIN | MODERATOR | USER), so the UI gates on the values the API actually issues rather than a parallel list that can drift.

Other decisions

  • expiresIn becomes an absolute expiresAt at the API boundary, so the rest of the app compares timestamps instead of recomputing a deadline from a duration whose origin it no longer knows.
  • Logout never throws. Local state is cleared first, then the API is called. A user on a flaky connection asked to sign out and must not be left holding a session because the request failed.
  • Expired sessions are cleared on read, not left to fail on first use.
  • useAuth throws outside a provider — that is a wiring bug, not a state to handle gracefully.
  • The API client follows the existing lib/api/profile.ts conventions: same base-URL resolution, same typed-error shape, same response parsing.

Tests

33 across three suites; 74 passing across the full dashboard suite, no regressions. Lint and prettier clean.

Covered: both storage backends · corrupt JSON · quota and security exceptions · expiry handling · session restore on mount · login success · rejected credentials surfaced in plain language · logout including the failing-network path · the provider-less error · and every ProtectedRoute branch.

Two testing notes, since both look unusual in the diff:

  • ProtectedRoute.unknown.spec.tsx stubs useAuth rather than going through AuthProvider. The unknown window closes the moment the restore effect runs, so it cannot be observed through the provider — but it is precisely the branch that prevents the login-screen flash, so it is pinned directly.
  • The localStorage failure tests swap the whole object rather than using jest.spyOn, because jsdom's localStorage is not spy-able.

Backend dependency

The client targets /auth/login, /auth/logout and /auth/me. The backend has an RBAC guard but no auth controller yet, so those endpoints still need implementing server-side. This PR establishes the contract the frontend expects — request and response shapes are documented in auth-api.ts. Happy to open a follow-up issue for the backend side if that is useful.

Adds `apps/web/lib/auth/`: a login flow, session storage, and a route guard for
the dashboard.

Token storage defaults to memory, not localStorage
--------------------------------------------------
A bearer token in localStorage is readable by any script that manages to run on
the page, so one XSS becomes full account takeover of a security console. The
default `MemoryTokenStorage` keeps the token in the JS heap, where it dies with
the tab and is never reachable through document-level access.

The cost is honest and stated: a refresh signs the user out. Persisting safely
across reloads needs the refresh token in an httpOnly cookie the frontend
cannot read, which requires backend support that does not exist yet.
`LocalStorageTokenStorage` is provided for teams that accept the trade, but it
is opt-in rather than the default. Its every access is guarded, because
localStorage throws in private browsing and when the quota is exceeded, and a
failure to persist must not take down the login flow.

Three states, not two
---------------------
`AuthStatus` is `unknown | authenticated | unauthenticated`. On first render the
app has not yet inspected storage, and collapsing that into "unauthenticated"
flashes the login screen at users who are in fact signed in. `ProtectedRoute`
renders a loading state for `unknown`.

It also separates being signed out from lacking permission. The first should
send you to sign in; the second should tell you that signing in again will not
help. `requiredRoles` mirrors the backend `Role` enum in
`src/modules/rbac/roles.enum.ts`, so the UI gates on the values the API issues.

Other decisions
---------------
- `expiresIn` is converted to an absolute `expiresAt` at the API boundary, so
  the rest of the app compares timestamps rather than recomputing a deadline
  from a duration whose origin it no longer knows.
- Logout clears local state first and never throws. A user on a flaky
  connection asked to sign out, and must not be left holding a session because
  the network call failed.
- Expired sessions found in storage are cleared on read rather than left to
  fail on first use.
- `useAuth` throws outside a provider, since that is a wiring bug rather than a
  state to handle.
- The API client follows the existing `lib/api/profile.ts` conventions: same
  base URL resolution, same typed-error shape, same response parsing.

Tests: 33 across three suites covering memory and localStorage backends,
corrupt JSON, quota and security exceptions, expiry handling, session restore,
login success and rejection, logout including the failing-network path, the
provider-less error, and every ProtectedRoute branch including the unresolved
state. Full dashboard suite: 74 passing across 8 suites, no regressions.
Lint and prettier clean.

Note: the client targets `/auth/login`, `/auth/logout` and `/auth/me`. The
backend has an RBAC guard but no auth controller yet, so those endpoints still
need implementing server-side; this establishes the contract the frontend
expects.
@mijinummi
mijinummi merged commit 4ea6827 into MD-Creative-Production:main Aug 20, 2026
12 checks passed
@grantfox-oss grantfox-oss Bot mentioned this pull request Aug 20, 2026
3 tasks
@mijinummi

Copy link
Copy Markdown
Collaborator

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Frontend Authentication

2 participants