feat: persistent session cookies (30d sliding) — nanodrop tier #4

Merged
brendan merged 6 commits from feat/persistent-session-cookies into main 2026-05-09 17:34:57 +00:00
Owner

Summary

Third tier of the cross-project persistent-session-cookies sweep, after authd PR #7 (562aca5) and buchinese PR #3 (9015328). Mirrors authd's stateless-JWT shape (no sessions table) since nanodrop has no session DB.

  • Family constants pinned in src/constants.ts: SESSION_TTL_DAYS=30, SESSION_TTL_SECONDS=2_592_000, SESSION_RENEW_THRESHOLD_SECONDS=3600. Verbatim from authd PR #7 and buchinese PR #3.
  • Cookie renamed tokennanodrop_session per the family <app>_session convention. Hard-cut migration with no dual-cookie shim: the existing token cookie has no Max-Age so it dies on browser close anyway, and this is a single-user deploy. You re-log in once after deploy.
  • Single mint site: issueSessionCookie(reply, server, claims, secure) is now the only place that signs a JWT and writes the session cookie. The cookie carries Max-Age=2592000 so it persists across browser restarts. grep -rn 'reply.setCookie\|app.jwt.sign' src/ converges to one site (auth.ts:29-30).
  • Sliding renewal: slideSessionIfNeeded runs after every successful request.jwtVerify() inside makeRequireAuth. It re-mints the cookie iff the JWT is older than 1 hour, avoiding Set-Cookie thrash on every request. Both logout paths (POST /logout, POST /api/v1/auth/logout) are URL-guarded in LOGOUT_PATHS so the renewer never resurrects a session the user is terminating. Architectural placement (renewer on requireAuth, NOT on opportunistic-auth blocks) is the primary defense; URL guard is belt-and-braces.
  • JWT_EXPIRY env var REMOVED. The family TTL is canonical; a stale JWT_EXPIRY=2h in a .env is now silently ignored. Removed from src/config.ts, tests/helpers/setup.ts, .env.example, docker-compose.yml, and the README env-var table.

Architectural decisions

  • Mirrors authd, not buchinese. Nanodrop has no sessions table, so the renewer slides the JWT itself by re-signing with a fresh iat. No DB migration, no expires_at column, no backfill.
  • sameSite: 'strict' retained. Family default is 'lax'; nanodrop kept 'strict' because the family rule is "preserved or strengthened, never relaxed". 'strict' is safe-stricter, so this is in spirit with the family policy.
  • requireAuth is now a factory makeRequireAuth(config). Renewer needs config.cookieSecure; threading via factory matches the existing Deps injection style.
  • Opportunistic-auth blocks (GET /, GET /f/:id) do NOT slide the session. They call request.jwtVerify() directly outside the factory — a public file view shouldn't extend the owner's session.

All four flags strictly stronger or equal vs. the old cookie:

Attribute Old New
HttpOnly yes yes
SameSite Strict Strict
Secure env-controlled env-controlled
Path / /
Max-Age (none — session cookie) 2592000 (30 days)

The only relaxation-direction change is adding Max-Age, which is the spec.

Phasing (one PR, six commits)

Each feat/refactor commit builds clean (npm run build && npm test):

  1. 86870db feat(auth): family-wide session constants + mint primitive + auth factory
  2. 623a337 feat(auth): rename session cookie to nanodrop_session
  3. 0f0c2f0 feat(auth): sliding session renewal middleware
  4. a4355e1 refactor(auth): drop JWT_EXPIRY env var (family TTL is canonical)
  5. cbc22dc refactor: drop redundant iat intersections and reuse JwtPayload in tests
  6. 3b3a56c docs: drop JWT_EXPIRY from .env.example, docker-compose, README (family TTL is canonical)

Test plan

  • npm run build green
  • npm test — 125 passed (was 112; +13 new: 7 unit + 6 integration on renewer/persistence)
  • No new env vars; one removed (JWT_EXPIRY)
  • grep -rn "'token'" src/ returns nothing for the cookie name
  • grep -rn "JWT_EXPIRY\|jwtExpiry" . returns zero hits across source, tests, deploy manifests, and docs
  • Login response carries Max-Age=2592000 (asserted in auth-api.test.ts and session-persistence.test.ts)
  • 25-day request triggers a fresh Set-Cookie; sub-threshold (30 min) does not
  • Both logout paths clear the cookie even after the renewal threshold has passed
  • 31-day request bounces with 401 (idle lapse beyond TTL)
  • Manual: npm run dev, log in, DevTools → Cookies, verify nanodrop_session with HttpOnly; SameSite=Strict; Max-Age=2592000 (deferred — no headed browser in autonomous loop)
  • Manual: refresh within an hour — no Set-Cookie in response (deferred)
  • Manual: click logout — cookie cleared, /upload redirects to / (deferred)

Security audit

Surface Risk Mitigation
slideSessionIfNeeded Resurrect-after-logout URL guard (LOGOUT_PATHS Set + query-string strip) AND architectural placement (renewer on requireAuth, not on opportunistic auth)
issueSessionCookie Cookie fixation Login always overwrites with freshly-signed JWT
issueSessionCookie Infinite Max-Age extension Intentional — "stay signed in until you clear cookies" is the spec
Opportunistic auth (GET /, GET /f/:id) Public file view extends owner's session Direct request.jwtVerify() bypasses the factory; no slide
JWT_EXPIRY removal Silent override of operator-set value Acceptable — family invariant; documented in README and Phase-4 commit
Cookie attrs Weakened security All four flags strictly stronger or equal; only Max-Age added

Reviewer cycle

  • Cycle 1 verdict: ITERATE — JWT_EXPIRY was removed from src/ but still appeared in .env.example, docker-compose.yml, README. Caught a real docs-drift risk: an operator setting JWT_EXPIRY=2h would expect 2-hour sessions but get 30-day.
  • Cycle 2 verdict: MERGE — docs cleanup commit 3b3a56c removed all three references. Reviewer confirmed grep -rn "JWT_EXPIRY|jwtExpiry" . returns zero hits, single mint primitive intact, cookie attrs intact, both logout paths URL-guarded, no new env vars.

Cross-project umbrella status

Remaining apps in the persistent-session-cookies umbrella: dashcam, movement. Inventory is BLOCKED behind PR #17 manual merge.

## Summary Third tier of the cross-project persistent-session-cookies sweep, after authd PR #7 (`562aca5`) and buchinese PR #3 (`9015328`). Mirrors authd's stateless-JWT shape (no sessions table) since nanodrop has no session DB. - **Family constants pinned in `src/constants.ts`:** `SESSION_TTL_DAYS=30`, `SESSION_TTL_SECONDS=2_592_000`, `SESSION_RENEW_THRESHOLD_SECONDS=3600`. Verbatim from authd PR #7 and buchinese PR #3. - **Cookie renamed `token` → `nanodrop_session`** per the family `<app>_session` convention. Hard-cut migration with no dual-cookie shim: the existing `token` cookie has no `Max-Age` so it dies on browser close anyway, and this is a single-user deploy. You re-log in once after deploy. - **Single mint site:** `issueSessionCookie(reply, server, claims, secure)` is now the only place that signs a JWT and writes the session cookie. The cookie carries `Max-Age=2592000` so it persists across browser restarts. `grep -rn 'reply.setCookie\|app.jwt.sign' src/` converges to one site (auth.ts:29-30). - **Sliding renewal:** `slideSessionIfNeeded` runs after every successful `request.jwtVerify()` inside `makeRequireAuth`. It re-mints the cookie iff the JWT is older than 1 hour, avoiding Set-Cookie thrash on every request. Both logout paths (`POST /logout`, `POST /api/v1/auth/logout`) are URL-guarded in `LOGOUT_PATHS` so the renewer never resurrects a session the user is terminating. Architectural placement (renewer on `requireAuth`, NOT on opportunistic-auth blocks) is the primary defense; URL guard is belt-and-braces. - **`JWT_EXPIRY` env var REMOVED.** The family TTL is canonical; a stale `JWT_EXPIRY=2h` in a `.env` is now silently ignored. Removed from `src/config.ts`, `tests/helpers/setup.ts`, `.env.example`, `docker-compose.yml`, and the README env-var table. ## Architectural decisions - **Mirrors authd, not buchinese.** Nanodrop has no sessions table, so the renewer slides the JWT itself by re-signing with a fresh `iat`. No DB migration, no `expires_at` column, no backfill. - **`sameSite: 'strict'` retained.** Family default is `'lax'`; nanodrop kept `'strict'` because the family rule is "preserved or strengthened, never relaxed". `'strict'` is safe-stricter, so this is in spirit with the family policy. - **`requireAuth` is now a factory `makeRequireAuth(config)`.** Renewer needs `config.cookieSecure`; threading via factory matches the existing `Deps` injection style. - **Opportunistic-auth blocks (`GET /`, `GET /f/:id`) do NOT slide the session.** They call `request.jwtVerify()` directly outside the factory — a public file view shouldn't extend the owner's session. ## Cookie attributes All four flags strictly stronger or equal vs. the old cookie: | Attribute | Old | New | |---|---|---| | `HttpOnly` | yes | yes | | `SameSite` | `Strict` | `Strict` | | `Secure` | env-controlled | env-controlled | | `Path` | `/` | `/` | | `Max-Age` | (none — session cookie) | `2592000` (30 days) | The only relaxation-direction change is adding `Max-Age`, which is the spec. ## Phasing (one PR, six commits) Each feat/refactor commit builds clean (`npm run build && npm test`): 1. `86870db` feat(auth): family-wide session constants + mint primitive + auth factory 2. `623a337` feat(auth): rename session cookie to nanodrop_session 3. `0f0c2f0` feat(auth): sliding session renewal middleware 4. `a4355e1` refactor(auth): drop JWT_EXPIRY env var (family TTL is canonical) 5. `cbc22dc` refactor: drop redundant iat intersections and reuse JwtPayload in tests 6. `3b3a56c` docs: drop JWT_EXPIRY from .env.example, docker-compose, README (family TTL is canonical) ## Test plan - [x] `npm run build` green - [x] `npm test` — 125 passed (was 112; +13 new: 7 unit + 6 integration on renewer/persistence) - [x] No new env vars; one removed (`JWT_EXPIRY`) - [x] `grep -rn "'token'" src/` returns nothing for the cookie name - [x] `grep -rn "JWT_EXPIRY\|jwtExpiry" .` returns zero hits across source, tests, deploy manifests, and docs - [x] Login response carries `Max-Age=2592000` (asserted in `auth-api.test.ts` and `session-persistence.test.ts`) - [x] 25-day request triggers a fresh Set-Cookie; sub-threshold (30 min) does not - [x] Both logout paths clear the cookie even after the renewal threshold has passed - [x] 31-day request bounces with 401 (idle lapse beyond TTL) - [ ] Manual: `npm run dev`, log in, DevTools → Cookies, verify `nanodrop_session` with `HttpOnly; SameSite=Strict; Max-Age=2592000` (deferred — no headed browser in autonomous loop) - [ ] Manual: refresh within an hour — no Set-Cookie in response (deferred) - [ ] Manual: click logout — cookie cleared, `/upload` redirects to `/` (deferred) ## Security audit | Surface | Risk | Mitigation | |---|---|---| | `slideSessionIfNeeded` | Resurrect-after-logout | URL guard (`LOGOUT_PATHS` Set + query-string strip) AND architectural placement (renewer on `requireAuth`, not on opportunistic auth) | | `issueSessionCookie` | Cookie fixation | Login always overwrites with freshly-signed JWT | | `issueSessionCookie` | Infinite Max-Age extension | Intentional — "stay signed in until you clear cookies" is the spec | | Opportunistic auth (`GET /`, `GET /f/:id`) | Public file view extends owner's session | Direct `request.jwtVerify()` bypasses the factory; no slide | | `JWT_EXPIRY` removal | Silent override of operator-set value | Acceptable — family invariant; documented in README and Phase-4 commit | | Cookie attrs | Weakened security | All four flags strictly stronger or equal; only `Max-Age` added | ## Reviewer cycle - **Cycle 1 verdict:** ITERATE — `JWT_EXPIRY` was removed from `src/` but still appeared in `.env.example`, `docker-compose.yml`, README. Caught a real docs-drift risk: an operator setting `JWT_EXPIRY=2h` would expect 2-hour sessions but get 30-day. - **Cycle 2 verdict:** MERGE — docs cleanup commit `3b3a56c` removed all three references. Reviewer confirmed `grep -rn "JWT_EXPIRY|jwtExpiry" .` returns zero hits, single mint primitive intact, cookie attrs intact, both logout paths URL-guarded, no new env vars. ## Cross-project umbrella status Remaining apps in the persistent-session-cookies umbrella: dashcam, movement. Inventory is BLOCKED behind PR #17 manual merge.
brendan added 6 commits 2026-05-09 17:34:10 +00:00
Adds src/constants.ts exporting the family-wide session policy
(SESSION_TTL_DAYS=30, SESSION_TTL_SECONDS=2_592_000,
SESSION_RENEW_THRESHOLD_SECONDS=3600, LOGOUT_PATHS) so every
bchen.dev app shares the same persistence window.

Introduces issueSessionCookie as the single mint site for
fastify-jwt sign + setCookie, replacing inlined jwt.sign +
setCookie calls in pages.ts and api/v1/auth.ts. The cookie now
carries Max-Age=SESSION_TTL_SECONDS so it persists across browser
restarts.

Converts requireAuth into a makeRequireAuth(config) factory; route
plugins build their own preHandler at registration time. Threads
through pages.ts, api/v1/auth.ts, and api/v1/files.ts.

SESSION_COOKIE_NAME stays 'token' in this commit so existing tests
remain green; the rename to 'nanodrop_session' lands in a follow-up.
JWT_EXPIRY env var is still read; its removal also lands in a
follow-up so each commit builds cleanly.
Flips SESSION_COOKIE_NAME from 'token' to 'nanodrop_session' per the
family per-app naming convention (<app>_session). fastify-jwt's
cookieName in server.ts is now sourced from the constant so a future
rename only needs to touch constants.ts.

Hard-cut migration with no dual-cookie shim: the existing 'token'
cookie has no Max-Age so it dies on browser close anyway, and this
is a single-user deployment per CLAUDE.md. Users re-log in once
after deploy.

Test files updated mechanically: cookies: { token } → cookies: {
nanodrop_session: token } (variable name 'token' kept locally),
clearCookie regex updated, login response now also asserts
Max-Age=2592000 from the family TTL.
Adds src/middleware/session-renewal.ts with slideSessionIfNeeded —
a pure function that re-mints the session cookie iff the JWT is
older than SESSION_RENEW_THRESHOLD_SECONDS (1 hour). Wired into
makeRequireAuth so every authenticated request bumps the cookie's
expiration window forward, giving the 'stay signed in unless you
clear cookies' UX.

Logout paths are explicitly guarded via LOGOUT_PATHS so the
renewer never resurrects a session the user is actively
terminating. Query-string strip prevents /logout?next=foo bypass.
Opportunistic-auth blocks (GET /, GET /f/:id) verify JWT directly
without going through makeRequireAuth, so they don't slide — by
design, a public file view shouldn't extend the owner's session.

Tests cover threshold semantics, both logout paths, query-string
handling, missing iat (legacy token forces refresh), and a full
integration suite simulating 25-day and 31-day jumps via
vi.setSystemTime.
The JWT lifetime is now pinned to SESSION_TTL_SECONDS (30 days)
inside issueSessionCookie, so JWT_EXPIRY had no effect after the
prior commits. Removing the field from Config and the env read in
loadConfig lets the TypeScript compiler verify no caller was still
relying on it.

Per the family invariant ('coherence across apps is a feature'),
the per-app override is intentionally gone — a deploy with stale
JWT_EXPIRY in .env will now silently use the 30-day family default
regardless of the value.

Test fixtures (Config literals in setup.ts, login-handler.test.ts,
lockout-service.test.ts) and config.test.ts assertions updated to
match the new shape.
JwtPayload already declares iat?: number, so the & { iat?: number } in
makeRequireAuth and slideSessionIfNeeded was a no-op. The unit test had a
local TestPayload duplicating the same shape — replaced with the canonical
import.
brendan merged commit aed9931e14 into main 2026-05-09 17:34:57 +00:00
Sign in to join this conversation.