fix: persist login session + add proactive PWA service-worker refresh
Build and publish docker image / Build and push image to Gitea's registry (push) Successful in 41s

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) <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-06-19 14:48:44 +02:00
co-authored by Claude Opus 4.8
parent 8de6de2af0
commit d89296abe5
2 changed files with 110 additions and 39 deletions
+72 -40
View File
@@ -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<Handle>[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,45 +92,19 @@ 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();
// 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
}
}
}
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 };
}
}
}
}
// 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);
+39
View File
@@ -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<typeof setInterval> | 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) => {