// A cookie session store based on https://lucia-auth.com/ examples which is recommended from Svelte's docs // Creates a cookie that links to a session store inside the database allowing the atproto oauth session to be loaded import { db } from './db'; import { atpOAuthClient } from './atproto/client'; import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding'; import { sha256 } from '@oslojs/crypto/sha2'; import { DAY } from '@atproto/common'; import { sessionStore } from '$lib/server/db/schema'; import type { RequestEvent } from '@sveltejs/kit'; import { eq } from 'drizzle-orm'; import { Agent } from '@atproto/api'; import type { NodeOAuthClient } from '@atproto/oauth-client-node'; import { logger } from '$lib/server/logger'; export class SessionRestorationError extends Error { constructor(message: string) { super(message); this.name = 'SessionRestorationError'; } } // This is a sliding expiration for the cookie session. Can change it if you want it to be less or more. // The actual atproto session goes for a while if it's a confidential client as long as it's refreshed // https://atproto.com/specs/oauth#tokens-and-session-lifetime const DEFAULT_EXPIRY = 30 * DAY; const NULL_SESSION_RESPONSE = { atpAgent: null, did: null, handle: null }; export class Session { db: typeof db; atpOAuthClient: NodeOAuthClient; constructor(database: typeof db, oauthClient: NodeOAuthClient) { this.db = database; this.atpOAuthClient = oauthClient; } async validateSessionToken(token: string): Promise { const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); const result = await this.db.select().from(sessionStore).where(eq(sessionStore.id, sessionId)).limit(1); if(result.length > 1){ throw new Error('Multiple sessions found for token. Should not happen'); } if(result.length === 0){ return NULL_SESSION_RESPONSE; } const session = result[0]; if (Date.now() >= session.expiresAt.getTime()) { await this.invalidateSession(session.id); logger.warn(`Session expired for the did: ${session.did}`); return NULL_SESSION_RESPONSE; } if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { session.expiresAt = new Date(Date.now() + DEFAULT_EXPIRY); await this.db.update(sessionStore).set(session).where(eq(sessionStore.id, sessionId)); } try{ const oAuthSession = await this.atpOAuthClient.restore(session.did); const agent = new Agent(oAuthSession); return { atpAgent: agent, did: session.did, handle: session.handle }; }catch (err){ const errorMessage = (err as Error).message; logger.warn(`Error restoring session for did: ${session.did}, error: ${errorMessage}`); //Counting any error when restoring a session as a failed session resume and deleting the users web browser session //You can go further and capture different types of errors await this.invalidateUserSessions(session.did); throw new SessionRestorationError(`Failed to restore your session: ${errorMessage}. Please log in again.`); } } private setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void { event.cookies.set('session', token, { httpOnly: true, path: '/', secure: import.meta.env.PROD, sameSite: 'lax', expires: expiresAt }); } deleteSessionTokenCookie(event: RequestEvent): void { event.cookies.set('session', '', { httpOnly: true, path: '/', secure: import.meta.env.PROD, sameSite: 'lax', maxAge: 0 }); } async invalidateSession(sessionId: string) { await this.db.delete(sessionStore).where(eq(sessionStore.id, sessionId)); } async invalidateSessionByToken(token: string) { const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); await this.invalidateSession(sessionId); } async invalidateUserSessions(did: string) { await this.db.delete(sessionStore).where(eq(sessionStore.did, did)); } private async createSession(token: string, did: string, handle: string) { const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); const expiresAt = new Date(Date.now() + DEFAULT_EXPIRY); const session = { id: sessionId, did, handle, expiresAt, createdAt: new Date() }; await this.db.insert(sessionStore).values(session); return session; } private generateSessionToken(): string { const tokenBytes = new Uint8Array(20); crypto.getRandomValues(tokenBytes); return encodeBase32LowerCaseNoPadding(tokenBytes); } async createAndSetSession(event: RequestEvent, did: string, handle: string) { const token = this.generateSessionToken(); const session = await this.createSession(token, did, handle); this.setSessionTokenCookie(event, token, session.expiresAt); return session; } async getSessionFromRequest(event: RequestEvent): Promise { const token = event.cookies.get('session'); if (!token) { return NULL_SESSION_RESPONSE; } try { return await this.validateSessionToken(token); } catch (err) { //We delete the cookie on any error and pass along the error this.deleteSessionTokenCookie(event); throw err; } } } type SessionValidationResult = { atpAgent: Agent, did: string, handle: string } | { atpAgent: null, did: null, handle: null }; let sessionManager: Promise | null = null; export const getSessionManager = async (): Promise => { if (!sessionManager) { sessionManager = (async () => { const client = await atpOAuthClient(); return new Session(db, client); })(); } return sessionManager; };