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
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:
co-authored by
Claude Opus 4.8
parent
8de6de2af0
commit
d89296abe5
+71
-39
@@ -1,12 +1,19 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
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 { db } from '$lib/server/db/index.js';
|
||||||
import { refreshTokens, users } from '$lib/server/db/schema.js';
|
import { refreshTokens, users } from '$lib/server/db/schema.js';
|
||||||
import { ensureAdminUser } from '$lib/server/auth/seed.js';
|
import { ensureAdminUser } from '$lib/server/auth/seed.js';
|
||||||
import { startRetention } from '$lib/server/retention.js';
|
import { startRetention } from '$lib/server/retention.js';
|
||||||
import { resetOrphanedJobs } from '$lib/server/queue.js';
|
import { resetOrphanedJobs } from '$lib/server/queue.js';
|
||||||
import { eq, and, isNull, gt } from 'drizzle-orm';
|
import { eq, and, isNull, gt } from 'drizzle-orm';
|
||||||
import { hashRefreshToken } from '$lib/server/auth/jwt.js';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
let booted = false;
|
let booted = false;
|
||||||
|
|
||||||
@@ -20,6 +27,57 @@ async function boot() {
|
|||||||
// success/failure) + the shared ig-keeper. See @mozempk/ig-auth.
|
// 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 }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
await boot();
|
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 };
|
event.locals.user = { id: user.id, username: user.username, role: user.role };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isExpiredError(e) && refreshToken) {
|
// An expired (or otherwise invalid) access token while a refresh
|
||||||
// try to rotate refresh token
|
// cookie is still present → rotate below.
|
||||||
const tokenHash = hashRefreshToken(refreshToken);
|
if (!isExpiredError(e)) {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
// malformed/forged token: ignore it, fall through to refresh attempt
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
// capture install prompt
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user