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

fix: handle token refresh race condition in serverless environments

When concurrent requests trigger token refresh simultaneously across
different isolates (e.g., Val Town, Deno Deploy), the second request
would fail with 'Refresh token replayed' error.

Now gracefully handles this by:
- Detecting the 'replayed' error from OAuth servers
- Waiting briefly for the other process to save
- Re-reading the session from storage to get fresh tokens

Also adds:
- errorDescription field on TokenExchangeError
- Proper JSON parsing of OAuth error responses

+21
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.2] - 2025-11-27 9 + 10 + ### Fixed 11 + 12 + - **Token refresh race condition in serverless environments**: When concurrent requests 13 + trigger token refresh simultaneously across different isolates (e.g., Val Town, Deno Deploy), 14 + the second request would fail with "Refresh token replayed" error. Now gracefully handles 15 + this by re-reading the session from storage after detecting the replay error. 16 + 17 + ### Added 18 + 19 + - **`errorDescription` field on `TokenExchangeError`**: OAuth `error_description` is now 20 + exposed as a separate field for better error handling and logging 21 + - **OAuth error response parsing**: Token exchange errors now properly parse JSON error 22 + responses from OAuth servers, extracting `error` and `error_description` fields 23 + 24 + ### Improved 25 + 26 + - Better error classification for token refresh failures 27 + - More informative error messages when OAuth operations fail 28 + 8 29 ## [4.0.1] - 2025-01-15 9 30 10 31 ### Fixed
-1
README.md
··· 413 413 ## See Also 414 414 415 415 - **[@tijs/atproto-oauth-hono](https://jsr.io/@tijs/atproto-oauth-hono)**: High-level Hono integration built on top of this client. 416 -
+1 -1
deno.json
··· 1 1 { 2 2 "name": "@tijs/oauth-client-deno", 3 - "version": "4.0.1", 3 + "version": "4.0.2", 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": {
+48
src/client.ts
··· 529 529 } catch (error) { 530 530 this.logger.error("Token refresh failed", { did, error }); 531 531 532 + // Check for token replay error (concurrent refresh in another isolate) 533 + if (this.isTokenReplayedError(error)) { 534 + this.logger.info("Token replay detected, fetching updated session from storage", { did }); 535 + 536 + // Wait briefly for the other process to save 537 + await this.sleep(200); 538 + 539 + // Re-read session from storage (the other process should have saved new tokens) 540 + const updatedSessionData = await this.storage.get<SessionData>(`session:${did}`); 541 + if (updatedSessionData) { 542 + const updatedSession = Session.fromJSON(updatedSessionData); 543 + if (!updatedSession.isExpired) { 544 + this.logger.info("Retrieved refreshed session from storage after replay detection", { 545 + did, 546 + }); 547 + return updatedSession; 548 + } 549 + } 550 + 551 + // Could not recover - throw the original error 552 + this.logger.error("Could not recover from token replay - no valid session in storage", { 553 + did, 554 + }); 555 + } 556 + 532 557 // Classify the error based on type 533 558 if (error instanceof TokenExchangeError) { 534 559 // Already a TokenExchangeError, check for specific grant errors ··· 629 654 630 655 private extractAuthServer(authorizationEndpoint: string): string { 631 656 return authorizationEndpoint.replace(/\/oauth\/authorize$/, ""); 657 + } 658 + 659 + /** 660 + * Check if an error is a token replay error from concurrent refresh attempts. 661 + * This happens in serverless environments where multiple isolates may try to 662 + * refresh the same token simultaneously. 663 + */ 664 + private isTokenReplayedError(error: unknown): boolean { 665 + if (error instanceof TokenExchangeError) { 666 + if (error.errorCode !== "invalid_grant") return false; 667 + // Check both the message and errorDescription for "replayed" 668 + const message = error.message.toLowerCase(); 669 + const description = error.errorDescription?.toLowerCase() || ""; 670 + return message.includes("replayed") || description.includes("replayed"); 671 + } 672 + return false; 673 + } 674 + 675 + /** 676 + * Sleep for a specified duration. 677 + */ 678 + private sleep(ms: number): Promise<void> { 679 + return new Promise((resolve) => setTimeout(resolve, ms)); 632 680 } 633 681 634 682 private async pushAuthorizationRequest(
+11 -1
src/errors.ts
··· 182 182 * if (error.errorCode) { 183 183 * console.log("OAuth error code:", error.errorCode); 184 184 * } 185 + * if (error.errorDescription) { 186 + * console.log("OAuth error description:", error.errorDescription); 187 + * } 185 188 * } 186 189 * } 187 190 * ``` ··· 189 192 export class TokenExchangeError extends OAuthError { 190 193 /** OAuth error code from the server (e.g., "invalid_grant") */ 191 194 public readonly errorCode?: string; 195 + 196 + /** OAuth error_description from the server (e.g., "Refresh token replayed") */ 197 + public readonly errorDescription?: string; 192 198 193 199 /** 194 200 * Create a new token exchange error. ··· 196 202 * @param message - Error message describing what went wrong 197 203 * @param errorCode - Optional OAuth error code from the server 198 204 * @param cause - Optional underlying error that caused the token exchange failure 205 + * @param errorDescription - Optional OAuth error_description from the server 199 206 */ 200 - constructor(message: string, errorCode?: string, cause?: Error) { 207 + constructor(message: string, errorCode?: string, cause?: Error, errorDescription?: string) { 201 208 super(`Token exchange failed: ${message}`, cause); 202 209 this.name = "TokenExchangeError"; 203 210 if (errorCode) { 204 211 this.errorCode = errorCode; 212 + } 213 + if (errorDescription) { 214 + this.errorDescription = errorDescription; 205 215 } 206 216 } 207 217 }
+34 -6
src/token-exchange.ts
··· 152 152 ); 153 153 154 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); 155 + const errorText = await response.text(); 156 + logger.error("Token exchange failed", { status: response.status, error: errorText }); 157 + 158 + // Try to parse OAuth error response (JSON format) 159 + try { 160 + const errorJson = JSON.parse(errorText); 161 + throw new TokenExchangeError( 162 + errorJson.error_description || errorJson.error || errorText, 163 + errorJson.error, // e.g., "invalid_client", "invalid_grant" 164 + undefined, // cause 165 + errorJson.error_description, // errorDescription 166 + ); 167 + } catch (parseError) { 168 + if (parseError instanceof TokenExchangeError) throw parseError; 169 + throw new TokenExchangeError(errorText); 170 + } 158 171 } 159 172 160 173 logger.info("Token exchange successful"); ··· 219 232 ); 220 233 221 234 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}`); 235 + const errorText = await response.text(); 236 + logger.error("Token refresh failed", { status: response.status, error: errorText }); 237 + 238 + // Try to parse OAuth error response (JSON format) 239 + try { 240 + const errorJson = JSON.parse(errorText); 241 + throw new TokenExchangeError( 242 + `Token refresh failed: ${errorJson.error_description || errorJson.error || errorText}`, 243 + errorJson.error, // e.g., "invalid_grant" 244 + undefined, // cause 245 + errorJson.error_description, // errorDescription 246 + ); 247 + } catch (parseError) { 248 + // If it's already our error, re-throw it 249 + if (parseError instanceof TokenExchangeError) throw parseError; 250 + // Otherwise, throw with raw text 251 + throw new TokenExchangeError(`Token refresh failed: ${errorText}`); 252 + } 225 253 } 226 254 227 255 const tokens = await response.json();