diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index c9c24b0..27c25bf 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,10 +1,27 @@ 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 }) => { - 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 = { sub: session.sub, name: session.name, diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts index 066f12c..594ba81 100644 --- a/frontend/src/lib/server/auth.ts +++ b/frontend/src/lib/server/auth.ts @@ -1,5 +1,5 @@ import * as client from 'openid-client'; -import { SignJWT, jwtVerify } from 'jose'; +import { EncryptJWT, jwtDecrypt } from 'jose'; import { env } from '$env/dynamic/private'; import type { SessionUser } from '$lib/types'; @@ -45,22 +45,61 @@ export const authConfig = { } }; -const sessionKey = () => new TextEncoder().encode(env.SESSION_SECRET); +let keyPromise: Promise | null = null; + +function sessionKey(): Promise { + 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 { - return new SignJWT({ ...session }) - .setProtectedHeader({ alg: 'HS256' }) + return new EncryptJWT({ ...session }) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) .setIssuedAt() .setExpirationTime('7d') - .sign(sessionKey()); + .encrypt(await sessionKey()); } export async function decodeSession(token: string | undefined): Promise { if (!token) return null; try { - const { payload } = await jwtVerify(token, sessionKey()); + const { payload } = await jwtDecrypt(token, await sessionKey()); return payload as unknown as Session; } catch { return null; } } + +export async function refreshSession(session: Session): Promise { + 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; + } +} diff --git a/frontend/src/routes/auth/callback/+server.ts b/frontend/src/routes/auth/callback/+server.ts index 1fed090..2928a7b 100644 --- a/frontend/src/routes/auth/callback/+server.ts +++ b/frontend/src/routes/auth/callback/+server.ts @@ -4,6 +4,7 @@ import type { RequestHandler } from './$types'; import { getOidcConfig, encodeSession, + sessionCookieOptions, type Session, SESSION_COOKIE, STATE_COOKIE, @@ -53,13 +54,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600) }; - cookies.set(SESSION_COOKIE, await encodeSession(session), { - httpOnly: true, - sameSite: 'lax', - secure: url.protocol === 'https:', - path: '/', - maxAge: 60 * 60 * 24 * 7 - }); + cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(url)); for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) { cookies.delete(name, { path: '/' });