changed cookie session store to valkey too

Changed files
+68 -46
src
lib
routes
+16 -1
dev.compose.yml
··· 1 1 services: 2 + postgres: 3 + image: postgres:18 4 + restart: unless-stopped 5 + environment: 6 + POSTGRES_USER: ${DB_USER} 7 + POSTGRES_PASSWORD: ${DB_PASSWORD} 8 + POSTGRES_DB: ${DB_NAME} 9 + ports: 10 + - "5432:5432" 11 + volumes: 12 + - postgres_data:/var/lib/postgresql 13 + extra_hosts: 14 + - "host.docker.internal:host-gateway" 15 + networks: 16 + - services-network 2 17 valkey: 3 18 image: valkey/valkey:9.0 4 19 ports: ··· 13 28 - services-network 14 29 volumes: 15 30 valkey_data: 16 - app_data: 31 + postgres_data: 17 32 networks: 18 33 services-network: 19 34 driver: bridge
+8 -3
src/lib/server/cache.ts
··· 62 62 return undefined; 63 63 } 64 64 65 - async set(key: string, value: string) { 66 - const expiryOptions = this.expire ? 65 + async set(key: string, value: string, customExpire?: number) { 66 + const expireSeconds = customExpire ?? this.expire; 67 + const expiryOptions = expireSeconds ? 67 68 { 68 69 expiry: { 69 70 type: TimeUnit.Seconds, 70 - count: this.expire as number 71 + count: expireSeconds 71 72 } 72 73 } : undefined; 73 74 return await this.valKeyClient.set(this.$key(key), value, expiryOptions); ··· 75 76 76 77 async delete(key: string) { 77 78 await this.valKeyClient.del([this.$key(key)]); 79 + } 80 + 81 + async refreshExpiry(key: string, seconds: number) { 82 + await this.valKeyClient.expire(this.$key(key), seconds); 78 83 } 79 84 80 85 }
+42 -40
src/lib/server/session.ts
··· 1 1 // A cookie session store based on https://lucia-auth.com/ examples which is recommended from Svelte's docs 2 - // Creates a cookie that links to a session store inside the database allowing the atproto oauth session to be loaded 2 + // Creates a cookie that links to a session store inside Valkey allowing the atproto oauth session to be loaded 3 3 4 - import { db } from './db'; 5 4 import { atpOAuthClient } from './atproto/client'; 6 5 import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding'; 7 6 import { sha256 } from '@oslojs/crypto/sha2'; 8 7 import { DAY } from '@atproto/common'; 9 - import { sessionStore } from '$lib/server/db/schema'; 10 8 import type { RequestEvent } from '@sveltejs/kit'; 11 - import { eq } from 'drizzle-orm'; 12 9 import { Agent } from '@atproto/api'; 13 10 import type { NodeOAuthClient } from '@atproto/oauth-client-node'; 14 11 import { logger } from '$lib/server/logger'; 12 + import { Cache, getAValKeyClient } from '$lib/server/cache'; 13 + 14 + const COOKIE_SESSION_STORE = 'cookie_sessions:'; 15 15 16 16 export class SessionRestorationError extends Error { 17 17 constructor(message: string) { ··· 23 23 // This is a sliding expiration for the cookie session. Can change it if you want it to be less or more. 24 24 // The actual atproto session goes for a while if it's a confidential client as long as it's refreshed 25 25 // https://atproto.com/specs/oauth#tokens-and-session-lifetime 26 - const DEFAULT_EXPIRY = 30 * DAY; 26 + const DEFAULT_EXPIRY_MS = 30 * DAY; 27 + const DEFAULT_EXPIRY_SECONDS = Math.floor(DEFAULT_EXPIRY_MS / 1000); 28 + 27 29 28 30 const NULL_SESSION_RESPONSE = { atpAgent: null, did: null, handle: null }; 29 31 32 + interface StoredSession { 33 + did: string; 34 + handle: string; 35 + createdAt: number; // timestamp in milliseconds 36 + } 30 37 31 38 export class Session { 32 - db: typeof db; 39 + cache: Cache; 33 40 atpOAuthClient: NodeOAuthClient; 34 41 35 - constructor(database: typeof db, oauthClient: NodeOAuthClient) { 36 - this.db = database; 42 + constructor(cache: Cache, oauthClient: NodeOAuthClient) { 43 + this.cache = cache; 37 44 this.atpOAuthClient = oauthClient; 38 45 } 39 46 40 47 41 48 async validateSessionToken(token: string): Promise<SessionValidationResult> { 42 49 const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 43 - const result = await this.db.select().from(sessionStore).where(eq(sessionStore.id, sessionId)).limit(1); 44 - if(result.length > 1){ 45 - throw new Error('Multiple sessions found for token. Should not happen'); 46 - } 47 - if(result.length === 0){ 48 - return NULL_SESSION_RESPONSE; 49 - } 50 - const session = result[0]; 50 + const sessionData = await this.cache.get(sessionId); 51 51 52 - if (Date.now() >= session.expiresAt.getTime()) { 53 - await this.invalidateSession(session.id); 54 - logger.warn(`Session expired for the did: ${session.did}`); 52 + // If session doesn't exist or has expired, Valkey returns undefined 53 + if (!sessionData) { 55 54 return NULL_SESSION_RESPONSE; 56 55 } 57 - if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 58 - session.expiresAt = new Date(Date.now() + DEFAULT_EXPIRY); 59 - await this.db.update(sessionStore).set(session).where(eq(sessionStore.id, sessionId)); 60 - } 61 - try{ 56 + 57 + const session: StoredSession = JSON.parse(sessionData); 58 + 59 + // Refresh TTL on each access (sliding window) 60 + await this.cache.refreshExpiry(sessionId, DEFAULT_EXPIRY_SECONDS); 61 + 62 + try { 62 63 const oAuthSession = await this.atpOAuthClient.restore(session.did); 63 - 64 64 const agent = new Agent(oAuthSession); 65 65 return { atpAgent: agent, did: session.did, handle: session.handle }; 66 - }catch (err){ 66 + } catch (err) { 67 67 const errorMessage = (err as Error).message; 68 68 logger.warn(`Error restoring session for did: ${session.did}, error: ${errorMessage}`); 69 - //Counting any error when restoring a session as a failed session resume and deleting the users web browser session 69 + //Counting any error when restoring a session as a failed session resume and deleting the user's web browser session 70 70 //You can go further and capture different types of errors 71 - await this.invalidateUserSessions(session.did); 71 + await this.invalidateSession(sessionId); 72 72 throw new SessionRestorationError(`Failed to restore your session: ${errorMessage}. Please log in again.`); 73 73 } 74 74 } 75 75 76 - private setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void { 76 + private setSessionTokenCookie(event: RequestEvent, token: string): void { 77 77 event.cookies.set('session', token, { 78 78 httpOnly: true, 79 79 path: '/', 80 80 secure: import.meta.env.PROD, 81 81 sameSite: 'lax', 82 - expires: expiresAt 82 + maxAge: DEFAULT_EXPIRY_SECONDS 83 83 }); 84 84 } 85 85 ··· 94 94 } 95 95 96 96 async invalidateSession(sessionId: string) { 97 - await this.db.delete(sessionStore).where(eq(sessionStore.id, sessionId)); 97 + await this.cache.delete(sessionId); 98 98 } 99 99 100 100 async invalidateSessionByToken(token: string) { ··· 102 102 await this.invalidateSession(sessionId); 103 103 } 104 104 105 - async invalidateUserSessions(did: string) { 106 - await this.db.delete(sessionStore).where(eq(sessionStore.did, did)); 107 - } 108 - 109 105 private async createSession(token: string, did: string, handle: string) { 110 106 const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 111 - const expiresAt = new Date(Date.now() + DEFAULT_EXPIRY); 112 - const session = { id: sessionId, did, handle, expiresAt, createdAt: new Date() }; 113 - await this.db.insert(sessionStore).values(session); 107 + const session: StoredSession = { 108 + did, 109 + handle, 110 + createdAt: Date.now() 111 + }; 112 + // Set with TTL - Valkey will automatically expire after DEFAULT_EXPIRY_SECONDS 113 + await this.cache.set(sessionId, JSON.stringify(session), DEFAULT_EXPIRY_SECONDS); 114 114 return session; 115 115 } 116 116 ··· 123 123 async createAndSetSession(event: RequestEvent, did: string, handle: string) { 124 124 const token = this.generateSessionToken(); 125 125 const session = await this.createSession(token, did, handle); 126 - this.setSessionTokenCookie(event, token, session.expiresAt); 126 + this.setSessionTokenCookie(event, token); 127 127 return session; 128 128 } 129 129 ··· 150 150 export const getSessionManager = async (): Promise<Session> => { 151 151 if (!sessionManager) { 152 152 sessionManager = (async () => { 153 + const valKeyClient = await getAValKeyClient(); 154 + const cache = new Cache(valKeyClient, COOKIE_SESSION_STORE); 153 155 const client = await atpOAuthClient(); 154 - return new Session(db, client); 156 + return new Session(cache, client); 155 157 })(); 156 158 } 157 159 return sessionManager;
+2 -2
src/routes/logout/+page.server.ts
··· 13 13 sessionManager.deleteSessionTokenCookie(event); 14 14 15 15 const oauthClient = await atpOAuthClient(); 16 - if(event.locals.did) { 17 - await oauthClient.revoke(event.locals.did); 16 + if(event.locals.session?.did) { 17 + await oauthClient.revoke(event.locals.session.did); 18 18 } 19 19 } 20 20