Session-based dashboard auth (stop storing the master key in the browser) #22

Closed
opened 2026-08-10 14:38:27 +00:00 by james.oates · 1 comment
Owner

Design/spec: Obsidian Personal/Projects/edge-router/specs/2026-08-10-dashboard-session-auth-design.md.

Problem: the dashboard stores the master API key in localStorage and sends it as X-API-Key on every call — an all-powerful, permanent credential sitting in the browser (XSS → exfiltration → persistent compromise).

Fix (step 1 of 2; step 2 = passkeys): exchange an API key for a server-issued httpOnly session cookie. The browser never holds the master key; the JS holds no credential. Header API-key auth is unchanged for machines.

Tasks

  • In-memory SessionStore on AppState (opaque 32-byte token; 60 min sliding idle + 12 h absolute cap; periodic sweep)
  • POST /api/session (login): validate key → set er_session cookie HttpOnly; SameSite=Strict; Secure(when HTTPS via IsHttps)
  • POST /api/session/logout: invalidate + clear cookie
  • auth_middleware: accept a valid session cookie (sliding-bump) OR X-API-Key (additive)
  • Route the session endpoints behind admin_host_gate + admin_ip_middleware but NOT auth_middleware
  • dashboard.html: login posts /api/session; delete all localStorage/X-API-Key JS; logout button
  • Tests (session store, middleware, login/logout) + full gate

Out of scope (separate): passkeys (step 2), API-key hashing at rest (#18), CSP hardening, login rate-limiting.

Design/spec: Obsidian `Personal/Projects/edge-router/specs/2026-08-10-dashboard-session-auth-design.md`. **Problem:** the dashboard stores the master API key in `localStorage` and sends it as `X-API-Key` on every call — an all-powerful, permanent credential sitting in the browser (XSS → exfiltration → persistent compromise). **Fix (step 1 of 2; step 2 = passkeys):** exchange an API key for a server-issued httpOnly session cookie. The browser never holds the master key; the JS holds no credential. Header API-key auth is unchanged for machines. ## Tasks - [ ] In-memory `SessionStore` on `AppState` (opaque 32-byte token; 60 min sliding idle + 12 h absolute cap; periodic sweep) - [ ] `POST /api/session` (login): validate key → set `er_session` cookie `HttpOnly; SameSite=Strict; Secure`(when HTTPS via `IsHttps`) - [ ] `POST /api/session/logout`: invalidate + clear cookie - [ ] `auth_middleware`: accept a valid session cookie (sliding-bump) OR `X-API-Key` (additive) - [ ] Route the session endpoints behind `admin_host_gate` + `admin_ip_middleware` but NOT `auth_middleware` - [ ] dashboard.html: login posts `/api/session`; delete all `localStorage`/`X-API-Key` JS; logout button - [ ] Tests (session store, middleware, login/logout) + full gate Out of scope (separate): passkeys (step 2), API-key hashing at rest (#18), CSP hardening, login rate-limiting.
Author
Owner

Implemented in 21ef3d5.

  • In-memory SessionStore (session.rs): opaque 32-byte token, 60 min sliding idle + 12 h absolute cap, periodic sweep. Not persisted (clears on restart).
  • POST /api/session (login) sets er_session cookie — HttpOnly; SameSite=Strict; Path=/, Secure only when the request arrived over HTTPS (via IsHttps).
  • POST /api/session/logout invalidates + clears.
  • auth_middleware accepts a valid session cookie (sliding-bumped) OR X-API-Key — additive; machines unchanged.
  • Session routes gated by admin_host_gate + admin_ip_middleware, not auth_middleware.
  • dashboard.html: login posts /api/session; all localStorage/X-API-Key JS removed — same-origin fetch carries the cookie, JS holds no credential; logout button added.

Tests: SessionStore (idle expiry, sliding bump, absolute cap, sweep, cookie parse+flags); end-to-end login → cookie → authenticated admin call → logout → 401; wrong key → 401 no cookie. Full suite green (180), clippy/fmt clean. The dashboard is include_str!-embedded in the binary, so deploying the image updates it.

Verification needed on deploy (inherently not unit-testable): the browser flow — login box submits, gate hides on success, logout re-shows it, a reload with a live cookie skips the login box, and localStorage is empty (DevTools → Application). Also confirm the real Set-Cookie — note Secure tracks the internal hop (edge-router's TLS binding), so if Cloudflare terminates TLS and forwards plain HTTP, the cookie may ship without Secure even though the browser↔CF leg is HTTPS.

Step 1 of 2 complete. Step 2 (passkeys) builds on this. Closing.

Implemented in 21ef3d5. - In-memory `SessionStore` (`session.rs`): opaque 32-byte token, 60 min sliding idle + 12 h absolute cap, periodic sweep. Not persisted (clears on restart). - `POST /api/session` (login) sets `er_session` cookie — `HttpOnly; SameSite=Strict; Path=/`, `Secure` only when the request arrived over HTTPS (via `IsHttps`). - `POST /api/session/logout` invalidates + clears. - `auth_middleware` accepts a valid session cookie (sliding-bumped) OR `X-API-Key` — additive; machines unchanged. - Session routes gated by `admin_host_gate` + `admin_ip_middleware`, not `auth_middleware`. - `dashboard.html`: login posts `/api/session`; **all `localStorage`/`X-API-Key` JS removed** — same-origin fetch carries the cookie, JS holds no credential; logout button added. Tests: `SessionStore` (idle expiry, sliding bump, absolute cap, sweep, cookie parse+flags); end-to-end login → cookie → authenticated admin call → logout → 401; wrong key → 401 no cookie. Full suite green (180), clippy/fmt clean. The dashboard is `include_str!`-embedded in the binary, so deploying the image updates it. **Verification needed on deploy** (inherently not unit-testable): the browser flow — login box submits, gate hides on success, logout re-shows it, a reload with a live cookie skips the login box, and `localStorage` is empty (DevTools → Application). Also confirm the real `Set-Cookie` — note `Secure` tracks the *internal* hop (edge-router's TLS binding), so if Cloudflare terminates TLS and forwards plain HTTP, the cookie may ship without `Secure` even though the browser↔CF leg is HTTPS. Step 1 of 2 complete. Step 2 (passkeys) builds on this. Closing.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
IsoHex/edge-router#22
No description provided.