A build your own ATProto adventure, OAuth already figured out for you.
at main 6.3 kB view raw
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 3 4import { db } from './db'; 5import { atpOAuthClient } from './atproto/client'; 6import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding'; 7import { sha256 } from '@oslojs/crypto/sha2'; 8import { DAY } from '@atproto/common'; 9import { sessionStore } from '$lib/server/db/schema'; 10import type { RequestEvent } from '@sveltejs/kit'; 11import { eq } from 'drizzle-orm'; 12import { Agent } from '@atproto/api'; 13import type { NodeOAuthClient } from '@atproto/oauth-client-node'; 14import { logger } from '$lib/server/logger'; 15 16export class SessionRestorationError extends Error { 17 constructor(message: string) { 18 super(message); 19 this.name = 'SessionRestorationError'; 20 } 21} 22 23// This is a sliding expiration for the cookie session. Can change it if you want it to be less or more. 24// The actual atproto session goes for a while if it's a confidential client as long as it's refreshed 25// https://atproto.com/specs/oauth#tokens-and-session-lifetime 26const DEFAULT_EXPIRY = 30 * DAY; 27 28const NULL_SESSION_RESPONSE = { atpAgent: null, did: null, handle: null }; 29 30 31export class Session { 32 db: typeof db; 33 atpOAuthClient: NodeOAuthClient; 34 35 constructor(database: typeof db, oauthClient: NodeOAuthClient) { 36 this.db = database; 37 this.atpOAuthClient = oauthClient; 38 } 39 40 41 async validateSessionToken(token: string): Promise<SessionValidationResult> { 42 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]; 51 52 if (Date.now() >= session.expiresAt.getTime()) { 53 await this.invalidateSession(session.id); 54 logger.warn(`Session expired for the did: ${session.did}`); 55 return NULL_SESSION_RESPONSE; 56 } 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{ 62 const oAuthSession = await this.atpOAuthClient.restore(session.did); 63 64 const agent = new Agent(oAuthSession); 65 return { atpAgent: agent, did: session.did, handle: session.handle }; 66 }catch (err){ 67 const errorMessage = (err as Error).message; 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 70 //You can go further and capture different types of errors 71 await this.invalidateUserSessions(session.did); 72 throw new SessionRestorationError(`Failed to restore your session: ${errorMessage}. Please log in again.`); 73 } 74 } 75 76 private setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void { 77 event.cookies.set('session', token, { 78 httpOnly: true, 79 path: '/', 80 secure: import.meta.env.PROD, 81 sameSite: 'lax', 82 expires: expiresAt 83 }); 84 } 85 86 deleteSessionTokenCookie(event: RequestEvent): void { 87 event.cookies.set('session', '', { 88 httpOnly: true, 89 path: '/', 90 secure: import.meta.env.PROD, 91 sameSite: 'lax', 92 maxAge: 0 93 }); 94 } 95 96 async invalidateSession(sessionId: string) { 97 await this.db.delete(sessionStore).where(eq(sessionStore.id, sessionId)); 98 } 99 100 async invalidateSessionByToken(token: string) { 101 const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 102 await this.invalidateSession(sessionId); 103 } 104 105 async invalidateUserSessions(did: string) { 106 await this.db.delete(sessionStore).where(eq(sessionStore.did, did)); 107 } 108 109 private async createSession(token: string, did: string, handle: string) { 110 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); 114 return session; 115 } 116 117 private generateSessionToken(): string { 118 const tokenBytes = new Uint8Array(20); 119 crypto.getRandomValues(tokenBytes); 120 return encodeBase32LowerCaseNoPadding(tokenBytes); 121 } 122 123 async createAndSetSession(event: RequestEvent, did: string, handle: string) { 124 const token = this.generateSessionToken(); 125 const session = await this.createSession(token, did, handle); 126 this.setSessionTokenCookie(event, token, session.expiresAt); 127 return session; 128 } 129 130 async getSessionFromRequest(event: RequestEvent): Promise<SessionValidationResult> { 131 const token = event.cookies.get('session'); 132 if (!token) { 133 return NULL_SESSION_RESPONSE; 134 } 135 try { 136 return await this.validateSessionToken(token); 137 } catch (err) { 138 //We delete the cookie on any error and pass along the error 139 this.deleteSessionTokenCookie(event); 140 throw err; 141 } 142 } 143 144} 145 146type SessionValidationResult = { atpAgent: Agent, did: string, handle: string } | { atpAgent: null, did: null, handle: null }; 147 148let sessionManager: Promise<Session> | null = null; 149 150export const getSessionManager = async (): Promise<Session> => { 151 if (!sessionManager) { 152 sessionManager = (async () => { 153 const client = await atpOAuthClient(); 154 return new Session(db, client); 155 })(); 156 } 157 return sessionManager; 158};