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) => {