From aabcb5d0436f0f8674bdf6aef4705780f579311f Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 10 Jun 2026 14:30:59 -0400 Subject: [PATCH] feat(260610-k1z-01): add persistSessionCookie() middleware (AUTH-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-issues oidc-auth cookie with maxAge so PWA sessions survive close/reopen - Guards on c.get('oidcAuthJwt'): only runs when @hono/oidc-auth set a valid session - Falsy oidcAuthJwt falls straight through — no resurrection of deleted/absent cookies - Cookie attrs mirror the library: httpOnly, secure, sameSite=Lax, conditional domain - maxAge reads OIDC_AUTH_EXPIRES (default 86400s) --- apps/api/src/auth/persistSessionCookie.ts | 80 +++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 apps/api/src/auth/persistSessionCookie.ts diff --git a/apps/api/src/auth/persistSessionCookie.ts b/apps/api/src/auth/persistSessionCookie.ts new file mode 100644 index 0000000..45b1781 --- /dev/null +++ b/apps/api/src/auth/persistSessionCookie.ts @@ -0,0 +1,80 @@ +/** + * persistSessionCookie — AUTH-02: upgrade the OIDC session cookie to a persistent one. + * + * WHY THIS EXISTS + * --------------- + * @hono/oidc-auth 1.8.3 sets its `oidc-auth` session cookie with + * { path, httpOnly: true, secure: true [, domain] } + * and NO maxAge/expires. That makes it a SESSION-SCOPED cookie: the browser discards + * it when the PWA or browser tab is closed. On reopen there is no cookie, so the OIDC + * guard 302-redirects to Authelia and forces a full re-login — even though the + * server-side JWT is still valid for OIDC_AUTH_EXPIRES seconds (default 86 400 / 1 day). + * + * The library has no option to set maxAge. This middleware wraps around it and + * re-issues the same signed JWT as a persistent cookie (with maxAge + sameSite:'Lax'). + * + * WHEN THE RE-ISSUE FIRES + * ----------------------- + * @hono/oidc-auth calls `c.set('oidcAuthJwt', session_jwt)` ONLY on requests where a + * valid session is created or refreshed. Logged-out / no-session / unauthenticated + * requests do NOT set `oidcAuthJwt`. This is the guard signal: re-issue the persistent + * cookie only when the library produced a valid session this request. + */ + +import type { MiddlewareHandler } from 'hono' +import { setCookie } from 'hono/cookie' + +/** + * Returns a Hono MiddlewareHandler that upgrades the session-scoped oidc-auth cookie + * (set by @hono/oidc-auth) to a persistent cookie with a Max-Age. + * + * Mount immediately after oidcAuthMiddleware() on '/api/*' inside the !devBypassActive + * block in index.ts. Under dev bypass there is no oidc-auth cookie so it must not run. + */ +export function persistSessionCookie(): MiddlewareHandler { + return async (c, next) => { + // Read the freshly-signed session JWT placed on context by @hono/oidc-auth. + // The 'as never' cast is required because 'oidcAuthJwt' is a library-internal key + // that is not declared in Hono's ContextVariableMap — mirrors the loose-context + // convention used elsewhere in this codebase (e.g. resolveUserId). + const jwt = c.get('oidcAuthJwt' as never) as string | undefined + + // CRITICAL CORRECTNESS GUARD: if no valid session JWT is on context (logged-out, + // deleted, or never-set request), do NOT touch cookies and fall straight through. + // This property prevents resurrecting a deleted or absent cookie. Without it a + // logged-out user would receive a new oidc-auth Set-Cookie with no value, which + // could re-authenticate them or produce confusing browser state. + if (!jwt) { + await next() + return + } + + // A valid session JWT is present — re-issue the cookie BEFORE next() so that if + // any future downstream handler deletes the cookie, that delete Set-Cookie header + // comes last and wins (safe ordering even without a current logout/revoke route). + const name = process.env.OIDC_COOKIE_NAME ?? 'oidc-auth' + const path = process.env.OIDC_COOKIE_PATH ?? '/' + const maxAge = Number(process.env.OIDC_AUTH_EXPIRES ?? 86400) + + // Build the options object. domain is included ONLY when OIDC_COOKIE_DOMAIN is set — + // mirroring the library's own conditional-domain logic so the two Set-Cookie headers + // carry identical scope attributes (avoids a split-cookie situation where the library + // sets domain=X but we set no domain, or vice-versa). + const options: Parameters[3] = { + path, + httpOnly: true, + secure: true, + sameSite: 'Lax', + maxAge, // seconds — Hono's setCookie maxAge unit matches OIDC_AUTH_EXPIRES unit + } + + if (process.env.OIDC_COOKIE_DOMAIN) { + options.domain = process.env.OIDC_COOKIE_DOMAIN + } + + // Re-issue the same JWT the library signed. Do NOT re-sign or modify the payload. + setCookie(c, name, jwt, options) + + await next() + } +}