156 lines
12 KiB
Markdown
156 lines
12 KiB
Markdown
---
|
|
phase: 260610-k1z
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- apps/api/src/auth/persistSessionCookie.ts
|
|
- apps/api/src/index.ts
|
|
- apps/api/tests/auth/persistSessionCookie.test.ts
|
|
autonomous: true
|
|
requirements: [AUTH-02]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "When @hono/oidc-auth produces a valid session this request (c.get('oidcAuthJwt') is truthy), the response re-sets the oidc-auth cookie with a persistent Max-Age so the browser keeps it across PWA/tab close"
|
|
- "When no valid session JWT is on context (logged-out / deleted / never-set request, c.get('oidcAuthJwt') is falsy), NO oidc-auth Set-Cookie is emitted — the middleware never resurrects a deleted or absent cookie (the critical correctness/security guard)"
|
|
- "The re-issued cookie carries httpOnly:true, secure:true, sameSite:'Lax', and the domain attribute only when OIDC_COOKIE_DOMAIN is set, mirroring the library's conditional-domain logic"
|
|
- "pnpm --filter @familysync/api typecheck exits 0"
|
|
artifacts:
|
|
- path: "apps/api/src/auth/persistSessionCookie.ts"
|
|
provides: "persistSessionCookie() Hono MiddlewareHandler that upgrades the session-scoped oidc-auth cookie to a persistent one"
|
|
exports: ["persistSessionCookie"]
|
|
min_lines: 25
|
|
- path: "apps/api/tests/auth/persistSessionCookie.test.ts"
|
|
provides: "Vitest unit tests for the persist + guard behaviors"
|
|
contains: "persistSessionCookie"
|
|
- path: "apps/api/src/index.ts"
|
|
provides: "Mounting of persistSessionCookie immediately after oidcAuthMiddleware inside the !devBypassActive block"
|
|
contains: "persistSessionCookie"
|
|
key_links:
|
|
- from: "apps/api/src/index.ts"
|
|
to: "apps/api/src/auth/persistSessionCookie.ts"
|
|
via: "import + app.use('/api/*', persistSessionCookie()) inside if (!devBypassActive)"
|
|
pattern: "persistSessionCookie\\(\\)"
|
|
- from: "apps/api/src/auth/persistSessionCookie.ts"
|
|
to: "oidcAuthJwt context var (set by @hono/oidc-auth)"
|
|
via: "c.get('oidcAuthJwt') read; cookie re-issued only when truthy"
|
|
pattern: "oidcAuthJwt"
|
|
---
|
|
|
|
<objective>
|
|
Persist the OIDC session cookie so browser-stored sessions survive PWA/browser close.
|
|
|
|
@hono/oidc-auth 1.8.3 sets its `oidc-auth` session cookie with `{ path, httpOnly, secure }` and NO `maxAge`/`expires` — a SESSION-SCOPED cookie the browser discards on PWA/tab close. On reopen there is no cookie, so the OIDC guard 302s to Authelia and forces re-login. The server-side session JWT is valid for OIDC_AUTH_EXPIRES (default 86400s), but the browser throws the cookie away regardless. The library exposes no option to set cookie maxAge.
|
|
|
|
Fix: a small Hono middleware mounted immediately after `oidcAuthMiddleware()` that re-issues the same `oidc-auth` cookie with a persistent `maxAge` (+ `sameSite:'Lax'`). It re-issues ONLY when the library set `c.get('oidcAuthJwt')` this request (i.e. a freshly created/refreshed valid session), and does NOTHING otherwise — so it never resurrects a deleted or absent cookie.
|
|
|
|
Purpose: Stop both household members from re-logging-in almost every time they open the PWA.
|
|
Output: New middleware file + its tests, wired into index.ts.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@CLAUDE.md
|
|
@apps/api/src/index.ts
|
|
@apps/api/src/auth/middleware.ts
|
|
@apps/api/src/auth/devBypass.ts
|
|
@apps/api/tests/auth/devBypass.test.ts
|
|
|
|
# Ground truth on the library's cookie + context behavior (do NOT edit the library):
|
|
# - @hono/oidc-auth/dist/index.js sets a session-scoped cookie via
|
|
# setCookie(c, OIDC_COOKIE_NAME, session_jwt, { path, httpOnly:true, secure:true [, domain if OIDC_COOKIE_DOMAIN] })
|
|
# with NO maxAge/expires, then immediately calls c.set('oidcAuthJwt', session_jwt).
|
|
# - oidcAuthJwt is set ONLY on requests where a valid session is created/refreshed.
|
|
# Logged-out / no-session requests do NOT set it. This is the guard signal.
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Create persistSessionCookie() middleware</name>
|
|
<files>apps/api/src/auth/persistSessionCookie.ts</files>
|
|
<behavior>
|
|
- Truthy oidcAuthJwt: middleware re-issues the oidc-auth cookie with Max-Age set (persistent), SameSite=Lax, HttpOnly, Secure.
|
|
- Falsy oidcAuthJwt (logged-out / never-set): middleware emits NO oidc-auth Set-Cookie (no resurrection) — the security guard.
|
|
- Cookie name/path read from env with library-matching fallbacks; domain attribute included only when OIDC_COOKIE_DOMAIN is set.
|
|
</behavior>
|
|
<action>
|
|
Create `apps/api/src/auth/persistSessionCookie.ts` exporting `persistSessionCookie(): MiddlewareHandler` (import the `MiddlewareHandler` type from 'hono'; import `setCookie` from 'hono/cookie').
|
|
|
|
Implements AUTH-02 session persistence. WHY-comment the file thoroughly: @hono/oidc-auth sets a SESSION-SCOPED oidc-auth cookie (no maxAge), so the browser drops it on PWA/tab close and the user is forced to re-login; this middleware upgrades that cookie to a persistent one. Cite that the library sets `c.set('oidcAuthJwt', session_jwt)` only on requests that create/refresh a valid session.
|
|
|
|
Behavior, inside the returned handler:
|
|
1. Read the freshly-signed session JWT from context: `const jwt = c.get('oidcAuthJwt' as never) as string | undefined`. Use the minimal cast because `oidcAuthJwt` is not in the typed Hono ContextVariableMap — mirror the existing "resolveUserId uses any" / loose-context convention rather than augmenting Hono generics for a library-internal key.
|
|
2. THE GUARD (must_have): if `jwt` is falsy, do NOT touch cookies — fall straight through to `await next()` and return. This is the property that prevents resurrecting a deleted/logged-out/never-set cookie. Comment it explicitly as the critical correctness property.
|
|
3. If `jwt` is truthy, re-issue the cookie BEFORE `await next()` (set-before-next: if any future downstream handler deletes the cookie, that delete becomes the LAST Set-Cookie and wins — safe ordering even though there is currently no logout/revoke route). Build options to mirror the library + harden:
|
|
- name: `process.env.OIDC_COOKIE_NAME ?? 'oidc-auth'`
|
|
- path: `process.env.OIDC_COOKIE_PATH ?? '/'`
|
|
- httpOnly: true, secure: true
|
|
- sameSite: 'Lax'
|
|
- maxAge: `Number(process.env.OIDC_AUTH_EXPIRES ?? 86400)` (seconds — Hono's setCookie maxAge is in seconds)
|
|
- domain: include the `domain` key ONLY when `process.env.OIDC_COOKIE_DOMAIN` is set (conditional-domain, mirroring the library). Build the options object so the domain key is absent (not `undefined`) when unset.
|
|
Call `setCookie(c, name, jwt, options)` with the SAME jwt value the library signed (do not re-sign).
|
|
4. Then `await next()`.
|
|
|
|
Keep it tiny. Do NOT import or call into @hono/oidc-auth. Do NOT add new dependencies (hono/cookie ships with hono, already a dependency). Do NOT place fenced code blocks in production comments.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @familysync/api typecheck</automated>
|
|
</verify>
|
|
<done>persistSessionCookie.ts exists, exports persistSessionCookie(): MiddlewareHandler, guards on c.get('oidcAuthJwt'), and `pnpm --filter @familysync/api typecheck` exits 0.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Wire middleware into index.ts + add unit tests</name>
|
|
<files>apps/api/src/index.ts, apps/api/tests/auth/persistSessionCookie.test.ts</files>
|
|
<behavior>
|
|
- Test A (persist): with a Hono app that sets c.set('oidcAuthJwt', '<jwt>') then runs persistSessionCookie(), the response Set-Cookie for oidc-auth includes Max-Age, SameSite=Lax, HttpOnly, and Secure.
|
|
- Test B (guard): with a Hono app that does NOT set oidcAuthJwt, the response has NO oidc-auth Set-Cookie (no resurrection).
|
|
</behavior>
|
|
<action>
|
|
Edit `apps/api/src/index.ts`:
|
|
- Add `import { persistSessionCookie } from './auth/persistSessionCookie.js'` alongside the other auth imports (use the `.js` extension — this is ESM/NodeNext, matching existing imports).
|
|
- Inside the existing `if (!devBypassActive) { ... }` block, register `app.use('/api/*', persistSessionCookie())` on the line IMMEDIATELY AFTER `app.use('/api/*', oidcAuthMiddleware())` (still inside the same block — it only applies when OIDC is active; under dev bypass there is no oidc cookie). Add a one-line comment: re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02). Make NO other changes to index.ts.
|
|
|
|
Create `apps/api/tests/auth/persistSessionCookie.test.ts` (vitest), following the harness style in apps/api/tests/auth/devBypass.test.ts (small Hono app, `app.request()`, inspect the Response). Tests live in tests/, never src/.
|
|
- Test A (persist path): build a Hono app; add a middleware that does `c.set('oidcAuthJwt' as never, 'header.payload.sig')` (a dummy non-empty JWT string) then calls the imported `persistSessionCookie()` next; add a GET /api/test handler returning 200. Request it, read `res.headers.get('set-cookie')`, and assert it contains the oidc-auth cookie name AND `Max-Age` AND `SameSite=Lax` AND `HttpOnly` AND `Secure`. (Match attribute names case-insensitively if needed; Hono emits `Max-Age`, `SameSite=Lax`, `HttpOnly`, `Secure`.)
|
|
- Test B (guard path — the security property): build a Hono app that does NOT set oidcAuthJwt, run `persistSessionCookie()`, hit a GET /api/test. Assert `res.headers.get('set-cookie')` is null OR does not contain the oidc-auth cookie name — i.e. no resurrection when there is no session JWT.
|
|
- Read the cookie NAME the same way the implementation does (`process.env.OIDC_COOKIE_NAME ?? 'oidc-auth'`) so the test stays correct if the env override is set; default 'oidc-auth' otherwise.
|
|
Do NOT call the real @hono/oidc-auth in the test — set the context var directly. Do NOT add DB/integration setup (keep it a pure unit test so it runs under the tests/auth/ filter without MariaDB).
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @familysync/api typecheck && pnpm --filter @familysync/api exec vitest run tests/auth/</automated>
|
|
</verify>
|
|
<done>index.ts mounts persistSessionCookie() immediately after oidcAuthMiddleware() inside the !devBypassActive block; both new tests pass; `pnpm --filter @familysync/api typecheck` exits 0 and `pnpm --filter @familysync/api exec vitest run tests/auth/` passes (including the existing devBypass/user auth tests).</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `pnpm --filter @familysync/api typecheck` exits 0.
|
|
- `pnpm --filter @familysync/api exec vitest run tests/auth/` passes — the new persist test, the new guard test, and the existing devBypass/user tests all green.
|
|
- Do NOT run the full suite: ~68 pre-existing failures come from real-DB integration tests (MariaDB host port not exposed) and are unrelated to this change.
|
|
- Manual confirmation of the wiring: persistSessionCookie() is registered on '/api/*' on the line immediately after oidcAuthMiddleware(), inside the `if (!devBypassActive)` block.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- A logged-in member who closes the PWA and reopens it later (within OIDC_AUTH_EXPIRES) is NOT bounced to Authelia — the oidc-auth cookie now has a Max-Age and persists.
|
|
- A logged-out / no-session request never receives an oidc-auth Set-Cookie from this middleware (no resurrection).
|
|
- No changes to @hono/oidc-auth, package.json, or .env.
|
|
</success_criteria>
|
|
|
|
<notes>
|
|
- Tuning the session lifetime is config-only, no code change: raising `OIDC_AUTH_EXPIRES` in .env (e.g. `2592000` for 30 days) extends both the server-side JWT lifetime AND the persisted cookie's Max-Age — the new middleware reads `OIDC_AUTH_EXPIRES` for maxAge automatically. The effective ceiling is still bounded by Authelia's refresh_token_lifespan.
|
|
- Hono's `setCookie` maxAge is expressed in SECONDS (not ms), matching the OIDC_AUTH_EXPIRES unit — no conversion needed.
|
|
</notes>
|
|
|
|
<output>
|
|
Create `.planning/quick/260610-k1z-persist-oidc-session-cookie-with-maxage-/260610-k1z-SUMMARY.md` when done
|
|
</output>
|