@solidxai/core 0.1.13-beta.18 → 0.1.13-beta.19

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/dist/config/cache.options.d.ts.map +1 -1
  3. package/dist/config/cache.options.js +2 -2
  4. package/dist/config/cache.options.js.map +1 -1
  5. package/dist/controllers/authentication.controller.d.ts +1 -1
  6. package/dist/controllers/authentication.controller.d.ts.map +1 -1
  7. package/dist/controllers/authentication.controller.js +4 -3
  8. package/dist/controllers/authentication.controller.js.map +1 -1
  9. package/dist/helpers/solid-microservice-adapter.service.d.ts.map +1 -1
  10. package/dist/helpers/solid-microservice-adapter.service.js +1 -1
  11. package/dist/helpers/solid-microservice-adapter.service.js.map +1 -1
  12. package/dist/interfaces/active-user-data.interface.d.ts +1 -0
  13. package/dist/interfaces/active-user-data.interface.d.ts.map +1 -1
  14. package/dist/interfaces/active-user-data.interface.js.map +1 -1
  15. package/dist/services/api-key.service.d.ts.map +1 -1
  16. package/dist/services/api-key.service.js +1 -1
  17. package/dist/services/api-key.service.js.map +1 -1
  18. package/dist/services/authentication.service.d.ts +5 -4
  19. package/dist/services/authentication.service.d.ts.map +1 -1
  20. package/dist/services/authentication.service.js +50 -19
  21. package/dist/services/authentication.service.js.map +1 -1
  22. package/dist/services/refresh-token-ids-storage.service.d.ts +25 -6
  23. package/dist/services/refresh-token-ids-storage.service.d.ts.map +1 -1
  24. package/dist/services/refresh-token-ids-storage.service.js +80 -42
  25. package/dist/services/refresh-token-ids-storage.service.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/config/cache.options.ts +9 -1
  28. package/src/controllers/authentication.controller.ts +5 -2
  29. package/src/helpers/solid-microservice-adapter.service.ts +6 -1
  30. package/src/interfaces/active-user-data.interface.ts +9 -0
  31. package/src/services/api-key.service.ts +13 -1
  32. package/src/services/authentication.service.ts +127 -36
  33. package/src/services/refresh-token-ids-storage.service.ts +229 -90
@@ -1793,7 +1793,13 @@ export class AuthenticationService {
1793
1793
  }
1794
1794
  }
1795
1795
 
1796
- async generateTokens(user: User) {
1796
+ /**
1797
+ * @param deviceId optional stable per-device identifier. Supplied by callers
1798
+ * that own a device credential; otherwise a key is minted per login so that
1799
+ * clients with no device concept (the web UI) still get their own bucket
1800
+ * instead of sharing - and evicting - one.
1801
+ */
1802
+ async generateTokens(user: User, deviceId?: string) {
1797
1803
  const sessionId = this.shouldPreventConcurrentLogins()
1798
1804
  ? randomUUID()
1799
1805
  : undefined;
@@ -1802,9 +1808,17 @@ export class AuthenticationService {
1802
1808
  } else {
1803
1809
  await this.activeSessionStorage.clearActiveSession(user.id);
1804
1810
  }
1811
+
1812
+ // Mutually exclusive with sessionId by construction: per-device buckets
1813
+ // exist only when preventConcurrentLogins is off, sessionId only when it
1814
+ // is on.
1815
+ const deviceKey = this.refreshTokenIdsStorage.areConcurrentLoginsAllowed()
1816
+ ? deviceId ?? randomUUID()
1817
+ : undefined;
1818
+
1805
1819
  const [accessToken, refreshToken] = await Promise.all([
1806
- await this.generateAccessToken(user, sessionId),
1807
- await this.generateRefreshToken(user),
1820
+ await this.generateAccessToken(user, sessionId, deviceKey),
1821
+ await this.generateRefreshToken(user, undefined, deviceKey),
1808
1822
  ]);
1809
1823
 
1810
1824
  return {
@@ -1813,7 +1827,7 @@ export class AuthenticationService {
1813
1827
  };
1814
1828
  }
1815
1829
 
1816
- async generateAccessToken(user: User, sessionId?: string) {
1830
+ async generateAccessToken(user: User, sessionId?: string, deviceKey?: string) {
1817
1831
  // const userRoleNames = user.roles.map((role) => role.name).join(';')
1818
1832
  const userRoleNames = user.roles.map((role) => role.name);
1819
1833
  const resolvedSessionId = this.shouldPreventConcurrentLogins()
@@ -1830,18 +1844,36 @@ export class AuthenticationService {
1830
1844
  email: user.email,
1831
1845
  roles: userRoleNames,
1832
1846
  ...(resolvedSessionId ? { sessionId: resolvedSessionId } : {}),
1847
+ // Carried so that bearer-authenticated endpoints which need this
1848
+ // session's refresh token - generateSsoCode, me - can locate its
1849
+ // bucket. The refresh token is not available to them.
1850
+ ...(deviceKey ? { deviceKey } : {}),
1833
1851
  },
1834
1852
  );
1835
1853
 
1836
1854
  return accessToken;
1837
1855
  }
1838
1856
 
1839
- async generateRefreshToken(user: User, previousRefreshToken?: string) {
1857
+ async generateRefreshToken(
1858
+ user: User,
1859
+ previousRefreshToken?: string,
1860
+ deviceKey?: string,
1861
+ ) {
1840
1862
  const refreshTokenId = randomUUID();
1841
1863
  const refreshTokenTtl =
1842
1864
  this.settingService.getConfigValue<SolidCoreSetting>("refreshTokenTtl");
1865
+
1866
+ // Only per-device tokens carry an epoch - it is what makes bulk
1867
+ // invalidation possible without enumerating buckets. Single-slot tokens
1868
+ // are invalidated by deleting their one key.
1869
+ const epoch = deviceKey
1870
+ ? await this.refreshTokenIdsStorage.getEpoch(user.id)
1871
+ : undefined;
1872
+
1843
1873
  const refreshToken = await this.signToken(user.id, refreshTokenTtl, {
1844
1874
  refreshTokenId,
1875
+ ...(deviceKey ? { deviceKey } : {}),
1876
+ ...(epoch !== undefined ? { epoch } : {}),
1845
1877
  });
1846
1878
 
1847
1879
  // store the refresh token id in the redis storage.
@@ -1849,6 +1881,7 @@ export class AuthenticationService {
1849
1881
  user.id,
1850
1882
  refreshToken,
1851
1883
  previousRefreshToken,
1884
+ deviceKey,
1852
1885
  );
1853
1886
 
1854
1887
  return refreshToken;
@@ -1863,8 +1896,16 @@ export class AuthenticationService {
1863
1896
  const issuer =
1864
1897
  this.settingService.getConfigValue<SolidCoreSetting>("issuer");
1865
1898
 
1866
- const { sub } = await this.jwtService.verifyAsync<
1867
- Pick<ActiveUserData, "sub"> & { refreshTokenId: string }
1899
+ // deviceKey/epoch are absent on tokens issued before per-device sessions,
1900
+ // and on tokens issued while preventConcurrentLogins is on. That absence
1901
+ // is the signal the storage layer branches on - no request-body change
1902
+ // and no client change is needed to carry it.
1903
+ const { sub, deviceKey, epoch } = await this.jwtService.verifyAsync<
1904
+ Pick<ActiveUserData, "sub"> & {
1905
+ refreshTokenId: string;
1906
+ deviceKey?: string;
1907
+ epoch?: number;
1908
+ }
1868
1909
  >(refreshTokenDto.refreshToken, {
1869
1910
  secret,
1870
1911
  audience,
@@ -1892,17 +1933,24 @@ export class AuthenticationService {
1892
1933
  // throw new Error('Refresh token is invalid');
1893
1934
  // }
1894
1935
 
1895
- const currentRefreshToken =
1896
- await this.refreshTokenIdsStorage.validateAndRotate(
1897
- user,
1898
- refreshTokenDto.refreshToken,
1899
- );
1936
+ const rotated = await this.refreshTokenIdsStorage.validateAndRotate(
1937
+ user,
1938
+ refreshTokenDto.refreshToken,
1939
+ { deviceKey, epoch },
1940
+ );
1900
1941
 
1901
1942
  await this.userActivityHistoryService.logEvent("tokenRefreshed", user);
1902
1943
 
1903
1944
  return {
1904
- accessToken: await this.generateAccessToken(user),
1905
- refreshToken: currentRefreshToken,
1945
+ // The rotated key, not the incoming one: a session migrating out of the
1946
+ // pre-deploy single slot is assigned a bucket here, and the new access
1947
+ // token has to name the same one.
1948
+ accessToken: await this.generateAccessToken(
1949
+ user,
1950
+ undefined,
1951
+ rotated.deviceKey,
1952
+ ),
1953
+ refreshToken: rotated.refreshToken,
1906
1954
  };
1907
1955
  } catch (err: any) {
1908
1956
  if (err instanceof InvalidatedRefreshTokenError) {
@@ -2291,28 +2339,31 @@ export class AuthenticationService {
2291
2339
  // // Invalidate the refresh token
2292
2340
  // // await this.refreshTokenIdsStorage.invalidate(user.id);
2293
2341
  // }
2294
- async logout(refreshToken: string) {
2342
+ async logout(refreshToken: string, allDevices = false) {
2295
2343
  try {
2296
- // const activeUser = this.requestContextService.getActiveUser();
2297
- // const userId = activeUser?.sub;
2298
- // const user = await this.userRepository.findOne({
2299
- // where: {
2300
- // id: userId,
2301
- // }
2302
- // })
2303
- // // Invalidate refresh token if you store them
2304
- // await this.refreshTokenIdsStorage.invalidate(userId); // Your existing logic
2305
- // if (!refreshToken) {
2306
- // throw new UnauthorizedException('Refresh token is required');
2307
- // }
2308
- const payload = this.jwtService.decode(refreshToken) as any;
2309
-
2310
- if (!payload || !payload.sub) {
2311
- throw new UnauthorizedException(ERROR_MESSAGES.INVALID_REFRESH_TOKEN);
2344
+ const payload = await this.verifyRefreshTokenForLogout(refreshToken);
2345
+
2346
+ // Nothing verifiable to act on: forged, malformed, or signed with another
2347
+ // secret. Acting on an unauthenticated `sub` is exactly the hole this
2348
+ // closes - the route is @Public(), so a decoded-but-unverified token let
2349
+ // anyone log out an arbitrary user. Report success either way: logout
2350
+ // must be safe to call blindly from a client error path, and a 401/200
2351
+ // split here would leak whether a token string is genuine (RFC 7009 §2.2
2352
+ // takes the same position for revocation endpoints).
2353
+ if (!payload?.sub) {
2354
+ this.logger.warn("logout called with an unverifiable refresh token");
2355
+ return { message: SUCCESS_MESSAGES.LOGOUT_SUCCESS };
2312
2356
  }
2313
2357
 
2314
2358
  const userId = payload.sub;
2315
- await this.refreshTokenIdsStorage.invalidate(userId);
2359
+ if (allDevices) {
2360
+ await this.refreshTokenIdsStorage.invalidateAll(userId);
2361
+ } else {
2362
+ // Scoped to the bucket this token belongs to, so signing out on one
2363
+ // device leaves the others alive. A token with no deviceKey names the
2364
+ // single slot, which is the same key as before.
2365
+ await this.refreshTokenIdsStorage.invalidate(userId, payload.deviceKey);
2366
+ }
2316
2367
  await this.activeSessionStorage.clearActiveSession(userId);
2317
2368
  const user = await this.userRepository.findOne({
2318
2369
  where: {
@@ -2320,10 +2371,15 @@ export class AuthenticationService {
2320
2371
  },
2321
2372
  });
2322
2373
  // Log logout event
2323
- await this.userActivityHistoryService.logEvent("logout", user);
2374
+ if (user) {
2375
+ await this.userActivityHistoryService.logEvent("logout", user);
2376
+ }
2324
2377
 
2325
2378
  return { message: SUCCESS_MESSAGES.LOGOUT_SUCCESS };
2326
2379
  } catch (err: any) {
2380
+ // JWT problems no longer reach here - verifyRefreshTokenForLogout
2381
+ // swallows them - so this is left for genuine infrastructure failures
2382
+ // (cache or database unavailable), where a 500 is the correct answer.
2327
2383
  throw err instanceof UnauthorizedException ||
2328
2384
  err instanceof InternalServerErrorException
2329
2385
  ? err
@@ -2331,6 +2387,31 @@ export class AuthenticationService {
2331
2387
  }
2332
2388
  }
2333
2389
 
2390
+ private async verifyRefreshTokenForLogout(
2391
+ refreshToken: string,
2392
+ ): Promise<{ sub: number; deviceKey?: string } | null> {
2393
+ try {
2394
+ const payload = await this.jwtService.verifyAsync(refreshToken, {
2395
+ secret: this.settingService.getConfigValue<SolidCoreSetting>("secret"),
2396
+ audience:
2397
+ this.settingService.getConfigValue<SolidCoreSetting>("audience"),
2398
+ issuer: this.settingService.getConfigValue<SolidCoreSetting>("issuer"),
2399
+ // Signature, audience and issuer are still enforced; only `exp` is
2400
+ // tolerated. An expired session must still be able to log out - the
2401
+ // previous jwtService.decode() allowed that, and a plain verifyAsync
2402
+ // would throw TokenExpiredError, which the catch above would turn into
2403
+ // a 500 on an ordinary sign-out.
2404
+ ignoreExpiration: true,
2405
+ });
2406
+
2407
+ // Access and refresh tokens share secret, audience and issuer, so only
2408
+ // this claim distinguishes them. Reject an access token presented here.
2409
+ return payload?.refreshTokenId ? payload : null;
2410
+ } catch {
2411
+ return null; // bad signature or malformed
2412
+ }
2413
+ }
2414
+
2334
2415
  async activateUser(userId: number) {
2335
2416
  const user = await this.userService.findOne(userId, {});
2336
2417
  if (!user) {
@@ -2352,9 +2433,14 @@ export class AuthenticationService {
2352
2433
 
2353
2434
  // const tokens = await this.generateTokens(user);
2354
2435
 
2355
- // Get the refresh token for a user from refresh token storage.
2436
+ // Get the refresh token for a user from refresh token storage. The bucket
2437
+ // is named by the access token's own deviceKey claim; absent for
2438
+ // single-slot sessions, which resolves to the same key as before.
2356
2439
  const refreshTokenState =
2357
- await this.refreshTokenIdsStorage.getCurrentRefreshTokenState(user.id);
2440
+ await this.refreshTokenIdsStorage.getCurrentRefreshTokenState(
2441
+ user.id,
2442
+ activeUser.deviceKey,
2443
+ );
2358
2444
 
2359
2445
  const response = {
2360
2446
  user: {
@@ -2365,7 +2451,11 @@ export class AuthenticationService {
2365
2451
  id: user.id,
2366
2452
  roles: user.roles.map((role) => role.name),
2367
2453
  },
2368
- refreshToken: refreshTokenState.currentRefreshToken,
2454
+ // Null-guarded because the cache entry now carries a TTL: once it
2455
+ // expires - or after a logout followed by a call with a still-valid
2456
+ // access token - there is no state to read. Matches the existing
2457
+ // handling in generateSsoCode.
2458
+ refreshToken: refreshTokenState?.currentRefreshToken ?? null,
2369
2459
  // ...tokens
2370
2460
  };
2371
2461
  return response;
@@ -2378,6 +2468,7 @@ export class AuthenticationService {
2378
2468
  const refreshTokenState =
2379
2469
  await this.refreshTokenIdsStorage.getCurrentRefreshTokenState(
2380
2470
  activeUser.sub,
2471
+ activeUser.deviceKey,
2381
2472
  );
2382
2473
  if (!refreshTokenState?.currentRefreshToken) {
2383
2474
  throw new UnauthorizedException("No active session found");
@@ -1,133 +1,272 @@
1
1
  import { CACHE_MANAGER } from '@nestjs/cache-manager';
2
2
  import { Inject, Injectable, forwardRef } from '@nestjs/common';
3
3
  import { Cache } from 'cache-manager';
4
+ import { randomUUID } from 'crypto';
5
+ import type { SolidCoreSetting } from 'src/services/settings/default-settings-provider.service';
4
6
  import { AuthenticationService } from './authentication.service';
7
+ import { SettingService } from './setting.service';
5
8
 
6
9
  // TODO: Ideally this should be in a separate file - putting this here for brevity
7
10
  export class InvalidatedRefreshTokenError extends Error { }
8
11
 
12
+ // How long a rotated-out refresh token stays acceptable. This window is the
13
+ // point of keeping a previous token at all: concurrent requests from one user
14
+ // must not break each other. core-ui's single-flight guard is module-level and
15
+ // therefore per tab, so two tabs crossing the refresh threshold together will
16
+ // both present the same token - the second one is served from here.
17
+ const PREVIOUS_TOKEN_GRACE_MS = 60 * 1000;
18
+
19
+ // The cache entry must outlive the JWT it holds. If it expired first, refresh
20
+ // would fail with ACCESS_DENIED (InvalidatedRefreshTokenError) rather than the
21
+ // SESSION_EXPIRED that the token's own `exp` produces - the same event
22
+ // reported two different ways.
23
+ const STATE_TTL_BUFFER_SECONDS = 60;
24
+
9
25
  type RefreshTokenState = {
10
26
  currentRefreshToken: string;
11
27
  previousRefreshToken: string;
28
+ // Absolute epoch-ms deadline after which previousRefreshToken is refused.
29
+ // Stored rather than scheduled: an in-process timer is lost when the pod
30
+ // restarts, which used to leave the rotated-out token valid until the next
31
+ // rotation instead of for one minute. Optional because entries written
32
+ // before this field existed will not carry it.
33
+ previousValidUntil?: number;
34
+ };
35
+
36
+ /**
37
+ * The bucket-identifying claims carried on a refresh token. Absent on tokens
38
+ * issued before per-device sessions existed, and on tokens issued while
39
+ * preventConcurrentLogins is on - which is exactly how the read path tells the
40
+ * two schemes apart without consulting any setting.
41
+ */
42
+ export type RefreshTokenClaims = {
43
+ deviceKey?: string;
44
+ epoch?: number;
45
+ };
46
+
47
+ export type RotatedRefreshToken = {
48
+ refreshToken: string;
49
+ // The bucket the rotated token now lives in. Differs from the incoming
50
+ // deviceKey only when a pre-migration token was just moved into one.
51
+ deviceKey?: string;
12
52
  };
13
53
 
14
54
  @Injectable()
15
- // export class RefreshTokenIdsStorageService implements OnApplicationBootstrap, OnApplicationShutdown {
16
55
  export class RefreshTokenIdsStorageService {
17
- // private redisClient: Redis;
18
- // onApplicationBootstrap() {
19
- // // TODO: Ideally, we should move this to the dedicated "RedisModule" instead of initiating the connection here.
20
- // this.redisClient = new Redis({
21
- // // TODO: According to best practices, we should use the environment variables here instead.
22
- // host: 'localhost',
23
- // port: 6379,
24
- // });
25
- // }
26
- // onApplicationShutdown(signal?: string) {
27
- // return this.redisClient.quit();
28
- // }
29
-
30
56
  constructor(
31
57
  @Inject(CACHE_MANAGER) private cacheManager: Cache,
32
58
  @Inject(forwardRef(() => AuthenticationService))
33
- private readonly authenticationService: AuthenticationService
59
+ private readonly authenticationService: AuthenticationService,
60
+ private readonly settingService: SettingService,
34
61
  ) { }
35
62
 
36
- async insert(userId: number, refreshToken: string, previousRefreshToken?: string): Promise<void> {
63
+ /**
64
+ * The inverse of the `preventConcurrentLogins` setting, and the only place
65
+ * it is interpreted.
66
+ *
67
+ * That setting already promises, when off, that sessions may coexist;
68
+ * splitting the refresh-token keyspace per device is what finally delivers
69
+ * it. When it is on, its stated purpose is a single live session, so
70
+ * per-device keys would be meaningless and the original single slot is kept.
71
+ *
72
+ * This governs the WRITE path only. Reads always branch on the token's own
73
+ * claim, so toggling the setting never invalidates a live session.
74
+ */
75
+ areConcurrentLoginsAllowed(): boolean {
76
+ return !this.settingService.getConfigValue<SolidCoreSetting>("preventConcurrentLogins");
77
+ }
78
+
79
+ async insert(
80
+ userId: number,
81
+ refreshToken: string,
82
+ previousRefreshToken?: string,
83
+ deviceKey?: string,
84
+ ): Promise<void> {
37
85
  const refreshTokenState: RefreshTokenState = {
38
86
  currentRefreshToken: refreshToken,
39
87
  previousRefreshToken: previousRefreshToken ?? "",
88
+ ...(previousRefreshToken
89
+ ? { previousValidUntil: Date.now() + PREVIOUS_TOKEN_GRACE_MS }
90
+ : {}),
40
91
  };
41
- await this.cacheManager.set(this.getKey(userId), refreshTokenState);
92
+ await this.cacheManager.set(
93
+ this.getKey(userId, deviceKey),
94
+ refreshTokenState,
95
+ this.getStateTtlMs(),
96
+ );
42
97
  }
43
98
 
44
- async validate(userId: number, refreshToken: string): Promise<boolean> {
45
- // TODO: Assume you get this shape out of the cache {"currentRefreshToken": "", "previousRefreshToken": ""}
46
- // Then you will compare against the currentRefreshToken.
47
- const storedId = await this.cacheManager.get(this.getKey(userId));
48
- if (storedId !== refreshToken) {
49
- throw new InvalidatedRefreshTokenError();
50
- }
51
- return storedId === refreshToken;
99
+ async invalidate(userId: number, deviceKey?: string): Promise<void> {
100
+ await this.cacheManager.del(this.getKey(userId, deviceKey));
52
101
  }
53
102
 
54
- async invalidate(userId: number): Promise<void> {
103
+ /**
104
+ * Bulk invalidation - "log out everywhere", and the right hook for password
105
+ * change or forced deactivation.
106
+ *
107
+ * Per-device buckets cannot be enumerated through cache-manager, so this
108
+ * bumps a per-user epoch that every per-device token carries as a claim.
109
+ * One write invalidates every outstanding token without a scan and without
110
+ * the read-modify-write race an index of device keys would have.
111
+ */
112
+ async invalidateAll(userId: number): Promise<void> {
113
+ const currentEpoch = (await this.getEpoch(userId)) ?? 0;
114
+ await this.cacheManager.set(
115
+ this.getEpochKey(userId),
116
+ currentEpoch + 1,
117
+ // Outlives every bucket that could hold a pre-bump token: those were
118
+ // written earlier, so they expire no later than this key does.
119
+ this.getStateTtlMs(),
120
+ );
121
+ // Single-slot mode and pre-migration entries live under the bare key,
122
+ // which carries no epoch claim - delete it directly.
55
123
  await this.cacheManager.del(this.getKey(userId));
56
124
  }
57
125
 
58
- async validateAndRotate(user: any, refreshToken: string): Promise<string> {
59
- let valid = false;
60
-
61
- // TODO: Assume you get this shape out of the cache {"currentRefreshToken": "", "previousRefreshToken": ""}
62
- // Then you will compare against the currentRefreshToken.
63
- const refreshTokenState = await this.cacheManager.get(this.getKey(user.id)) as RefreshTokenState | undefined;
64
-
65
- // Use the authentication service to generate a new refresh token, set it in the currentRefreshToken in scenario 1 and return.
66
-
67
- // if UI.refresh_token is matching with Cache.currentRefreshToken
68
- // then invalidate (updated cache state, no need to delete anything), then generate new token and return.
69
- // also set a setTimeout to run after X minutes, this will simply update the RefreshTokenCacheState to this object {"currentRefreshToken": "R2","justInvalidatedRefreshToken": ""}
70
- // valid=true
71
-
72
- // - if UI.refresh_token is matching Cache.justInvalidatedRefreshToken
73
- // then use the Cache.currentRefreshToken, generate new access token and return.
74
- // We do not modify the cache state at all.
75
- // valid=true
76
-
77
- let newRefreshToken: string | undefined;
78
- if (
79
- refreshTokenState &&
80
- typeof refreshTokenState === 'object' &&
81
- 'currentRefreshToken' in refreshTokenState &&
82
- 'previousRefreshToken' in refreshTokenState
83
- ) {
84
- if (refreshTokenState.currentRefreshToken === refreshToken) {
85
- // Scenario 1: Token matches currentRefreshToken
86
- valid = true;
87
- // Rotate tokens: move current to previous, set new current (simulate generation)
88
- newRefreshToken = await this.authenticationService.generateRefreshToken(user, refreshTokenState.currentRefreshToken); // Replace with real token generation logic
89
-
90
-
91
- // updated cache state
92
- // await this.cacheManager.set(this.getKey(user.id), {
93
- // currentRefreshToken: newRefreshToken,
94
- // previousRefreshToken: refreshTokenState.currentRefreshToken,
95
- // });
96
-
97
- // Optionally, set a timeout to clear previousRefreshToken after X minutes
98
- setTimeout(async () => {
99
- const state = (await this.cacheManager.get(this.getKey(user.id))) as any;
100
- if (state && state.currentRefreshToken === newRefreshToken) {
101
- await this.cacheManager.set(this.getKey(user.id), {
102
- currentRefreshToken: newRefreshToken,
103
- previousRefreshToken: "",
104
- });
105
- }
106
- }, 1 * 60 * 1000); // 5 minutes
107
- } else if (refreshTokenState.previousRefreshToken === refreshToken) {
108
- // Scenario 2: Token matches previousRefreshToken
109
- valid = true;
110
- // Do not modify cache
111
- // Generate new refresh token based on currentRefreshToken
112
- const existingRefreshTokenState = (await this.cacheManager.get(this.getKey(user.id))) as RefreshTokenState | undefined;
113
- newRefreshToken = existingRefreshTokenState?.currentRefreshToken;
126
+ async getEpoch(userId: number): Promise<number | undefined> {
127
+ return (await this.cacheManager.get<number>(this.getEpochKey(userId))) ?? undefined;
128
+ }
129
+
130
+ async validateAndRotate(
131
+ user: any,
132
+ refreshToken: string,
133
+ claims: RefreshTokenClaims = {},
134
+ ): Promise<RotatedRefreshToken> {
135
+ const { deviceKey, epoch } = claims;
136
+
137
+ // The read key comes from the token's own claim, never from a setting.
138
+ // A token issued under either scheme therefore stays valid when
139
+ // preventConcurrentLogins is toggled - in both directions - and a
140
+ // pre-migration token still finds the bare key it was written to.
141
+ const refreshTokenState = await this.cacheManager.get(
142
+ this.getKey(user.id, deviceKey),
143
+ ) as RefreshTokenState | undefined;
144
+
145
+ if (!this.isRefreshTokenState(refreshTokenState)) {
146
+ throw new InvalidatedRefreshTokenError();
147
+ }
148
+
149
+ if (deviceKey && !(await this.isEpochCurrent(user.id, epoch))) {
150
+ throw new InvalidatedRefreshTokenError();
151
+ }
152
+
153
+ // Scenario 1: the live token. Rotate it. generateRefreshToken calls
154
+ // insert(), which writes the new state and stamps the grace deadline
155
+ // onto the token being rotated out.
156
+ if (refreshTokenState.currentRefreshToken === refreshToken) {
157
+ const nextDeviceKey = this.resolveNextDeviceKey(deviceKey);
158
+ const rotated = await this.authenticationService.generateRefreshToken(
159
+ user,
160
+ refreshToken,
161
+ nextDeviceKey,
162
+ );
163
+
164
+ if (nextDeviceKey !== deviceKey) {
165
+ // The session changed buckets - either migrating out of the
166
+ // pre-deploy single slot, or collapsing back into it because
167
+ // preventConcurrentLogins was switched on. The new state is
168
+ // already written; drop the key it came from so nothing is
169
+ // left behind.
170
+ await this.cacheManager.del(this.getKey(user.id, deviceKey));
171
+ }
172
+
173
+ return { refreshToken: rotated, deviceKey: nextDeviceKey };
174
+ }
175
+
176
+ // Scenario 2: the token just rotated out. This is the concurrent-request
177
+ // case the previous slot exists for - a second in-flight request that
178
+ // was issued the old token before the first one rotated it. Hand back
179
+ // the live token so it succeeds, provided the grace window is still open.
180
+ if (refreshTokenState.previousRefreshToken && refreshTokenState.previousRefreshToken === refreshToken) {
181
+ if (!this.isWithinGraceWindow(refreshTokenState)) {
182
+ throw new InvalidatedRefreshTokenError();
114
183
  }
184
+ return { refreshToken: refreshTokenState.currentRefreshToken, deviceKey };
115
185
  }
116
186
 
187
+ throw new InvalidatedRefreshTokenError();
188
+ }
117
189
 
118
- if (!valid) {
119
- throw new InvalidatedRefreshTokenError();
190
+ getCurrentRefreshTokenState(
191
+ userId: number,
192
+ deviceKey?: string,
193
+ ): Promise<RefreshTokenState | undefined> {
194
+ return this.cacheManager.get(this.getKey(userId, deviceKey));
195
+ }
196
+
197
+ private getKey(userId: number, deviceKey?: string): string {
198
+ return deviceKey ? `user-${userId}-${deviceKey}` : `user-${userId}`;
199
+ }
200
+
201
+ // Deliberately not `user-${userId}-epoch`, which a device key of the
202
+ // literal string "epoch" would collide with.
203
+ private getEpochKey(userId: number): string {
204
+ return `user-epoch-${userId}`;
205
+ }
206
+
207
+ /**
208
+ * Which bucket a rotated token should land in. The setting is consulted
209
+ * first so that turning preventConcurrentLogins ON collapses existing
210
+ * per-device sessions back into the single slot on their next refresh -
211
+ * where normal last-login-wins applies, which is what that setting means.
212
+ * Leaving them in their own buckets would keep concurrent sessions alive
213
+ * while the setting claimed to forbid them.
214
+ */
215
+ private resolveNextDeviceKey(deviceKey?: string): string | undefined {
216
+ if (!this.areConcurrentLoginsAllowed()) {
217
+ return undefined;
120
218
  }
219
+ return deviceKey ?? randomUUID();
220
+ }
121
221
 
122
- // TODO: return the refresh token either currentRefreshToken
123
- return newRefreshToken; // Fallback to the provided tokenId if no new token was generated
222
+ private async isEpochCurrent(userId: number, epoch?: number): Promise<boolean> {
223
+ const storedEpoch = await this.getEpoch(userId);
224
+ // Nothing has ever been bulk-invalidated for this user - or the cache
225
+ // was flushed, in which case every bucket is gone and the token is
226
+ // already dead by the read above. Either way, accept.
227
+ if (storedEpoch === undefined) {
228
+ return true;
229
+ }
230
+ return epoch === storedEpoch;
231
+ }
232
+
233
+ // cache-manager v5 expects milliseconds; refreshTokenTtl is configured in
234
+ // seconds. Without an explicit TTL these entries never expire - neither
235
+ // cache path supplies a default - and the keyspace grows without bound.
236
+ private getStateTtlMs(): number {
237
+ const refreshTokenTtlSeconds = Number(
238
+ this.settingService.getConfigValue<SolidCoreSetting>("refreshTokenTtl"),
239
+ );
240
+ return (refreshTokenTtlSeconds + STATE_TTL_BUFFER_SECONDS) * 1000;
124
241
  }
125
242
 
126
- private getKey(userId: number): string {
127
- return `user-${userId}`;
243
+ private isRefreshTokenState(state: unknown): state is RefreshTokenState {
244
+ return (
245
+ !!state &&
246
+ typeof state === 'object' &&
247
+ 'currentRefreshToken' in state &&
248
+ 'previousRefreshToken' in state
249
+ );
128
250
  }
129
251
 
130
- getCurrentRefreshTokenState(userId: number): Promise<RefreshTokenState | undefined> {
131
- return this.cacheManager.get(this.getKey(userId));
252
+ private isWithinGraceWindow(state: RefreshTokenState): boolean {
253
+ // TRANSITIONAL - safe to delete one refreshTokenTtl after this ships.
254
+ //
255
+ // Entries written before previousValidUntil existed carry no deadline
256
+ // to test. They are accepted so that the deploy rejects nobody: a tab
257
+ // that legitimately rotated moments earlier keeps working. The cost is
258
+ // that for such an entry the previous token stays acceptable until its
259
+ // own JWT `exp` rather than for 60s - which is exactly how the old code
260
+ // already behaved whenever its in-process timer was lost to a restart,
261
+ // so this is an unfixed pre-existing case, not a new one.
262
+ //
263
+ // It self-heals: the user's next rotation writes a new-format state
264
+ // with a deadline. Once every pre-deploy entry has rotated or expired
265
+ // (one refreshTokenTtl), this branch is unreachable - drop it, and the
266
+ // undefined case becomes a rejection.
267
+ if (state.previousValidUntil === undefined) {
268
+ return true;
269
+ }
270
+ return Date.now() < state.previousValidUntil;
132
271
  }
133
272
  }