@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.
- package/CHANGELOG.md +582 -0
- package/dist/config/cache.options.d.ts.map +1 -1
- package/dist/config/cache.options.js +2 -2
- package/dist/config/cache.options.js.map +1 -1
- package/dist/controllers/authentication.controller.d.ts +1 -1
- package/dist/controllers/authentication.controller.d.ts.map +1 -1
- package/dist/controllers/authentication.controller.js +4 -3
- package/dist/controllers/authentication.controller.js.map +1 -1
- package/dist/helpers/solid-microservice-adapter.service.d.ts.map +1 -1
- package/dist/helpers/solid-microservice-adapter.service.js +1 -1
- package/dist/helpers/solid-microservice-adapter.service.js.map +1 -1
- package/dist/interfaces/active-user-data.interface.d.ts +1 -0
- package/dist/interfaces/active-user-data.interface.d.ts.map +1 -1
- package/dist/interfaces/active-user-data.interface.js.map +1 -1
- package/dist/services/api-key.service.d.ts.map +1 -1
- package/dist/services/api-key.service.js +1 -1
- package/dist/services/api-key.service.js.map +1 -1
- package/dist/services/authentication.service.d.ts +5 -4
- package/dist/services/authentication.service.d.ts.map +1 -1
- package/dist/services/authentication.service.js +50 -19
- package/dist/services/authentication.service.js.map +1 -1
- package/dist/services/refresh-token-ids-storage.service.d.ts +25 -6
- package/dist/services/refresh-token-ids-storage.service.d.ts.map +1 -1
- package/dist/services/refresh-token-ids-storage.service.js +80 -42
- package/dist/services/refresh-token-ids-storage.service.js.map +1 -1
- package/package.json +2 -2
- package/src/config/cache.options.ts +9 -1
- package/src/controllers/authentication.controller.ts +5 -2
- package/src/helpers/solid-microservice-adapter.service.ts +6 -1
- package/src/interfaces/active-user-data.interface.ts +9 -0
- package/src/services/api-key.service.ts +13 -1
- package/src/services/authentication.service.ts +127 -36
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
1867
|
-
|
|
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
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
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
|
-
|
|
1905
|
-
|
|
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
|
-
|
|
2297
|
-
|
|
2298
|
-
//
|
|
2299
|
-
//
|
|
2300
|
-
//
|
|
2301
|
-
//
|
|
2302
|
-
//
|
|
2303
|
-
//
|
|
2304
|
-
//
|
|
2305
|
-
|
|
2306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
92
|
+
await this.cacheManager.set(
|
|
93
|
+
this.getKey(userId, deviceKey),
|
|
94
|
+
refreshTokenState,
|
|
95
|
+
this.getStateTtlMs(),
|
|
96
|
+
);
|
|
42
97
|
}
|
|
43
98
|
|
|
44
|
-
async
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
// valid
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
127
|
-
return
|
|
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
|
-
|
|
131
|
-
|
|
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
|
}
|