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

v4.0.0: Major refactoring with logging, improved type safety, and better error handling

Breaking Changes:
- restore() now throws errors instead of returning null
- Removed unused API parameters (signal, CallbackOptions)

New Features:
- Configurable logging system (Logger interface, NoOpLogger, ConsoleLogger)
- Dual concurrency locks (restore + refresh operations)
- New modules: pkce.ts, token-exchange.ts, logger.ts

Improvements:
- Removed all type assertions, added runtime validation
- Deduplicated DPoP retry logic
- Better code organization (all files <700 lines)
- Enhanced error handling with typed errors

See CHANGELOG.md for migration guide.

+82
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 + ## [4.0.0] - 2025-01-15 9 + 10 + ### Breaking Changes 11 + 12 + - **`restore()` now throws errors instead of returning null** 13 + - Use try/catch to handle `SessionNotFoundError`, `RefreshTokenExpiredError`, `NetworkError` 14 + - More explicit error handling with typed error classes 15 + - See migration guide below for update instructions 16 + 17 + ### Added 18 + 19 + - **Logging System**: Configurable logging abstraction 20 + - `Logger` interface for custom logging implementations 21 + - `NoOpLogger` (default, silent) 22 + - `ConsoleLogger` for development/debugging 23 + - Inject via `OAuthClientConfig.logger` 24 + - **New Modules**: Better code organization 25 + - `src/pkce.ts`: PKCE utilities (code verifier, challenge, base64url) 26 + - `src/token-exchange.ts`: Token exchange and refresh operations 27 + - `src/logger.ts`: Logging abstractions 28 + - **Concurrency Protection**: Dual locking system 29 + - `restoreLocks` for session restoration (prevents duplicate restore operations) 30 + - `refreshLocks` for token refresh (prevents duplicate token requests) 31 + - Concurrent calls wait on single operation and share results 32 + 33 + ### Improved 34 + 35 + - **Type Safety**: Removed all type assertions 36 + - Added runtime validation in `dpop.ts` for JWK imports 37 + - Added type guards in `storage.ts` for SQLite results 38 + - Proper type narrowing throughout codebase 39 + - **Code Deduplication**: Shared DPoP retry logic 40 + - Single `fetchWithDPoPRetry` utility handles nonce challenges 41 + - Eliminates duplicate code in token exchange and refresh 42 + - **File Organization**: All files now under 700 lines 43 + - `client.ts`: 731 → 683 lines 44 + - Better separation of concerns across modules 45 + 46 + ### Removed 47 + 48 + - **Unused API Parameters**: 49 + - Removed `signal?: AbortSignal` from `AuthorizeOptions` (not implemented) 50 + - Removed `CallbackOptions` interface and parameter (unused) 51 + - **Console Logging**: All console.* calls replaced with Logger interface 52 + 53 + ### Migration Guide 54 + 55 + **Update restore() error handling:** 56 + 57 + ```typescript 58 + // Before (v3.x): 59 + const session = await client.restore("session-id"); 60 + if (!session) { 61 + console.log("Session not found"); 62 + } 63 + 64 + // After (v4.x): 65 + try { 66 + const session = await client.restore("session-id"); 67 + console.log("Welcome back,", session.handle); 68 + } catch (error) { 69 + if (error instanceof SessionNotFoundError) { 70 + console.log("Please log in again"); 71 + } else if (error instanceof RefreshTokenExpiredError) { 72 + console.log("Session expired, please re-authenticate"); 73 + } else { 74 + throw error; 75 + } 76 + } 77 + ``` 78 + 79 + **Add logging (optional):** 80 + 81 + ```typescript 82 + import { ConsoleLogger } from "@tijs/oauth-client-deno"; 83 + 84 + const client = new OAuthClient({ 85 + // ... other config 86 + logger: new ConsoleLogger(), // Enable debug logging 87 + }); 88 + ``` 89 + 8 90 ## [3.0.0] - 2025-01-11 9 91 10 92 ### Changed
+42 -9
CLAUDE.md
··· 42 42 43 43 ### Core Components 44 44 45 - 1. **OAuthClient** (`src/client.ts`) - Main OAuth flow orchestration 46 - - Handles authorization URL generation with PKCE 45 + 1. **OAuthClient** (`src/client.ts`) - Main OAuth flow orchestration (~680 lines) 46 + - Authorization URL generation with PKCE 47 47 - Token exchange and refresh with DPoP 48 48 - Pushed Authorization Request (PAR) support 49 - - Concurrency-safe token refresh with per-session locks 49 + - Dual concurrency locks: `restoreLocks` for sessions, `refreshLocks` for refresh operations 50 + - Configurable logging via Logger interface 50 51 51 52 2. **Session** (`src/session.ts`) - Authenticated session management 52 53 - Token lifecycle management (access + refresh tokens) ··· 60 61 - `CustomResolver`: User-provided resolution function 61 62 - OAuth endpoint discovery from PDS metadata 62 63 63 - 4. **DPoP** (`src/dpop.ts`) - Proof of Possession implementation 64 + 4. **PKCE** (`src/pkce.ts`) - PKCE utilities 65 + - Code verifier generation (32 random bytes) 66 + - Code challenge generation (SHA-256 of verifier) 67 + - Base64URL encoding utility 68 + 69 + 5. **Token Exchange** (`src/token-exchange.ts`) - Token operations 70 + - Authorization code exchange for tokens 71 + - Refresh token exchange 72 + - Shared DPoP retry logic (deduplicates nonce handling) 73 + 74 + 6. **DPoP** (`src/dpop.ts`) - Proof of Possession implementation 64 75 - ES256 (ECDSA P-256) key generation using Web Crypto API 65 76 - JWT proof generation with `jsr:@panva/jose` (not npm version) 77 + - Key import with validation (no type assertions) 66 78 - Automatic nonce handling on 401 challenges 67 79 68 - 5. **Storage** (`src/storage.ts`) - Session persistence abstractions 80 + 7. **Storage** (`src/storage.ts`) - Session persistence abstractions 69 81 - `MemoryStorage`: In-memory with TTL support 70 - - `SQLiteStorage`: Deno SQLite backend example 82 + - `SQLiteStorage`: Deno SQLite backend example with type validation 71 83 - `LocalStorage`: Browser/localStorage compatible 72 84 - All storage is async with TTL support 73 85 74 - 6. **Error Handling** (`src/errors.ts`) - Typed error hierarchy 86 + 8. **Logger** (`src/logger.ts`) - Logging abstraction 87 + - `Logger` interface with debug/info/warn/error methods 88 + - `NoOpLogger`: Default silent logger 89 + - `ConsoleLogger`: Development/debugging logger 90 + - Inject custom logger via `OAuthClientConfig.logger` 91 + 92 + 9. **Error Handling** (`src/errors.ts`) - Typed error hierarchy 75 93 - All errors extend `OAuthError` base class 76 94 - Specific error types for each failure mode 77 95 - Error chaining with `cause` support ··· 92 110 93 111 **Concurrency Safety:** 94 112 95 - - `OAuthClient.restore()` uses `refreshLocks` Map to prevent duplicate refresh requests 96 - - Multiple concurrent restore calls for same session wait on single refresh operation 113 + - `OAuthClient.restore()` uses `restoreLocks` Map (keyed by sessionId) 114 + - `OAuthClient.refresh()` uses `refreshLocks` Map (keyed by DID) 115 + - Multiple concurrent calls wait on single operation and share the result 116 + - Locks are always cleaned up in finally blocks 117 + 118 + **Error Handling Strategy:** 119 + 120 + - `restore()` ALWAYS throws errors (never returns null) 121 + - Use try/catch to handle `SessionNotFoundError`, `RefreshTokenExpiredError`, `NetworkError` 122 + - All errors include `cause` chain for debugging 123 + 124 + **Logging:** 125 + 126 + - Client uses injected `Logger` instance (defaults to `NoOpLogger`) 127 + - Use `ConsoleLogger` for development/debugging 128 + - Implement custom `Logger` interface for production logging 129 + - All sensitive operations logged at appropriate levels 97 130 98 131 **Token Refresh:** 99 132
+50 -2
README.md
··· 89 89 await client.store(sessionId, session); 90 90 91 91 // Restore session (with automatic token refresh if needed) 92 - const restoredSession = await client.restore(sessionId); 93 - if (restoredSession) { 92 + try { 93 + const restoredSession = await client.restore(sessionId); 94 94 console.log("Welcome back,", restoredSession.handle); 95 + } catch (error) { 96 + if (error instanceof SessionNotFoundError) { 97 + console.log("Please log in again"); 98 + } else if (error instanceof RefreshTokenExpiredError) { 99 + console.log("Session expired, please re-authenticate"); 100 + } else { 101 + throw error; // Unexpected error 102 + } 95 103 } 96 104 97 105 // Manual token refresh ··· 183 191 ``` 184 192 185 193 > **Why Slingshot?** Slingshot is a production-grade cache of AT Protocol data that provides faster handle resolution and better reliability, especially during high-traffic periods. It uses the `resolveMiniDoc` endpoint which returns both DID and PDS URL in a single request, reducing the need for multiple lookups. However, it does introduce a dependency on a third-party service. The fallback mechanisms ensure your application continues to work even if Slingshot is unavailable. 194 + 195 + ### Logging 196 + 197 + Control client logging output by providing a custom logger: 198 + 199 + ```typescript 200 + import { ConsoleLogger, type Logger } from "jsr:@tijs/oauth-client-deno"; 201 + 202 + // Use built-in console logger for development 203 + const client = new OAuthClient({ 204 + // ... other config 205 + logger: new ConsoleLogger(), 206 + }); 207 + 208 + // Or implement custom logger for production 209 + class ProductionLogger implements Logger { 210 + debug(message: string, ...args: unknown[]): void { 211 + // Send to your logging service 212 + } 213 + 214 + info(message: string, ...args: unknown[]): void { 215 + logger.log(message, ...args); 216 + } 217 + 218 + warn(message: string, ...args: unknown[]): void { 219 + logger.warn(message, ...args); 220 + } 221 + 222 + error(message: string, ...args: unknown[]): void { 223 + logger.error(message, ...args); 224 + } 225 + } 226 + 227 + const client = new OAuthClient({ 228 + // ... other config 229 + logger: new ProductionLogger(), 230 + }); 231 + ``` 232 + 233 + > **Note**: By default, the client uses a no-op logger that produces no output. 186 234 187 235 ## 🏗️ Advanced Usage 188 236
+1 -1
deno.json
··· 1 1 { 2 2 "name": "@tijs/oauth-client-deno", 3 - "version": "3.0.0", 3 + "version": "4.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": {
+1 -1
mod.ts
··· 41 41 DirectoryResolver, 42 42 SlingshotResolver, 43 43 } from "./src/resolvers.ts"; 44 + export { ConsoleLogger, type Logger, NoOpLogger } from "./src/logger.ts"; 44 45 export type { 45 46 AuthorizeOptions, 46 - CallbackOptions, 47 47 HandleResolver, 48 48 OAuthClientConfig, 49 49 OAuthSession,
+196 -249
src/client.ts
··· 4 4 */ 5 5 6 6 import { isValidHandle } from "@atproto/syntax"; 7 - import type { 8 - AuthorizeOptions, 9 - CallbackOptions, 10 - OAuthClientConfig, 11 - OAuthSession, 12 - OAuthStorage, 13 - } from "./types.ts"; 7 + import type { AuthorizeOptions, OAuthClientConfig, OAuthSession, OAuthStorage } from "./types.ts"; 14 8 import { Session, type SessionData } from "./session.ts"; 15 - import { generateDPoPKeyPair, generateDPoPProof } from "./dpop.ts"; 9 + import { generateDPoPKeyPair } from "./dpop.ts"; 16 10 import { 17 11 AuthorizationError, 18 12 InvalidHandleError, ··· 26 20 TokenExchangeError, 27 21 } from "./errors.ts"; 28 22 import { createDefaultResolver, discoverOAuthEndpointsFromPDS } from "./resolvers.ts"; 23 + import { generateCodeChallenge, generateCodeVerifier } from "./pkce.ts"; 24 + import { exchangeCodeForTokens, refreshTokens } from "./token-exchange.ts"; 25 + import type { Logger } from "./logger.ts"; 26 + import { NoOpLogger } from "./logger.ts"; 27 + 28 + /** PKCE state TTL in seconds (10 minutes) */ 29 + const PKCE_STATE_TTL = 600; 29 30 30 31 /** 31 32 * AT Protocol OAuth client for Deno environments. ··· 46 47 * const authUrl = await client.authorize("alice.bsky.social"); 47 48 * 48 49 * // Handle callback 49 - * const { session } = await client.callback({ code: "auth_code", state: "state" }); 50 + * const { session } = await client.callback(params); 50 51 * 51 52 * // Use authenticated session 52 53 * const response = await session.makeRequest("GET", "https://bsky.social/xrpc/com.atproto.repo.listRecords"); 53 54 * ``` 54 55 * 55 - * @example Custom handle resolution 56 + * @example Custom logging 56 57 * ```ts 58 + * import { ConsoleLogger } from "@tijs/oauth-client-deno"; 59 + * 57 60 * const client = new OAuthClient({ 58 61 * // ... other config 59 - * handleResolver: new DirectoryResolver(), // Use AT Protocol directory instead of Slingshot 60 - * slingshotUrl: "https://my-slingshot.example.com", // Or custom Slingshot URL 62 + * logger: new ConsoleLogger(), // Enable debug logging 61 63 * }); 62 64 * ``` 63 65 */ ··· 66 68 private readonly redirectUri: string; 67 69 private readonly storage: OAuthStorage; 68 70 private readonly handleResolver: (handle: string) => Promise<{ did: string; pdsUrl: string }>; 71 + private readonly logger: Logger; 69 72 70 73 /** 71 - * Per-session lock manager to prevent concurrent refresh operations. 74 + * Per-session lock manager to prevent concurrent restore/refresh operations. 72 75 * Maps sessionId to the in-flight restore Promise to queue concurrent requests. 73 76 */ 74 - private readonly refreshLocks = new Map<string, Promise<Session | null>>(); 77 + private readonly restoreLocks = new Map<string, Promise<Session>>(); 78 + 79 + /** 80 + * Per-DID lock manager to prevent concurrent refresh operations. 81 + * Maps DID to the in-flight refresh Promise to queue concurrent requests. 82 + */ 83 + private readonly refreshLocks = new Map<string, Promise<Session>>(); 75 84 76 85 /** 77 86 * Create a new OAuth client instance. ··· 87 96 * storage: new MemoryStorage(), 88 97 * handleResolver: new SlingshotResolver(), // optional 89 98 * slingshotUrl: "https://custom-slingshot.com", // optional 99 + * logger: new ConsoleLogger(), // optional 90 100 * }); 91 101 * ``` 92 102 */ ··· 94 104 this.clientId = config.clientId; 95 105 this.redirectUri = config.redirectUri; 96 106 this.storage = config.storage; 107 + this.logger = config.logger ?? new NoOpLogger(); 97 108 98 109 // Create handle resolver - either custom or default with optional Slingshot URL 99 110 const resolver = config.handleResolver ?? createDefaultResolver(config.slingshotUrl); ··· 106 117 if (!this.redirectUri) { 107 118 throw new OAuthError("redirectUri is required"); 108 119 } 120 + 121 + this.logger.debug("OAuth client initialized", { clientId: this.clientId }); 109 122 } 110 123 111 124 /** ··· 139 152 options?: AuthorizeOptions, 140 153 ): Promise<URL> { 141 154 if (!isValidHandle(handle)) { 155 + this.logger.error("Invalid handle format", { handle }); 142 156 throw new InvalidHandleError(handle); 143 157 } 144 158 159 + this.logger.info("Starting authorization flow", { handle }); 160 + 145 161 try { 146 162 // Resolve handle to get user's PDS and DID 163 + this.logger.debug("Resolving handle to DID and PDS", { handle }); 147 164 const resolved = await this.handleResolver(handle); 165 + this.logger.debug("Handle resolved", { did: resolved.did, pdsUrl: resolved.pdsUrl }); 148 166 149 167 // Discover OAuth endpoints from the PDS 150 168 const oauthEndpoints = await discoverOAuthEndpointsFromPDS(resolved.pdsUrl); 151 169 const authServer = this.extractAuthServer(oauthEndpoints.authorizationEndpoint); 170 + this.logger.debug("OAuth endpoints discovered", { authServer }); 152 171 153 172 // Generate PKCE parameters 154 - const codeVerifier = this.generateCodeVerifier(); 155 - const codeChallenge = await this.generateCodeChallenge(codeVerifier); 173 + const codeVerifier = generateCodeVerifier(); 174 + const codeChallenge = await generateCodeChallenge(codeVerifier); 156 175 const state = options?.state ?? crypto.randomUUID(); 157 176 158 177 // Store PKCE data for callback ··· 162 181 handle: handle, 163 182 did: resolved.did, 164 183 pdsUrl: resolved.pdsUrl, 165 - }, { ttl: 600 }); // 10 minutes 184 + }, { ttl: PKCE_STATE_TTL }); 185 + 186 + this.logger.debug("PKCE state stored", { state }); 166 187 167 188 // Pushed Authorization Request (PAR) - required by most AT Protocol servers 168 189 const parUrl = await this.pushAuthorizationRequest( ··· 175 196 }, 176 197 ); 177 198 199 + this.logger.info("Authorization URL created", { parUrl }); 178 200 return new URL(parUrl); 179 201 } catch (error) { 180 202 if (error instanceof OAuthError) { 181 203 throw error; 182 204 } 205 + this.logger.error("Authorization failed", { error }); 183 206 throw new OAuthError("Failed to initiate authorization", error as Error); 184 207 } 185 208 } ··· 202 225 * ```ts 203 226 * // Extract callback parameters from URL 204 227 * const url = new URL(window.location.href); 205 - * const params = { 206 - * code: url.searchParams.get("code")!, 207 - * state: url.searchParams.get("state")!, 208 - * error: url.searchParams.get("error"), 209 - * error_description: url.searchParams.get("error_description"), 210 - * }; 228 + * const params = new URLSearchParams(url.search); 211 229 * 212 230 * const { session } = await client.callback(params); 213 231 * console.log("Authenticated as:", session.handle); ··· 215 233 */ 216 234 async callback( 217 235 params: URLSearchParams, 218 - _options?: CallbackOptions, 219 236 ): Promise<{ session: OAuthSession; state: string | null }> { 220 237 const error = params.get("error"); 221 238 if (error) { 239 + this.logger.error("Authorization callback error", { 240 + error, 241 + description: params.get("error_description"), 242 + }); 222 243 throw new AuthorizationError(error, params.get("error_description") || undefined); 223 244 } 224 245 225 246 const code = params.get("code"); 226 247 if (!code) { 248 + this.logger.error("Missing authorization code in callback"); 227 249 throw new OAuthError("Missing authorization code in callback"); 228 250 } 229 251 230 252 const state = params.get("state") || ""; 231 253 254 + this.logger.info("Processing authorization callback", { state }); 255 + 232 256 // Retrieve PKCE data 233 257 const pkceData = await this.storage.get<{ 234 258 codeVerifier: string; ··· 237 261 did: string; 238 262 pdsUrl: string; 239 263 }>(`pkce:${state}`); 264 + 240 265 if (!pkceData) { 266 + this.logger.error("Invalid or expired state parameter", { state }); 241 267 throw new InvalidStateError(); 242 268 } 243 269 244 270 try { 245 271 // Generate DPoP keys for token exchange 272 + this.logger.debug("Generating DPoP keys"); 246 273 const dpopKeys = await generateDPoPKeyPair(); 247 274 248 275 // Exchange authorization code for tokens 249 - const tokens = await this.exchangeCodeForTokens( 276 + const tokens = await exchangeCodeForTokens( 250 277 pkceData.authServer, 251 278 code, 252 279 pkceData.codeVerifier, 280 + this.clientId, 281 + this.redirectUri, 253 282 dpopKeys, 283 + this.logger, 254 284 ); 255 285 256 286 // Create session ··· 259 289 handle: pkceData.handle, 260 290 pdsUrl: pkceData.pdsUrl, 261 291 accessToken: tokens.access_token, 262 - refreshToken: tokens.refresh_token, 292 + refreshToken: tokens.refresh_token ?? "", 263 293 dpopPrivateKeyJWK: dpopKeys.privateKeyJWK, 264 294 dpopPublicKeyJWK: dpopKeys.publicKeyJWK, 265 295 tokenExpiresAt: Date.now() + (tokens.expires_in * 1000), ··· 270 300 // Clean up PKCE data 271 301 await this.storage.delete(`pkce:${state}`); 272 302 273 - return { session: session as OAuthSession, state: params.get("state") }; 303 + this.logger.info("Authorization callback completed", { did: pkceData.did }); 304 + return { session: session, state: params.get("state") }; 274 305 } catch (error) { 275 306 // Clean up PKCE data even on error 276 307 await this.storage.delete(`pkce:${state}`); ··· 278 309 if (error instanceof OAuthError) { 279 310 throw error; 280 311 } 312 + this.logger.error("Token exchange failed", { error }); 281 313 throw new TokenExchangeError("Token exchange failed", undefined, error as Error); 282 314 } 283 315 } ··· 286 318 * Restore an authenticated session from storage. 287 319 * 288 320 * Retrieves a previously stored session by its ID and automatically refreshes 289 - * the access token if it has expired. Returns null if the session doesn't exist 321 + * the access token if it has expired. Throws errors if the session doesn't exist 290 322 * or cannot be restored. 291 323 * 292 324 * **Concurrency safe:** If multiple concurrent requests try to restore the same ··· 295 327 * token refresh requests. 296 328 * 297 329 * @param sessionId - Unique identifier for the stored session 298 - * @returns Promise resolving to restored session, or null if not found 330 + * @returns Promise resolving to restored session 331 + * @throws {SessionNotFoundError} When session doesn't exist in storage 332 + * @throws {RefreshTokenExpiredError} When refresh token has expired 333 + * @throws {NetworkError} When network request fails 334 + * @throws {TokenExchangeError} When token refresh fails 335 + * 299 336 * @example 300 337 * ```ts 301 - * const session = await client.restore("user-session-123"); 302 - * if (session) { 338 + * try { 339 + * const session = await client.restore("user-session-123"); 303 340 * console.log("Welcome back,", session.handle); 304 - * } else { 305 - * console.log("Please log in again"); 341 + * } catch (error) { 342 + * if (error instanceof SessionNotFoundError) { 343 + * console.log("Please log in again"); 344 + * } else if (error instanceof RefreshTokenExpiredError) { 345 + * console.log("Session expired, please re-authenticate"); 346 + * } else { 347 + * throw error; 348 + * } 306 349 * } 307 350 * ``` 308 351 */ 309 - restore(sessionId: string): Promise<Session | null> { 352 + restore(sessionId: string): Promise<Session> { 310 353 // Check if another request is already restoring/refreshing this session 311 - const existingLock = this.refreshLocks.get(sessionId); 354 + const existingLock = this.restoreLocks.get(sessionId); 312 355 if (existingLock) { 356 + this.logger.debug("Waiting for in-flight restore operation", { sessionId }); 313 357 // Wait for and reuse the in-flight restore operation 314 358 return existingLock; 315 359 } ··· 317 361 // Create a new restore operation 318 362 const restorePromise = (async () => { 319 363 try { 364 + this.logger.info("Restoring session", { sessionId }); 365 + 320 366 const sessionData = await this.storage.get<SessionData>(`session:${sessionId}`); 321 367 if (!sessionData) { 322 - console.log(`Session not found in storage: ${sessionId}`); 368 + this.logger.warn("Session not found in storage", { sessionId }); 323 369 throw new SessionNotFoundError(sessionId); 324 370 } 325 371 ··· 327 373 328 374 // Auto-refresh if needed 329 375 if (session.isExpired) { 330 - console.log(`Session expired, attempting token refresh for: ${sessionId}`); 376 + this.logger.info("Session expired, refreshing token", { 377 + sessionId, 378 + did: session.did, 379 + }); 380 + 331 381 try { 332 382 const refreshedSession = await this.refresh(session); 333 383 await this.storage.set(`session:${sessionId}`, refreshedSession.toJSON()); 334 - console.log(`Token refresh successful for: ${sessionId}`); 384 + this.logger.info("Session restored and refreshed", { sessionId }); 335 385 return refreshedSession; 336 386 } catch (error) { 337 - console.error(`Token refresh failed for ${sessionId}:`, error); 387 + this.logger.error("Token refresh failed during restore", { 388 + sessionId, 389 + error, 390 + }); 391 + 338 392 // Re-throw with proper error classification 339 393 if (error instanceof TokenExchangeError) { 340 394 // Check for specific refresh token error responses ··· 355 409 } 356 410 } 357 411 412 + this.logger.info("Session restored", { sessionId }); 358 413 return session; 359 414 } catch (error) { 360 - // Log all errors for debugging 361 - console.error(`Session restoration failed for ${sessionId}:`, error); 362 - 363 415 // Re-throw typed errors as-is 364 416 if ( 365 417 error instanceof SessionNotFoundError || ··· 372 424 } 373 425 374 426 // Wrap unexpected errors 427 + this.logger.error("Session restoration failed", { sessionId, error }); 375 428 throw new SessionError( 376 429 `Failed to restore session: ${sessionId}`, 377 430 error as Error, 378 431 ); 379 432 } finally { 380 433 // Always cleanup the lock when done 381 - this.refreshLocks.delete(sessionId); 434 + this.restoreLocks.delete(sessionId); 382 435 } 383 436 })(); 384 437 385 438 // Store the promise so concurrent requests can wait for it 386 - this.refreshLocks.set(sessionId, restorePromise); 439 + this.restoreLocks.set(sessionId, restorePromise); 387 440 388 441 return restorePromise; 389 442 } ··· 397 450 * 398 451 * @param sessionId - Unique identifier for the session 399 452 * @param session - Authenticated session to store 453 + * 400 454 * @example 401 455 * ```ts 402 456 * const { session } = await client.callback(params); ··· 405 459 * ``` 406 460 */ 407 461 async store(sessionId: string, session: Session): Promise<void> { 462 + this.logger.info("Storing session", { sessionId, did: session.did }); 408 463 await this.storage.set(`session:${sessionId}`, session.toJSON()); 409 464 } 410 465 ··· 414 469 * Exchanges the current refresh token for new access and refresh tokens using 415 470 * the OAuth 2.0 refresh_token grant type with DPoP authentication. The session 416 471 * is updated in-place with the new token data. 472 + * 473 + * **Concurrency safe:** If multiple concurrent requests try to refresh the same 474 + * session, they will all wait for and share the result of the first refresh 475 + * operation. This prevents duplicate token refresh requests. 417 476 * 418 477 * @param session Current session with valid refresh token 419 478 * @returns New session with refreshed tokens ··· 422 481 * @example 423 482 * ```ts 424 483 * if (session.isExpired) { 425 - * const refreshedSession = await client.refresh(session); 426 - * console.log("Token refreshed, expires in:", refreshedSession.timeUntilExpiry, "ms"); 484 + * try { 485 + * const refreshedSession = await client.refresh(session); 486 + * console.log("Token refreshed, expires in:", refreshedSession.timeUntilExpiry, "ms"); 487 + * } catch (error) { 488 + * if (error instanceof RefreshTokenExpiredError) { 489 + * console.log("Please log in again"); 490 + * } 491 + * } 427 492 * } 428 493 * ``` 429 494 */ 430 - async refresh(session: Session): Promise<Session> { 431 - console.log(`Refreshing tokens for session with DID: ${session.did}`); 495 + refresh(session: Session): Promise<Session> { 496 + const did = session.did; 497 + 498 + // Check if another request is already refreshing this session 499 + const existingLock = this.refreshLocks.get(did); 500 + if (existingLock) { 501 + this.logger.debug("Waiting for in-flight refresh operation", { did }); 502 + return existingLock; 503 + } 504 + 505 + // Create a new refresh operation 506 + const refreshPromise = (async () => { 507 + this.logger.info("Refreshing tokens", { did }); 432 508 433 - try { 434 - // Discover OAuth endpoints from PDS 435 - const oauthEndpoints = await discoverOAuthEndpointsFromPDS(session.pdsUrl); 436 - console.log(`Token endpoint discovered: ${oauthEndpoints.tokenEndpoint}`); 509 + try { 510 + // Discover OAuth endpoints from PDS 511 + const oauthEndpoints = await discoverOAuthEndpointsFromPDS(session.pdsUrl); 512 + this.logger.debug("Token endpoint discovered", { 513 + tokenEndpoint: oauthEndpoints.tokenEndpoint, 514 + }); 437 515 438 - const refreshedTokens = await this.refreshTokens( 439 - oauthEndpoints.tokenEndpoint, 440 - session.refreshToken, 441 - session.toJSON().dpopPrivateKeyJWK, 442 - session.toJSON().dpopPublicKeyJWK, 443 - ); 516 + const refreshedTokens = await refreshTokens( 517 + oauthEndpoints.tokenEndpoint, 518 + session.refreshToken, 519 + this.clientId, 520 + session.toJSON().dpopPrivateKeyJWK, 521 + session.toJSON().dpopPublicKeyJWK, 522 + this.logger, 523 + ); 444 524 445 - // Update session with new tokens 446 - session.updateTokens(refreshedTokens); 447 - console.log(`Token refresh successful for DID: ${session.did}`); 448 - return session; 449 - } catch (error) { 450 - console.error(`Token refresh failed for DID ${session.did}:`, error); 525 + // Update session with new tokens 526 + session.updateTokens(refreshedTokens); 527 + this.logger.info("Token refresh successful", { did }); 528 + return session; 529 + } catch (error) { 530 + this.logger.error("Token refresh failed", { did, error }); 451 531 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); 532 + // Classify the error based on type 533 + if (error instanceof TokenExchangeError) { 534 + // Already a TokenExchangeError, check for specific grant errors 535 + if (error.errorCode === "invalid_grant") { 536 + throw new RefreshTokenExpiredError(error); 537 + } 538 + throw error; 457 539 } 458 - throw error; 459 - } 460 540 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); 541 + // Check for network-related errors 542 + if (error instanceof Error) { 543 + const errorMessage = error.message.toLowerCase(); 544 + if ( 545 + errorMessage.includes("network") || 546 + errorMessage.includes("timeout") || 547 + errorMessage.includes("connection") || 548 + errorMessage.includes("fetch") 549 + ) { 550 + throw new NetworkError("Failed to reach token endpoint", error); 551 + } 471 552 } 553 + 554 + // Default to generic token exchange error 555 + throw new TokenExchangeError("Token refresh failed", undefined, error as Error); 556 + } finally { 557 + // Always cleanup the lock when done 558 + this.refreshLocks.delete(did); 472 559 } 560 + })(); 473 561 474 - // Default to generic token exchange error 475 - throw new TokenExchangeError("Token refresh failed", undefined, error as Error); 476 - } 562 + // Store the promise so concurrent requests can wait for it 563 + this.refreshLocks.set(did, refreshPromise); 564 + 565 + return refreshPromise; 477 566 } 478 567 479 568 /** ··· 485 574 * 486 575 * @param sessionId - Session identifier to remove from storage 487 576 * @param session - Session containing tokens to revoke 577 + * 488 578 * @example 489 579 * ```ts 490 580 * await client.signOut("user-session-123", session); ··· 492 582 * ``` 493 583 */ 494 584 async signOut(sessionId: string, session: Session): Promise<void> { 585 + this.logger.info("Signing out session", { sessionId, did: session.did }); 586 + 495 587 try { 496 588 // Try to revoke tokens (best effort) 497 589 const oauthEndpoints = await discoverOAuthEndpointsFromPDS(session.pdsUrl); 498 590 const revokeEndpoint = oauthEndpoints.revocationEndpoint; 499 591 500 592 if (revokeEndpoint) { 593 + this.logger.debug("Revoking refresh token", { revokeEndpoint }); 594 + 501 595 // Revoke refresh token 502 - await fetch(revokeEndpoint, { 596 + const response = await fetch(revokeEndpoint, { 503 597 method: "POST", 504 598 headers: { 505 599 "Content-Type": "application/x-www-form-urlencoded", ··· 509 603 client_id: this.clientId, 510 604 }), 511 605 }); 606 + 607 + if (!response.ok) { 608 + this.logger.warn("Token revocation failed", { 609 + status: response.status, 610 + statusText: response.statusText, 611 + }); 612 + } else { 613 + this.logger.debug("Token revocation successful"); 614 + } 615 + } else { 616 + this.logger.warn("No revocation endpoint available"); 512 617 } 513 - } catch { 618 + } catch (error) { 514 619 // Ignore revocation errors - clean up storage anyway 620 + this.logger.warn("Token revocation error (continuing with cleanup)", { error }); 515 621 } finally { 516 622 // Always clean up storage 517 623 await this.storage.delete(`session:${sessionId}`); 624 + this.logger.info("Session signed out", { sessionId }); 518 625 } 519 626 } 520 627 ··· 544 651 login_hint: params.loginHint, 545 652 }); 546 653 654 + this.logger.debug("Sending Pushed Authorization Request", { authServer }); 655 + 547 656 const response = await fetch(`${authServer}/oauth/par`, { 548 657 method: "POST", 549 658 headers: { ··· 554 663 555 664 if (!response.ok) { 556 665 const error = await response.text(); 666 + this.logger.error("PAR request failed", { status: response.status, error }); 557 667 throw new OAuthError(`Pushed Authorization Request failed: ${error}`); 558 668 } 559 669 ··· 564 674 }); 565 675 566 676 return `${authServer}/oauth/authorize?${authParams}`; 567 - } 568 - 569 - private async exchangeCodeForTokens( 570 - authServer: string, 571 - code: string, 572 - codeVerifier: string, 573 - dpopKeys: { privateKey: CryptoKey; publicKeyJWK: JsonWebKey }, 574 - ) { 575 - const tokenUrl = `${authServer}/oauth/token`; 576 - 577 - // Create DPoP proof for token exchange 578 - const dpopProof = await generateDPoPProof( 579 - "POST", 580 - tokenUrl, 581 - dpopKeys.privateKey, 582 - dpopKeys.publicKeyJWK, 583 - ); 584 - 585 - const tokenBody = new URLSearchParams({ 586 - grant_type: "authorization_code", 587 - client_id: this.clientId, 588 - redirect_uri: this.redirectUri, 589 - code, 590 - code_verifier: codeVerifier, 591 - }); 592 - 593 - let response = await fetch(tokenUrl, { 594 - method: "POST", 595 - headers: { 596 - "Content-Type": "application/x-www-form-urlencoded", 597 - "DPoP": dpopProof, 598 - }, 599 - body: tokenBody, 600 - }); 601 - 602 - // Handle DPoP nonce requirement - AT Protocol uses 400 status 603 - if (!response.ok && response.status === 400) { 604 - const nonce = response.headers.get("DPoP-Nonce"); 605 - if (nonce) { 606 - // Retry with nonce 607 - const dpopProofWithNonce = await generateDPoPProof( 608 - "POST", 609 - tokenUrl, 610 - dpopKeys.privateKey, 611 - dpopKeys.publicKeyJWK, 612 - undefined, 613 - nonce, 614 - ); 615 - 616 - response = await fetch(tokenUrl, { 617 - method: "POST", 618 - headers: { 619 - "Content-Type": "application/x-www-form-urlencoded", 620 - "DPoP": dpopProofWithNonce, 621 - }, 622 - body: tokenBody, 623 - }); 624 - } 625 - } 626 - 627 - if (!response.ok) { 628 - const error = await response.text(); 629 - throw new TokenExchangeError(error); 630 - } 631 - 632 - return await response.json(); 633 - } 634 - 635 - private async refreshTokens( 636 - tokenEndpoint: string, 637 - refreshToken: string, 638 - privateKeyJWK: JsonWebKey, 639 - publicKeyJWK: JsonWebKey, 640 - ): Promise<{ accessToken: string; refreshToken?: string; expiresIn: number }> { 641 - try { 642 - // Import private key for DPoP signing 643 - const { importPrivateKeyFromJWK } = await import("./dpop.ts"); 644 - const privateKey = await importPrivateKeyFromJWK(privateKeyJWK); 645 - 646 - // Create DPoP proof for token refresh 647 - const dpopProof = await generateDPoPProof( 648 - "POST", 649 - tokenEndpoint, 650 - privateKey, 651 - publicKeyJWK, 652 - ); 653 - 654 - const tokenBody = new URLSearchParams({ 655 - grant_type: "refresh_token", 656 - client_id: this.clientId, 657 - refresh_token: refreshToken, 658 - }); 659 - 660 - let response = await fetch(tokenEndpoint, { 661 - method: "POST", 662 - headers: { 663 - "Content-Type": "application/x-www-form-urlencoded", 664 - "DPoP": dpopProof, 665 - }, 666 - body: tokenBody, 667 - }); 668 - 669 - // Handle DPoP nonce requirement - AT Protocol uses 400 status 670 - if (!response.ok && response.status === 400) { 671 - const nonce = response.headers.get("DPoP-Nonce"); 672 - if (nonce) { 673 - // Retry with nonce 674 - const dpopProofWithNonce = await generateDPoPProof( 675 - "POST", 676 - tokenEndpoint, 677 - privateKey, 678 - publicKeyJWK, 679 - undefined, 680 - nonce, 681 - ); 682 - 683 - response = await fetch(tokenEndpoint, { 684 - method: "POST", 685 - headers: { 686 - "Content-Type": "application/x-www-form-urlencoded", 687 - "DPoP": dpopProofWithNonce, 688 - }, 689 - body: tokenBody, 690 - }); 691 - } 692 - } 693 - 694 - if (!response.ok) { 695 - const error = await response.text(); 696 - throw new TokenExchangeError(`Token refresh failed: ${error}`); 697 - } 698 - 699 - const tokens = await response.json(); 700 - 701 - return { 702 - accessToken: tokens.access_token, 703 - refreshToken: tokens.refresh_token, // May be undefined if server doesn't rotate refresh tokens 704 - expiresIn: tokens.expires_in, 705 - }; 706 - } catch (error) { 707 - if (error instanceof TokenExchangeError) { 708 - throw error; 709 - } 710 - throw new TokenExchangeError("Token refresh failed", undefined, error as Error); 711 - } 712 - } 713 - 714 - // PKCE helper methods 715 - private generateCodeVerifier(): string { 716 - const array = new Uint8Array(32); 717 - crypto.getRandomValues(array); 718 - return btoa(String.fromCharCode(...array)) 719 - .replace(/[+/]/g, (match) => match === "+" ? "-" : "_") 720 - .replace(/=/g, ""); 721 - } 722 - 723 - private async generateCodeChallenge(verifier: string): Promise<string> { 724 - const encoder = new TextEncoder(); 725 - const data = encoder.encode(verifier); 726 - const digest = await crypto.subtle.digest("SHA-256", data); 727 - return btoa(String.fromCharCode(...new Uint8Array(digest))) 728 - .replace(/[+/]/g, (match) => match === "+" ? "-" : "_") 729 - .replace(/=/g, ""); 730 677 } 731 678 }
+16 -5
src/dpop.ts
··· 99 99 privateKeyJWK: JsonWebKey, 100 100 ): Promise<CryptoKey> { 101 101 try { 102 + // Validate required JWK fields for EC private key 103 + if ( 104 + typeof privateKeyJWK.kty !== "string" || 105 + typeof privateKeyJWK.crv !== "string" || 106 + typeof privateKeyJWK.x !== "string" || 107 + typeof privateKeyJWK.y !== "string" || 108 + typeof privateKeyJWK.d !== "string" 109 + ) { 110 + throw new Error("Invalid JWK format: missing required EC private key fields"); 111 + } 112 + 102 113 // Clean JWK to remove any conflicting key_ops that might have been added by exportJWK 103 114 const cleanJWK: JsonWebKey = { 104 - kty: privateKeyJWK.kty!, 105 - crv: privateKeyJWK.crv!, 106 - x: privateKeyJWK.x!, 107 - y: privateKeyJWK.y!, 108 - d: privateKeyJWK.d!, 115 + kty: privateKeyJWK.kty, 116 + crv: privateKeyJWK.crv, 117 + x: privateKeyJWK.x, 118 + y: privateKeyJWK.y, 119 + d: privateKeyJWK.d, 109 120 }; 110 121 111 122 return await crypto.subtle.importKey(
+124
src/logger.ts
··· 1 + /** 2 + * @fileoverview Logging abstraction for OAuth client operations 3 + * @module 4 + */ 5 + 6 + /** 7 + * Logger interface for OAuth client operations. 8 + * 9 + * Implement this interface to provide custom logging for the OAuth client. 10 + * By default, the client uses a no-op logger that produces no output. 11 + * 12 + * @example Custom logger implementation 13 + * ```ts 14 + * class ConsoleLogger implements Logger { 15 + * debug(message: string, ...args: unknown[]): void { 16 + * console.debug(`[DEBUG] ${message}`, ...args); 17 + * } 18 + * 19 + * info(message: string, ...args: unknown[]): void { 20 + * console.info(`[INFO] ${message}`, ...args); 21 + * } 22 + * 23 + * warn(message: string, ...args: unknown[]): void { 24 + * console.warn(`[WARN] ${message}`, ...args); 25 + * } 26 + * 27 + * error(message: string, ...args: unknown[]): void { 28 + * console.error(`[ERROR] ${message}`, ...args); 29 + * } 30 + * } 31 + * 32 + * const client = new OAuthClient({ 33 + * // ... other config 34 + * logger: new ConsoleLogger(), 35 + * }); 36 + * ``` 37 + */ 38 + export interface Logger { 39 + /** 40 + * Log debug-level message (lowest priority). 41 + * Use for detailed diagnostic information. 42 + */ 43 + debug(message: string, ...args: unknown[]): void; 44 + 45 + /** 46 + * Log info-level message. 47 + * Use for general informational messages. 48 + */ 49 + info(message: string, ...args: unknown[]): void; 50 + 51 + /** 52 + * Log warning-level message. 53 + * Use for potentially harmful situations. 54 + */ 55 + warn(message: string, ...args: unknown[]): void; 56 + 57 + /** 58 + * Log error-level message (highest priority). 59 + * Use for error events that might still allow the application to continue. 60 + */ 61 + error(message: string, ...args: unknown[]): void; 62 + } 63 + 64 + /** 65 + * No-op logger implementation that produces no output. 66 + * 67 + * This is the default logger used by the OAuth client when no custom 68 + * logger is provided. All log methods are no-ops. 69 + * 70 + * @example 71 + * ```ts 72 + * const logger = new NoOpLogger(); 73 + * logger.info("This will not be logged anywhere"); 74 + * ``` 75 + */ 76 + export class NoOpLogger implements Logger { 77 + debug(_message: string, ..._args: unknown[]): void { 78 + // No-op 79 + } 80 + 81 + info(_message: string, ..._args: unknown[]): void { 82 + // No-op 83 + } 84 + 85 + warn(_message: string, ..._args: unknown[]): void { 86 + // No-op 87 + } 88 + 89 + error(_message: string, ..._args: unknown[]): void { 90 + // No-op 91 + } 92 + } 93 + 94 + /** 95 + * Console logger implementation for development and debugging. 96 + * 97 + * Logs all messages to the console with appropriate log levels. 98 + * Useful for development but not recommended for production. 99 + * 100 + * @example 101 + * ```ts 102 + * const client = new OAuthClient({ 103 + * // ... other config 104 + * logger: new ConsoleLogger(), 105 + * }); 106 + * ``` 107 + */ 108 + export class ConsoleLogger implements Logger { 109 + debug(message: string, ...args: unknown[]): void { 110 + console.debug(`[DEBUG] ${message}`, ...args); 111 + } 112 + 113 + info(message: string, ...args: unknown[]): void { 114 + console.info(`[INFO] ${message}`, ...args); 115 + } 116 + 117 + warn(message: string, ...args: unknown[]): void { 118 + console.warn(`[WARN] ${message}`, ...args); 119 + } 120 + 121 + error(message: string, ...args: unknown[]): void { 122 + console.error(`[ERROR] ${message}`, ...args); 123 + } 124 + }
+73
src/pkce.ts
··· 1 + /** 2 + * @fileoverview PKCE (Proof Key for Code Exchange) utilities for OAuth 2.0 3 + * @module 4 + */ 5 + 6 + /** 7 + * Generate a cryptographically random code verifier for PKCE. 8 + * 9 + * Creates a URL-safe base64-encoded string from 32 random bytes, which is 10 + * used to protect the authorization code exchange in OAuth 2.0 flows. 11 + * 12 + * @returns Code verifier string (43-128 characters, URL-safe base64) 13 + * @see https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 14 + * 15 + * @example 16 + * ```ts 17 + * const verifier = generateCodeVerifier(); 18 + * console.log(verifier); // "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" 19 + * ``` 20 + */ 21 + export function generateCodeVerifier(): string { 22 + const array = new Uint8Array(32); 23 + crypto.getRandomValues(array); 24 + return base64UrlEncode(array); 25 + } 26 + 27 + /** 28 + * Generate a code challenge from a code verifier for PKCE. 29 + * 30 + * Creates a URL-safe base64-encoded SHA-256 hash of the code verifier, 31 + * which is sent to the authorization server during the initial request. 32 + * 33 + * @param verifier - Code verifier string 34 + * @returns Promise resolving to code challenge string (URL-safe base64) 35 + * @see https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 36 + * 37 + * @example 38 + * ```ts 39 + * const verifier = generateCodeVerifier(); 40 + * const challenge = await generateCodeChallenge(verifier); 41 + * console.log(challenge); // "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" 42 + * ``` 43 + */ 44 + export async function generateCodeChallenge(verifier: string): Promise<string> { 45 + const encoder = new TextEncoder(); 46 + const data = encoder.encode(verifier); 47 + const digest = await crypto.subtle.digest("SHA-256", data); 48 + return base64UrlEncode(new Uint8Array(digest)); 49 + } 50 + 51 + /** 52 + * Convert a byte array to URL-safe base64 encoding. 53 + * 54 + * Encodes the input bytes as base64 and makes it URL-safe by replacing 55 + * `+` with `-`, `/` with `_`, and removing `=` padding. 56 + * 57 + * @param data - Byte array to encode 58 + * @returns URL-safe base64 encoded string 59 + * @internal 60 + * 61 + * @example 62 + * ```ts 63 + * const bytes = new Uint8Array([72, 101, 108, 108, 111]); 64 + * const encoded = base64UrlEncode(bytes); 65 + * console.log(encoded); // "SGVsbG8" 66 + * ``` 67 + */ 68 + export function base64UrlEncode(data: Uint8Array): string { 69 + return btoa(String.fromCharCode(...data)) 70 + .replace(/\+/g, "-") 71 + .replace(/\//g, "_") 72 + .replace(/=/g, ""); 73 + }
+12 -3
src/storage.ts
··· 80 80 81 81 if (result.rows.length === 0) return null; 82 82 83 - const [value, expiresAt] = result.rows[0]; 84 - if (expiresAt && typeof expiresAt === "number" && Date.now() > expiresAt) { 83 + const row = result.rows[0]; 84 + if (!row || row.length < 2) return null; 85 + 86 + const [value, expiresAt] = row; 87 + 88 + // Validate types from database 89 + if (typeof value !== "string") { 90 + throw new Error("Invalid storage value: expected string"); 91 + } 92 + 93 + if (expiresAt !== null && typeof expiresAt === "number" && Date.now() > expiresAt) { 85 94 await this.delete(key); 86 95 return null; 87 96 } 88 97 89 - return JSON.parse(value as string) as T; 98 + return JSON.parse(value) as T; 90 99 } 91 100 92 101 async set<T = unknown>(key: string, value: T, options?: { ttl?: number }): Promise<void> {
+243
src/token-exchange.ts
··· 1 + /** 2 + * @fileoverview Token exchange and refresh operations with DPoP support 3 + * @module 4 + */ 5 + 6 + import { generateDPoPProof, importPrivateKeyFromJWK } from "./dpop.ts"; 7 + import { TokenExchangeError } from "./errors.ts"; 8 + import type { Logger } from "./logger.ts"; 9 + 10 + /** 11 + * Token response from OAuth server. 12 + * @internal 13 + */ 14 + export interface TokenResponse { 15 + access_token: string; 16 + refresh_token?: string; 17 + expires_in: number; 18 + } 19 + 20 + /** 21 + * Fetch tokens with DPoP authentication and automatic nonce retry. 22 + * 23 + * Handles DPoP nonce challenges by automatically retrying the request 24 + * with the nonce when the server returns a 400 status with DPoP-Nonce header. 25 + * 26 + * @param tokenUrl - Token endpoint URL 27 + * @param body - Request body as URLSearchParams 28 + * @param privateKey - DPoP private key for signing 29 + * @param publicKeyJWK - DPoP public key JWK 30 + * @param accessToken - Optional access token for ath claim 31 + * @param logger - Logger instance for debugging 32 + * @returns Promise resolving to token response 33 + * @throws {TokenExchangeError} When token request fails 34 + * @internal 35 + */ 36 + async function fetchWithDPoPRetry( 37 + tokenUrl: string, 38 + body: URLSearchParams, 39 + privateKey: CryptoKey, 40 + publicKeyJWK: JsonWebKey, 41 + accessToken: string | undefined, 42 + logger: Logger, 43 + ): Promise<Response> { 44 + // Create initial DPoP proof 45 + let dpopProof = await generateDPoPProof( 46 + "POST", 47 + tokenUrl, 48 + privateKey, 49 + publicKeyJWK, 50 + accessToken, 51 + ); 52 + 53 + logger.debug("Making token request with DPoP proof", { tokenUrl }); 54 + 55 + let response = await fetch(tokenUrl, { 56 + method: "POST", 57 + headers: { 58 + "Content-Type": "application/x-www-form-urlencoded", 59 + "DPoP": dpopProof, 60 + }, 61 + body, 62 + }); 63 + 64 + // Handle DPoP nonce requirement - AT Protocol uses 400 status 65 + if (!response.ok && response.status === 400) { 66 + const nonce = response.headers.get("DPoP-Nonce"); 67 + if (nonce) { 68 + logger.debug("Retrying token request with DPoP nonce", { nonce }); 69 + 70 + // Retry with nonce 71 + dpopProof = await generateDPoPProof( 72 + "POST", 73 + tokenUrl, 74 + privateKey, 75 + publicKeyJWK, 76 + accessToken, 77 + nonce, 78 + ); 79 + 80 + response = await fetch(tokenUrl, { 81 + method: "POST", 82 + headers: { 83 + "Content-Type": "application/x-www-form-urlencoded", 84 + "DPoP": dpopProof, 85 + }, 86 + body, 87 + }); 88 + } 89 + } 90 + 91 + return response; 92 + } 93 + 94 + /** 95 + * Exchange authorization code for access and refresh tokens. 96 + * 97 + * Performs the OAuth 2.0 authorization code exchange with PKCE verification 98 + * and DPoP token binding. Automatically handles DPoP nonce challenges. 99 + * 100 + * @param authServer - Authorization server base URL 101 + * @param code - Authorization code from callback 102 + * @param codeVerifier - PKCE code verifier 103 + * @param clientId - OAuth client ID 104 + * @param redirectUri - Redirect URI used in authorization 105 + * @param dpopKeys - DPoP key pair for token binding 106 + * @param logger - Logger instance 107 + * @returns Promise resolving to token response 108 + * @throws {TokenExchangeError} When token exchange fails 109 + * 110 + * @example 111 + * ```ts 112 + * const tokens = await exchangeCodeForTokens( 113 + * "https://bsky.social", 114 + * "auth_code_123", 115 + * "code_verifier_xyz", 116 + * "https://myapp.com/client-metadata.json", 117 + * "https://myapp.com/oauth/callback", 118 + * dpopKeys, 119 + * logger 120 + * ); 121 + * console.log("Access token:", tokens.access_token); 122 + * ``` 123 + */ 124 + export async function exchangeCodeForTokens( 125 + authServer: string, 126 + code: string, 127 + codeVerifier: string, 128 + clientId: string, 129 + redirectUri: string, 130 + dpopKeys: { privateKey: CryptoKey; publicKeyJWK: JsonWebKey }, 131 + logger: Logger, 132 + ): Promise<TokenResponse> { 133 + const tokenUrl = `${authServer}/oauth/token`; 134 + 135 + logger.info("Exchanging authorization code for tokens", { authServer }); 136 + 137 + const tokenBody = new URLSearchParams({ 138 + grant_type: "authorization_code", 139 + client_id: clientId, 140 + redirect_uri: redirectUri, 141 + code, 142 + code_verifier: codeVerifier, 143 + }); 144 + 145 + const response = await fetchWithDPoPRetry( 146 + tokenUrl, 147 + tokenBody, 148 + dpopKeys.privateKey, 149 + dpopKeys.publicKeyJWK, 150 + undefined, 151 + logger, 152 + ); 153 + 154 + if (!response.ok) { 155 + const error = await response.text(); 156 + logger.error("Token exchange failed", { status: response.status, error }); 157 + throw new TokenExchangeError(error); 158 + } 159 + 160 + logger.info("Token exchange successful"); 161 + return await response.json(); 162 + } 163 + 164 + /** 165 + * Refresh access token using refresh token. 166 + * 167 + * Exchanges a refresh token for new access and optionally refresh tokens 168 + * using the OAuth 2.0 refresh_token grant with DPoP authentication. 169 + * 170 + * @param tokenEndpoint - Token endpoint URL 171 + * @param refreshToken - Current refresh token 172 + * @param clientId - OAuth client ID 173 + * @param privateKeyJWK - DPoP private key as JWK 174 + * @param publicKeyJWK - DPoP public key as JWK 175 + * @param logger - Logger instance 176 + * @returns Promise resolving to refreshed tokens 177 + * @throws {TokenExchangeError} When token refresh fails 178 + * 179 + * @example 180 + * ```ts 181 + * const tokens = await refreshTokens( 182 + * "https://bsky.social/oauth/token", 183 + * "refresh_token_123", 184 + * "https://myapp.com/client-metadata.json", 185 + * privateKeyJWK, 186 + * publicKeyJWK, 187 + * logger 188 + * ); 189 + * console.log("New access token:", tokens.accessToken); 190 + * ``` 191 + */ 192 + export async function refreshTokens( 193 + tokenEndpoint: string, 194 + refreshToken: string, 195 + clientId: string, 196 + privateKeyJWK: JsonWebKey, 197 + publicKeyJWK: JsonWebKey, 198 + logger: Logger, 199 + ): Promise<{ accessToken: string; refreshToken?: string; expiresIn: number }> { 200 + try { 201 + logger.info("Refreshing access token", { tokenEndpoint }); 202 + 203 + // Import private key for DPoP signing 204 + const privateKey = await importPrivateKeyFromJWK(privateKeyJWK); 205 + 206 + const tokenBody = new URLSearchParams({ 207 + grant_type: "refresh_token", 208 + client_id: clientId, 209 + refresh_token: refreshToken, 210 + }); 211 + 212 + const response = await fetchWithDPoPRetry( 213 + tokenEndpoint, 214 + tokenBody, 215 + privateKey, 216 + publicKeyJWK, 217 + undefined, 218 + logger, 219 + ); 220 + 221 + if (!response.ok) { 222 + const error = await response.text(); 223 + logger.error("Token refresh failed", { status: response.status, error }); 224 + throw new TokenExchangeError(`Token refresh failed: ${error}`); 225 + } 226 + 227 + const tokens = await response.json(); 228 + 229 + logger.info("Token refresh successful"); 230 + 231 + return { 232 + accessToken: tokens.access_token, 233 + refreshToken: tokens.refresh_token, // May be undefined if server doesn't rotate refresh tokens 234 + expiresIn: tokens.expires_in, 235 + }; 236 + } catch (error) { 237 + if (error instanceof TokenExchangeError) { 238 + throw error; 239 + } 240 + logger.error("Token refresh error", { error }); 241 + throw new TokenExchangeError("Token refresh failed", undefined, error as Error); 242 + } 243 + }
+8 -15
src/types.ts
··· 3 3 * @module 4 4 */ 5 5 6 + import type { Logger } from "./logger.ts"; 7 + 6 8 /** 7 9 * Storage interface for persisting OAuth sessions and state data. 8 10 * ··· 99 101 * Only used when using the default handle resolver 100 102 */ 101 103 slingshotUrl?: string; 104 + 105 + /** 106 + * Logger for debugging and diagnostics (optional, defaults to no-op logger) 107 + * Implement the Logger interface to capture client logging output 108 + */ 109 + logger?: Logger; 102 110 } 103 111 104 112 /** ··· 119 127 * Login hint for the authorization server 120 128 */ 121 129 loginHint?: string; 122 - 123 - /** 124 - * AbortSignal for canceling authorization 125 - */ 126 - signal?: AbortSignal; 127 - } 128 - 129 - /** 130 - * Callback options matching @atproto/oauth-client interface 131 - */ 132 - export interface CallbackOptions { 133 - /** 134 - * Redirect URI override (optional) 135 - */ 136 - redirect_uri?: string; 137 130 } 138 131 139 132 /**