Barazo AppView backend barazo.forum
at main 284 lines 9.9 kB view raw
1import crypto from 'node:crypto' 2import type { Cache } from '../cache/index.js' 3import type { Logger } from '../lib/logger.js' 4 5// --------------------------------------------------------------------------- 6// Key prefixes 7// --------------------------------------------------------------------------- 8 9const SESSION_DATA_PREFIX = 'barazo:session:data:' 10const ACCESS_TOKEN_PREFIX = 'barazo:session:access:' 11const DID_INDEX_PREFIX = 'barazo:session:did:' 12 13// --------------------------------------------------------------------------- 14// Types 15// --------------------------------------------------------------------------- 16 17export interface SessionConfig { 18 /** Session TTL in seconds (default: 604800 = 7 days) */ 19 sessionTtl: number 20 /** Access token TTL in seconds (default: 900 = 15 min) */ 21 accessTokenTtl: number 22} 23 24/** 25 * Persisted session data stored in Valkey. 26 * Contains only the access token HASH (never the raw token). 27 */ 28export interface Session { 29 /** Unique session identifier (used as refresh token) */ 30 sid: string 31 /** User's AT Protocol DID */ 32 did: string 33 /** User's AT Protocol handle */ 34 handle: string 35 /** SHA-256 hash of the access token (raw token is never persisted) */ 36 accessTokenHash: string 37 /** When the access token expires (epoch ms) */ 38 accessTokenExpiresAt: number 39 /** When the session was created (epoch ms) */ 40 createdAt: number 41} 42 43/** 44 * Session data returned to callers (includes the raw access token). 45 * Only exists in memory -- never serialized to Valkey. 46 */ 47export interface SessionWithToken extends Session { 48 /** Raw access token (returned to caller for HTTP response, never persisted) */ 49 accessToken: string 50} 51 52export interface SessionService { 53 /** 54 * Create a new session after successful OAuth callback. 55 * Generates session ID and access token, stores both in Valkey. 56 * Returns SessionWithToken (includes raw access token for HTTP response). 57 */ 58 createSession(did: string, handle: string): Promise<SessionWithToken> 59 60 /** 61 * Validate an access token. Returns the session if valid, undefined if invalid/expired. 62 * Looks up by access token hash, then fetches full session data. 63 */ 64 validateAccessToken(accessToken: string): Promise<Session | undefined> 65 66 /** 67 * Refresh a session: generate new access token, keep same session ID. 68 * The refresh token (session ID) comes from the HTTP-only cookie. 69 * Returns SessionWithToken with new access token, or undefined if session expired. 70 */ 71 refreshSession(sid: string): Promise<SessionWithToken | undefined> 72 73 /** 74 * Delete a session (logout). Removes both the session data and the access token lookup. 75 */ 76 deleteSession(sid: string): Promise<void> 77 78 /** 79 * Delete ALL sessions for a given DID (used on account deletion). 80 * Uses the DID-to-sessions index to find all sessions. 81 */ 82 deleteAllSessionsForDid(did: string): Promise<number> 83} 84 85// --------------------------------------------------------------------------- 86// Helpers 87// --------------------------------------------------------------------------- 88 89/** Generate a cryptographically random 32-byte hex string (64 chars). */ 90function generateToken(): string { 91 return crypto.randomBytes(32).toString('hex') 92} 93 94/** SHA-256 hash a value and return the hex digest. */ 95function sha256(value: string): string { 96 return crypto.createHash('sha256').update(value).digest('hex') 97} 98 99/** Truncate a hash to 8 characters for safe logging. */ 100function truncateForLog(value: string): string { 101 return value.slice(0, 8) 102} 103 104// --------------------------------------------------------------------------- 105// Factory 106// --------------------------------------------------------------------------- 107 108export function createSessionService( 109 cache: Cache, 110 logger: Logger, 111 config: SessionConfig 112): SessionService { 113 const { sessionTtl, accessTokenTtl } = config 114 115 async function createSession(did: string, handle: string): Promise<SessionWithToken> { 116 const sid = generateToken() 117 const accessToken = generateToken() 118 const tokenHash = sha256(accessToken) 119 const now = Date.now() 120 121 // Persisted session stores only the hash (never the raw token) 122 const session: Session = { 123 sid, 124 did, 125 handle, 126 accessTokenHash: tokenHash, 127 accessTokenExpiresAt: now + accessTokenTtl * 1000, 128 createdAt: now, 129 } 130 131 try { 132 // Store session data (TTL = session lifetime) 133 await cache.set(`${SESSION_DATA_PREFIX}${sid}`, JSON.stringify(session), 'EX', sessionTtl) 134 135 // Store access token hash → session ID mapping (TTL = access token lifetime) 136 await cache.set(`${ACCESS_TOKEN_PREFIX}${tokenHash}`, sid, 'EX', accessTokenTtl) 137 138 // Add session ID to DID index set and refresh its TTL 139 await cache.sadd(`${DID_INDEX_PREFIX}${did}`, sid) 140 await cache.expire(`${DID_INDEX_PREFIX}${did}`, sessionTtl) 141 142 logger.debug({ did, sid: truncateForLog(sid) }, 'Session created') 143 144 // Return with raw token for the HTTP response (never persisted) 145 return { ...session, accessToken } 146 } catch (err: unknown) { 147 logger.error({ err, did, sid: truncateForLog(sid) }, 'Failed to create session') 148 throw err 149 } 150 } 151 152 async function validateAccessToken(accessToken: string): Promise<Session | undefined> { 153 const tokenHash = sha256(accessToken) 154 155 try { 156 // Look up session ID by access token hash 157 const sid = await cache.get(`${ACCESS_TOKEN_PREFIX}${tokenHash}`) 158 if (sid === null) { 159 logger.debug({ tokenHash: truncateForLog(tokenHash) }, 'Access token not found') 160 return undefined 161 } 162 163 // Fetch full session data 164 const data = await cache.get(`${SESSION_DATA_PREFIX}${sid}`) 165 if (data === null) { 166 logger.debug( 167 { sid: truncateForLog(sid), tokenHash: truncateForLog(tokenHash) }, 168 'Session data not found (orphaned token)' 169 ) 170 return undefined 171 } 172 173 // Safe cast: we control all writes to this key via createSession/refreshSession 174 return JSON.parse(data) as Session 175 } catch (err: unknown) { 176 logger.error({ err, tokenHash: truncateForLog(tokenHash) }, 'Failed to validate access token') 177 throw err 178 } 179 } 180 181 async function refreshSession(sid: string): Promise<SessionWithToken | undefined> { 182 try { 183 // Fetch existing session 184 const data = await cache.get(`${SESSION_DATA_PREFIX}${sid}`) 185 if (data === null) { 186 logger.debug({ sid: truncateForLog(sid) }, 'Session not found for refresh') 187 return undefined 188 } 189 190 // Safe cast: we control all writes to this key via createSession/refreshSession 191 const existing = JSON.parse(data) as Session 192 193 // Delete old access token lookup (session stores only the hash) 194 await cache.del(`${ACCESS_TOKEN_PREFIX}${existing.accessTokenHash}`) 195 196 // Generate new access token 197 const newAccessToken = generateToken() 198 const newTokenHash = sha256(newAccessToken) 199 const now = Date.now() 200 201 const updated: Session = { 202 ...existing, 203 accessTokenHash: newTokenHash, 204 accessTokenExpiresAt: now + accessTokenTtl * 1000, 205 } 206 207 // Store new access token hash → session ID mapping 208 await cache.set(`${ACCESS_TOKEN_PREFIX}${newTokenHash}`, sid, 'EX', accessTokenTtl) 209 210 // Update session data (sliding window: resets TTL on refresh) 211 await cache.set(`${SESSION_DATA_PREFIX}${sid}`, JSON.stringify(updated), 'EX', sessionTtl) 212 213 logger.debug({ sid: truncateForLog(sid) }, 'Session refreshed') 214 215 // Return with raw token for the HTTP response (never persisted) 216 return { ...updated, accessToken: newAccessToken } 217 } catch (err: unknown) { 218 logger.error({ err, sid: truncateForLog(sid) }, 'Failed to refresh session') 219 throw err 220 } 221 } 222 223 async function deleteSession(sid: string): Promise<void> { 224 try { 225 // Fetch session to get access token hash and DID for cleanup 226 const data = await cache.get(`${SESSION_DATA_PREFIX}${sid}`) 227 if (data === null) { 228 logger.debug({ sid: truncateForLog(sid) }, 'Session not found for deletion') 229 return 230 } 231 232 // Safe cast: we control all writes to this key via createSession/refreshSession 233 const session = JSON.parse(data) as Session 234 235 // Delete access token lookup (session stores only the hash, no re-hashing needed) 236 await cache.del(`${ACCESS_TOKEN_PREFIX}${session.accessTokenHash}`) 237 // Delete session data 238 await cache.del(`${SESSION_DATA_PREFIX}${sid}`) 239 // Remove session ID from DID index 240 await cache.srem(`${DID_INDEX_PREFIX}${session.did}`, sid) 241 242 logger.debug({ sid: truncateForLog(sid) }, 'Session deleted') 243 } catch (err: unknown) { 244 logger.error({ err, sid: truncateForLog(sid) }, 'Failed to delete session') 245 throw err 246 } 247 } 248 249 async function deleteAllSessionsForDid(did: string): Promise<number> { 250 try { 251 // Get all session IDs for this DID 252 const sids = await cache.smembers(`${DID_INDEX_PREFIX}${did}`) 253 254 if (sids.length === 0) { 255 logger.debug({ did, count: 0 }, 'All sessions deleted for DID') 256 return 0 257 } 258 259 // Delete each session individually (cleans up access token lookups too) 260 // TODO(phase-3): Pipeline deletes for performance when moving to multi-instance (#36) 261 for (const sid of sids) { 262 await deleteSession(sid) 263 } 264 265 // Delete the DID index set itself 266 await cache.del(`${DID_INDEX_PREFIX}${did}`) 267 268 logger.debug({ did, count: sids.length }, 'All sessions deleted for DID') 269 270 return sids.length 271 } catch (err: unknown) { 272 logger.error({ err, did }, 'Failed to delete all sessions for DID') 273 throw err 274 } 275 } 276 277 return { 278 createSession, 279 validateAccessToken, 280 refreshSession, 281 deleteSession, 282 deleteAllSessionsForDid, 283 } 284}