From d89296abe5b30b49819050f5ad866c54e3b4b94c Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Fri, 19 Jun 2026 14:48:44 +0200 Subject: [PATCH] fix: persist login session + add proactive PWA service-worker refresh Sessions silently dropped ~15-30 min after login on the deployed instance. Two compounding bugs in hooks.server.ts: - The refresh path was gated behind `if (accessToken)`, but the access cookie's maxAge (15m) equals the JWT TTL, so the browser deletes the cookie exactly when the JWT expires. Subsequent requests arrived with no access cookie and skipped refresh entirely -> logout. Rotation now keys off the `refresh` cookie (30d) instead of an expired access token. - The old code revoked the refresh token without issuing a new `refresh` cookie (it assumed /api/auth/refresh handled rotation, but nothing calls it). Now does a full one-time-use rotation: revoke + new access + new refresh cookie -> sliding 30d session. Also adopt the SvelteKit PWA proactive auto-refresh standard: the worker already does skipWaiting()+clients.claim(); add watchSwUpdates() in the root layout to call registration.update() on load/focus/reconnect/hourly and reload once on controllerchange (guarded against first-install reload). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/hooks.server.ts | 110 ++++++++++++++++++++++++-------------- src/routes/+layout.svelte | 39 ++++++++++++++ 2 files changed, 110 insertions(+), 39 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0071056..d87af24 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,12 +1,19 @@ import type { Handle } from '@sveltejs/kit'; -import { verifyAccessToken, isExpiredError, signAccessToken } from '$lib/server/auth/jwt.js'; +import { + verifyAccessToken, + isExpiredError, + signAccessToken, + hashRefreshToken, + generateRefreshToken, + refreshTokenExpiresAt +} from '$lib/server/auth/jwt.js'; import { db } from '$lib/server/db/index.js'; import { refreshTokens, users } from '$lib/server/db/schema.js'; import { ensureAdminUser } from '$lib/server/auth/seed.js'; import { startRetention } from '$lib/server/retention.js'; import { resetOrphanedJobs } from '$lib/server/queue.js'; import { eq, and, isNull, gt } from 'drizzle-orm'; -import { hashRefreshToken } from '$lib/server/auth/jwt.js'; +import { randomUUID } from 'node:crypto'; let booted = false; @@ -20,6 +27,57 @@ async function boot() { // success/failure) + the shared ig-keeper. See @mozempk/ig-auth. } +const cookieOpts = { path: '/', httpOnly: true, sameSite: 'lax' as const, secure: true }; + +/** + * Mint a fresh access + refresh pair from a still-valid refresh token, rotating + * the refresh token (one-time use). Returns the user if rotation succeeded. + * + * NOTE: the `refresh` cookie lives 30d while the `access` cookie/JWT live 15m, + * so most requests after the 15m mark arrive with NO access cookie at all — the + * rotation must key off the refresh cookie, not off an expired access token. + */ +async function rotateFromRefresh(event: Parameters[0]['event'], refreshToken: string) { + const tokenHash = hashRefreshToken(refreshToken); + const now = Math.floor(Date.now() / 1000); + const rt = db + .select() + .from(refreshTokens) + .where( + and( + eq(refreshTokens.tokenHash, tokenHash), + isNull(refreshTokens.revokedAt), + gt(refreshTokens.expiresAt, now) + ) + ) + .get(); + if (!rt) return null; + + const user = db.select().from(users).where(eq(users.id, rt.userId)).get(); + if (!user) return null; + + // revoke the used refresh token and issue a fresh access + refresh pair + // (one-time-use refresh, sliding 30d session). + db.update(refreshTokens).set({ revokedAt: now }).where(eq(refreshTokens.id, rt.id)).run(); + + const newRefresh = generateRefreshToken(); + db.insert(refreshTokens) + .values({ + id: randomUUID(), + userId: user.id, + tokenHash: hashRefreshToken(newRefresh), + expiresAt: refreshTokenExpiresAt(), + userAgent: event.request.headers.get('user-agent') ?? undefined + }) + .run(); + + const newAccess = await signAccessToken({ sub: user.id, role: user.role }); + event.cookies.set('access', newAccess, { ...cookieOpts, maxAge: 15 * 60 }); + event.cookies.set('refresh', newRefresh, { ...cookieOpts, maxAge: 30 * 86400 }); + + return { id: user.id, username: user.username, role: user.role }; +} + export const handle: Handle = async ({ event, resolve }) => { await boot(); @@ -34,46 +92,20 @@ export const handle: Handle = async ({ event, resolve }) => { event.locals.user = { id: user.id, username: user.username, role: user.role }; } } catch (e) { - if (isExpiredError(e) && refreshToken) { - // try to rotate refresh token - const tokenHash = hashRefreshToken(refreshToken); - const now = Math.floor(Date.now() / 1000); - const rt = db - .select() - .from(refreshTokens) - .where( - and( - eq(refreshTokens.tokenHash, tokenHash), - isNull(refreshTokens.revokedAt), - gt(refreshTokens.expiresAt, now) - ) - ) - .get(); - - if (rt) { - const user = db.select().from(users).where(eq(users.id, rt.userId)).get(); - if (user) { - // revoke old token - db.update(refreshTokens) - .set({ revokedAt: now }) - .where(eq(refreshTokens.id, rt.id)) - .run(); - - // issue new access token (refresh rotation done in /api/auth/refresh) - const newAccess = await signAccessToken({ sub: user.id, role: user.role }); - event.cookies.set('access', newAccess, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: true, - maxAge: 15 * 60 - }); - event.locals.user = { id: user.id, username: user.username, role: user.role }; - } - } + // An expired (or otherwise invalid) access token while a refresh + // cookie is still present → rotate below. + if (!isExpiredError(e)) { + // malformed/forged token: ignore it, fall through to refresh attempt } } } + // No valid access token yet, but a refresh cookie is present — this is the + // common case once the 15m access cookie has been dropped by the browser. + if (!event.locals.user && refreshToken) { + const user = await rotateFromRefresh(event, refreshToken); + if (user) event.locals.user = user; + } + return resolve(event); }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a2e4ac6..3791f40 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -25,6 +25,45 @@ } }); + // Standard: proactive SW auto-refresh — keep installed PWAs off a stale build. + // Worker does skipWaiting()+clients.claim(); here we drive update checks + // (load/focus/reconnect/hourly) and reload once when the new worker takes over. + $effect(() => { + if (!('serviceWorker' in navigator)) return; + + let refreshing = false; + // Don't reload on first install (no prior controller → claim fires controllerchange once). + const hadController = !!navigator.serviceWorker.controller; + const onControllerChange = () => { + if (refreshing || !hadController) return; + refreshing = true; + location.reload(); + }; + navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); + + let reg: ServiceWorkerRegistration | undefined; + const checkForUpdate = () => void reg?.update().catch(() => {}); + const onVisible = () => { + if (document.visibilityState === 'visible') checkForUpdate(); + }; + let timer: ReturnType | undefined; + + navigator.serviceWorker.ready.then((r) => { + reg = r; + checkForUpdate(); // on load + timer = setInterval(checkForUpdate, 60 * 60 * 1000); // hourly + }); + document.addEventListener('visibilitychange', onVisible); // on focus + window.addEventListener('online', checkForUpdate); // on reconnect + + return () => { + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('online', checkForUpdate); + if (timer) clearInterval(timer); + }; + }); + // capture install prompt $effect(() => { const handler = (e: Event) => {