A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add concurrency-safe session restore with per-DID locking

Prevents race conditions when multiple concurrent requests try to restore
the same session during token refresh. Concurrent requests now wait for and
share the result of the first refresh operation instead of triggering
multiple simultaneous refreshes.

- Add per-session lock manager to OAuthClient
- Locks are per-DID, preventing cross-user blocking
- Automatic lock cleanup when restore completes
- Enhanced JSDoc documentation
- Zero breaking changes

Fixes intermittent "OAuth session not found" errors in multi-endpoint
applications when sessions expire.

+68 -17
+22
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [2.1.0] - 2025-01-17 9 + 10 + ### Added 11 + 12 + - **Concurrency-Safe Session Restore**: Added per-session lock manager to prevent race conditions when multiple concurrent requests try to restore the same session 13 + - Prevents duplicate token refresh requests when session expires 14 + - Concurrent requests for the same session now wait for and share the result of the first refresh operation 15 + - Locks are per-DID, so different users' sessions are not affected by each other 16 + - Automatic lock cleanup when restore operation completes 17 + - Zero breaking changes - completely internal implementation detail 18 + 19 + ### Fixed 20 + 21 + - **Race Condition in Token Refresh**: Fixed issue where concurrent API requests during session expiry could cause "OAuth session not found" errors 22 + - Multiple endpoints calling `restore()` simultaneously would all trigger refresh, causing race conditions 23 + - Now only one refresh happens per session even if 10+ endpoints call `restore()` concurrently 24 + - Resolves intermittent 503 errors in multi-endpoint applications 25 + 26 + ### Improved 27 + 28 + - **Developer Experience**: Enhanced JSDoc documentation for `restore()` method to clarify concurrency-safe behavior 29 + 8 30 ## [2.0.0] - 2025-09-17 9 31 10 32 ### Changed
+1 -1
deno.json
··· 1 1 { 2 2 "name": "@tijs/oauth-client-deno", 3 - "version": "2.0.0", 3 + "version": "2.1.0", 4 4 "description": "AT Protocol OAuth client for Deno - handle-focused alternative to @atproto/oauth-client-node with Web Crypto API compatibility", 5 5 "license": "MIT", 6 6 "repository": {
+45 -16
src/client.ts
··· 63 63 private readonly handleResolver: (handle: string) => Promise<{ did: string; pdsUrl: string }>; 64 64 65 65 /** 66 + * Per-session lock manager to prevent concurrent refresh operations. 67 + * Maps sessionId to the in-flight restore Promise to queue concurrent requests. 68 + */ 69 + private readonly refreshLocks = new Map<string, Promise<Session | null>>(); 70 + 71 + /** 66 72 * Create a new OAuth client instance. 67 73 * 68 74 * @param config - OAuth client configuration options ··· 278 284 * the access token if it has expired. Returns null if the session doesn't exist 279 285 * or cannot be restored. 280 286 * 287 + * **Concurrency safe:** If multiple concurrent requests try to restore the same 288 + * session while it's being refreshed, they will all wait for and share the result 289 + * of the first refresh operation. This prevents race conditions and duplicate 290 + * token refresh requests. 291 + * 281 292 * @param sessionId - Unique identifier for the stored session 282 293 * @returns Promise resolving to restored session, or null if not found 283 294 * @example ··· 290 301 * } 291 302 * ``` 292 303 */ 293 - async restore(sessionId: string): Promise<Session | null> { 294 - try { 295 - const sessionData = await this.storage.get<SessionData>(`session:${sessionId}`); 296 - if (!sessionData) { 304 + restore(sessionId: string): Promise<Session | null> { 305 + // Check if another request is already restoring/refreshing this session 306 + const existingLock = this.refreshLocks.get(sessionId); 307 + if (existingLock) { 308 + // Wait for and reuse the in-flight restore operation 309 + return existingLock; 310 + } 311 + 312 + // Create a new restore operation 313 + const restorePromise = (async () => { 314 + try { 315 + const sessionData = await this.storage.get<SessionData>(`session:${sessionId}`); 316 + if (!sessionData) { 317 + return null; 318 + } 319 + 320 + const session = Session.fromJSON(sessionData); 321 + 322 + // Auto-refresh if needed 323 + if (session.isExpired) { 324 + const refreshedSession = await this.refresh(session); 325 + await this.storage.set(`session:${sessionId}`, refreshedSession.toJSON()); 326 + return refreshedSession; 327 + } 328 + 329 + return session; 330 + } catch { 297 331 return null; 332 + } finally { 333 + // Always cleanup the lock when done 334 + this.refreshLocks.delete(sessionId); 298 335 } 336 + })(); 299 337 300 - const session = Session.fromJSON(sessionData); 338 + // Store the promise so concurrent requests can wait for it 339 + this.refreshLocks.set(sessionId, restorePromise); 301 340 302 - // Auto-refresh if needed 303 - if (session.isExpired) { 304 - const refreshedSession = await this.refresh(session); 305 - await this.storage.set(`session:${sessionId}`, refreshedSession.toJSON()); 306 - return refreshedSession; 307 - } 308 - 309 - return session; 310 - } catch { 311 - return null; 312 - } 341 + return restorePromise; 313 342 } 314 343 315 344 /**