+21
CHANGELOG.md
+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
-1
README.md
+1
-1
deno.json
+1
-1
deno.json
+48
src/client.ts
+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
+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
+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();