feat(frontend): encrypt session cookie (JWE) and auto-refresh access token

This commit is contained in:
2026-07-05 23:41:49 +02:00
parent 33ead1daab
commit 9613cd928d
3 changed files with 67 additions and 16 deletions
+20 -3
View File
@@ -1,10 +1,27 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { decodeSession, SESSION_COOKIE } from '$lib/server/auth'; import {
decodeSession,
encodeSession,
refreshSession,
sessionCookieOptions,
SESSION_COOKIE
} from '$lib/server/auth';
const REFRESH_THRESHOLD_SECONDS = 30;
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const session = await decodeSession(event.cookies.get(SESSION_COOKIE)); let session = await decodeSession(event.cookies.get(SESSION_COOKIE));
if (session && session.expiresAt * 1000 > Date.now()) { if (session && session.expiresAt - REFRESH_THRESHOLD_SECONDS <= Math.floor(Date.now() / 1000)) {
session = await refreshSession(session);
if (session) {
event.cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(event.url));
} else {
event.cookies.delete(SESSION_COOKIE, { path: '/' });
}
}
if (session) {
event.locals.user = { event.locals.user = {
sub: session.sub, sub: session.sub,
name: session.name, name: session.name,
+45 -6
View File
@@ -1,5 +1,5 @@
import * as client from 'openid-client'; import * as client from 'openid-client';
import { SignJWT, jwtVerify } from 'jose'; import { EncryptJWT, jwtDecrypt } from 'jose';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
@@ -45,22 +45,61 @@ export const authConfig = {
} }
}; };
const sessionKey = () => new TextEncoder().encode(env.SESSION_SECRET); let keyPromise: Promise<Uint8Array> | null = null;
function sessionKey(): Promise<Uint8Array> {
keyPromise ??= crypto.subtle
.digest('SHA-256', new TextEncoder().encode(env.SESSION_SECRET))
.then((digest) => new Uint8Array(digest));
return keyPromise;
}
export function sessionCookieOptions(url: URL) {
return {
httpOnly: true,
sameSite: 'lax' as const,
secure: url.protocol === 'https:',
path: '/',
maxAge: 60 * 60 * 24 * 7
};
}
export async function encodeSession(session: Session): Promise<string> { export async function encodeSession(session: Session): Promise<string> {
return new SignJWT({ ...session }) return new EncryptJWT({ ...session })
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt() .setIssuedAt()
.setExpirationTime('7d') .setExpirationTime('7d')
.sign(sessionKey()); .encrypt(await sessionKey());
} }
export async function decodeSession(token: string | undefined): Promise<Session | null> { export async function decodeSession(token: string | undefined): Promise<Session | null> {
if (!token) return null; if (!token) return null;
try { try {
const { payload } = await jwtVerify(token, sessionKey()); const { payload } = await jwtDecrypt(token, await sessionKey());
return payload as unknown as Session; return payload as unknown as Session;
} catch { } catch {
return null; return null;
} }
} }
export async function refreshSession(session: Session): Promise<Session | null> {
if (!session.refreshToken) return null;
try {
const config = await getOidcConfig();
const tokens = await client.refreshTokenGrant(config, session.refreshToken);
const claims = tokens.claims();
return {
sub: session.sub,
name: (claims?.name as string | undefined) ?? session.name,
email: (claims?.email as string | undefined) ?? session.email,
picture: (claims?.picture as string | undefined) ?? session.picture,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token ?? session.refreshToken,
idToken: tokens.id_token ?? session.idToken,
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
};
} catch (exception) {
console.error('Token refresh failed', exception);
return null;
}
}
+2 -7
View File
@@ -4,6 +4,7 @@ import type { RequestHandler } from './$types';
import { import {
getOidcConfig, getOidcConfig,
encodeSession, encodeSession,
sessionCookieOptions,
type Session, type Session,
SESSION_COOKIE, SESSION_COOKIE,
STATE_COOKIE, STATE_COOKIE,
@@ -53,13 +54,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600) expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
}; };
cookies.set(SESSION_COOKIE, await encodeSession(session), { cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(url));
httpOnly: true,
sameSite: 'lax',
secure: url.protocol === 'https:',
path: '/',
maxAge: 60 * 60 * 24 * 7
});
for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) { for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) {
cookies.delete(name, { path: '/' }); cookies.delete(name, { path: '/' });