@solidxai/core 0.1.13-beta.8 → 0.1.13

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 (260) hide show
  1. package/CHANGELOG.md +8568 -0
  2. package/README.md +19 -0
  3. package/dist/commands/run-tests.command.d.ts +2 -0
  4. package/dist/commands/run-tests.command.d.ts.map +1 -1
  5. package/dist/commands/run-tests.command.js +20 -3
  6. package/dist/commands/run-tests.command.js.map +1 -1
  7. package/dist/config/cache.options.d.ts.map +1 -1
  8. package/dist/config/cache.options.js +2 -2
  9. package/dist/config/cache.options.js.map +1 -1
  10. package/dist/constants/error-messages.d.ts +7 -0
  11. package/dist/constants/error-messages.d.ts.map +1 -1
  12. package/dist/constants/error-messages.js +7 -0
  13. package/dist/constants/error-messages.js.map +1 -1
  14. package/dist/constants/media-file-types.d.ts +3 -0
  15. package/dist/constants/media-file-types.d.ts.map +1 -1
  16. package/dist/constants/media-file-types.js +47 -8
  17. package/dist/constants/media-file-types.js.map +1 -1
  18. package/dist/constants/success-messages.d.ts +3 -0
  19. package/dist/constants/success-messages.d.ts.map +1 -1
  20. package/dist/constants/success-messages.js +3 -0
  21. package/dist/constants/success-messages.js.map +1 -1
  22. package/dist/controllers/authentication.controller.d.ts +1 -1
  23. package/dist/controllers/authentication.controller.d.ts.map +1 -1
  24. package/dist/controllers/authentication.controller.js +4 -3
  25. package/dist/controllers/authentication.controller.js.map +1 -1
  26. package/dist/controllers/field-metadata.controller.d.ts +2 -2
  27. package/dist/controllers/mpin-authentication.controller.d.ts +44 -0
  28. package/dist/controllers/mpin-authentication.controller.d.ts.map +1 -0
  29. package/dist/controllers/mpin-authentication.controller.js +102 -0
  30. package/dist/controllers/mpin-authentication.controller.js.map +1 -0
  31. package/dist/dtos/change-mpin.dto.d.ts +6 -0
  32. package/dist/dtos/change-mpin.dto.d.ts.map +1 -0
  33. package/dist/dtos/change-mpin.dto.js +39 -0
  34. package/dist/dtos/change-mpin.dto.js.map +1 -0
  35. package/dist/dtos/create-field-metadata.dto.d.ts +3 -6
  36. package/dist/dtos/create-field-metadata.dto.d.ts.map +1 -1
  37. package/dist/dtos/create-field-metadata.dto.js +15 -10
  38. package/dist/dtos/create-field-metadata.dto.js.map +1 -1
  39. package/dist/dtos/create-model-sequence.dto.d.ts +1 -1
  40. package/dist/dtos/create-model-sequence.dto.d.ts.map +1 -1
  41. package/dist/dtos/create-model-sequence.dto.js +1 -1
  42. package/dist/dtos/create-model-sequence.dto.js.map +1 -1
  43. package/dist/dtos/mpin-login.dto.d.ts +5 -0
  44. package/dist/dtos/mpin-login.dto.d.ts.map +1 -0
  45. package/dist/dtos/mpin-login.dto.js +33 -0
  46. package/dist/dtos/mpin-login.dto.js.map +1 -0
  47. package/dist/dtos/setup-mpin.dto.d.ts +7 -0
  48. package/dist/dtos/setup-mpin.dto.d.ts.map +1 -0
  49. package/dist/dtos/setup-mpin.dto.js +47 -0
  50. package/dist/dtos/setup-mpin.dto.js.map +1 -0
  51. package/dist/entities/common.entity.d.ts +4 -4
  52. package/dist/entities/common.entity.d.ts.map +1 -1
  53. package/dist/entities/common.entity.js +1 -1
  54. package/dist/entities/common.entity.js.map +1 -1
  55. package/dist/entities/dashboard-user-layout.entity.js +2 -2
  56. package/dist/entities/dashboard-user-layout.entity.js.map +1 -1
  57. package/dist/entities/field-metadata.entity.d.ts +1 -0
  58. package/dist/entities/field-metadata.entity.d.ts.map +1 -1
  59. package/dist/entities/field-metadata.entity.js +5 -1
  60. package/dist/entities/field-metadata.entity.js.map +1 -1
  61. package/dist/entities/user-device-credential.entity.d.ts +18 -0
  62. package/dist/entities/user-device-credential.entity.d.ts.map +1 -0
  63. package/dist/entities/user-device-credential.entity.js +91 -0
  64. package/dist/entities/user-device-credential.entity.js.map +1 -0
  65. package/dist/entities/user.entity.js +2 -2
  66. package/dist/entities/user.entity.js.map +1 -1
  67. package/dist/entities/view-metadata.entity.js.map +1 -1
  68. package/dist/entities/workflow-execution-artifact.entity.d.ts.map +1 -1
  69. package/dist/entities/workflow-execution-artifact.entity.js +4 -3
  70. package/dist/entities/workflow-execution-artifact.entity.js.map +1 -1
  71. package/dist/entities/workflow-execution-log.entity.d.ts.map +1 -1
  72. package/dist/entities/workflow-execution-log.entity.js +4 -3
  73. package/dist/entities/workflow-execution-log.entity.js.map +1 -1
  74. package/dist/entities/workflow-execution.entity.d.ts.map +1 -1
  75. package/dist/entities/workflow-execution.entity.js +3 -2
  76. package/dist/entities/workflow-execution.entity.js.map +1 -1
  77. package/dist/entities/workflow-secret.entity.d.ts.map +1 -1
  78. package/dist/entities/workflow-secret.entity.js +3 -2
  79. package/dist/entities/workflow-secret.entity.js.map +1 -1
  80. package/dist/entities/workflow-step-execution.entity.d.ts.map +1 -1
  81. package/dist/entities/workflow-step-execution.entity.js +3 -2
  82. package/dist/entities/workflow-step-execution.entity.js.map +1 -1
  83. package/dist/entities/workflow-trigger-execution.entity.d.ts.map +1 -1
  84. package/dist/entities/workflow-trigger-execution.entity.js +6 -5
  85. package/dist/entities/workflow-trigger-execution.entity.js.map +1 -1
  86. package/dist/filters/http-exception.filter.d.ts.map +1 -1
  87. package/dist/filters/http-exception.filter.js +5 -1
  88. package/dist/filters/http-exception.filter.js.map +1 -1
  89. package/dist/helpers/bootstrap.helper.d.ts +5 -2
  90. package/dist/helpers/bootstrap.helper.d.ts.map +1 -1
  91. package/dist/helpers/bootstrap.helper.js +7 -3
  92. package/dist/helpers/bootstrap.helper.js.map +1 -1
  93. package/dist/helpers/field-crud-managers/MediaFieldCrudManager.d.ts +6 -0
  94. package/dist/helpers/field-crud-managers/MediaFieldCrudManager.d.ts.map +1 -1
  95. package/dist/helpers/field-crud-managers/MediaFieldCrudManager.js +54 -27
  96. package/dist/helpers/field-crud-managers/MediaFieldCrudManager.js.map +1 -1
  97. package/dist/helpers/file-download.helper.d.ts.map +1 -1
  98. package/dist/helpers/file-download.helper.js +6 -1
  99. package/dist/helpers/file-download.helper.js.map +1 -1
  100. package/dist/helpers/security.helper.d.ts +5 -1
  101. package/dist/helpers/security.helper.d.ts.map +1 -1
  102. package/dist/helpers/security.helper.js +15 -4
  103. package/dist/helpers/security.helper.js.map +1 -1
  104. package/dist/helpers/solid-core-error-codes-provider.service.d.ts.map +1 -1
  105. package/dist/helpers/solid-core-error-codes-provider.service.js +45 -0
  106. package/dist/helpers/solid-core-error-codes-provider.service.js.map +1 -1
  107. package/dist/helpers/solid-microservice-adapter.service.d.ts.map +1 -1
  108. package/dist/helpers/solid-microservice-adapter.service.js +1 -1
  109. package/dist/helpers/solid-microservice-adapter.service.js.map +1 -1
  110. package/dist/helpers/typeorm-db-helper.d.ts.map +1 -1
  111. package/dist/helpers/typeorm-db-helper.js +9 -0
  112. package/dist/helpers/typeorm-db-helper.js.map +1 -1
  113. package/dist/index.d.ts +6 -0
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +6 -0
  116. package/dist/index.js.map +1 -1
  117. package/dist/interfaces/active-user-data.interface.d.ts +1 -0
  118. package/dist/interfaces/active-user-data.interface.d.ts.map +1 -1
  119. package/dist/interfaces/active-user-data.interface.js.map +1 -1
  120. package/dist/repository/user-device-credential.repository.d.ts +12 -0
  121. package/dist/repository/user-device-credential.repository.d.ts.map +1 -0
  122. package/dist/repository/user-device-credential.repository.js +34 -0
  123. package/dist/repository/user-device-credential.repository.js.map +1 -0
  124. package/dist/seeders/module-metadata-seeder.service.d.ts.map +1 -1
  125. package/dist/seeders/module-metadata-seeder.service.js +7 -3
  126. package/dist/seeders/module-metadata-seeder.service.js.map +1 -1
  127. package/dist/seeders/seed-data/solid-core-metadata.json +168 -2
  128. package/dist/services/api-key.service.d.ts.map +1 -1
  129. package/dist/services/api-key.service.js +1 -1
  130. package/dist/services/api-key.service.js.map +1 -1
  131. package/dist/services/authentication.service.d.ts +5 -4
  132. package/dist/services/authentication.service.d.ts.map +1 -1
  133. package/dist/services/authentication.service.js +50 -19
  134. package/dist/services/authentication.service.js.map +1 -1
  135. package/dist/services/chatter-message.service.d.ts +5 -1
  136. package/dist/services/chatter-message.service.d.ts.map +1 -1
  137. package/dist/services/chatter-message.service.js +48 -13
  138. package/dist/services/chatter-message.service.js.map +1 -1
  139. package/dist/services/crud-helper.service.d.ts +1 -2
  140. package/dist/services/crud-helper.service.d.ts.map +1 -1
  141. package/dist/services/crud-helper.service.js +2 -16
  142. package/dist/services/crud-helper.service.js.map +1 -1
  143. package/dist/services/crud.service.d.ts.map +1 -1
  144. package/dist/services/crud.service.js +2 -1
  145. package/dist/services/crud.service.js.map +1 -1
  146. package/dist/services/draft-publish-helper.service.d.ts +1 -0
  147. package/dist/services/draft-publish-helper.service.d.ts.map +1 -1
  148. package/dist/services/draft-publish-helper.service.js +38 -4
  149. package/dist/services/draft-publish-helper.service.js.map +1 -1
  150. package/dist/services/field-metadata.service.d.ts +2 -2
  151. package/dist/services/field-metadata.service.d.ts.map +1 -1
  152. package/dist/services/field-metadata.service.js +4 -1
  153. package/dist/services/field-metadata.service.js.map +1 -1
  154. package/dist/services/media-storage.utils.d.ts +1 -0
  155. package/dist/services/media-storage.utils.d.ts.map +1 -1
  156. package/dist/services/media-storage.utils.js +12 -1
  157. package/dist/services/media-storage.utils.js.map +1 -1
  158. package/dist/services/media.service.d.ts.map +1 -1
  159. package/dist/services/media.service.js +1 -0
  160. package/dist/services/media.service.js.map +1 -1
  161. package/dist/services/module-metadata.service.d.ts.map +1 -1
  162. package/dist/services/module-metadata.service.js +9 -1
  163. package/dist/services/module-metadata.service.js.map +1 -1
  164. package/dist/services/mpin.service.d.ts +67 -0
  165. package/dist/services/mpin.service.d.ts.map +1 -0
  166. package/dist/services/mpin.service.js +282 -0
  167. package/dist/services/mpin.service.js.map +1 -0
  168. package/dist/services/refresh-token-ids-storage.service.d.ts +25 -6
  169. package/dist/services/refresh-token-ids-storage.service.d.ts.map +1 -1
  170. package/dist/services/refresh-token-ids-storage.service.js +80 -42
  171. package/dist/services/refresh-token-ids-storage.service.js.map +1 -1
  172. package/dist/services/setting.service.d.ts.map +1 -1
  173. package/dist/services/setting.service.js +6 -1
  174. package/dist/services/setting.service.js.map +1 -1
  175. package/dist/services/settings/default-settings-provider.service.d.ts +206 -10
  176. package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
  177. package/dist/services/settings/default-settings-provider.service.js +120 -5
  178. package/dist/services/settings/default-settings-provider.service.js.map +1 -1
  179. package/dist/solid-core.module.d.ts +1 -3
  180. package/dist/solid-core.module.d.ts.map +1 -1
  181. package/dist/solid-core.module.js +14 -39
  182. package/dist/solid-core.module.js.map +1 -1
  183. package/dist/testing/reporter/console-reporter.d.ts +3 -0
  184. package/dist/testing/reporter/console-reporter.d.ts.map +1 -1
  185. package/dist/testing/reporter/console-reporter.js +2 -0
  186. package/dist/testing/reporter/console-reporter.js.map +1 -1
  187. package/dist/testing/reporter/progress-reporter.d.ts +64 -0
  188. package/dist/testing/reporter/progress-reporter.d.ts.map +1 -0
  189. package/dist/testing/reporter/progress-reporter.js +224 -0
  190. package/dist/testing/reporter/progress-reporter.js.map +1 -0
  191. package/dist/testing/reporter/reporter.types.d.ts +3 -0
  192. package/dist/testing/reporter/reporter.types.d.ts.map +1 -1
  193. package/dist/testing/reporter/reporter.types.js.map +1 -1
  194. package/dist/testing/runner/run-from-metadata.d.ts.map +1 -1
  195. package/dist/testing/runner/run-from-metadata.js +1 -0
  196. package/dist/testing/runner/run-from-metadata.js.map +1 -1
  197. package/dist/theme/theme-registry.d.ts +14 -0
  198. package/dist/theme/theme-registry.d.ts.map +1 -0
  199. package/dist/theme/theme-registry.js +147 -0
  200. package/dist/theme/theme-registry.js.map +1 -0
  201. package/package.json +2 -2
  202. package/src/commands/run-tests.command.ts +17 -3
  203. package/src/config/cache.options.ts +9 -1
  204. package/src/constants/error-messages.ts +14 -0
  205. package/src/constants/media-file-types.ts +87 -9
  206. package/src/constants/success-messages.ts +5 -0
  207. package/src/controllers/authentication.controller.ts +5 -2
  208. package/src/controllers/mpin-authentication.controller.ts +67 -0
  209. package/src/dtos/change-mpin.dto.ts +18 -0
  210. package/src/dtos/create-field-metadata.dto.ts +17 -9
  211. package/src/dtos/create-model-sequence.dto.ts +1 -1
  212. package/src/dtos/mpin-login.dto.ts +24 -0
  213. package/src/dtos/setup-mpin.dto.ts +51 -0
  214. package/src/entities/common.entity.ts +8 -4
  215. package/src/entities/dashboard-user-layout.entity.ts +2 -2
  216. package/src/entities/field-metadata.entity.ts +3 -0
  217. package/src/entities/user-device-credential.entity.ts +130 -0
  218. package/src/entities/user.entity.ts +2 -2
  219. package/src/entities/view-metadata.entity.ts +2 -2
  220. package/src/entities/workflow-execution-artifact.entity.ts +4 -3
  221. package/src/entities/workflow-execution-log.entity.ts +4 -3
  222. package/src/entities/workflow-execution.entity.ts +3 -2
  223. package/src/entities/workflow-secret.entity.ts +3 -2
  224. package/src/entities/workflow-step-execution.entity.ts +3 -2
  225. package/src/entities/workflow-trigger-execution.entity.ts +6 -5
  226. package/src/filters/http-exception.filter.ts +11 -1
  227. package/src/helpers/bootstrap.helper.ts +43 -6
  228. package/src/helpers/field-crud-managers/MediaFieldCrudManager.ts +73 -34
  229. package/src/helpers/file-download.helper.ts +15 -1
  230. package/src/helpers/security.helper.ts +35 -8
  231. package/src/helpers/solid-core-error-codes-provider.service.ts +53 -0
  232. package/src/helpers/solid-microservice-adapter.service.ts +6 -1
  233. package/src/helpers/typeorm-db-helper.ts +12 -1
  234. package/src/index.ts +6 -0
  235. package/src/interfaces/active-user-data.interface.ts +9 -0
  236. package/src/repository/user-device-credential.repository.ts +17 -0
  237. package/src/seeders/module-metadata-seeder.service.ts +11 -3
  238. package/src/seeders/seed-data/solid-core-metadata.json +168 -2
  239. package/src/services/api-key.service.ts +13 -1
  240. package/src/services/authentication.service.ts +127 -36
  241. package/src/services/chatter-message.service.ts +78 -15
  242. package/src/services/crud-helper.service.ts +19 -16
  243. package/src/services/crud.service.ts +3 -1
  244. package/src/services/draft-publish-helper.service.ts +56 -4
  245. package/src/services/field-metadata.service.ts +5 -2
  246. package/src/services/media-storage.utils.ts +26 -1
  247. package/src/services/media.service.ts +1 -0
  248. package/src/services/module-metadata.service.ts +10 -1
  249. package/src/services/mpin.service.ts +447 -0
  250. package/src/services/refresh-token-ids-storage.service.ts +229 -90
  251. package/src/services/setting.service.ts +8 -1
  252. package/src/services/settings/default-settings-provider.service.ts +131 -5
  253. package/src/solid-core.module.ts +26 -12
  254. package/src/testing/README.md +11 -1
  255. package/src/testing/reporter/console-reporter.ts +4 -0
  256. package/src/testing/reporter/progress-reporter.ts +251 -0
  257. package/src/testing/reporter/reporter.types.ts +3 -0
  258. package/src/testing/runner/run-from-metadata.ts +1 -0
  259. package/src/theme/theme-registry.ts +181 -0
  260. package/CLAUDE.md +0 -26
@@ -0,0 +1,282 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var MpinService_1;
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.MpinService = void 0;
14
+ const common_1 = require("@nestjs/common");
15
+ const crypto_1 = require("crypto");
16
+ const error_messages_1 = require("../constants/error-messages");
17
+ const success_messages_1 = require("../constants/success-messages");
18
+ const user_device_credential_repository_1 = require("../repository/user-device-credential.repository");
19
+ const user_repository_1 = require("../repository/user.repository");
20
+ const authentication_service_1 = require("./authentication.service");
21
+ const hashing_service_1 = require("./hashing.service");
22
+ const setting_service_1 = require("./setting.service");
23
+ const user_activity_history_service_1 = require("./user-activity-history.service");
24
+ const MPIN_DENYLIST = new Set([
25
+ '0000', '1111', '2222', '3333', '4444', '5555', '6666', '7777', '8888', '9999',
26
+ '1234', '4321', '1212', '2001', '1004', '2000', '6969', '2580',
27
+ '000000', '111111', '123456', '654321', '121212', '112233', '123123',
28
+ ]);
29
+ let MpinService = MpinService_1 = class MpinService {
30
+ constructor(credentialRepository, userRepository, hashingService, settingService, authenticationService, userActivityHistoryService) {
31
+ this.credentialRepository = credentialRepository;
32
+ this.userRepository = userRepository;
33
+ this.hashingService = hashingService;
34
+ this.settingService = settingService;
35
+ this.authenticationService = authenticationService;
36
+ this.userActivityHistoryService = userActivityHistoryService;
37
+ this.logger = new common_1.Logger(MpinService_1.name);
38
+ }
39
+ async setupMpin(activeUser, dto) {
40
+ this.assertEnabled();
41
+ this.assertMpinAcceptable(dto.mpin);
42
+ const user = await this.userRepository.findOne({ where: { id: activeUser.sub } });
43
+ if (!user) {
44
+ throw new common_1.NotFoundException(error_messages_1.ERROR_MESSAGES.USER_NOT_FOUND);
45
+ }
46
+ const deviceId = dto.deviceId.toLowerCase();
47
+ const existing = await this.credentialRepository.findOne({
48
+ where: { user: { id: user.id }, deviceId },
49
+ });
50
+ const record = existing ?? this.credentialRepository.create({
51
+ user,
52
+ deviceId,
53
+ });
54
+ record.credentialKey = this.buildCredentialKey(user.username, deviceId);
55
+ const credentialRef = this.mintCredentialRef();
56
+ record.hashedCredentialRef = this.hashCredentialRef(credentialRef);
57
+ record.hashedMpin = await this.hashingService.hash(dto.mpin);
58
+ record.mpinScheme = this.hashingService.name();
59
+ record.mpinSchemeVersion = this.hashingService.currentVersion();
60
+ record.deviceName = dto.deviceName ?? null;
61
+ record.platform = dto.platform ?? null;
62
+ record.isActive = true;
63
+ record.failedAttempts = 0;
64
+ record.lockedUntil = null;
65
+ await this.evictOldestIfAtCap(user.id, existing?.id);
66
+ await this.credentialRepository.save(record);
67
+ return { credentialRef, message: success_messages_1.SUCCESS_MESSAGES.MPIN_SETUP_SUCCESS };
68
+ }
69
+ async changeMpin(activeUser, dto) {
70
+ this.assertEnabled();
71
+ this.assertMpinAcceptable(dto.newMpin);
72
+ const credential = await this.credentialRepository.findOne({
73
+ where: {
74
+ hashedCredentialRef: this.hashCredentialRef(dto.credentialRef),
75
+ user: { id: activeUser.sub },
76
+ },
77
+ });
78
+ if (!credential || !credential.isActive) {
79
+ throw new common_1.UnauthorizedException(error_messages_1.ERROR_MESSAGES.MPIN_INVALID);
80
+ }
81
+ this.assertNotLocked(credential);
82
+ const matches = await this.hashingService.compare(dto.currentMpin, credential.hashedMpin, credential.mpinSchemeVersion);
83
+ if (!matches) {
84
+ throw await this.registerFailedAttempt(credential);
85
+ }
86
+ const credentialRef = this.mintCredentialRef();
87
+ credential.hashedCredentialRef = this.hashCredentialRef(credentialRef);
88
+ credential.hashedMpin = await this.hashingService.hash(dto.newMpin);
89
+ credential.mpinScheme = this.hashingService.name();
90
+ credential.mpinSchemeVersion = this.hashingService.currentVersion();
91
+ credential.failedAttempts = 0;
92
+ credential.lockedUntil = null;
93
+ await this.credentialRepository.save(credential);
94
+ return { credentialRef, message: success_messages_1.SUCCESS_MESSAGES.MPIN_CHANGED_SUCCESS };
95
+ }
96
+ async loginWithMpin(dto) {
97
+ this.assertEnabled();
98
+ const credential = await this.credentialRepository.findOne({
99
+ where: { hashedCredentialRef: this.hashCredentialRef(dto.credentialRef) },
100
+ relations: { user: { roles: true } },
101
+ });
102
+ if (!credential || !credential.user) {
103
+ throw new common_1.UnauthorizedException(error_messages_1.ERROR_MESSAGES.MPIN_INVALID);
104
+ }
105
+ if (!credential.isActive) {
106
+ throw new common_1.UnauthorizedException(error_messages_1.ERROR_MESSAGES.MPIN_REVOKED);
107
+ }
108
+ this.assertNotLocked(credential);
109
+ const matches = await this.hashingService.compare(dto.mpin, credential.hashedMpin, credential.mpinSchemeVersion);
110
+ if (!matches) {
111
+ throw await this.registerFailedAttempt(credential);
112
+ }
113
+ credential.failedAttempts = 0;
114
+ credential.lockedUntil = null;
115
+ credential.lastUsedAt = new Date();
116
+ if (this.hashingService.needsRehash(credential.hashedMpin, credential.mpinSchemeVersion)) {
117
+ credential.hashedMpin = await this.hashingService.hash(dto.mpin);
118
+ credential.mpinScheme = this.hashingService.name();
119
+ credential.mpinSchemeVersion = this.hashingService.currentVersion();
120
+ }
121
+ await this.credentialRepository.save(credential);
122
+ const user = credential.user;
123
+ await this.userActivityHistoryService.logEvent('login', user);
124
+ const tokens = await this.authenticationService.generateTokens(user, credential.deviceId);
125
+ return {
126
+ user: {
127
+ id: user.id,
128
+ email: user.email,
129
+ mobile: user.mobile,
130
+ username: user.username,
131
+ forcePasswordChange: user.forcePasswordChange,
132
+ roles: (user.roles ?? []).map((role) => role.name),
133
+ },
134
+ ...tokens,
135
+ };
136
+ }
137
+ async listDevices(activeUser) {
138
+ this.assertEnabled();
139
+ const credentials = await this.credentialRepository.find({
140
+ where: { user: { id: activeUser.sub }, isActive: true },
141
+ });
142
+ return credentials
143
+ .sort((a, b) => this.lastUsedMillis(b) - this.lastUsedMillis(a))
144
+ .map((credential) => this.toDeviceView(credential));
145
+ }
146
+ async revokeDevice(activeUser, id) {
147
+ this.assertEnabled();
148
+ const credential = await this.credentialRepository.findOne({
149
+ where: { id, user: { id: activeUser.sub } },
150
+ });
151
+ if (!credential) {
152
+ throw new common_1.NotFoundException(error_messages_1.ERROR_MESSAGES.MPIN_CREDENTIAL_NOT_FOUND);
153
+ }
154
+ credential.isActive = false;
155
+ await this.credentialRepository.save(credential);
156
+ return { message: success_messages_1.SUCCESS_MESSAGES.MPIN_DEVICE_REVOKED };
157
+ }
158
+ toDeviceView(credential) {
159
+ return {
160
+ id: credential.id,
161
+ deviceId: credential.deviceId,
162
+ deviceName: credential.deviceName,
163
+ platform: credential.platform,
164
+ isActive: credential.isActive,
165
+ failedAttempts: credential.failedAttempts,
166
+ lockedUntil: credential.lockedUntil,
167
+ lastUsedAt: credential.lastUsedAt,
168
+ createdAt: credential.createdAt,
169
+ };
170
+ }
171
+ assertEnabled() {
172
+ if (!this.settingService.getConfigValue('mpinEnabled')) {
173
+ throw new common_1.NotFoundException(error_messages_1.ERROR_MESSAGES.MPIN_NOT_ENABLED);
174
+ }
175
+ }
176
+ assertNotLocked(credential) {
177
+ if (credential.lockedUntil && credential.lockedUntil.getTime() > Date.now()) {
178
+ throw new common_1.UnauthorizedException(error_messages_1.ERROR_MESSAGES.MPIN_LOCKED);
179
+ }
180
+ }
181
+ assertMpinAcceptable(mpin) {
182
+ const pattern = String(this.settingService.getConfigValue('mpinRegex') ?? '^\\d{4,6}$');
183
+ let matches = false;
184
+ try {
185
+ matches = new RegExp(pattern).test(mpin);
186
+ }
187
+ catch {
188
+ this.logger.error(`Invalid mpinRegex setting: ${pattern}`);
189
+ throw new common_1.BadRequestException(error_messages_1.ERROR_MESSAGES.MPIN_FORMAT_INVALID);
190
+ }
191
+ if (!matches) {
192
+ throw new common_1.BadRequestException(error_messages_1.ERROR_MESSAGES.MPIN_FORMAT_INVALID);
193
+ }
194
+ if (this.isPredictable(mpin)) {
195
+ throw new common_1.BadRequestException(error_messages_1.ERROR_MESSAGES.MPIN_TOO_PREDICTABLE);
196
+ }
197
+ }
198
+ isPredictable(mpin) {
199
+ if (/^(\d)\1+$/.test(mpin)) {
200
+ return true;
201
+ }
202
+ if (this.isSequential(mpin)) {
203
+ return true;
204
+ }
205
+ return MPIN_DENYLIST.has(mpin);
206
+ }
207
+ isSequential(mpin) {
208
+ let ascending = true;
209
+ let descending = true;
210
+ for (let i = 1; i < mpin.length; i++) {
211
+ const step = mpin.charCodeAt(i) - mpin.charCodeAt(i - 1);
212
+ if (step !== 1)
213
+ ascending = false;
214
+ if (step !== -1)
215
+ descending = false;
216
+ }
217
+ return ascending || descending;
218
+ }
219
+ async registerFailedAttempt(credential) {
220
+ const lockThreshold = Number(this.settingService.getConfigValue('mpinMaxFailedAttempts'));
221
+ const deactivateThreshold = Number(this.settingService.getConfigValue('mpinMaxTotalFailedAttempts'));
222
+ const lockoutSeconds = Number(this.settingService.getConfigValue('mpinLockoutDuration'));
223
+ credential.failedAttempts += 1;
224
+ let error = new common_1.UnauthorizedException(error_messages_1.ERROR_MESSAGES.MPIN_INVALID);
225
+ if (credential.isActive && deactivateThreshold > 0 && credential.failedAttempts >= deactivateThreshold) {
226
+ credential.isActive = false;
227
+ credential.lockedUntil = null;
228
+ error = new common_1.UnauthorizedException(error_messages_1.ERROR_MESSAGES.MPIN_REVOKED);
229
+ }
230
+ else if (credential.isActive && lockThreshold > 0 && credential.failedAttempts % lockThreshold === 0) {
231
+ credential.lockedUntil = new Date(Date.now() + lockoutSeconds * 1000);
232
+ error = new common_1.UnauthorizedException(error_messages_1.ERROR_MESSAGES.MPIN_LOCKED);
233
+ }
234
+ await this.credentialRepository.save(credential);
235
+ return error;
236
+ }
237
+ async evictOldestIfAtCap(userId, excludeId) {
238
+ const cap = Number(this.settingService.getConfigValue('mpinMaxDevicesPerUser'));
239
+ if (!cap || cap <= 0) {
240
+ return;
241
+ }
242
+ const active = (await this.credentialRepository.find({
243
+ where: { user: { id: userId }, isActive: true },
244
+ })).filter((credential) => credential.id !== excludeId);
245
+ const overflow = active.length - (cap - 1);
246
+ if (overflow <= 0) {
247
+ return;
248
+ }
249
+ const doomed = active
250
+ .sort((a, b) => this.lastUsedMillis(a) - this.lastUsedMillis(b))
251
+ .slice(0, overflow);
252
+ for (const credential of doomed) {
253
+ credential.isActive = false;
254
+ }
255
+ await this.credentialRepository.save(doomed);
256
+ this.logger.log(`Evicted ${doomed.length} MPIN credential(s) for user ${userId} at the device cap of ${cap}`);
257
+ }
258
+ lastUsedMillis(credential) {
259
+ return credential.lastUsedAt?.getTime() ?? credential.createdAt?.getTime() ?? 0;
260
+ }
261
+ buildCredentialKey(username, deviceId) {
262
+ const normalizedUsername = (username ?? '').trim().toLowerCase().replace(/\s+/g, '-');
263
+ return `${normalizedUsername}-${deviceId}`;
264
+ }
265
+ mintCredentialRef() {
266
+ return (0, crypto_1.randomBytes)(32).toString('hex');
267
+ }
268
+ hashCredentialRef(credentialRef) {
269
+ return (0, crypto_1.createHash)('sha256').update(credentialRef).digest('hex');
270
+ }
271
+ };
272
+ exports.MpinService = MpinService;
273
+ exports.MpinService = MpinService = MpinService_1 = __decorate([
274
+ (0, common_1.Injectable)(),
275
+ __metadata("design:paramtypes", [user_device_credential_repository_1.UserDeviceCredentialRepository,
276
+ user_repository_1.UserRepository,
277
+ hashing_service_1.HashingService,
278
+ setting_service_1.SettingService,
279
+ authentication_service_1.AuthenticationService,
280
+ user_activity_history_service_1.UserActivityHistoryService])
281
+ ], MpinService);
282
+ //# sourceMappingURL=mpin.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mpin.service.js","sourceRoot":"","sources":["../../src/services/mpin.service.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n BadRequestException,\n Injectable,\n Logger,\n NotFoundException,\n UnauthorizedException,\n} from '@nestjs/common';\nimport { createHash, randomBytes } from 'crypto';\nimport { ERROR_MESSAGES } from 'src/constants/error-messages';\nimport { SUCCESS_MESSAGES } from 'src/constants/success-messages';\nimport { ChangeMpinDto } from 'src/dtos/change-mpin.dto';\nimport { MpinLoginDto } from 'src/dtos/mpin-login.dto';\nimport { SetupMpinDto } from 'src/dtos/setup-mpin.dto';\nimport { UserDeviceCredential } from 'src/entities/user-device-credential.entity';\nimport { ActiveUserData } from 'src/interfaces/active-user-data.interface';\nimport { UserDeviceCredentialRepository } from 'src/repository/user-device-credential.repository';\nimport { UserRepository } from 'src/repository/user.repository';\nimport type { SolidCoreSetting } from 'src/services/settings/default-settings-provider.service';\nimport { AuthenticationService } from './authentication.service';\nimport { HashingService } from './hashing.service';\nimport { SettingService } from './setting.service';\nimport { UserActivityHistoryService } from './user-activity-history.service';\n\n/**\n * The most-chosen PINs in published analyses of breached sets. `1234` alone\n * accounts for roughly a tenth of four-digit choices, so a list this short\n * removes a disproportionate share of guessable values.\n */\nconst MPIN_DENYLIST = new Set([\n '0000', '1111', '2222', '3333', '4444', '5555', '6666', '7777', '8888', '9999',\n '1234', '4321', '1212', '2001', '1004', '2000', '6969', '2580',\n '000000', '111111', '123456', '654321', '121212', '112233', '123123',\n]);\n\n@Injectable()\nexport class MpinService {\n private readonly logger = new Logger(MpinService.name);\n\n constructor(\n private readonly credentialRepository: UserDeviceCredentialRepository,\n private readonly userRepository: UserRepository,\n private readonly hashingService: HashingService,\n private readonly settingService: SettingService,\n private readonly authenticationService: AuthenticationService,\n private readonly userActivityHistoryService: UserActivityHistoryService,\n ) { }\n\n // ---------------------------------------------------------------- setup\n\n async setupMpin(activeUser: ActiveUserData, dto: SetupMpinDto) {\n this.assertEnabled();\n this.assertMpinAcceptable(dto.mpin);\n\n // The account comes from the access token, never from the body - a\n // client can only ever create a credential for the user it is signed\n // in as.\n const user = await this.userRepository.findOne({ where: { id: activeUser.sub } });\n if (!user) {\n throw new NotFoundException(ERROR_MESSAGES.USER_NOT_FOUND);\n }\n\n // Lower-cased because @IsUUID is case-insensitive: iOS renders a UUID\n // uppercase (identifierForVendor.uuidString) while Android renders it\n // lowercase, and both validate. Without normalising, the same physical\n // device could present two spellings, miss this exact-match lookup and\n // end up with two credential rows consuming two slots against the cap.\n const deviceId = dto.deviceId.toLowerCase();\n\n const existing = await this.credentialRepository.findOne({\n where: { user: { id: user.id }, deviceId },\n });\n\n // Re-running setup on the same device rotates the credential rather\n // than adding a second one.\n const record = existing ?? this.credentialRepository.create({\n user,\n deviceId,\n });\n\n // Recomputed on every setup so a renamed user does not keep a stale\n // key. It is an identifier for humans and tooling, never a lookup key,\n // so drift here costs nothing operationally.\n record.credentialKey = this.buildCredentialKey(user.username, deviceId);\n\n const credentialRef = this.mintCredentialRef();\n record.hashedCredentialRef = this.hashCredentialRef(credentialRef);\n record.hashedMpin = await this.hashingService.hash(dto.mpin);\n record.mpinScheme = this.hashingService.name();\n record.mpinSchemeVersion = this.hashingService.currentVersion();\n record.deviceName = dto.deviceName ?? null;\n record.platform = dto.platform ?? null;\n record.isActive = true;\n record.failedAttempts = 0;\n record.lockedUntil = null;\n\n await this.evictOldestIfAtCap(user.id, existing?.id);\n await this.credentialRepository.save(record);\n\n // Returned once. Only its SHA-256 is stored, so it can never be\n // re-issued - the client must persist it before reporting success.\n return { credentialRef, message: SUCCESS_MESSAGES.MPIN_SETUP_SUCCESS };\n }\n\n async changeMpin(activeUser: ActiveUserData, dto: ChangeMpinDto) {\n this.assertEnabled();\n this.assertMpinAcceptable(dto.newMpin);\n\n // Scoped to the caller, so holding someone else's handle is not enough\n // to change their MPIN.\n const credential = await this.credentialRepository.findOne({\n where: {\n hashedCredentialRef: this.hashCredentialRef(dto.credentialRef),\n user: { id: activeUser.sub },\n },\n });\n\n if (!credential || !credential.isActive) {\n throw new UnauthorizedException(ERROR_MESSAGES.MPIN_INVALID);\n }\n this.assertNotLocked(credential);\n\n const matches = await this.hashingService.compare(\n dto.currentMpin,\n credential.hashedMpin,\n credential.mpinSchemeVersion,\n );\n if (!matches) {\n throw await this.registerFailedAttempt(credential);\n }\n\n // Rotate the handle alongside the MPIN: a changed credential should\n // invalidate anything captured previously.\n const credentialRef = this.mintCredentialRef();\n credential.hashedCredentialRef = this.hashCredentialRef(credentialRef);\n credential.hashedMpin = await this.hashingService.hash(dto.newMpin);\n credential.mpinScheme = this.hashingService.name();\n credential.mpinSchemeVersion = this.hashingService.currentVersion();\n credential.failedAttempts = 0;\n credential.lockedUntil = null;\n await this.credentialRepository.save(credential);\n\n return { credentialRef, message: SUCCESS_MESSAGES.MPIN_CHANGED_SUCCESS };\n }\n\n // ---------------------------------------------------------------- login\n\n async loginWithMpin(dto: MpinLoginDto) {\n this.assertEnabled();\n\n // A single indexed exact-match lookup. The row already names the user,\n // so there is nothing to resolve and no identifier to enumerate with.\n const credential = await this.credentialRepository.findOne({\n where: { hashedCredentialRef: this.hashCredentialRef(dto.credentialRef) },\n relations: { user: { roles: true } },\n });\n\n // An unknown handle is reported exactly as a wrong MPIN is.\n if (!credential || !credential.user) {\n throw new UnauthorizedException(ERROR_MESSAGES.MPIN_INVALID);\n }\n\n // A dead credential is reported as such regardless of what MPIN was\n // supplied. Gating this on a correct MPIN would be marginally more\n // conservative, but it protects almost nothing - \"this dead handle was\n // once real\" grants no capability - and it costs an asymmetry that\n // reads as a bug: the attempt that causes deactivation reports\n // MPIN_REVOKED, while the very next identical attempt would report\n // MPIN_INVALID. The enumeration resistance that matters comes from the\n // handle being unguessable, not from this branch.\n if (!credential.isActive) {\n throw new UnauthorizedException(ERROR_MESSAGES.MPIN_REVOKED);\n }\n\n // Checked before the hash comparison, so attempts made during a lockout\n // neither extend it nor cost a bcrypt call.\n this.assertNotLocked(credential);\n\n const matches = await this.hashingService.compare(\n dto.mpin,\n credential.hashedMpin,\n credential.mpinSchemeVersion,\n );\n\n if (!matches) {\n throw await this.registerFailedAttempt(credential);\n }\n\n credential.failedAttempts = 0;\n credential.lockedUntil = null;\n credential.lastUsedAt = new Date();\n if (this.hashingService.needsRehash(credential.hashedMpin, credential.mpinSchemeVersion)) {\n credential.hashedMpin = await this.hashingService.hash(dto.mpin);\n credential.mpinScheme = this.hashingService.name();\n credential.mpinSchemeVersion = this.hashingService.currentVersion();\n }\n await this.credentialRepository.save(credential);\n\n const user = credential.user;\n await this.userActivityHistoryService.logEvent('login', user);\n\n // A fresh token pair - never a replay of a stored refresh token. This\n // is what makes MPIN survive logout and refresh-token expiry. The\n // stable deviceId keeps repeat logins in one session bucket.\n const tokens = await this.authenticationService.generateTokens(user, credential.deviceId);\n\n return {\n user: {\n id: user.id,\n email: user.email,\n mobile: user.mobile,\n username: user.username,\n forcePasswordChange: user.forcePasswordChange,\n roles: (user.roles ?? []).map((role) => role.name),\n },\n ...tokens,\n };\n }\n\n // ----------------------------------------------------- device management\n\n async listDevices(activeUser: ActiveUserData) {\n this.assertEnabled();\n\n const credentials = await this.credentialRepository.find({\n where: { user: { id: activeUser.sub }, isActive: true },\n });\n\n return credentials\n .sort((a, b) => this.lastUsedMillis(b) - this.lastUsedMillis(a))\n .map((credential) => this.toDeviceView(credential));\n }\n\n async revokeDevice(activeUser: ActiveUserData, id: number) {\n this.assertEnabled();\n\n const credential = await this.credentialRepository.findOne({\n where: { id, user: { id: activeUser.sub } },\n });\n if (!credential) {\n throw new NotFoundException(ERROR_MESSAGES.MPIN_CREDENTIAL_NOT_FOUND);\n }\n\n // Soft-delete. The row survives so a correct MPIN can be answered with\n // MPIN_REVOKED rather than a baffling MPIN_INVALID; only active rows\n // count towards the device cap.\n credential.isActive = false;\n await this.credentialRepository.save(credential);\n\n return { message: SUCCESS_MESSAGES.MPIN_DEVICE_REVOKED };\n }\n\n // -------------------------------------------------------------- internals\n\n /**\n * Never exposes the stored hashes, independently of whatever serialiser\n * configuration a consuming app runs. The entity also withholds them via\n * @Exclude()/@Expose(), but building the response explicitly means a\n * carelessly added decorator cannot leak a credential.\n */\n private toDeviceView(credential: UserDeviceCredential) {\n return {\n id: credential.id,\n deviceId: credential.deviceId,\n deviceName: credential.deviceName,\n platform: credential.platform,\n isActive: credential.isActive,\n failedAttempts: credential.failedAttempts,\n lockedUntil: credential.lockedUntil,\n lastUsedAt: credential.lastUsedAt,\n createdAt: credential.createdAt,\n };\n }\n\n private assertEnabled(): void {\n if (!this.settingService.getConfigValue<SolidCoreSetting>('mpinEnabled')) {\n // 404 rather than 403: a disabled feature should look absent.\n throw new NotFoundException(ERROR_MESSAGES.MPIN_NOT_ENABLED);\n }\n }\n\n private assertNotLocked(credential: UserDeviceCredential): void {\n if (credential.lockedUntil && credential.lockedUntil.getTime() > Date.now()) {\n throw new UnauthorizedException(ERROR_MESSAGES.MPIN_LOCKED);\n }\n }\n\n private assertMpinAcceptable(mpin: string): void {\n const pattern = String(\n this.settingService.getConfigValue<SolidCoreSetting>('mpinRegex') ?? '^\\\\d{4,6}$',\n );\n\n // Fails closed. `matches` starts false and a malformed pattern - the\n // setting is admin-editable free text - becomes a rejection rather\n // than a skipped check, so a typo cannot silently let users set an\n // MPIN weaker than policy allows.\n let matches = false;\n try {\n matches = new RegExp(pattern).test(mpin);\n } catch {\n this.logger.error(`Invalid mpinRegex setting: ${pattern}`);\n throw new BadRequestException(ERROR_MESSAGES.MPIN_FORMAT_INVALID);\n }\n\n if (!matches) {\n throw new BadRequestException(ERROR_MESSAGES.MPIN_FORMAT_INVALID);\n }\n\n // Predictability is semantics, not shape - a regex expressing \"not\n // sequential\" would be unreadable and easy to get subtly wrong.\n if (this.isPredictable(mpin)) {\n // Safe to distinguish, and 400 rather than 401: this only ever\n // happens on a bearer-authenticated route, so it is a validation\n // failure rather than an authentication one.\n throw new BadRequestException(ERROR_MESSAGES.MPIN_TOO_PREDICTABLE);\n }\n }\n\n private isPredictable(mpin: string): boolean {\n if (/^(\\d)\\1+$/.test(mpin)) {\n return true;\n }\n if (this.isSequential(mpin)) {\n return true;\n }\n return MPIN_DENYLIST.has(mpin);\n }\n\n private isSequential(mpin: string): boolean {\n let ascending = true;\n let descending = true;\n for (let i = 1; i < mpin.length; i++) {\n const step = mpin.charCodeAt(i) - mpin.charCodeAt(i - 1);\n if (step !== 1) ascending = false;\n if (step !== -1) descending = false;\n }\n return ascending || descending;\n }\n\n /**\n * Records a failure and escalates. `failedAttempts` is cumulative and\n * resets only on success - never when a lockout expires - which is what\n * makes the second threshold detectable without a separate counter.\n *\n * Returns the exception for the caller to throw, so the save is always\n * awaited before the response leaves.\n */\n private async registerFailedAttempt(\n credential: UserDeviceCredential,\n ): Promise<UnauthorizedException> {\n const lockThreshold = Number(\n this.settingService.getConfigValue<SolidCoreSetting>('mpinMaxFailedAttempts'),\n );\n const deactivateThreshold = Number(\n this.settingService.getConfigValue<SolidCoreSetting>('mpinMaxTotalFailedAttempts'),\n );\n const lockoutSeconds = Number(\n this.settingService.getConfigValue<SolidCoreSetting>('mpinLockoutDuration'),\n );\n\n credential.failedAttempts += 1;\n let error = new UnauthorizedException(ERROR_MESSAGES.MPIN_INVALID);\n\n if (credential.isActive && deactivateThreshold > 0 && credential.failedAttempts >= deactivateThreshold) {\n credential.isActive = false;\n credential.lockedUntil = null;\n // This attempt caused the deactivation, so it is reported. The\n // caller reached the limit on a handle they demonstrably hold and\n // caused this state themselves, so nothing is disclosed - and\n // without it they would keep retrying a permanently dead credential.\n error = new UnauthorizedException(ERROR_MESSAGES.MPIN_REVOKED);\n } else if (credential.isActive && lockThreshold > 0 && credential.failedAttempts % lockThreshold === 0) {\n credential.lockedUntil = new Date(Date.now() + lockoutSeconds * 1000);\n error = new UnauthorizedException(ERROR_MESSAGES.MPIN_LOCKED);\n }\n\n await this.credentialRepository.save(credential);\n return error;\n }\n\n /**\n * The cap evicts rather than rejects. A reinstall wipes the client's\n * deviceId, so the app generates a new one and setup adds a row instead of\n * replacing the old, orphaned one. Rejecting at the cap would mean a user\n * who reinstalls a few times could never set MPIN up again.\n */\n private async evictOldestIfAtCap(userId: number, excludeId?: number): Promise<void> {\n const cap = Number(\n this.settingService.getConfigValue<SolidCoreSetting>('mpinMaxDevicesPerUser'),\n );\n if (!cap || cap <= 0) {\n return;\n }\n\n const active = (await this.credentialRepository.find({\n where: { user: { id: userId }, isActive: true },\n })).filter((credential) => credential.id !== excludeId);\n\n // One slot is about to be taken by the credential being written.\n const overflow = active.length - (cap - 1);\n if (overflow <= 0) {\n return;\n }\n\n // Sorted in JS rather than SQL because a never-used credential has a\n // null lastUsedAt, and Postgres sorts nulls last on ASC - which would\n // treat the least-used rows as the most recently used.\n const doomed = active\n .sort((a, b) => this.lastUsedMillis(a) - this.lastUsedMillis(b))\n .slice(0, overflow);\n\n for (const credential of doomed) {\n credential.isActive = false;\n }\n await this.credentialRepository.save(doomed);\n this.logger.log(\n `Evicted ${doomed.length} MPIN credential(s) for user ${userId} at the device cap of ${cap}`,\n );\n }\n\n private lastUsedMillis(credential: UserDeviceCredential): number {\n return credential.lastUsedAt?.getTime() ?? credential.createdAt?.getTime() ?? 0;\n }\n\n /**\n * `<username>-<deviceId>`, the model's natural key.\n *\n * Not slugified: lodash `kebabCase` splits digit/letter boundaries, so it\n * would shred a UUID into `3-f-7-b-8-a-10-...`. The username is lower-cased\n * and stripped of whitespace, which is enough for a key that is only ever\n * read, never parsed back into its parts.\n */\n private buildCredentialKey(username: string, deviceId: string): string {\n const normalizedUsername = (username ?? '').trim().toLowerCase().replace(/\\s+/g, '-');\n return `${normalizedUsername}-${deviceId}`;\n }\n\n private mintCredentialRef(): string {\n return randomBytes(32).toString('hex');\n }\n\n // SHA-256, not bcrypt: 256 bits of randomness has nothing to brute-force,\n // so a slow hash would only add latency to every login. Same reasoning as\n // ApiKeyService.hash.\n private hashCredentialRef(credentialRef: string): string {\n return createHash('sha256').update(credentialRef).digest('hex');\n }\n}\n"]}
@@ -1,21 +1,40 @@
1
1
  import { Cache } from 'cache-manager';
2
2
  import { AuthenticationService } from './authentication.service';
3
+ import { SettingService } from './setting.service';
3
4
  export declare class InvalidatedRefreshTokenError extends Error {
4
5
  }
5
6
  type RefreshTokenState = {
6
7
  currentRefreshToken: string;
7
8
  previousRefreshToken: string;
9
+ previousValidUntil?: number;
10
+ };
11
+ export type RefreshTokenClaims = {
12
+ deviceKey?: string;
13
+ epoch?: number;
14
+ };
15
+ export type RotatedRefreshToken = {
16
+ refreshToken: string;
17
+ deviceKey?: string;
8
18
  };
9
19
  export declare class RefreshTokenIdsStorageService {
10
20
  private cacheManager;
11
21
  private readonly authenticationService;
12
- constructor(cacheManager: Cache, authenticationService: AuthenticationService);
13
- insert(userId: number, refreshToken: string, previousRefreshToken?: string): Promise<void>;
14
- validate(userId: number, refreshToken: string): Promise<boolean>;
15
- invalidate(userId: number): Promise<void>;
16
- validateAndRotate(user: any, refreshToken: string): Promise<string>;
22
+ private readonly settingService;
23
+ constructor(cacheManager: Cache, authenticationService: AuthenticationService, settingService: SettingService);
24
+ areConcurrentLoginsAllowed(): boolean;
25
+ insert(userId: number, refreshToken: string, previousRefreshToken?: string, deviceKey?: string): Promise<void>;
26
+ invalidate(userId: number, deviceKey?: string): Promise<void>;
27
+ invalidateAll(userId: number): Promise<void>;
28
+ getEpoch(userId: number): Promise<number | undefined>;
29
+ validateAndRotate(user: any, refreshToken: string, claims?: RefreshTokenClaims): Promise<RotatedRefreshToken>;
30
+ getCurrentRefreshTokenState(userId: number, deviceKey?: string): Promise<RefreshTokenState | undefined>;
17
31
  private getKey;
18
- getCurrentRefreshTokenState(userId: number): Promise<RefreshTokenState | undefined>;
32
+ private getEpochKey;
33
+ private resolveNextDeviceKey;
34
+ private isEpochCurrent;
35
+ private getStateTtlMs;
36
+ private isRefreshTokenState;
37
+ private isWithinGraceWindow;
19
38
  }
20
39
  export {};
21
40
  //# sourceMappingURL=refresh-token-ids-storage.service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"refresh-token-ids-storage.service.d.ts","sourceRoot":"","sources":["../../src/services/refresh-token-ids-storage.service.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AACtC,OAAO,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAGjE,qBAAa,4BAA6B,SAAQ,KAAK;CAAI;AAE3D,KAAK,iBAAiB,GAAG;IACrB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,oBAAoB,EAAE,MAAM,CAAC;CAChC,CAAC;AAEF,qBAEa,6BAA6B;IAeX,OAAO,CAAC,YAAY;IAE3C,OAAO,CAAC,QAAQ,CAAC,qBAAqB;gBAFP,YAAY,EAAE,KAAK,EAEjC,qBAAqB,EAAE,qBAAqB;IAG3D,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,oBAAoB,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQ1F,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAUhE,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIzC,iBAAiB,CAAC,IAAI,EAAE,GAAG,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAoEzE,OAAO,CAAC,MAAM;IAId,2BAA2B,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;CAGtF"}
1
+ {"version":3,"file":"refresh-token-ids-storage.service.d.ts","sourceRoot":"","sources":["../../src/services/refresh-token-ids-storage.service.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAGtC,OAAO,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AACjE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGnD,qBAAa,4BAA6B,SAAQ,KAAK;CAAI;AAe3D,KAAK,iBAAiB,GAAG;IACrB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,oBAAoB,EAAE,MAAM,CAAC;IAM7B,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AAQF,MAAM,MAAM,kBAAkB,GAAG;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAC9B,YAAY,EAAE,MAAM,CAAC;IAGrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,qBACa,6BAA6B;IAEX,OAAO,CAAC,YAAY;IAE3C,OAAO,CAAC,QAAQ,CAAC,qBAAqB;IACtC,OAAO,CAAC,QAAQ,CAAC,cAAc;gBAHA,YAAY,EAAE,KAAK,EAEjC,qBAAqB,EAAE,qBAAqB,EAC5C,cAAc,EAAE,cAAc;IAenD,0BAA0B,IAAI,OAAO;IAI/B,MAAM,CACR,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,MAAM,EACpB,oBAAoB,CAAC,EAAE,MAAM,EAC7B,SAAS,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC;IAeV,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAa7D,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAc5C,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAIrD,iBAAiB,CACnB,IAAI,EAAE,GAAG,EACT,YAAY,EAAE,MAAM,EACpB,MAAM,GAAE,kBAAuB,GAChC,OAAO,CAAC,mBAAmB,CAAC;IAwD/B,2BAA2B,CACvB,MAAM,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIzC,OAAO,CAAC,MAAM;IAMd,OAAO,CAAC,WAAW;IAYnB,OAAO,CAAC,oBAAoB;YAOd,cAAc;IAc5B,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,mBAAmB;IAS3B,OAAO,CAAC,mBAAmB;CAoB9B"}
@@ -15,69 +15,106 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.RefreshTokenIdsStorageService = exports.InvalidatedRefreshTokenError = void 0;
16
16
  const cache_manager_1 = require("@nestjs/cache-manager");
17
17
  const common_1 = require("@nestjs/common");
18
+ const crypto_1 = require("crypto");
18
19
  const authentication_service_1 = require("./authentication.service");
20
+ const setting_service_1 = require("./setting.service");
19
21
  class InvalidatedRefreshTokenError extends Error {
20
22
  }
21
23
  exports.InvalidatedRefreshTokenError = InvalidatedRefreshTokenError;
24
+ const PREVIOUS_TOKEN_GRACE_MS = 60 * 1000;
25
+ const STATE_TTL_BUFFER_SECONDS = 60;
22
26
  let RefreshTokenIdsStorageService = class RefreshTokenIdsStorageService {
23
- constructor(cacheManager, authenticationService) {
27
+ constructor(cacheManager, authenticationService, settingService) {
24
28
  this.cacheManager = cacheManager;
25
29
  this.authenticationService = authenticationService;
30
+ this.settingService = settingService;
26
31
  }
27
- async insert(userId, refreshToken, previousRefreshToken) {
32
+ areConcurrentLoginsAllowed() {
33
+ return !this.settingService.getConfigValue("preventConcurrentLogins");
34
+ }
35
+ async insert(userId, refreshToken, previousRefreshToken, deviceKey) {
28
36
  const refreshTokenState = {
29
37
  currentRefreshToken: refreshToken,
30
38
  previousRefreshToken: previousRefreshToken ?? "",
39
+ ...(previousRefreshToken
40
+ ? { previousValidUntil: Date.now() + PREVIOUS_TOKEN_GRACE_MS }
41
+ : {}),
31
42
  };
32
- await this.cacheManager.set(this.getKey(userId), refreshTokenState);
43
+ await this.cacheManager.set(this.getKey(userId, deviceKey), refreshTokenState, this.getStateTtlMs());
33
44
  }
34
- async validate(userId, refreshToken) {
35
- const storedId = await this.cacheManager.get(this.getKey(userId));
36
- if (storedId !== refreshToken) {
37
- throw new InvalidatedRefreshTokenError();
38
- }
39
- return storedId === refreshToken;
45
+ async invalidate(userId, deviceKey) {
46
+ await this.cacheManager.del(this.getKey(userId, deviceKey));
40
47
  }
41
- async invalidate(userId) {
48
+ async invalidateAll(userId) {
49
+ const currentEpoch = (await this.getEpoch(userId)) ?? 0;
50
+ await this.cacheManager.set(this.getEpochKey(userId), currentEpoch + 1, this.getStateTtlMs());
42
51
  await this.cacheManager.del(this.getKey(userId));
43
52
  }
44
- async validateAndRotate(user, refreshToken) {
45
- let valid = false;
46
- const refreshTokenState = await this.cacheManager.get(this.getKey(user.id));
47
- let newRefreshToken;
48
- if (refreshTokenState &&
49
- typeof refreshTokenState === 'object' &&
50
- 'currentRefreshToken' in refreshTokenState &&
51
- 'previousRefreshToken' in refreshTokenState) {
52
- if (refreshTokenState.currentRefreshToken === refreshToken) {
53
- valid = true;
54
- newRefreshToken = await this.authenticationService.generateRefreshToken(user, refreshTokenState.currentRefreshToken);
55
- setTimeout(async () => {
56
- const state = (await this.cacheManager.get(this.getKey(user.id)));
57
- if (state && state.currentRefreshToken === newRefreshToken) {
58
- await this.cacheManager.set(this.getKey(user.id), {
59
- currentRefreshToken: newRefreshToken,
60
- previousRefreshToken: "",
61
- });
62
- }
63
- }, 1 * 60 * 1000);
53
+ async getEpoch(userId) {
54
+ return (await this.cacheManager.get(this.getEpochKey(userId))) ?? undefined;
55
+ }
56
+ async validateAndRotate(user, refreshToken, claims = {}) {
57
+ const { deviceKey, epoch } = claims;
58
+ const refreshTokenState = await this.cacheManager.get(this.getKey(user.id, deviceKey));
59
+ if (!this.isRefreshTokenState(refreshTokenState)) {
60
+ throw new InvalidatedRefreshTokenError();
61
+ }
62
+ if (deviceKey && !(await this.isEpochCurrent(user.id, epoch))) {
63
+ throw new InvalidatedRefreshTokenError();
64
+ }
65
+ if (refreshTokenState.currentRefreshToken === refreshToken) {
66
+ const nextDeviceKey = this.resolveNextDeviceKey(deviceKey);
67
+ const rotated = await this.authenticationService.generateRefreshToken(user, refreshToken, nextDeviceKey);
68
+ if (nextDeviceKey !== deviceKey) {
69
+ await this.cacheManager.del(this.getKey(user.id, deviceKey));
64
70
  }
65
- else if (refreshTokenState.previousRefreshToken === refreshToken) {
66
- valid = true;
67
- const existingRefreshTokenState = (await this.cacheManager.get(this.getKey(user.id)));
68
- newRefreshToken = existingRefreshTokenState?.currentRefreshToken;
71
+ return { refreshToken: rotated, deviceKey: nextDeviceKey };
72
+ }
73
+ if (refreshTokenState.previousRefreshToken && refreshTokenState.previousRefreshToken === refreshToken) {
74
+ if (!this.isWithinGraceWindow(refreshTokenState)) {
75
+ throw new InvalidatedRefreshTokenError();
69
76
  }
77
+ return { refreshToken: refreshTokenState.currentRefreshToken, deviceKey };
70
78
  }
71
- if (!valid) {
72
- throw new InvalidatedRefreshTokenError();
79
+ throw new InvalidatedRefreshTokenError();
80
+ }
81
+ getCurrentRefreshTokenState(userId, deviceKey) {
82
+ return this.cacheManager.get(this.getKey(userId, deviceKey));
83
+ }
84
+ getKey(userId, deviceKey) {
85
+ return deviceKey ? `user-${userId}-${deviceKey}` : `user-${userId}`;
86
+ }
87
+ getEpochKey(userId) {
88
+ return `user-epoch-${userId}`;
89
+ }
90
+ resolveNextDeviceKey(deviceKey) {
91
+ if (!this.areConcurrentLoginsAllowed()) {
92
+ return undefined;
73
93
  }
74
- return newRefreshToken;
94
+ return deviceKey ?? (0, crypto_1.randomUUID)();
75
95
  }
76
- getKey(userId) {
77
- return `user-${userId}`;
96
+ async isEpochCurrent(userId, epoch) {
97
+ const storedEpoch = await this.getEpoch(userId);
98
+ if (storedEpoch === undefined) {
99
+ return true;
100
+ }
101
+ return epoch === storedEpoch;
102
+ }
103
+ getStateTtlMs() {
104
+ const refreshTokenTtlSeconds = Number(this.settingService.getConfigValue("refreshTokenTtl"));
105
+ return (refreshTokenTtlSeconds + STATE_TTL_BUFFER_SECONDS) * 1000;
78
106
  }
79
- getCurrentRefreshTokenState(userId) {
80
- return this.cacheManager.get(this.getKey(userId));
107
+ isRefreshTokenState(state) {
108
+ return (!!state &&
109
+ typeof state === 'object' &&
110
+ 'currentRefreshToken' in state &&
111
+ 'previousRefreshToken' in state);
112
+ }
113
+ isWithinGraceWindow(state) {
114
+ if (state.previousValidUntil === undefined) {
115
+ return true;
116
+ }
117
+ return Date.now() < state.previousValidUntil;
81
118
  }
82
119
  };
83
120
  exports.RefreshTokenIdsStorageService = RefreshTokenIdsStorageService;
@@ -85,6 +122,7 @@ exports.RefreshTokenIdsStorageService = RefreshTokenIdsStorageService = __decora
85
122
  (0, common_1.Injectable)(),
86
123
  __param(0, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
87
124
  __param(1, (0, common_1.Inject)((0, common_1.forwardRef)(() => authentication_service_1.AuthenticationService))),
88
- __metadata("design:paramtypes", [Object, authentication_service_1.AuthenticationService])
125
+ __metadata("design:paramtypes", [Object, authentication_service_1.AuthenticationService,
126
+ setting_service_1.SettingService])
89
127
  ], RefreshTokenIdsStorageService);
90
128
  //# sourceMappingURL=refresh-token-ids-storage.service.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"refresh-token-ids-storage.service.js","sourceRoot":"","sources":["../../src/services/refresh-token-ids-storage.service.ts"],"names":[],"mappings":"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","sourcesContent":["import { CACHE_MANAGER } from '@nestjs/cache-manager';\nimport { Inject, Injectable, forwardRef } from '@nestjs/common';\nimport { Cache } from 'cache-manager';\nimport { AuthenticationService } from './authentication.service';\n\n// TODO: Ideally this should be in a separate file - putting this here for brevity\nexport class InvalidatedRefreshTokenError extends Error { }\n\ntype RefreshTokenState = {\n currentRefreshToken: string;\n previousRefreshToken: string;\n};\n\n@Injectable()\n// export class RefreshTokenIdsStorageService implements OnApplicationBootstrap, OnApplicationShutdown {\nexport class RefreshTokenIdsStorageService {\n // private redisClient: Redis;\n // onApplicationBootstrap() {\n // // TODO: Ideally, we should move this to the dedicated \"RedisModule\" instead of initiating the connection here.\n // this.redisClient = new Redis({\n // // TODO: According to best practices, we should use the environment variables here instead.\n // host: 'localhost',\n // port: 6379,\n // });\n // }\n // onApplicationShutdown(signal?: string) {\n // return this.redisClient.quit();\n // }\n\n constructor(\n @Inject(CACHE_MANAGER) private cacheManager: Cache,\n @Inject(forwardRef(() => AuthenticationService))\n private readonly authenticationService: AuthenticationService\n ) { }\n\n async insert(userId: number, refreshToken: string, previousRefreshToken?: string): Promise<void> {\n const refreshTokenState: RefreshTokenState = {\n currentRefreshToken: refreshToken,\n previousRefreshToken: previousRefreshToken ?? \"\",\n };\n await this.cacheManager.set(this.getKey(userId), refreshTokenState);\n }\n\n async validate(userId: number, refreshToken: string): Promise<boolean> {\n // TODO: Assume you get this shape out of the cache {\"currentRefreshToken\": \"\", \"previousRefreshToken\": \"\"}\n // Then you will compare against the currentRefreshToken.\n const storedId = await this.cacheManager.get(this.getKey(userId));\n if (storedId !== refreshToken) {\n throw new InvalidatedRefreshTokenError();\n }\n return storedId === refreshToken;\n }\n\n async invalidate(userId: number): Promise<void> {\n await this.cacheManager.del(this.getKey(userId));\n }\n\n async validateAndRotate(user: any, refreshToken: string): Promise<string> {\n let valid = false;\n\n // TODO: Assume you get this shape out of the cache {\"currentRefreshToken\": \"\", \"previousRefreshToken\": \"\"}\n // Then you will compare against the currentRefreshToken.\n const refreshTokenState = await this.cacheManager.get(this.getKey(user.id)) as RefreshTokenState | undefined;\n\n // Use the authentication service to generate a new refresh token, set it in the currentRefreshToken in scenario 1 and return.\n\n // if UI.refresh_token is matching with Cache.currentRefreshToken\n // then invalidate (updated cache state, no need to delete anything), then generate new token and return.\n // also set a setTimeout to run after X minutes, this will simply update the RefreshTokenCacheState to this object {\"currentRefreshToken\": \"R2\",\"justInvalidatedRefreshToken\": \"\"}\n // valid=true\n\n // - if UI.refresh_token is matching Cache.justInvalidatedRefreshToken \n // then use the Cache.currentRefreshToken, generate new access token and return.\n // We do not modify the cache state at all.\n // valid=true\n\n let newRefreshToken: string | undefined;\n if (\n refreshTokenState &&\n typeof refreshTokenState === 'object' &&\n 'currentRefreshToken' in refreshTokenState &&\n 'previousRefreshToken' in refreshTokenState\n ) {\n if (refreshTokenState.currentRefreshToken === refreshToken) {\n // Scenario 1: Token matches currentRefreshToken\n valid = true;\n // Rotate tokens: move current to previous, set new current (simulate generation)\n newRefreshToken = await this.authenticationService.generateRefreshToken(user, refreshTokenState.currentRefreshToken); // Replace with real token generation logic\n\n\n // updated cache state\n // await this.cacheManager.set(this.getKey(user.id), {\n // currentRefreshToken: newRefreshToken,\n // previousRefreshToken: refreshTokenState.currentRefreshToken,\n // });\n\n // Optionally, set a timeout to clear previousRefreshToken after X minutes\n setTimeout(async () => {\n const state = (await this.cacheManager.get(this.getKey(user.id))) as any;\n if (state && state.currentRefreshToken === newRefreshToken) {\n await this.cacheManager.set(this.getKey(user.id), {\n currentRefreshToken: newRefreshToken,\n previousRefreshToken: \"\",\n });\n }\n }, 1 * 60 * 1000); // 5 minutes\n } else if (refreshTokenState.previousRefreshToken === refreshToken) {\n // Scenario 2: Token matches previousRefreshToken\n valid = true;\n // Do not modify cache\n // Generate new refresh token based on currentRefreshToken\n const existingRefreshTokenState = (await this.cacheManager.get(this.getKey(user.id))) as RefreshTokenState | undefined;\n newRefreshToken = existingRefreshTokenState?.currentRefreshToken;\n }\n }\n\n\n if (!valid) {\n throw new InvalidatedRefreshTokenError();\n }\n\n // TODO: return the refresh token either currentRefreshToken\n return newRefreshToken; // Fallback to the provided tokenId if no new token was generated\n }\n\n private getKey(userId: number): string {\n return `user-${userId}`;\n }\n\n getCurrentRefreshTokenState(userId: number): Promise<RefreshTokenState | undefined> {\n return this.cacheManager.get(this.getKey(userId));\n }\n}\n"]}
1
+ {"version":3,"file":"refresh-token-ids-storage.service.js","sourceRoot":"","sources":["../../src/services/refresh-token-ids-storage.service.ts"],"names":[],"mappings":"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","sourcesContent":["import { CACHE_MANAGER } from '@nestjs/cache-manager';\nimport { Inject, Injectable, forwardRef } from '@nestjs/common';\nimport { Cache } from 'cache-manager';\nimport { randomUUID } from 'crypto';\nimport type { SolidCoreSetting } from 'src/services/settings/default-settings-provider.service';\nimport { AuthenticationService } from './authentication.service';\nimport { SettingService } from './setting.service';\n\n// TODO: Ideally this should be in a separate file - putting this here for brevity\nexport class InvalidatedRefreshTokenError extends Error { }\n\n// How long a rotated-out refresh token stays acceptable. This window is the\n// point of keeping a previous token at all: concurrent requests from one user\n// must not break each other. core-ui's single-flight guard is module-level and\n// therefore per tab, so two tabs crossing the refresh threshold together will\n// both present the same token - the second one is served from here.\nconst PREVIOUS_TOKEN_GRACE_MS = 60 * 1000;\n\n// The cache entry must outlive the JWT it holds. If it expired first, refresh\n// would fail with ACCESS_DENIED (InvalidatedRefreshTokenError) rather than the\n// SESSION_EXPIRED that the token's own `exp` produces - the same event\n// reported two different ways.\nconst STATE_TTL_BUFFER_SECONDS = 60;\n\ntype RefreshTokenState = {\n currentRefreshToken: string;\n previousRefreshToken: string;\n // Absolute epoch-ms deadline after which previousRefreshToken is refused.\n // Stored rather than scheduled: an in-process timer is lost when the pod\n // restarts, which used to leave the rotated-out token valid until the next\n // rotation instead of for one minute. Optional because entries written\n // before this field existed will not carry it.\n previousValidUntil?: number;\n};\n\n/**\n * The bucket-identifying claims carried on a refresh token. Absent on tokens\n * issued before per-device sessions existed, and on tokens issued while\n * preventConcurrentLogins is on - which is exactly how the read path tells the\n * two schemes apart without consulting any setting.\n */\nexport type RefreshTokenClaims = {\n deviceKey?: string;\n epoch?: number;\n};\n\nexport type RotatedRefreshToken = {\n refreshToken: string;\n // The bucket the rotated token now lives in. Differs from the incoming\n // deviceKey only when a pre-migration token was just moved into one.\n deviceKey?: string;\n};\n\n@Injectable()\nexport class RefreshTokenIdsStorageService {\n constructor(\n @Inject(CACHE_MANAGER) private cacheManager: Cache,\n @Inject(forwardRef(() => AuthenticationService))\n private readonly authenticationService: AuthenticationService,\n private readonly settingService: SettingService,\n ) { }\n\n /**\n * The inverse of the `preventConcurrentLogins` setting, and the only place\n * it is interpreted.\n *\n * That setting already promises, when off, that sessions may coexist;\n * splitting the refresh-token keyspace per device is what finally delivers\n * it. When it is on, its stated purpose is a single live session, so\n * per-device keys would be meaningless and the original single slot is kept.\n *\n * This governs the WRITE path only. Reads always branch on the token's own\n * claim, so toggling the setting never invalidates a live session.\n */\n areConcurrentLoginsAllowed(): boolean {\n return !this.settingService.getConfigValue<SolidCoreSetting>(\"preventConcurrentLogins\");\n }\n\n async insert(\n userId: number,\n refreshToken: string,\n previousRefreshToken?: string,\n deviceKey?: string,\n ): Promise<void> {\n const refreshTokenState: RefreshTokenState = {\n currentRefreshToken: refreshToken,\n previousRefreshToken: previousRefreshToken ?? \"\",\n ...(previousRefreshToken\n ? { previousValidUntil: Date.now() + PREVIOUS_TOKEN_GRACE_MS }\n : {}),\n };\n await this.cacheManager.set(\n this.getKey(userId, deviceKey),\n refreshTokenState,\n this.getStateTtlMs(),\n );\n }\n\n async invalidate(userId: number, deviceKey?: string): Promise<void> {\n await this.cacheManager.del(this.getKey(userId, deviceKey));\n }\n\n /**\n * Bulk invalidation - \"log out everywhere\", and the right hook for password\n * change or forced deactivation.\n *\n * Per-device buckets cannot be enumerated through cache-manager, so this\n * bumps a per-user epoch that every per-device token carries as a claim.\n * One write invalidates every outstanding token without a scan and without\n * the read-modify-write race an index of device keys would have.\n */\n async invalidateAll(userId: number): Promise<void> {\n const currentEpoch = (await this.getEpoch(userId)) ?? 0;\n await this.cacheManager.set(\n this.getEpochKey(userId),\n currentEpoch + 1,\n // Outlives every bucket that could hold a pre-bump token: those were\n // written earlier, so they expire no later than this key does.\n this.getStateTtlMs(),\n );\n // Single-slot mode and pre-migration entries live under the bare key,\n // which carries no epoch claim - delete it directly.\n await this.cacheManager.del(this.getKey(userId));\n }\n\n async getEpoch(userId: number): Promise<number | undefined> {\n return (await this.cacheManager.get<number>(this.getEpochKey(userId))) ?? undefined;\n }\n\n async validateAndRotate(\n user: any,\n refreshToken: string,\n claims: RefreshTokenClaims = {},\n ): Promise<RotatedRefreshToken> {\n const { deviceKey, epoch } = claims;\n\n // The read key comes from the token's own claim, never from a setting.\n // A token issued under either scheme therefore stays valid when\n // preventConcurrentLogins is toggled - in both directions - and a\n // pre-migration token still finds the bare key it was written to.\n const refreshTokenState = await this.cacheManager.get(\n this.getKey(user.id, deviceKey),\n ) as RefreshTokenState | undefined;\n\n if (!this.isRefreshTokenState(refreshTokenState)) {\n throw new InvalidatedRefreshTokenError();\n }\n\n if (deviceKey && !(await this.isEpochCurrent(user.id, epoch))) {\n throw new InvalidatedRefreshTokenError();\n }\n\n // Scenario 1: the live token. Rotate it. generateRefreshToken calls\n // insert(), which writes the new state and stamps the grace deadline\n // onto the token being rotated out.\n if (refreshTokenState.currentRefreshToken === refreshToken) {\n const nextDeviceKey = this.resolveNextDeviceKey(deviceKey);\n const rotated = await this.authenticationService.generateRefreshToken(\n user,\n refreshToken,\n nextDeviceKey,\n );\n\n if (nextDeviceKey !== deviceKey) {\n // The session changed buckets - either migrating out of the\n // pre-deploy single slot, or collapsing back into it because\n // preventConcurrentLogins was switched on. The new state is\n // already written; drop the key it came from so nothing is\n // left behind.\n await this.cacheManager.del(this.getKey(user.id, deviceKey));\n }\n\n return { refreshToken: rotated, deviceKey: nextDeviceKey };\n }\n\n // Scenario 2: the token just rotated out. This is the concurrent-request\n // case the previous slot exists for - a second in-flight request that\n // was issued the old token before the first one rotated it. Hand back\n // the live token so it succeeds, provided the grace window is still open.\n if (refreshTokenState.previousRefreshToken && refreshTokenState.previousRefreshToken === refreshToken) {\n if (!this.isWithinGraceWindow(refreshTokenState)) {\n throw new InvalidatedRefreshTokenError();\n }\n return { refreshToken: refreshTokenState.currentRefreshToken, deviceKey };\n }\n\n throw new InvalidatedRefreshTokenError();\n }\n\n getCurrentRefreshTokenState(\n userId: number,\n deviceKey?: string,\n ): Promise<RefreshTokenState | undefined> {\n return this.cacheManager.get(this.getKey(userId, deviceKey));\n }\n\n private getKey(userId: number, deviceKey?: string): string {\n return deviceKey ? `user-${userId}-${deviceKey}` : `user-${userId}`;\n }\n\n // Deliberately not `user-${userId}-epoch`, which a device key of the\n // literal string \"epoch\" would collide with.\n private getEpochKey(userId: number): string {\n return `user-epoch-${userId}`;\n }\n\n /**\n * Which bucket a rotated token should land in. The setting is consulted\n * first so that turning preventConcurrentLogins ON collapses existing\n * per-device sessions back into the single slot on their next refresh -\n * where normal last-login-wins applies, which is what that setting means.\n * Leaving them in their own buckets would keep concurrent sessions alive\n * while the setting claimed to forbid them.\n */\n private resolveNextDeviceKey(deviceKey?: string): string | undefined {\n if (!this.areConcurrentLoginsAllowed()) {\n return undefined;\n }\n return deviceKey ?? randomUUID();\n }\n\n private async isEpochCurrent(userId: number, epoch?: number): Promise<boolean> {\n const storedEpoch = await this.getEpoch(userId);\n // Nothing has ever been bulk-invalidated for this user - or the cache\n // was flushed, in which case every bucket is gone and the token is\n // already dead by the read above. Either way, accept.\n if (storedEpoch === undefined) {\n return true;\n }\n return epoch === storedEpoch;\n }\n\n // cache-manager v5 expects milliseconds; refreshTokenTtl is configured in\n // seconds. Without an explicit TTL these entries never expire - neither\n // cache path supplies a default - and the keyspace grows without bound.\n private getStateTtlMs(): number {\n const refreshTokenTtlSeconds = Number(\n this.settingService.getConfigValue<SolidCoreSetting>(\"refreshTokenTtl\"),\n );\n return (refreshTokenTtlSeconds + STATE_TTL_BUFFER_SECONDS) * 1000;\n }\n\n private isRefreshTokenState(state: unknown): state is RefreshTokenState {\n return (\n !!state &&\n typeof state === 'object' &&\n 'currentRefreshToken' in state &&\n 'previousRefreshToken' in state\n );\n }\n\n private isWithinGraceWindow(state: RefreshTokenState): boolean {\n // TRANSITIONAL - safe to delete one refreshTokenTtl after this ships.\n //\n // Entries written before previousValidUntil existed carry no deadline\n // to test. They are accepted so that the deploy rejects nobody: a tab\n // that legitimately rotated moments earlier keeps working. The cost is\n // that for such an entry the previous token stays acceptable until its\n // own JWT `exp` rather than for 60s - which is exactly how the old code\n // already behaved whenever its in-process timer was lost to a restart,\n // so this is an unfixed pre-existing case, not a new one.\n //\n // It self-heals: the user's next rotation writes a new-format state\n // with a deadline. Once every pre-deploy entry has rotated or expired\n // (one refreshTokenTtl), this branch is unreachable - drop it, and the\n // undefined case becomes a rejection.\n if (state.previousValidUntil === undefined) {\n return true;\n }\n return Date.now() < state.previousValidUntil;\n }\n}\n"]}