Barazo AppView backend
barazo.forum
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}