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

v3.0.0: Add typed error handling for session restoration

- BREAKING: restore() now throws typed errors instead of returning null
- Add SessionNotFoundError, RefreshTokenExpiredError, RefreshTokenRevokedError, NetworkError
- Add comprehensive error logging throughout session restoration and token refresh
- Improve error visibility and classification for better debugging

Changed files
+262 -8
src
+59
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 + ## [3.0.0] - 2025-01-11 9 + 10 + ### Changed 11 + 12 + - **BREAKING**: `restore()` method now throws typed errors instead of returning `null` on failure 13 + - Throws `SessionNotFoundError` when session doesn't exist in storage 14 + - Throws `RefreshTokenExpiredError` when refresh token has expired 15 + - Throws `RefreshTokenRevokedError` when refresh token has been revoked 16 + - Throws `NetworkError` for transient network failures 17 + - Throws `TokenExchangeError` for other token refresh failures 18 + - Throws `SessionError` for unexpected session restoration failures 19 + 20 + ### Added 21 + 22 + - **New Error Types**: Added specific error classes for better error handling and debugging 23 + - `SessionNotFoundError`: Session not found in storage 24 + - `RefreshTokenExpiredError`: Refresh token has expired 25 + - `RefreshTokenRevokedError`: Refresh token has been revoked 26 + - `NetworkError`: Network-related failures (retryable) 27 + - **Detailed Error Logging**: Added comprehensive logging throughout session restoration and token refresh flows 28 + - Logs session lookup attempts 29 + - Logs token refresh operations 30 + - Logs all error conditions with context 31 + 32 + ### Improved 33 + 34 + - **Error Visibility**: Session restoration failures now provide detailed error information instead of silent null returns 35 + - **Error Classification**: Automatic classification of token exchange errors into specific error types 36 + - **Debugging**: Enhanced logging makes it easier to diagnose OAuth session issues in production 37 + 38 + ### Migration Guide 39 + 40 + Applications using `restore()` must now handle errors instead of checking for `null`: 41 + 42 + **Before (v2.x):** 43 + ```typescript 44 + const session = await client.restore("session-id"); 45 + if (!session) { 46 + // Handle failure - but why did it fail? 47 + console.log("Session not found"); 48 + } 49 + ``` 50 + 51 + **After (v3.x):** 52 + ```typescript 53 + try { 54 + const session = await client.restore("session-id"); 55 + // Use session 56 + } catch (error) { 57 + if (error instanceof SessionNotFoundError) { 58 + // User needs to log in again 59 + } else if (error instanceof RefreshTokenExpiredError) { 60 + // Refresh token expired - re-authenticate required 61 + } else if (error instanceof NetworkError) { 62 + // Temporary network issue - retry may help 63 + } 64 + } 65 + ``` 66 + 8 67 ## [2.1.0] - 2025-01-17 9 68 10 69 ### Added
+2 -2
deno.json
··· 1 1 { 2 2 "name": "@tijs/oauth-client-deno", 3 - "version": "2.1.0", 3 + "version": "3.0.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": { ··· 64 64 "exactOptionalPropertyTypes": true, 65 65 "noImplicitOverride": false 66 66 } 67 - } 67 + }
+84 -6
src/client.ts
··· 17 17 AuthorizationError, 18 18 InvalidHandleError, 19 19 InvalidStateError, 20 + NetworkError, 20 21 OAuthError, 22 + RefreshTokenExpiredError, 23 + RefreshTokenRevokedError, 24 + SessionError, 25 + SessionNotFoundError, 21 26 TokenExchangeError, 22 27 } from "./errors.ts"; 23 28 import { createDefaultResolver, discoverOAuthEndpointsFromPDS } from "./resolvers.ts"; ··· 314 319 try { 315 320 const sessionData = await this.storage.get<SessionData>(`session:${sessionId}`); 316 321 if (!sessionData) { 317 - return null; 322 + console.log(`Session not found in storage: ${sessionId}`); 323 + throw new SessionNotFoundError(sessionId); 318 324 } 319 325 320 326 const session = Session.fromJSON(sessionData); 321 327 322 328 // Auto-refresh if needed 323 329 if (session.isExpired) { 324 - const refreshedSession = await this.refresh(session); 325 - await this.storage.set(`session:${sessionId}`, refreshedSession.toJSON()); 326 - return refreshedSession; 330 + console.log(`Session expired, attempting token refresh for: ${sessionId}`); 331 + try { 332 + const refreshedSession = await this.refresh(session); 333 + await this.storage.set(`session:${sessionId}`, refreshedSession.toJSON()); 334 + console.log(`Token refresh successful for: ${sessionId}`); 335 + return refreshedSession; 336 + } catch (error) { 337 + console.error(`Token refresh failed for ${sessionId}:`, error); 338 + // Re-throw with proper error classification 339 + if (error instanceof TokenExchangeError) { 340 + // Check for specific refresh token error responses 341 + if (error.errorCode === "invalid_grant") { 342 + throw new RefreshTokenExpiredError(error); 343 + } 344 + throw error; 345 + } 346 + if (error instanceof NetworkError) { 347 + throw error; 348 + } 349 + // Wrap unknown errors as generic token exchange errors 350 + throw new TokenExchangeError( 351 + "Token refresh failed", 352 + undefined, 353 + error as Error, 354 + ); 355 + } 327 356 } 328 357 329 358 return session; 330 - } catch { 331 - return null; 359 + } catch (error) { 360 + // Log all errors for debugging 361 + console.error(`Session restoration failed for ${sessionId}:`, error); 362 + 363 + // Re-throw typed errors as-is 364 + if ( 365 + error instanceof SessionNotFoundError || 366 + error instanceof RefreshTokenExpiredError || 367 + error instanceof RefreshTokenRevokedError || 368 + error instanceof NetworkError || 369 + error instanceof TokenExchangeError 370 + ) { 371 + throw error; 372 + } 373 + 374 + // Wrap unexpected errors 375 + throw new SessionError( 376 + `Failed to restore session: ${sessionId}`, 377 + error as Error, 378 + ); 332 379 } finally { 333 380 // Always cleanup the lock when done 334 381 this.refreshLocks.delete(sessionId); ··· 381 428 * ``` 382 429 */ 383 430 async refresh(session: Session): Promise<Session> { 431 + console.log(`Refreshing tokens for session with DID: ${session.did}`); 432 + 384 433 try { 434 + // Discover OAuth endpoints from PDS 385 435 const oauthEndpoints = await discoverOAuthEndpointsFromPDS(session.pdsUrl); 436 + console.log(`Token endpoint discovered: ${oauthEndpoints.tokenEndpoint}`); 437 + 386 438 const refreshedTokens = await this.refreshTokens( 387 439 oauthEndpoints.tokenEndpoint, 388 440 session.refreshToken, ··· 392 444 393 445 // Update session with new tokens 394 446 session.updateTokens(refreshedTokens); 447 + console.log(`Token refresh successful for DID: ${session.did}`); 395 448 return session; 396 449 } catch (error) { 450 + console.error(`Token refresh failed for DID ${session.did}:`, error); 451 + 452 + // Classify the error based on type 453 + if (error instanceof TokenExchangeError) { 454 + // Already a TokenExchangeError, check for specific grant errors 455 + if (error.errorCode === "invalid_grant") { 456 + throw new RefreshTokenExpiredError(error); 457 + } 458 + throw error; 459 + } 460 + 461 + // Check for network-related errors 462 + if (error instanceof Error) { 463 + const errorMessage = error.message.toLowerCase(); 464 + if ( 465 + errorMessage.includes("network") || 466 + errorMessage.includes("timeout") || 467 + errorMessage.includes("connection") || 468 + errorMessage.includes("fetch") 469 + ) { 470 + throw new NetworkError("Failed to reach token endpoint", error); 471 + } 472 + } 473 + 474 + // Default to generic token exchange error 397 475 throw new TokenExchangeError("Token refresh failed", undefined, error as Error); 398 476 } 399 477 }
+117
src/errors.ts
··· 326 326 this.name = "AuthorizationError"; 327 327 } 328 328 } 329 + 330 + /** 331 + * Thrown when a session cannot be found in storage. 332 + * 333 + * This error occurs during session restoration when the requested session 334 + * ID does not exist in storage, indicating the user needs to re-authenticate. 335 + * 336 + * @example 337 + * ```ts 338 + * try { 339 + * const session = await client.restore("unknown-session-id"); 340 + * } catch (error) { 341 + * if (error instanceof SessionNotFoundError) { 342 + * console.log("Session expired or doesn't exist - please log in again"); 343 + * } 344 + * } 345 + * ``` 346 + */ 347 + export class SessionNotFoundError extends SessionError { 348 + /** 349 + * Create a new session not found error. 350 + * 351 + * @param sessionId - The session ID that was not found 352 + */ 353 + constructor(sessionId: string) { 354 + super(`Session not found: ${sessionId}`); 355 + this.name = "SessionNotFoundError"; 356 + } 357 + } 358 + 359 + /** 360 + * Thrown when a refresh token has expired and cannot be used. 361 + * 362 + * Refresh tokens have a limited lifetime. This error occurs when attempting 363 + * to use an expired refresh token, requiring the user to re-authenticate. 364 + * 365 + * @example 366 + * ```ts 367 + * try { 368 + * const session = await client.restore("session-id"); 369 + * } catch (error) { 370 + * if (error instanceof RefreshTokenExpiredError) { 371 + * console.log("Refresh token expired - please log in again"); 372 + * } 373 + * } 374 + * ``` 375 + */ 376 + export class RefreshTokenExpiredError extends TokenExchangeError { 377 + /** 378 + * Create a new refresh token expired error. 379 + * 380 + * @param cause - Optional underlying error from the token endpoint 381 + */ 382 + constructor(cause?: Error) { 383 + super("Refresh token has expired", "invalid_grant", cause); 384 + this.name = "RefreshTokenExpiredError"; 385 + } 386 + } 387 + 388 + /** 389 + * Thrown when a refresh token has been revoked by the authorization server. 390 + * 391 + * This error occurs when attempting to use a refresh token that has been 392 + * explicitly revoked, requiring the user to re-authenticate. 393 + * 394 + * @example 395 + * ```ts 396 + * try { 397 + * const session = await client.restore("session-id"); 398 + * } catch (error) { 399 + * if (error instanceof RefreshTokenRevokedError) { 400 + * console.log("Access has been revoked - please log in again"); 401 + * } 402 + * } 403 + * ``` 404 + */ 405 + export class RefreshTokenRevokedError extends TokenExchangeError { 406 + /** 407 + * Create a new refresh token revoked error. 408 + * 409 + * @param cause - Optional underlying error from the token endpoint 410 + */ 411 + constructor(cause?: Error) { 412 + super("Refresh token has been revoked", "invalid_grant", cause); 413 + this.name = "RefreshTokenRevokedError"; 414 + } 415 + } 416 + 417 + /** 418 + * Thrown when network operations fail during OAuth operations. 419 + * 420 + * This error indicates a transient network failure that may be retryable, 421 + * such as connection timeouts, DNS failures, or network unavailability. 422 + * 423 + * @example 424 + * ```ts 425 + * try { 426 + * const session = await client.restore("session-id"); 427 + * } catch (error) { 428 + * if (error instanceof NetworkError) { 429 + * console.log("Network error - retrying may help:", error.message); 430 + * } 431 + * } 432 + * ``` 433 + */ 434 + export class NetworkError extends OAuthError { 435 + /** 436 + * Create a new network error. 437 + * 438 + * @param message - Error message describing the network failure 439 + * @param cause - Optional underlying error from the network operation 440 + */ 441 + constructor(message: string, cause?: Error) { 442 + super(`Network error: ${message}`, cause); 443 + this.name = "NetworkError"; 444 + } 445 + }