@lobehub/lobehub 2.0.0-next.148 → 2.0.0-next.149
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/CLAUDE.md +8 -4
- package/apps/desktop/package.json +2 -0
- package/apps/desktop/src/main/controllers/AuthCtr.ts +60 -27
- package/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts +100 -8
- package/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts +39 -2
- package/changelog/v1.json +5 -0
- package/package.json +1 -1
- package/packages/database/src/server/models/__tests__/adapter.test.ts +97 -69
- package/src/libs/oidc-provider/adapter.ts +42 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [Version 2.0.0-next.149](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.148...v2.0.0-next.149)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2025-12-03**</sup>
|
|
8
|
+
|
|
9
|
+
#### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- **desktop**: Add token refresh retry mechanism.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### What's fixed
|
|
19
|
+
|
|
20
|
+
- **desktop**: Add token refresh retry mechanism, closes [#10575](https://github.com/lobehub/lobe-chat/issues/10575) ([83fc2e8](https://github.com/lobehub/lobe-chat/commit/83fc2e8))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
5
30
|
## [Version 2.0.0-next.148](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.147...v2.0.0-next.148)
|
|
6
31
|
|
|
7
32
|
<sup>Released on **2025-12-03**</sup>
|
package/CLAUDE.md
CHANGED
|
@@ -81,17 +81,21 @@ When working with Linear issues:
|
|
|
81
81
|
1. Complete the implementation for this specific issue
|
|
82
82
|
2. Run type check: `bun run type-check`
|
|
83
83
|
3. Run related tests if applicable
|
|
84
|
-
4.
|
|
85
|
-
5. **IMMEDIATELY**
|
|
86
|
-
6.
|
|
84
|
+
4. Create PR if needed
|
|
85
|
+
5. **IMMEDIATELY** update issue status to **"In Review"** (NOT "Done"): `mcp__linear-server__update_issue`
|
|
86
|
+
6. **IMMEDIATELY** add completion comment: `mcp__linear-server__create_comment`
|
|
87
|
+
7. Only then move on to the next issue
|
|
88
|
+
|
|
89
|
+
**Note:** Issue status should be set to **"In Review"** when PR is created. The status will be updated to **"Done"** only after the PR is merged (usually handled by Linear-GitHub integration or manually).
|
|
87
90
|
|
|
88
91
|
**❌ Wrong approach:**
|
|
89
92
|
|
|
90
93
|
- Complete Issue A → Complete Issue B → Complete Issue C → Update all statuses → Add all comments
|
|
94
|
+
- Mark issue as "Done" immediately after creating PR
|
|
91
95
|
|
|
92
96
|
**✅ Correct approach:**
|
|
93
97
|
|
|
94
|
-
- Complete Issue A → Update A status → Add A comment → Complete Issue B →
|
|
98
|
+
- Complete Issue A → Create PR → Update A status to "In Review" → Add A comment → Complete Issue B → ...
|
|
95
99
|
|
|
96
100
|
## Rules Index
|
|
97
101
|
|
|
@@ -45,11 +45,13 @@
|
|
|
45
45
|
"@lobechat/electron-server-ipc": "workspace:*",
|
|
46
46
|
"@lobechat/file-loaders": "workspace:*",
|
|
47
47
|
"@lobehub/i18n-cli": "^1.25.1",
|
|
48
|
+
"@types/async-retry": "^1.4.9",
|
|
48
49
|
"@types/lodash": "^4.17.21",
|
|
49
50
|
"@types/resolve": "^1.20.6",
|
|
50
51
|
"@types/semver": "^7.7.1",
|
|
51
52
|
"@types/set-cookie-parser": "^2.4.10",
|
|
52
53
|
"@typescript/native-preview": "7.0.0-dev.20250711.1",
|
|
54
|
+
"async-retry": "^1.3.3",
|
|
53
55
|
"consola": "^3.4.2",
|
|
54
56
|
"cookie": "^1.1.1",
|
|
55
57
|
"diff": "^8.0.2",
|
|
@@ -246,12 +246,23 @@ export default class AuthCtr extends ControllerModule {
|
|
|
246
246
|
logger.info('Auto-refresh successful');
|
|
247
247
|
this.broadcastTokenRefreshed();
|
|
248
248
|
} else {
|
|
249
|
-
logger.error(`Auto-refresh failed: ${result.error}`);
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
249
|
+
logger.error(`Auto-refresh failed after retries: ${result.error}`);
|
|
250
|
+
|
|
251
|
+
// Only clear tokens for non-retryable errors (e.g., invalid_grant)
|
|
252
|
+
// The retry mechanism in RemoteServerConfigCtr already handles transient errors
|
|
253
|
+
if (this.remoteServerConfigCtr.isNonRetryableError(result.error)) {
|
|
254
|
+
logger.warn(
|
|
255
|
+
'Non-retryable error detected, clearing tokens and requiring re-authorization',
|
|
256
|
+
);
|
|
257
|
+
this.stopAutoRefresh();
|
|
258
|
+
await this.remoteServerConfigCtr.clearTokens();
|
|
259
|
+
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
|
260
|
+
this.broadcastAuthorizationRequired();
|
|
261
|
+
} else {
|
|
262
|
+
// For other errors (after retries exhausted), log but don't clear tokens immediately
|
|
263
|
+
// The next refresh cycle will retry
|
|
264
|
+
logger.warn('Refresh failed but error may be transient, will retry on next cycle');
|
|
265
|
+
}
|
|
255
266
|
}
|
|
256
267
|
}
|
|
257
268
|
} catch (error) {
|
|
@@ -335,11 +346,12 @@ export default class AuthCtr extends ControllerModule {
|
|
|
335
346
|
|
|
336
347
|
/**
|
|
337
348
|
* Refresh access token
|
|
349
|
+
* This method includes retry mechanism via RemoteServerConfigCtr.refreshAccessToken()
|
|
338
350
|
*/
|
|
339
351
|
async refreshAccessToken() {
|
|
340
352
|
logger.info('Starting to refresh access token');
|
|
341
353
|
try {
|
|
342
|
-
// Call the centralized refresh logic in RemoteServerConfigCtr
|
|
354
|
+
// Call the centralized refresh logic in RemoteServerConfigCtr (includes retry)
|
|
343
355
|
const result = await this.remoteServerConfigCtr.refreshAccessToken();
|
|
344
356
|
|
|
345
357
|
if (result.success) {
|
|
@@ -350,25 +362,38 @@ export default class AuthCtr extends ControllerModule {
|
|
|
350
362
|
this.startAutoRefresh();
|
|
351
363
|
return { success: true };
|
|
352
364
|
} else {
|
|
353
|
-
// Throw an error to be caught by the catch block below
|
|
354
|
-
// This maintains the existing behavior of clearing tokens on failure
|
|
355
365
|
logger.error(`Token refresh failed via AuthCtr call: ${result.error}`);
|
|
356
|
-
throw new Error(result.error || 'Token refresh failed');
|
|
357
|
-
}
|
|
358
|
-
} catch (error) {
|
|
359
|
-
// Keep the existing logic to clear tokens and require re-auth on failure
|
|
360
|
-
logger.error('Token refresh operation failed via AuthCtr, initiating cleanup:', error);
|
|
361
366
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
+
// Only clear tokens for non-retryable errors (e.g., invalid_grant)
|
|
368
|
+
if (this.remoteServerConfigCtr.isNonRetryableError(result.error)) {
|
|
369
|
+
logger.warn(
|
|
370
|
+
'Non-retryable error detected, clearing tokens and requiring re-authorization',
|
|
371
|
+
);
|
|
372
|
+
this.stopAutoRefresh();
|
|
373
|
+
await this.remoteServerConfigCtr.clearTokens();
|
|
374
|
+
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
|
375
|
+
this.broadcastAuthorizationRequired();
|
|
376
|
+
} else {
|
|
377
|
+
// For transient errors, don't clear tokens - allow manual retry
|
|
378
|
+
logger.warn('Refresh failed but error may be transient, tokens preserved for retry');
|
|
379
|
+
}
|
|
367
380
|
|
|
368
|
-
|
|
369
|
-
|
|
381
|
+
return { error: result.error, success: false };
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
385
|
+
logger.error('Token refresh operation failed via AuthCtr:', errorMessage);
|
|
386
|
+
|
|
387
|
+
// Only clear tokens for non-retryable errors
|
|
388
|
+
if (this.remoteServerConfigCtr.isNonRetryableError(errorMessage)) {
|
|
389
|
+
logger.warn('Non-retryable error in catch block, clearing tokens');
|
|
390
|
+
this.stopAutoRefresh();
|
|
391
|
+
await this.remoteServerConfigCtr.clearTokens();
|
|
392
|
+
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
|
393
|
+
this.broadcastAuthorizationRequired();
|
|
394
|
+
}
|
|
370
395
|
|
|
371
|
-
return { error:
|
|
396
|
+
return { error: errorMessage, success: false };
|
|
372
397
|
}
|
|
373
398
|
}
|
|
374
399
|
|
|
@@ -601,7 +626,7 @@ export default class AuthCtr extends ControllerModule {
|
|
|
601
626
|
if (currentTime >= expiresAt) {
|
|
602
627
|
logger.info('Token has expired, attempting to refresh it');
|
|
603
628
|
|
|
604
|
-
// Attempt to refresh token
|
|
629
|
+
// Attempt to refresh token (includes retry mechanism)
|
|
605
630
|
const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken();
|
|
606
631
|
if (refreshResult.success) {
|
|
607
632
|
logger.info('Token refresh successful during initialization');
|
|
@@ -611,10 +636,18 @@ export default class AuthCtr extends ControllerModule {
|
|
|
611
636
|
return;
|
|
612
637
|
} else {
|
|
613
638
|
logger.error(`Token refresh failed during initialization: ${refreshResult.error}`);
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
639
|
+
|
|
640
|
+
// Only clear token for non-retryable errors
|
|
641
|
+
if (this.remoteServerConfigCtr.isNonRetryableError(refreshResult.error)) {
|
|
642
|
+
logger.warn('Non-retryable error during initialization, clearing tokens');
|
|
643
|
+
await this.remoteServerConfigCtr.clearTokens();
|
|
644
|
+
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
|
645
|
+
this.broadcastAuthorizationRequired();
|
|
646
|
+
} else {
|
|
647
|
+
// For transient errors, still start auto-refresh timer to retry later
|
|
648
|
+
logger.warn('Transient error during initialization, will retry via auto-refresh');
|
|
649
|
+
this.startAutoRefresh();
|
|
650
|
+
}
|
|
618
651
|
return;
|
|
619
652
|
}
|
|
620
653
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
|
2
|
+
import retry from 'async-retry';
|
|
2
3
|
import { safeStorage } from 'electron';
|
|
3
4
|
import querystring from 'node:querystring';
|
|
4
5
|
import { URL } from 'node:url';
|
|
@@ -8,6 +9,28 @@ import { createLogger } from '@/utils/logger';
|
|
|
8
9
|
|
|
9
10
|
import { ControllerModule, ipcClientEvent } from './index';
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Non-retryable OIDC error codes
|
|
14
|
+
* These errors indicate the refresh token is invalid and retry won't help
|
|
15
|
+
*/
|
|
16
|
+
const NON_RETRYABLE_OIDC_ERRORS = [
|
|
17
|
+
'invalid_grant', // refresh token is invalid, expired, or revoked
|
|
18
|
+
'invalid_client', // client configuration error
|
|
19
|
+
'unauthorized_client', // client not authorized
|
|
20
|
+
'access_denied', // user denied access
|
|
21
|
+
'invalid_scope', // requested scope is invalid
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Deterministic failures that will never succeed on retry
|
|
26
|
+
* These are permanent state issues that require user intervention
|
|
27
|
+
*/
|
|
28
|
+
const DETERMINISTIC_FAILURES = [
|
|
29
|
+
'no refresh token available', // refresh token is missing from storage
|
|
30
|
+
'remote server is not active or configured', // config is invalid or disabled
|
|
31
|
+
'missing tokens in refresh response', // server returned incomplete response
|
|
32
|
+
];
|
|
33
|
+
|
|
11
34
|
// Create logger
|
|
12
35
|
const logger = createLogger('controllers:RemoteServerConfigCtr');
|
|
13
36
|
|
|
@@ -246,9 +269,34 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
|
|
246
269
|
}
|
|
247
270
|
|
|
248
271
|
/**
|
|
249
|
-
*
|
|
272
|
+
* Check if an error is non-retryable
|
|
273
|
+
* Includes OIDC errors (e.g., invalid_grant) and deterministic failures
|
|
274
|
+
* (e.g., missing refresh token, invalid config)
|
|
275
|
+
* @param error Error message to check
|
|
276
|
+
* @returns true if the error should not be retried
|
|
277
|
+
*/
|
|
278
|
+
isNonRetryableError(error?: string): boolean {
|
|
279
|
+
if (!error) return false;
|
|
280
|
+
const lowerError = error.toLowerCase();
|
|
281
|
+
|
|
282
|
+
// Check OIDC error codes
|
|
283
|
+
if (NON_RETRYABLE_OIDC_ERRORS.some((code) => lowerError.includes(code))) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Check deterministic failures that require user intervention
|
|
288
|
+
if (DETERMINISTIC_FAILURES.some((msg) => lowerError.includes(msg))) {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Refresh access token with retry mechanism
|
|
250
297
|
* Use stored refresh token to obtain a new access token
|
|
251
298
|
* Handles concurrent requests by returning the existing refresh promise if one is in progress.
|
|
299
|
+
* Retries up to 3 times with exponential backoff for transient errors.
|
|
252
300
|
*/
|
|
253
301
|
async refreshAccessToken(): Promise<{ error?: string; success: boolean }> {
|
|
254
302
|
// If a refresh is already in progress, return the existing promise
|
|
@@ -257,14 +305,62 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
|
|
257
305
|
return this.refreshPromise;
|
|
258
306
|
}
|
|
259
307
|
|
|
260
|
-
// Start a new refresh operation
|
|
261
|
-
logger.info('Initiating new token refresh operation.');
|
|
262
|
-
this.refreshPromise = this.
|
|
308
|
+
// Start a new refresh operation with retry
|
|
309
|
+
logger.info('Initiating new token refresh operation with retry.');
|
|
310
|
+
this.refreshPromise = this.performTokenRefreshWithRetry();
|
|
263
311
|
|
|
264
312
|
// Return the promise so callers can wait
|
|
265
313
|
return this.refreshPromise;
|
|
266
314
|
}
|
|
267
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Performs token refresh with retry mechanism
|
|
318
|
+
* Uses exponential backoff: 1s, 2s, 4s
|
|
319
|
+
*/
|
|
320
|
+
private async performTokenRefreshWithRetry(): Promise<{ error?: string; success: boolean }> {
|
|
321
|
+
try {
|
|
322
|
+
return await retry(
|
|
323
|
+
async (bail, attemptNumber) => {
|
|
324
|
+
logger.debug(`Token refresh attempt ${attemptNumber}/3`);
|
|
325
|
+
|
|
326
|
+
const result = await this.performTokenRefresh();
|
|
327
|
+
|
|
328
|
+
if (result.success) {
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Check if error is non-retryable
|
|
333
|
+
if (this.isNonRetryableError(result.error)) {
|
|
334
|
+
logger.warn(`Non-retryable error encountered: ${result.error}`);
|
|
335
|
+
// Use bail to stop retrying immediately
|
|
336
|
+
bail(new Error(result.error));
|
|
337
|
+
return result; // This won't be reached, but TypeScript needs it
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Throw error to trigger retry for transient errors
|
|
341
|
+
throw new Error(result.error);
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
factor: 2, // Exponential backoff factor
|
|
345
|
+
maxTimeout: 4000, // Max wait time between retries: 4s
|
|
346
|
+
minTimeout: 1000, // Min wait time between retries: 1s
|
|
347
|
+
onRetry: (err: Error, attempt: number) => {
|
|
348
|
+
logger.info(`Token refresh retry ${attempt}/3: ${err.message}`);
|
|
349
|
+
},
|
|
350
|
+
retries: 3, // Total retry attempts
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
355
|
+
logger.error('Token refresh failed after all retries:', errorMessage);
|
|
356
|
+
return { error: errorMessage, success: false };
|
|
357
|
+
} finally {
|
|
358
|
+
// Ensure the promise reference is cleared once the operation completes
|
|
359
|
+
logger.debug('Clearing the refresh promise reference.');
|
|
360
|
+
this.refreshPromise = null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
268
364
|
/**
|
|
269
365
|
* Performs the actual token refresh logic.
|
|
270
366
|
* This method is called by refreshAccessToken and wrapped in a promise.
|
|
@@ -337,10 +433,6 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
|
|
337
433
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
338
434
|
logger.error('Exception during token refresh operation:', errorMessage, error);
|
|
339
435
|
return { error: `Exception occurred during token refresh: ${errorMessage}`, success: false };
|
|
340
|
-
} finally {
|
|
341
|
-
// Ensure the promise reference is cleared once the operation completes
|
|
342
|
-
logger.debug('Clearing the refresh promise reference.');
|
|
343
|
-
this.refreshPromise = null;
|
|
344
436
|
}
|
|
345
437
|
}
|
|
346
438
|
|
|
@@ -355,6 +355,41 @@ describe('RemoteServerConfigCtr', () => {
|
|
|
355
355
|
});
|
|
356
356
|
});
|
|
357
357
|
|
|
358
|
+
describe('isNonRetryableError', () => {
|
|
359
|
+
it('should return false for null/undefined error', () => {
|
|
360
|
+
expect(controller.isNonRetryableError(undefined)).toBe(false);
|
|
361
|
+
expect(controller.isNonRetryableError('')).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should return true for OIDC error codes', () => {
|
|
365
|
+
expect(controller.isNonRetryableError('invalid_grant')).toBe(true);
|
|
366
|
+
expect(controller.isNonRetryableError('Token refresh failed: invalid_client')).toBe(true);
|
|
367
|
+
expect(controller.isNonRetryableError('unauthorized_client error')).toBe(true);
|
|
368
|
+
expect(controller.isNonRetryableError('access_denied by user')).toBe(true);
|
|
369
|
+
expect(controller.isNonRetryableError('invalid_scope requested')).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should return true for deterministic failures', () => {
|
|
373
|
+
expect(controller.isNonRetryableError('No refresh token available')).toBe(true);
|
|
374
|
+
expect(controller.isNonRetryableError('Remote server is not active or configured')).toBe(
|
|
375
|
+
true,
|
|
376
|
+
);
|
|
377
|
+
expect(controller.isNonRetryableError('Missing tokens in refresh response')).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should return false for transient/network errors', () => {
|
|
381
|
+
expect(controller.isNonRetryableError('Network error')).toBe(false);
|
|
382
|
+
expect(controller.isNonRetryableError('fetch failed')).toBe(false);
|
|
383
|
+
expect(controller.isNonRetryableError('ETIMEDOUT')).toBe(false);
|
|
384
|
+
expect(controller.isNonRetryableError('Connection refused')).toBe(false);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('should be case insensitive', () => {
|
|
388
|
+
expect(controller.isNonRetryableError('INVALID_GRANT')).toBe(true);
|
|
389
|
+
expect(controller.isNonRetryableError('NO REFRESH TOKEN AVAILABLE')).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
358
393
|
describe('refreshAccessToken', () => {
|
|
359
394
|
let mockFetch: ReturnType<typeof vi.fn>;
|
|
360
395
|
|
|
@@ -556,7 +591,7 @@ describe('RemoteServerConfigCtr', () => {
|
|
|
556
591
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
557
592
|
});
|
|
558
593
|
|
|
559
|
-
it('should handle network errors', async () => {
|
|
594
|
+
it('should handle network errors with retry', async () => {
|
|
560
595
|
const { safeStorage } = await import('electron');
|
|
561
596
|
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
562
597
|
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
|
@@ -582,7 +617,9 @@ describe('RemoteServerConfigCtr', () => {
|
|
|
582
617
|
|
|
583
618
|
expect(result.success).toBe(false);
|
|
584
619
|
expect(result.error).toContain('Network error');
|
|
585
|
-
|
|
620
|
+
// With retry mechanism, fetch should be called 4 times (1 initial + 3 retries)
|
|
621
|
+
expect(mockFetch).toHaveBeenCalledTimes(4);
|
|
622
|
+
}, 15000);
|
|
586
623
|
});
|
|
587
624
|
|
|
588
625
|
describe('afterAppReady', () => {
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.149",
|
|
4
4
|
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -16,20 +16,20 @@ import {
|
|
|
16
16
|
|
|
17
17
|
let serverDB = await getTestDBInstance();
|
|
18
18
|
|
|
19
|
-
//
|
|
19
|
+
// Test data
|
|
20
20
|
const testModelName = 'Session';
|
|
21
21
|
const testId = 'test-id';
|
|
22
22
|
const testUserId = 'test-user-id';
|
|
23
23
|
const testClientId = 'test-client-id';
|
|
24
24
|
const testGrantId = 'test-grant-id';
|
|
25
25
|
const testUserCode = 'test-user-code';
|
|
26
|
-
const testExpires = new Date(Date.now() + 3600 * 1000); // 1
|
|
26
|
+
const testExpires = new Date(Date.now() + 3600 * 1000); // Expires in 1 hour
|
|
27
27
|
|
|
28
28
|
beforeEach(async () => {
|
|
29
29
|
await serverDB.insert(users).values({ id: testUserId }).onConflictDoNothing();
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
//
|
|
32
|
+
// Clean up data after each test
|
|
33
33
|
afterEach(async () => {
|
|
34
34
|
await serverDB.delete(users);
|
|
35
35
|
await serverDB.delete(oidcClients);
|
|
@@ -39,14 +39,14 @@ afterEach(async () => {
|
|
|
39
39
|
|
|
40
40
|
describe('DrizzleAdapter', () => {
|
|
41
41
|
describe('constructor', () => {
|
|
42
|
-
it('
|
|
42
|
+
it('should create adapter instance correctly', () => {
|
|
43
43
|
const adapter = new DrizzleAdapter(testModelName, serverDB);
|
|
44
44
|
expect(adapter).toBeDefined();
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
describe('upsert', () => {
|
|
49
|
-
it('
|
|
49
|
+
it('should create new record for Session model', async () => {
|
|
50
50
|
const adapter = new DrizzleAdapter('Session', serverDB);
|
|
51
51
|
const payload = {
|
|
52
52
|
accountId: testUserId,
|
|
@@ -66,7 +66,7 @@ describe('DrizzleAdapter', () => {
|
|
|
66
66
|
expect(result?.data).toEqual(payload);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it('
|
|
69
|
+
it('should create new record for Client model', async () => {
|
|
70
70
|
const adapter = new DrizzleAdapter('Client', serverDB);
|
|
71
71
|
const payload = {
|
|
72
72
|
client_id: testClientId,
|
|
@@ -94,7 +94,7 @@ describe('DrizzleAdapter', () => {
|
|
|
94
94
|
expect(result?.scopes).toEqual(['openid', 'profile', 'email']);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
it('
|
|
97
|
+
it('should create new record for AccessToken model', async () => {
|
|
98
98
|
const adapter = new DrizzleAdapter('AccessToken', serverDB);
|
|
99
99
|
const payload = {
|
|
100
100
|
accountId: testUserId,
|
|
@@ -118,7 +118,7 @@ describe('DrizzleAdapter', () => {
|
|
|
118
118
|
expect(result?.data).toEqual(payload);
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
it('
|
|
121
|
+
it('should create new record for DeviceCode model with userCode', async () => {
|
|
122
122
|
const adapter = new DrizzleAdapter('DeviceCode', serverDB);
|
|
123
123
|
const payload = {
|
|
124
124
|
clientId: testClientId,
|
|
@@ -139,30 +139,30 @@ describe('DrizzleAdapter', () => {
|
|
|
139
139
|
expect(result?.data).toEqual(payload);
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
-
it('
|
|
142
|
+
it('should update existing Session record', async () => {
|
|
143
143
|
const adapter = new DrizzleAdapter('Session', serverDB);
|
|
144
144
|
const initialPayload = { accountId: testUserId, cookie: 'initial-cookie' };
|
|
145
145
|
const updatedPayload = { accountId: testUserId, cookie: 'updated-cookie' };
|
|
146
146
|
|
|
147
|
-
//
|
|
147
|
+
// Initial insert
|
|
148
148
|
await adapter.upsert(testId, initialPayload, 3600);
|
|
149
149
|
let result = await serverDB.query.oidcSessions.findFirst({
|
|
150
150
|
where: eq(oidcSessions.id, testId),
|
|
151
151
|
});
|
|
152
152
|
expect(result?.data).toEqual(initialPayload);
|
|
153
153
|
|
|
154
|
-
//
|
|
155
|
-
await adapter.upsert(testId, updatedPayload, 7200); //
|
|
154
|
+
// Update
|
|
155
|
+
await adapter.upsert(testId, updatedPayload, 7200); // New expiration time
|
|
156
156
|
result = await serverDB.query.oidcSessions.findFirst({ where: eq(oidcSessions.id, testId) });
|
|
157
157
|
expect(result?.data).toEqual(updatedPayload);
|
|
158
|
-
//
|
|
158
|
+
// Verify expiresAt is also updated (approximately 2 hours later)
|
|
159
159
|
expect(result?.expiresAt).toBeInstanceOf(Date);
|
|
160
160
|
const expectedExpires = Date.now() + 7200 * 1000;
|
|
161
|
-
expect(result!.expiresAt!.getTime()).toBeGreaterThan(expectedExpires - 5000); //
|
|
161
|
+
expect(result!.expiresAt!.getTime()).toBeGreaterThan(expectedExpires - 5000); // Allow 5 second tolerance
|
|
162
162
|
expect(result!.expiresAt!.getTime()).toBeLessThan(expectedExpires + 5000);
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
it('
|
|
165
|
+
it('should update existing Client record', async () => {
|
|
166
166
|
const adapter = new DrizzleAdapter('Client', serverDB);
|
|
167
167
|
const initialPayload = {
|
|
168
168
|
client_id: testClientId,
|
|
@@ -175,12 +175,12 @@ describe('DrizzleAdapter', () => {
|
|
|
175
175
|
...initialPayload,
|
|
176
176
|
client_uri: 'https://updated.com',
|
|
177
177
|
name: 'Updated Client',
|
|
178
|
-
scopes: ['openid', 'profile'],
|
|
178
|
+
scopes: ['openid', 'profile'],
|
|
179
179
|
scope: 'openid profile',
|
|
180
180
|
redirectUris: ['https://updated.com/callback'],
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
-
//
|
|
183
|
+
// Initial insert
|
|
184
184
|
await adapter.upsert(testClientId, initialPayload, 0);
|
|
185
185
|
let result = await serverDB.query.oidcClients.findFirst({
|
|
186
186
|
where: eq(oidcClients.id, testClientId),
|
|
@@ -190,21 +190,20 @@ describe('DrizzleAdapter', () => {
|
|
|
190
190
|
expect(result?.clientUri).toBe('https://initial.com');
|
|
191
191
|
expect(result?.scopes).toEqual(['openid']);
|
|
192
192
|
|
|
193
|
-
//
|
|
193
|
+
// Update
|
|
194
194
|
await adapter.upsert(testClientId, updatedPayload, 0);
|
|
195
195
|
result = await serverDB.query.oidcClients.findFirst({
|
|
196
196
|
where: eq(oidcClients.id, testClientId),
|
|
197
197
|
});
|
|
198
198
|
expect(result?.name).toBe('Updated Client');
|
|
199
199
|
expect(result?.clientUri).toBe('https://updated.com');
|
|
200
|
-
expect(result?.scopes).toEqual(['openid', 'profile']);
|
|
200
|
+
expect(result?.scopes).toEqual(['openid', 'profile']);
|
|
201
201
|
expect(result?.redirectUris).toEqual(['https://updated.com/callback']);
|
|
202
202
|
});
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
describe('find', () => {
|
|
206
|
-
it('
|
|
207
|
-
// 先创建一个记录
|
|
206
|
+
it('should find existing record', async () => {
|
|
208
207
|
const adapter = new DrizzleAdapter('Session', serverDB);
|
|
209
208
|
const payload = {
|
|
210
209
|
accountId: testUserId,
|
|
@@ -214,15 +213,13 @@ describe('DrizzleAdapter', () => {
|
|
|
214
213
|
|
|
215
214
|
await adapter.upsert(testId, payload, 3600);
|
|
216
215
|
|
|
217
|
-
// 然后查找它
|
|
218
216
|
const result = await adapter.find(testId);
|
|
219
217
|
|
|
220
218
|
expect(result).toBeDefined();
|
|
221
219
|
expect(result).toEqual(payload);
|
|
222
220
|
});
|
|
223
221
|
|
|
224
|
-
it('
|
|
225
|
-
// 先创建一个Client记录
|
|
222
|
+
it('should return correct format for Client model', async () => {
|
|
226
223
|
const adapter = new DrizzleAdapter('Client', serverDB);
|
|
227
224
|
const payload = {
|
|
228
225
|
client_id: testClientId,
|
|
@@ -239,7 +236,6 @@ describe('DrizzleAdapter', () => {
|
|
|
239
236
|
|
|
240
237
|
await adapter.upsert(testClientId, payload, 0);
|
|
241
238
|
|
|
242
|
-
// 然后查找它
|
|
243
239
|
const result = await adapter.find(testClientId);
|
|
244
240
|
|
|
245
241
|
expect(result).toBeDefined();
|
|
@@ -249,50 +245,87 @@ describe('DrizzleAdapter', () => {
|
|
|
249
245
|
expect(result.scope).toBe(payload.scope);
|
|
250
246
|
});
|
|
251
247
|
|
|
252
|
-
it('
|
|
248
|
+
it('should return undefined if record does not exist', async () => {
|
|
253
249
|
const adapter = new DrizzleAdapter('Session', serverDB);
|
|
254
250
|
const result = await adapter.find('non-existent-id');
|
|
255
251
|
expect(result).toBeUndefined();
|
|
256
252
|
});
|
|
257
253
|
|
|
258
|
-
it('
|
|
259
|
-
// 创建一个过期的记录(过期时间设为过去)
|
|
254
|
+
it('should return undefined if record is expired', async () => {
|
|
260
255
|
const adapter = new DrizzleAdapter('Session', serverDB);
|
|
261
256
|
const payload = {
|
|
262
257
|
accountId: testUserId,
|
|
263
258
|
cookie: 'cookie-value',
|
|
264
|
-
exp: Math.floor(Date.now() / 1000) - 3600, // 1
|
|
259
|
+
exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
|
265
260
|
};
|
|
266
261
|
|
|
267
|
-
//
|
|
262
|
+
// Negative expiration time means immediate expiration
|
|
268
263
|
await adapter.upsert(testId, payload, -1);
|
|
269
264
|
|
|
270
|
-
//
|
|
265
|
+
// Wait briefly to ensure expiration
|
|
271
266
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
272
267
|
|
|
273
|
-
// 然后查找它
|
|
274
268
|
const result = await adapter.find(testId);
|
|
275
269
|
|
|
276
270
|
expect(result).toBeUndefined();
|
|
277
271
|
});
|
|
278
272
|
|
|
279
|
-
it('
|
|
273
|
+
it('should return undefined if record is consumed', async () => {
|
|
280
274
|
const adapter = new DrizzleAdapter('AccessToken', serverDB);
|
|
281
275
|
const payload = { accountId: testUserId, clientId: testClientId };
|
|
282
276
|
await adapter.upsert(testId, payload, 3600);
|
|
283
277
|
|
|
284
|
-
//
|
|
278
|
+
// Consume the record
|
|
285
279
|
await adapter.consume(testId);
|
|
286
280
|
|
|
287
|
-
//
|
|
281
|
+
// Find consumed record
|
|
282
|
+
const result = await adapter.find(testId);
|
|
283
|
+
expect(result).toBeUndefined();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should allow RefreshToken reuse within grace period', async () => {
|
|
287
|
+
const adapter = new DrizzleAdapter('RefreshToken', serverDB);
|
|
288
|
+
const payload = {
|
|
289
|
+
accountId: testUserId,
|
|
290
|
+
clientId: testClientId,
|
|
291
|
+
grantId: testGrantId,
|
|
292
|
+
};
|
|
293
|
+
await adapter.upsert(testId, payload, 3600);
|
|
294
|
+
|
|
295
|
+
// Consume the record
|
|
296
|
+
await adapter.consume(testId);
|
|
297
|
+
|
|
298
|
+
// Within grace period (180 seconds), should still find the record
|
|
299
|
+
const result = await adapter.find(testId);
|
|
300
|
+
expect(result).toBeDefined();
|
|
301
|
+
expect(result).toEqual(payload);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should reject RefreshToken reuse after grace period expires', async () => {
|
|
305
|
+
const adapter = new DrizzleAdapter('RefreshToken', serverDB);
|
|
306
|
+
const payload = {
|
|
307
|
+
accountId: testUserId,
|
|
308
|
+
clientId: testClientId,
|
|
309
|
+
grantId: testGrantId,
|
|
310
|
+
};
|
|
311
|
+
await adapter.upsert(testId, payload, 3600);
|
|
312
|
+
|
|
313
|
+
// Directly update consumedAt to a past time (beyond grace period)
|
|
314
|
+
// Grace period is 180 seconds, set to 200 seconds ago
|
|
315
|
+
const pastConsumedAt = new Date(Date.now() - 200 * 1000);
|
|
316
|
+
await serverDB
|
|
317
|
+
.update(oidcRefreshTokens)
|
|
318
|
+
.set({ consumedAt: pastConsumedAt })
|
|
319
|
+
.where(eq(oidcRefreshTokens.id, testId));
|
|
320
|
+
|
|
321
|
+
// Grace period expired, should return undefined
|
|
288
322
|
const result = await adapter.find(testId);
|
|
289
323
|
expect(result).toBeUndefined();
|
|
290
324
|
});
|
|
291
325
|
});
|
|
292
326
|
|
|
293
327
|
describe('findByUserCode', () => {
|
|
294
|
-
it('
|
|
295
|
-
// 先创建一个DeviceCode记录
|
|
328
|
+
it('should find DeviceCode record by userCode', async () => {
|
|
296
329
|
const adapter = new DrizzleAdapter('DeviceCode', serverDB);
|
|
297
330
|
const payload = {
|
|
298
331
|
clientId: testClientId,
|
|
@@ -302,46 +335,44 @@ describe('DrizzleAdapter', () => {
|
|
|
302
335
|
|
|
303
336
|
await adapter.upsert(testId, payload, 3600);
|
|
304
337
|
|
|
305
|
-
// 然后通过userCode查找它
|
|
306
338
|
const result = await adapter.findByUserCode(testUserCode);
|
|
307
339
|
|
|
308
340
|
expect(result).toBeDefined();
|
|
309
341
|
expect(result).toEqual(payload);
|
|
310
342
|
});
|
|
311
343
|
|
|
312
|
-
it('
|
|
344
|
+
it('should return undefined if DeviceCode record is expired', async () => {
|
|
313
345
|
const adapter = new DrizzleAdapter('DeviceCode', serverDB);
|
|
314
346
|
const payload = { clientId: testClientId, userCode: testUserCode };
|
|
315
|
-
//
|
|
347
|
+
// Use negative expiresIn to make it expire immediately
|
|
316
348
|
await adapter.upsert(testId, payload, -1);
|
|
317
|
-
await new Promise((resolve) => setTimeout(resolve, 10)); //
|
|
349
|
+
await new Promise((resolve) => setTimeout(resolve, 10)); // Brief wait to ensure expiration
|
|
318
350
|
|
|
319
351
|
const result = await adapter.findByUserCode(testUserCode);
|
|
320
352
|
expect(result).toBeUndefined();
|
|
321
353
|
});
|
|
322
354
|
|
|
323
|
-
it('
|
|
355
|
+
it('should return undefined if DeviceCode record is consumed', async () => {
|
|
324
356
|
const adapter = new DrizzleAdapter('DeviceCode', serverDB);
|
|
325
357
|
const payload = { clientId: testClientId, userCode: testUserCode };
|
|
326
358
|
await adapter.upsert(testId, payload, 3600);
|
|
327
359
|
|
|
328
|
-
//
|
|
360
|
+
// Consume the record
|
|
329
361
|
await adapter.consume(testId);
|
|
330
362
|
|
|
331
|
-
//
|
|
363
|
+
// Find consumed record
|
|
332
364
|
const result = await adapter.findByUserCode(testUserCode);
|
|
333
365
|
expect(result).toBeUndefined();
|
|
334
366
|
});
|
|
335
367
|
|
|
336
|
-
it('
|
|
368
|
+
it('should throw error on non-DeviceCode model', async () => {
|
|
337
369
|
const adapter = new DrizzleAdapter('Session', serverDB);
|
|
338
370
|
await expect(adapter.findByUserCode(testUserCode)).rejects.toThrow();
|
|
339
371
|
});
|
|
340
372
|
});
|
|
341
373
|
|
|
342
374
|
describe('findSessionByUserId', () => {
|
|
343
|
-
it('
|
|
344
|
-
// 先创建一个Session记录
|
|
375
|
+
it('should find Session record by userId', async () => {
|
|
345
376
|
const adapter = new DrizzleAdapter('Session', serverDB);
|
|
346
377
|
const payload = {
|
|
347
378
|
accountId: testUserId,
|
|
@@ -351,14 +382,13 @@ describe('DrizzleAdapter', () => {
|
|
|
351
382
|
|
|
352
383
|
await adapter.upsert(testId, payload, 3600);
|
|
353
384
|
|
|
354
|
-
// 然后通过userId查找它
|
|
355
385
|
const result = await adapter.findSessionByUserId(testUserId);
|
|
356
386
|
|
|
357
387
|
expect(result).toBeDefined();
|
|
358
388
|
expect(result).toEqual(payload);
|
|
359
389
|
});
|
|
360
390
|
|
|
361
|
-
it('
|
|
391
|
+
it('should return undefined on non-Session model', async () => {
|
|
362
392
|
const adapter = new DrizzleAdapter('AccessToken', serverDB);
|
|
363
393
|
const result = await adapter.findSessionByUserId(testUserId);
|
|
364
394
|
expect(result).toBeUndefined();
|
|
@@ -366,8 +396,7 @@ describe('DrizzleAdapter', () => {
|
|
|
366
396
|
});
|
|
367
397
|
|
|
368
398
|
describe('destroy', () => {
|
|
369
|
-
it('
|
|
370
|
-
// 先创建一个记录
|
|
399
|
+
it('should delete existing record', async () => {
|
|
371
400
|
const adapter = new DrizzleAdapter('Session', serverDB);
|
|
372
401
|
const payload = {
|
|
373
402
|
accountId: testUserId,
|
|
@@ -377,16 +406,16 @@ describe('DrizzleAdapter', () => {
|
|
|
377
406
|
|
|
378
407
|
await adapter.upsert(testId, payload, 3600);
|
|
379
408
|
|
|
380
|
-
//
|
|
409
|
+
// Confirm record exists
|
|
381
410
|
let result = await serverDB.query.oidcSessions.findFirst({
|
|
382
411
|
where: eq(oidcSessions.id, testId),
|
|
383
412
|
});
|
|
384
413
|
expect(result).toBeDefined();
|
|
385
414
|
|
|
386
|
-
//
|
|
415
|
+
// Delete record
|
|
387
416
|
await adapter.destroy(testId);
|
|
388
417
|
|
|
389
|
-
//
|
|
418
|
+
// Verify record is deleted
|
|
390
419
|
result = await serverDB.query.oidcSessions.findFirst({
|
|
391
420
|
where: eq(oidcSessions.id, testId),
|
|
392
421
|
});
|
|
@@ -395,8 +424,7 @@ describe('DrizzleAdapter', () => {
|
|
|
395
424
|
});
|
|
396
425
|
|
|
397
426
|
describe('consume', () => {
|
|
398
|
-
it('
|
|
399
|
-
// 先创建一个记录
|
|
427
|
+
it('should mark record as consumed', async () => {
|
|
400
428
|
const adapter = new DrizzleAdapter('AccessToken', serverDB);
|
|
401
429
|
const payload = {
|
|
402
430
|
accountId: testUserId,
|
|
@@ -406,10 +434,10 @@ describe('DrizzleAdapter', () => {
|
|
|
406
434
|
|
|
407
435
|
await adapter.upsert(testId, payload, 3600);
|
|
408
436
|
|
|
409
|
-
//
|
|
437
|
+
// Consume the record
|
|
410
438
|
await adapter.consume(testId);
|
|
411
439
|
|
|
412
|
-
//
|
|
440
|
+
// Verify record is marked as consumed
|
|
413
441
|
const result = await serverDB.query.oidcAccessTokens.findFirst({
|
|
414
442
|
where: eq(oidcAccessTokens.id, testId),
|
|
415
443
|
});
|
|
@@ -420,8 +448,8 @@ describe('DrizzleAdapter', () => {
|
|
|
420
448
|
});
|
|
421
449
|
|
|
422
450
|
describe('revokeByGrantId', () => {
|
|
423
|
-
it('
|
|
424
|
-
//
|
|
451
|
+
it('should revoke all records associated with specified grantId', async () => {
|
|
452
|
+
// Create AccessToken record
|
|
425
453
|
const accessTokenAdapter = new DrizzleAdapter('AccessToken', serverDB);
|
|
426
454
|
const accessTokenPayload = {
|
|
427
455
|
accountId: testUserId,
|
|
@@ -431,7 +459,7 @@ describe('DrizzleAdapter', () => {
|
|
|
431
459
|
};
|
|
432
460
|
await accessTokenAdapter.upsert(testId, accessTokenPayload, 3600);
|
|
433
461
|
|
|
434
|
-
//
|
|
462
|
+
// Create RefreshToken record
|
|
435
463
|
const refreshTokenAdapter = new DrizzleAdapter('RefreshToken', serverDB);
|
|
436
464
|
const refreshTokenPayload = {
|
|
437
465
|
accountId: testUserId,
|
|
@@ -441,10 +469,10 @@ describe('DrizzleAdapter', () => {
|
|
|
441
469
|
};
|
|
442
470
|
await refreshTokenAdapter.upsert('refresh-' + testId, refreshTokenPayload, 3600);
|
|
443
471
|
|
|
444
|
-
//
|
|
472
|
+
// Revoke all records associated with testGrantId
|
|
445
473
|
await accessTokenAdapter.revokeByGrantId(testGrantId);
|
|
446
474
|
|
|
447
|
-
//
|
|
475
|
+
// Verify records are deleted
|
|
448
476
|
const accessTokenResult = await serverDB.query.oidcAccessTokens.findFirst({
|
|
449
477
|
where: eq(oidcAccessTokens.id, testId),
|
|
450
478
|
});
|
|
@@ -458,16 +486,16 @@ describe('DrizzleAdapter', () => {
|
|
|
458
486
|
expect(refreshTokenResult).toBeUndefined();
|
|
459
487
|
});
|
|
460
488
|
|
|
461
|
-
it('
|
|
462
|
-
// Grant
|
|
489
|
+
it('should return directly on Grant model', async () => {
|
|
490
|
+
// Grant model does not need to be revoked by grantId
|
|
463
491
|
const adapter = new DrizzleAdapter('Grant', serverDB);
|
|
464
492
|
await adapter.revokeByGrantId(testGrantId);
|
|
465
|
-
//
|
|
493
|
+
// Test passes if no error is thrown
|
|
466
494
|
});
|
|
467
495
|
});
|
|
468
496
|
|
|
469
497
|
describe('createAdapterFactory', () => {
|
|
470
|
-
it('
|
|
498
|
+
it('should create an adapter factory function', () => {
|
|
471
499
|
const factory = DrizzleAdapter.createAdapterFactory(serverDB as any);
|
|
472
500
|
expect(factory).toBeDefined();
|
|
473
501
|
expect(typeof factory).toBe('function');
|
|
@@ -479,9 +507,9 @@ describe('DrizzleAdapter', () => {
|
|
|
479
507
|
});
|
|
480
508
|
|
|
481
509
|
describe('getTable (indirectly via public methods)', () => {
|
|
482
|
-
it('
|
|
510
|
+
it('should throw error when using unsupported model name', async () => {
|
|
483
511
|
const invalidAdapter = new DrizzleAdapter('InvalidModelName', serverDB);
|
|
484
|
-
//
|
|
512
|
+
// Call a method that triggers getTable
|
|
485
513
|
await expect(invalidAdapter.find('any-id')).rejects.toThrow('不支持的模型: InvalidModelName');
|
|
486
514
|
await expect(invalidAdapter.upsert('any-id', {}, 3600)).rejects.toThrow(
|
|
487
515
|
'不支持的模型: InvalidModelName',
|
|
@@ -15,6 +15,20 @@ import { eq, sql } from 'drizzle-orm';
|
|
|
15
15
|
// 创建 adapter 日志命名空间
|
|
16
16
|
const log = debug('lobe-oidc:adapter');
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Grace period for consumed RefreshToken (in seconds)
|
|
20
|
+
*
|
|
21
|
+
* When rotateRefreshToken is enabled, the old refresh token is consumed
|
|
22
|
+
* when a new one is issued. However, if the client fails to receive/save
|
|
23
|
+
* the new token (network issues, crashes), the old token becomes unusable.
|
|
24
|
+
*
|
|
25
|
+
* This grace period allows the consumed refresh token to be reused within
|
|
26
|
+
* a short window, giving clients a chance to retry the refresh operation.
|
|
27
|
+
*
|
|
28
|
+
* Default: 180 seconds (3 minutes)
|
|
29
|
+
*/
|
|
30
|
+
const REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 180;
|
|
31
|
+
|
|
18
32
|
class OIDCAdapter {
|
|
19
33
|
private db: LobeChatDatabase;
|
|
20
34
|
private name: string;
|
|
@@ -278,8 +292,35 @@ class OIDCAdapter {
|
|
|
278
292
|
return undefined;
|
|
279
293
|
}
|
|
280
294
|
|
|
281
|
-
//
|
|
295
|
+
// 如果记录已被消费,检查是否在宽限期内
|
|
282
296
|
if (model.consumedAt) {
|
|
297
|
+
// For RefreshToken, allow reuse within grace period
|
|
298
|
+
if (this.name === 'RefreshToken') {
|
|
299
|
+
const consumedAt = new Date(model.consumedAt);
|
|
300
|
+
const gracePeriodEnd = new Date(
|
|
301
|
+
consumedAt.getTime() + REFRESH_TOKEN_GRACE_PERIOD_SECONDS * 1000,
|
|
302
|
+
);
|
|
303
|
+
const now = new Date();
|
|
304
|
+
|
|
305
|
+
if (now <= gracePeriodEnd) {
|
|
306
|
+
// Within grace period, allow reuse for retry scenarios
|
|
307
|
+
log(
|
|
308
|
+
'[RefreshToken] Token consumed at %s but within grace period (ends %s), allowing reuse',
|
|
309
|
+
consumedAt.toISOString(),
|
|
310
|
+
gracePeriodEnd.toISOString(),
|
|
311
|
+
);
|
|
312
|
+
return model.data;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
log(
|
|
316
|
+
'[RefreshToken] Token consumed at %s, grace period expired at %s, returning undefined',
|
|
317
|
+
consumedAt.toISOString(),
|
|
318
|
+
gracePeriodEnd.toISOString(),
|
|
319
|
+
);
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// For other token types, consumed means invalid
|
|
283
324
|
log(
|
|
284
325
|
'[%s] Record already consumed (consumedAt: %s), returning undefined',
|
|
285
326
|
this.name,
|