@babylonjs-toolkit/server 0.0.0-stage → 9.35.0

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 (236) hide show
  1. package/CUSTOM.md +1690 -0
  2. package/DEPLOY.md +2012 -0
  3. package/README.md +1081 -2
  4. package/lib/AbuseLimits.d.ts +64 -0
  5. package/lib/AbuseLimits.js +121 -0
  6. package/lib/Authentication.d.ts +86 -0
  7. package/lib/Authentication.js +272 -0
  8. package/lib/BootSessionOwner.d.ts +86 -0
  9. package/lib/BootSessionOwner.js +116 -0
  10. package/lib/ColyseusLoggerAdapter.d.ts +32 -0
  11. package/lib/ColyseusLoggerAdapter.js +73 -0
  12. package/lib/DedicatedSceneRegistry.d.ts +52 -0
  13. package/lib/DedicatedSceneRegistry.js +74 -0
  14. package/lib/DedicatedSession.d.ts +152 -0
  15. package/lib/DedicatedSession.js +173 -0
  16. package/lib/DualTransport.d.ts +112 -0
  17. package/lib/DualTransport.js +197 -0
  18. package/lib/GameServer.d.ts +64 -0
  19. package/lib/GameServer.js +647 -0
  20. package/lib/GameServerConfig.d.ts +387 -0
  21. package/lib/GameServerConfig.js +1059 -0
  22. package/lib/GameServerLobbyRoom.d.ts +33 -0
  23. package/lib/GameServerLobbyRoom.js +56 -0
  24. package/lib/GameServerLogger.d.ts +111 -0
  25. package/lib/GameServerLogger.js +190 -0
  26. package/lib/GameServerMetrics.d.ts +158 -0
  27. package/lib/GameServerMetrics.js +276 -0
  28. package/lib/GameServerState.d.ts +164 -0
  29. package/lib/GameServerState.js +26 -0
  30. package/lib/HeadlessRuntime.d.ts +316 -0
  31. package/lib/HeadlessRuntime.js +380 -0
  32. package/lib/JoinGate.d.ts +34 -0
  33. package/lib/JoinGate.js +221 -0
  34. package/lib/NetworkAddresses.d.ts +33 -0
  35. package/lib/NetworkAddresses.js +90 -0
  36. package/lib/PlayerTokens.d.ts +17 -0
  37. package/lib/PlayerTokens.js +24 -0
  38. package/lib/RoomServerTransport.d.ts +73 -0
  39. package/lib/RoomServerTransport.js +90 -0
  40. package/lib/ServerEndpoints.d.ts +133 -0
  41. package/lib/ServerEndpoints.js +247 -0
  42. package/lib/ServerInit.d.ts +28 -0
  43. package/lib/ServerInit.js +113 -0
  44. package/lib/SessionRoom.d.ts +391 -0
  45. package/lib/SessionRoom.js +930 -0
  46. package/lib/TlsCertificates.d.ts +35 -0
  47. package/lib/TlsCertificates.js +130 -0
  48. package/lib/admin/AccountRoutes.d.ts +8 -0
  49. package/lib/admin/AccountRoutes.js +502 -0
  50. package/lib/admin/AdminAccounts.d.ts +329 -0
  51. package/lib/admin/AdminAccounts.js +607 -0
  52. package/lib/admin/AdminApiRouter.d.ts +202 -0
  53. package/lib/admin/AdminApiRouter.js +465 -0
  54. package/lib/admin/AdminApiTypes.d.ts +1782 -0
  55. package/lib/admin/AdminApiTypes.js +4 -0
  56. package/lib/admin/AdminAudit.d.ts +113 -0
  57. package/lib/admin/AdminAudit.js +266 -0
  58. package/lib/admin/AdminCli.d.ts +7 -0
  59. package/lib/admin/AdminCli.js +351 -0
  60. package/lib/admin/AdminClock.d.ts +51 -0
  61. package/lib/admin/AdminClock.js +10 -0
  62. package/lib/admin/AdminConsole.d.ts +39 -0
  63. package/lib/admin/AdminConsole.js +325 -0
  64. package/lib/admin/AdminConstants.d.ts +147 -0
  65. package/lib/admin/AdminConstants.js +167 -0
  66. package/lib/admin/AdminCrypto.d.ts +19 -0
  67. package/lib/admin/AdminCrypto.js +29 -0
  68. package/lib/admin/AdminExtensionsHost.d.ts +176 -0
  69. package/lib/admin/AdminExtensionsHost.js +307 -0
  70. package/lib/admin/AdminPasswords.d.ts +33 -0
  71. package/lib/admin/AdminPasswords.js +92 -0
  72. package/lib/admin/AdminPermissions.d.ts +28 -0
  73. package/lib/admin/AdminPermissions.js +74 -0
  74. package/lib/admin/AdminRecords.d.ts +196 -0
  75. package/lib/admin/AdminRecords.js +3 -0
  76. package/lib/admin/AdminRequestBodies.d.ts +47 -0
  77. package/lib/admin/AdminRequestBodies.js +84 -0
  78. package/lib/admin/AdminRequestGate.d.ts +38 -0
  79. package/lib/admin/AdminRequestGate.js +59 -0
  80. package/lib/admin/AdminResponses.d.ts +103 -0
  81. package/lib/admin/AdminResponses.js +164 -0
  82. package/lib/admin/AdminServices.d.ts +99 -0
  83. package/lib/admin/AdminServices.js +4 -0
  84. package/lib/admin/AdminStaticFiles.d.ts +29 -0
  85. package/lib/admin/AdminStaticFiles.js +97 -0
  86. package/lib/admin/AdminStore.d.ts +152 -0
  87. package/lib/admin/AdminStore.js +46 -0
  88. package/lib/admin/AlertChannels.d.ts +43 -0
  89. package/lib/admin/AlertChannels.js +120 -0
  90. package/lib/admin/AlertEngine.d.ts +231 -0
  91. package/lib/admin/AlertEngine.js +681 -0
  92. package/lib/admin/AlertRoutes.d.ts +8 -0
  93. package/lib/admin/AlertRoutes.js +180 -0
  94. package/lib/admin/ApiKeyRoutes.d.ts +8 -0
  95. package/lib/admin/ApiKeyRoutes.js +53 -0
  96. package/lib/admin/ApiKeys.d.ts +63 -0
  97. package/lib/admin/ApiKeys.js +167 -0
  98. package/lib/admin/AuditRoutes.d.ts +8 -0
  99. package/lib/admin/AuditRoutes.js +106 -0
  100. package/lib/admin/AuthToolRoutes.d.ts +13 -0
  101. package/lib/admin/AuthToolRoutes.js +164 -0
  102. package/lib/admin/BanList.d.ts +87 -0
  103. package/lib/admin/BanList.js +152 -0
  104. package/lib/admin/CborDecoder.d.ts +18 -0
  105. package/lib/admin/CborDecoder.js +201 -0
  106. package/lib/admin/ColyseusCloudClient.d.ts +313 -0
  107. package/lib/admin/ColyseusCloudClient.js +272 -0
  108. package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
  109. package/lib/admin/ColyseusCloudRoutes.js +91 -0
  110. package/lib/admin/ColyseusCloudService.d.ts +180 -0
  111. package/lib/admin/ColyseusCloudService.js +476 -0
  112. package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
  113. package/lib/admin/ColyseusCloudSettings.js +29 -0
  114. package/lib/admin/ColyseusCloudViews.d.ts +105 -0
  115. package/lib/admin/ColyseusCloudViews.js +371 -0
  116. package/lib/admin/CommonPasswords.d.ts +6 -0
  117. package/lib/admin/CommonPasswords.js +19 -0
  118. package/lib/admin/ConfigOverrides.d.ts +174 -0
  119. package/lib/admin/ConfigOverrides.js +406 -0
  120. package/lib/admin/ConfigRoutes.d.ts +8 -0
  121. package/lib/admin/ConfigRoutes.js +91 -0
  122. package/lib/admin/ConsoleSessions.d.ts +112 -0
  123. package/lib/admin/ConsoleSessions.js +201 -0
  124. package/lib/admin/DedicatedRoutes.d.ts +21 -0
  125. package/lib/admin/DedicatedRoutes.js +254 -0
  126. package/lib/admin/ExtensionRoutes.d.ts +8 -0
  127. package/lib/admin/ExtensionRoutes.js +145 -0
  128. package/lib/admin/FileAdminStore.d.ts +157 -0
  129. package/lib/admin/FileAdminStore.js +349 -0
  130. package/lib/admin/FleetDirectory.d.ts +323 -0
  131. package/lib/admin/FleetDirectory.js +489 -0
  132. package/lib/admin/FleetLeader.d.ts +43 -0
  133. package/lib/admin/FleetLeader.js +89 -0
  134. package/lib/admin/FleetRoutes.d.ts +55 -0
  135. package/lib/admin/FleetRoutes.js +465 -0
  136. package/lib/admin/HealthWarnings.d.ts +10 -0
  137. package/lib/admin/HealthWarnings.js +95 -0
  138. package/lib/admin/LeaderboardRoutes.d.ts +8 -0
  139. package/lib/admin/LeaderboardRoutes.js +569 -0
  140. package/lib/admin/LogBuffer.d.ts +60 -0
  141. package/lib/admin/LogBuffer.js +92 -0
  142. package/lib/admin/LogRedaction.d.ts +15 -0
  143. package/lib/admin/LogRedaction.js +72 -0
  144. package/lib/admin/LogRoutes.d.ts +45 -0
  145. package/lib/admin/LogRoutes.js +307 -0
  146. package/lib/admin/MaintenanceMode.d.ts +41 -0
  147. package/lib/admin/MaintenanceMode.js +74 -0
  148. package/lib/admin/MaintenanceWindows.d.ts +91 -0
  149. package/lib/admin/MaintenanceWindows.js +207 -0
  150. package/lib/admin/MetricsRoutes.d.ts +8 -0
  151. package/lib/admin/MetricsRoutes.js +209 -0
  152. package/lib/admin/MetricsSampler.d.ts +124 -0
  153. package/lib/admin/MetricsSampler.js +211 -0
  154. package/lib/admin/OneTimeCodes.d.ts +60 -0
  155. package/lib/admin/OneTimeCodes.js +162 -0
  156. package/lib/admin/OpenIdConnect.d.ts +146 -0
  157. package/lib/admin/OpenIdConnect.js +335 -0
  158. package/lib/admin/Passkeys.d.ts +130 -0
  159. package/lib/admin/Passkeys.js +432 -0
  160. package/lib/admin/PlayerRoutes.d.ts +20 -0
  161. package/lib/admin/PlayerRoutes.js +676 -0
  162. package/lib/admin/RedisAdminStore.d.ts +114 -0
  163. package/lib/admin/RedisAdminStore.js +258 -0
  164. package/lib/admin/RolloutCoordinator.d.ts +99 -0
  165. package/lib/admin/RolloutCoordinator.js +321 -0
  166. package/lib/admin/ServerEvents.d.ts +57 -0
  167. package/lib/admin/ServerEvents.js +89 -0
  168. package/lib/admin/SessionRoutes.d.ts +113 -0
  169. package/lib/admin/SessionRoutes.js +386 -0
  170. package/lib/admin/UserRoutes.d.ts +8 -0
  171. package/lib/admin/UserRoutes.js +214 -0
  172. package/lib/cli.d.ts +2 -0
  173. package/lib/cli.js +221 -0
  174. package/lib/console/assets/index-CI5N7c-T.js +21 -0
  175. package/lib/console/assets/index-DPitvzLp.css +1 -0
  176. package/lib/console/favicon.svg +1 -0
  177. package/lib/console/index.html +16 -0
  178. package/lib/discord/DiscordOAuth.d.ts +57 -0
  179. package/lib/discord/DiscordOAuth.js +127 -0
  180. package/lib/discord/DiscordSessions.d.ts +79 -0
  181. package/lib/discord/DiscordSessions.js +135 -0
  182. package/lib/discord/DiscordSignInConstants.d.ts +54 -0
  183. package/lib/discord/DiscordSignInConstants.js +55 -0
  184. package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
  185. package/lib/discord/DiscordSignInRoutes.js +359 -0
  186. package/lib/index.d.ts +24 -0
  187. package/lib/index.js +18 -0
  188. package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
  189. package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
  190. package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
  191. package/lib/leaderboards/LeaderboardConstants.js +70 -0
  192. package/lib/leaderboards/LeaderboardError.d.ts +15 -0
  193. package/lib/leaderboards/LeaderboardError.js +20 -0
  194. package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
  195. package/lib/leaderboards/LeaderboardIdentity.js +72 -0
  196. package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
  197. package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
  198. package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
  199. package/lib/leaderboards/LeaderboardRankStore.js +18 -0
  200. package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
  201. package/lib/leaderboards/LeaderboardRules.js +472 -0
  202. package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
  203. package/lib/leaderboards/LeaderboardScheduler.js +103 -0
  204. package/lib/leaderboards/LeaderboardService.d.ts +669 -0
  205. package/lib/leaderboards/LeaderboardService.js +1667 -0
  206. package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
  207. package/lib/leaderboards/LeaderboardTransfer.js +580 -0
  208. package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
  209. package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
  210. package/package.json +64 -4
  211. package/templates/init/Caddyfile +6 -0
  212. package/templates/init/DEPLOY.md +233 -0
  213. package/templates/init/Dockerfile +12 -0
  214. package/templates/init/app.json +21 -0
  215. package/templates/init/aws/README.md +118 -0
  216. package/templates/init/aws/ecs-fargate-alb.yml +207 -0
  217. package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
  218. package/templates/init/babylon-game-server.config.mjs +158 -0
  219. package/templates/init/colyseus-cloud/README.md +133 -0
  220. package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
  221. package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
  222. package/templates/init/colyseus-cloud/gitignore +6 -0
  223. package/templates/init/colyseus-cloud/package.json +15 -0
  224. package/templates/init/digitalocean-app.yaml +30 -0
  225. package/templates/init/docker-compose.full.yml +45 -0
  226. package/templates/init/docker-compose.websocket.yml +41 -0
  227. package/templates/init/fly.toml +56 -0
  228. package/templates/init/heroku.yml +11 -0
  229. package/templates/init/kubernetes/deployment.yaml +71 -0
  230. package/templates/init/kubernetes/redis.yaml +73 -0
  231. package/templates/init/kubernetes/service.yaml +24 -0
  232. package/templates/init/monitoring/grafana-dashboard.json +539 -0
  233. package/templates/init/monitoring/prometheus-alerts.yml +51 -0
  234. package/templates/init/monitoring/prometheus-scrape.yml +17 -0
  235. package/templates/init/railway.json +13 -0
  236. package/templates/init/render.yaml +19 -0
@@ -0,0 +1,607 @@
1
+ // Admin accounts (game-server-admin-console D8, D30, DR §7.1, §7.2): the first-owner setup link, account creation,
2
+ // the two-factor sign-in with lockout, authenticator enrolment, recovery codes and step-up checks.
3
+ import { getLogger } from "../GameServerLogger.js";
4
+ import { AdminRequestError } from "./AdminApiRouter.js";
5
+ import { ACCOUNT_LOCKOUT_FAILURES, ACCOUNT_LOCKOUT_WINDOW_MS, ADDRESS_SIGN_IN_FAILURES, CONSOLE_BASE_PATH, PENDING_SIGN_IN_LIFETIME_MS, SETUP_LINK_LIFETIME_MS, SIGN_IN_DELAY_MAX_MS, SIGN_IN_DELAY_STEP_MS } from "./AdminConstants.js";
6
+ import { randomToken, sha256Hex, textsMatch } from "./AdminCrypto.js";
7
+ import { hashPassword, passwordProblem, timingEqualizerPasswordHash, verifyPassword } from "./AdminPasswords.js";
8
+ import { buildOtpauthUri, consumeRecoveryCode, generateRecoveryCodes, generateTotpSecret, verifyTotp } from "./OneTimeCodes.js";
9
+ /** HTTP statuses of account refusals. */
10
+ const HTTP_BAD_REQUEST = 400;
11
+ const HTTP_UNAUTHORIZED = 401;
12
+ const HTTP_NOT_FOUND = 404;
13
+ const HTTP_CONFLICT = 409;
14
+ const HTTP_LOCKED = 423;
15
+ const HTTP_TOO_MANY_REQUESTS = 429;
16
+ /** Usernames: lower case letters, digits, dot, underscore and dash, 3 to 32 characters. */
17
+ const USERNAME_PATTERN = /^[a-z0-9._-]{3,32}$/;
18
+ /** Random bytes of a user id. */
19
+ const USER_ID_BYTES = 16;
20
+ /** Random bytes of a setup token and a pending sign-in token. */
21
+ const TOKEN_BYTES = 32;
22
+ /** Preferences of a new account. */
23
+ const DEFAULT_PREFERENCES = { theme: "system", timeZone: "local" };
24
+ /** The answer of every failed password check (the same for an unknown user). */
25
+ const WRONG_CREDENTIALS_MESSAGE = "wrong username or password";
26
+ /** The answer of a locked account (R33). */
27
+ const LOCKED_MESSAGE = "this account is locked; try again later";
28
+ /** The answer of a used, expired or wrong setup token (R33). */
29
+ const INVALID_SETUP_MESSAGE = "this setup link is not valid";
30
+ /** The answer of a wrong, reused or missing second factor. */
31
+ const WRONG_CODE_MESSAGE = "wrong or already used code";
32
+ /** The answer of an unknown or expired pending sign-in. */
33
+ const SIGN_IN_EXPIRED_MESSAGE = "this sign-in has expired; sign in again";
34
+ /** The admin accounts of the store. */
35
+ export class AdminAccounts {
36
+ /** The admin store. */
37
+ store;
38
+ /** The clock of expiries and lockouts. */
39
+ clock;
40
+ /** The console sessions. */
41
+ sessions;
42
+ /** Whether the console is on (a setup link is offered only then). */
43
+ consoleEnabled;
44
+ /** The pending first-owner setup, or null. */
45
+ setupLink = null;
46
+ /** Told when an account is locked. */
47
+ lockoutListener = null;
48
+ /**
49
+ * Creates the account service.
50
+ * @param store The admin store.
51
+ * @param clock The clock.
52
+ * @param sessions The console sessions.
53
+ * @param consoleEnabled admin.console.
54
+ */
55
+ constructor(store, clock, sessions, consoleEnabled) {
56
+ this.store = store;
57
+ this.clock = clock;
58
+ this.sessions = sessions;
59
+ this.consoleEnabled = consoleEnabled;
60
+ }
61
+ /**
62
+ * Sets who is told when an account is locked (the audit log).
63
+ * @param listener The listener.
64
+ */
65
+ onLockout(listener) {
66
+ this.lockoutListener = listener;
67
+ }
68
+ /**
69
+ * Offers a one-time first-owner setup link when no account exists (D30, DR §7.1): the link is logged once at warn
70
+ * level and only its hash is kept. On Redis the hash is stored as well as held here, because behind a load balancer
71
+ * the request that uses the link can reach any process, and on Colyseus Cloud there is no shell to create an owner
72
+ * from. A file store has one process, so a first start writes nothing to the data folder.
73
+ * @param urls Where the server is reached.
74
+ */
75
+ async ensureSetupLink(urls) {
76
+ if (!this.consoleEnabled || await this.hasUsers())
77
+ return;
78
+ const setupToken = randomToken(TOKEN_BYTES);
79
+ this.setupLink = { tokenHash: sha256Hex(setupToken), expiresAtMilliseconds: this.clock.now() + SETUP_LINK_LIFETIME_MS };
80
+ if (this.store.kind === "redis")
81
+ await this.store.putDocument("setupLinks", this.setupLink.tokenHash, this.setupLink, null);
82
+ getLogger().warn("admin setup link", { url: urls.http + CONSOLE_BASE_PATH + "#/setup?token=" + setupToken });
83
+ }
84
+ /**
85
+ * Whether a first-owner setup link is waiting to be used.
86
+ * @returns True until it is used or expires.
87
+ */
88
+ hasPendingSetup() {
89
+ return this.setupLink != null && this.clock.now() < this.setupLink.expiresAtMilliseconds;
90
+ }
91
+ /**
92
+ * Whether any account exists.
93
+ * @returns True when the users collection is not empty.
94
+ */
95
+ async hasUsers() {
96
+ return (await this.store.listDocuments("users")).length > 0;
97
+ }
98
+ /**
99
+ * Whether an enabled owner other than this account exists (DR §5: the last enabled owner is never removed, demoted
100
+ * or disabled — by the users routes or by a single sign-on role mapping).
101
+ * @param ownerId The owner that would lose the role.
102
+ * @returns True when another enabled owner remains.
103
+ */
104
+ async hasAnotherEnabledOwner(ownerId) {
105
+ const users = await this.store.listDocuments("users");
106
+ return users.some((stored) => stored.value.role === "owner" && !stored.value.disabled && stored.id !== ownerId);
107
+ }
108
+ /**
109
+ * Creates the first owner from the setup link (single use, DR §7.1).
110
+ * @param request The setup token and the owner's details.
111
+ * @returns The enrolment step of the owner's first sign-in.
112
+ * @throws AdminRequestError 404 for a used, expired or wrong token; 400 for an invalid username or password.
113
+ */
114
+ async createOwnerFromSetup(request) {
115
+ const tokenHash = sha256Hex(request.setupToken);
116
+ if (!await this.isSetupLinkLive(tokenHash) || await this.hasUsers())
117
+ throw new AdminRequestError(HTTP_NOT_FOUND, INVALID_SETUP_MESSAGE);
118
+ const owner = await this.createUser({ username: request.username, displayName: request.displayName, email: null, role: "owner", password: request.password, oidcSubject: null });
119
+ this.setupLink = null;
120
+ await this.forgetSetupLinks();
121
+ return this.startPendingSignIn(owner, "enrolSecondFactor");
122
+ }
123
+ /**
124
+ * Whether a token hash names a live setup link of this process or, in the store, of any other. The store is looked
125
+ * up by the hash, so a wrong token finds nothing.
126
+ * @param tokenHash The hash of the token offered.
127
+ * @returns True for a live link.
128
+ */
129
+ async isSetupLinkLive(tokenHash) {
130
+ if (this.setupLink != null && this.hasPendingSetup() && textsMatch(tokenHash, this.setupLink.tokenHash))
131
+ return true;
132
+ if (this.store.kind !== "redis")
133
+ return false;
134
+ const stored = await this.store.getDocument("setupLinks", tokenHash);
135
+ return stored != null && this.clock.now() < stored.value.expiresAtMilliseconds;
136
+ }
137
+ /** Removes every process's setup link once the first owner exists: one owner ends them all. */
138
+ async forgetSetupLinks() {
139
+ if (this.store.kind !== "redis")
140
+ return;
141
+ const links = await this.store.listDocuments("setupLinks");
142
+ await Promise.all(links.map((link) => this.store.deleteDocument("setupLinks", link.id)));
143
+ }
144
+ /**
145
+ * Creates an account (setup, the CLI and the users routes).
146
+ * @param input The account.
147
+ * @returns The stored account.
148
+ * @throws AdminRequestError 400 for an invalid username or password, 409 when the username is taken.
149
+ */
150
+ async createUser(input) {
151
+ const username = input.username.trim().toLowerCase();
152
+ if (!USERNAME_PATTERN.test(username))
153
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "the username must be 3 to 32 lower-case letters, digits, dots, dashes or underscores");
154
+ if (await this.findByUsername(username) != null)
155
+ throw new AdminRequestError(HTTP_CONFLICT, "this username is taken");
156
+ if (input.password != null) {
157
+ const problem = passwordProblem(input.password, username);
158
+ if (problem != null)
159
+ throw new AdminRequestError(HTTP_BAD_REQUEST, problem);
160
+ }
161
+ const now = this.nowIso();
162
+ const user = {
163
+ userId: randomToken(USER_ID_BYTES),
164
+ username: username,
165
+ displayName: input.displayName.trim() !== "" ? input.displayName.trim() : username,
166
+ email: input.email,
167
+ role: input.role,
168
+ passwordHash: (input.password != null) ? await hashPassword(input.password) : null,
169
+ totpSecret: null,
170
+ lastUsedTotpStep: 0,
171
+ recoveryCodeHashes: [],
172
+ disabled: false,
173
+ lockedUntil: null,
174
+ oidcSubject: input.oidcSubject,
175
+ preferences: Object.assign({}, DEFAULT_PREFERENCES),
176
+ createdAt: now,
177
+ passwordChangedAt: now,
178
+ lastSignInAt: null
179
+ };
180
+ await this.store.putDocument("users", user.userId, user, 0);
181
+ return user;
182
+ }
183
+ /**
184
+ * Reads an account.
185
+ * @param userId The account id.
186
+ * @returns The stored account, or null.
187
+ */
188
+ getUser(userId) {
189
+ return this.store.getDocument("users", userId);
190
+ }
191
+ /**
192
+ * Finds an account by username.
193
+ * @param username The username (any case).
194
+ * @returns The stored account, or null.
195
+ */
196
+ async findByUsername(username) {
197
+ const wanted = username.trim().toLowerCase();
198
+ const users = await this.store.listDocuments("users");
199
+ return users.find((stored) => stored.value.username === wanted) ?? null;
200
+ }
201
+ /**
202
+ * The first sign-in step (DR §7.2 step 1): the address limit, the account state and the password.
203
+ * @param request The username and password.
204
+ * @param context Where the sign-in comes from.
205
+ * @returns The next step (second factor or enrolment).
206
+ * @throws AdminRequestError 429 for a noisy address, 423 for a locked account, 401 for wrong credentials.
207
+ */
208
+ async beginSignIn(request, context) {
209
+ const addressFailures = await this.recentFailures(addressFailureId(context.clientAddress));
210
+ if (addressFailures >= ADDRESS_SIGN_IN_FAILURES)
211
+ throw new AdminRequestError(HTTP_TOO_MANY_REQUESTS, "too many failed sign-ins from this address; try again later");
212
+ const stored = await this.findByUsername(request.username);
213
+ if (stored != null && this.isLocked(stored.value))
214
+ throw new AdminRequestError(HTTP_LOCKED, LOCKED_MESSAGE);
215
+ const user = stored?.value ?? null;
216
+ const accountHash = (user != null && !user.disabled) ? user.passwordHash : null;
217
+ // An unknown, disabled or password-less account still pays one scrypt, so timing never reveals a username.
218
+ const hashMatches = await verifyPassword(request.password, accountHash ?? await timingEqualizerPasswordHash());
219
+ const passwordMatches = accountHash != null && hashMatches;
220
+ if (user == null || !passwordMatches) {
221
+ await this.recordFailure(user, context.clientAddress);
222
+ throw new AdminRequestError(HTTP_UNAUTHORIZED, WRONG_CREDENTIALS_MESSAGE);
223
+ }
224
+ return this.startPendingSignIn(user, (user.totpSecret != null) ? "secondFactor" : "enrolSecondFactor");
225
+ }
226
+ /**
227
+ * Offers an authenticator secret to a sign-in that must enrol one (the only answer that shows a secret).
228
+ * @param pendingToken The pending sign-in.
229
+ * @returns The secret and its otpauth URI.
230
+ * @throws AdminRequestError 401 for an unknown or expired sign-in.
231
+ */
232
+ async beginEnrolment(pendingToken) {
233
+ const pending = await this.requirePendingSignIn(pendingToken, "enrolSecondFactor");
234
+ const user = await this.requireUser(pending.value.userId);
235
+ const secret = generateTotpSecret();
236
+ await this.store.putDocument("pendingSignIns", pending.id, Object.assign({}, pending.value, { pendingTotpSecret: secret }), pending.revision);
237
+ return { secret: secret, otpauthUri: buildOtpauthUri(user.value.username, secret) };
238
+ }
239
+ /**
240
+ * Confirms an enrolment with a code from the new secret and signs in (DR §7.2 step 3).
241
+ * @param pendingToken The pending sign-in.
242
+ * @param code The authenticator code.
243
+ * @param context Where the sign-in comes from.
244
+ * @returns The session and the account's new recovery codes.
245
+ * @throws AdminRequestError 401 for an unknown sign-in or a wrong code.
246
+ */
247
+ async completeEnrolment(pendingToken, code, context) {
248
+ const pending = await this.requirePendingSignIn(pendingToken, "enrolSecondFactor");
249
+ const secret = pending.value.pendingTotpSecret;
250
+ const stored = await this.requireUser(pending.value.userId);
251
+ const check = (secret != null) ? verifyTotp(secret, code, 0, this.clock.now()) : { ok: false, step: 0 };
252
+ if (secret == null || !check.ok) {
253
+ await this.recordFailure(stored.value, context.clientAddress);
254
+ throw new AdminRequestError(HTTP_UNAUTHORIZED, WRONG_CODE_MESSAGE);
255
+ }
256
+ const recovery = generateRecoveryCodes();
257
+ const enrolled = Object.assign({}, stored.value, { totpSecret: secret, lastUsedTotpStep: check.step, recoveryCodeHashes: recovery.hashes });
258
+ return this.finishSignIn(pending.id, stored, enrolled, context, recovery.codes);
259
+ }
260
+ /**
261
+ * Checks the second factor of a sign-in and signs in (DR §7.2 step 3).
262
+ * @param pendingToken The pending sign-in.
263
+ * @param proof An authenticator code or a recovery code.
264
+ * @param context Where the sign-in comes from.
265
+ * @returns The session.
266
+ * @throws AdminRequestError 401 for an unknown sign-in or a wrong, reused code.
267
+ */
268
+ async completeSecondFactor(pendingToken, proof, context) {
269
+ const pending = await this.requirePendingSignIn(pendingToken, "secondFactor");
270
+ const stored = await this.requireUser(pending.value.userId);
271
+ const verified = this.applySecondFactor(stored.value, proof);
272
+ if (verified == null) {
273
+ await this.recordFailure(stored.value, context.clientAddress);
274
+ throw new AdminRequestError(HTTP_UNAUTHORIZED, WRONG_CODE_MESSAGE);
275
+ }
276
+ return this.finishSignIn(pending.id, stored, verified, context, []);
277
+ }
278
+ /**
279
+ * Starts the second-factor step of a single sign-on whose provider gave no multi-factor proof (D26): the account's
280
+ * authenticator code is asked, or enrolled when it has none.
281
+ * @param user The account the provider signed in.
282
+ * @returns The step answer with its pending token.
283
+ */
284
+ startSingleSignOnSecondFactor(user) {
285
+ return this.startPendingSignIn(user, (user.totpSecret != null) ? "secondFactor" : "enrolSecondFactor", true);
286
+ }
287
+ /**
288
+ * Signs in an account whose sign-in already proved both factors: a single sign-on with a multi-factor proof (D26) or
289
+ * a user-verified passkey (D31).
290
+ * @param userId The account.
291
+ * @param context Where the sign-in comes from.
292
+ * @returns The session.
293
+ * @throws AdminRequestError 404 for an unknown account, 423 for a locked one, 401 for a disabled one.
294
+ */
295
+ async signInVerified(userId, context) {
296
+ const stored = await this.requireUsableUser(userId);
297
+ return this.finishSignIn(null, stored, stored.value, context, []);
298
+ }
299
+ /**
300
+ * Completes a password sign-in with a passkey as its second factor (D31).
301
+ * @param pendingToken The pending sign-in of the password step.
302
+ * @param userId The account the passkey belongs to.
303
+ * @param context Where the sign-in comes from.
304
+ * @returns The session.
305
+ * @throws AdminRequestError 401 for an unknown or expired sign-in or a passkey of another account.
306
+ */
307
+ async completeSecondFactorWithPasskey(pendingToken, userId, context) {
308
+ const pending = await this.requirePendingSignIn(pendingToken, null);
309
+ if (pending.value.userId !== userId)
310
+ throw new AdminRequestError(HTTP_UNAUTHORIZED, SIGN_IN_EXPIRED_MESSAGE);
311
+ const stored = await this.requireUsableUser(userId);
312
+ return this.finishSignIn(pending.id, stored, stored.value, context, []);
313
+ }
314
+ /**
315
+ * Re-checks the second factor of a signed-in admin, so dangerous actions are allowed for STEP_UP_WINDOW_MS.
316
+ * @param caller The signed-in caller.
317
+ * @param proof An authenticator code or a recovery code.
318
+ * @returns The session with its new secondFactorAt.
319
+ * @throws AdminRequestError 401 for a wrong code or a caller without a session.
320
+ */
321
+ async stepUp(caller, proof) {
322
+ if (caller.session == null || caller.user == null)
323
+ throw new AdminRequestError(HTTP_UNAUTHORIZED, "sign in first");
324
+ const stored = await this.requireUser(caller.user.userId);
325
+ const verified = this.applySecondFactor(stored.value, proof);
326
+ if (verified == null)
327
+ throw new AdminRequestError(HTTP_UNAUTHORIZED, WRONG_CODE_MESSAGE);
328
+ await this.store.putDocument("users", stored.id, verified, stored.revision);
329
+ const session = await this.sessions.markSecondFactor(caller.session.sessionId);
330
+ if (session == null)
331
+ throw new AdminRequestError(HTTP_UNAUTHORIZED, "sign in first");
332
+ return session;
333
+ }
334
+ /**
335
+ * Changes a signed-in admin's password.
336
+ * @param userId The account.
337
+ * @param currentPassword The current password.
338
+ * @param newPassword The new password.
339
+ * @throws AdminRequestError 401 for a wrong current password, 400 for a weak new one.
340
+ */
341
+ async changePassword(userId, currentPassword, newPassword) {
342
+ const stored = await this.requireUser(userId);
343
+ const currentMatches = stored.value.passwordHash != null && await verifyPassword(currentPassword, stored.value.passwordHash);
344
+ if (!currentMatches)
345
+ throw new AdminRequestError(HTTP_UNAUTHORIZED, "the current password is wrong");
346
+ await this.setPassword(stored, newPassword);
347
+ }
348
+ /**
349
+ * Sets a new password without the old one (CLI recovery, admin reset).
350
+ * @param stored The account.
351
+ * @param newPassword The new password.
352
+ * @throws AdminRequestError 400 for a weak password.
353
+ */
354
+ async setPassword(stored, newPassword) {
355
+ const problem = passwordProblem(newPassword, stored.value.username);
356
+ if (problem != null)
357
+ throw new AdminRequestError(HTTP_BAD_REQUEST, problem);
358
+ const updated = Object.assign({}, stored.value, { passwordHash: await hashPassword(newPassword), passwordChangedAt: this.nowIso() });
359
+ await this.store.putDocument("users", stored.id, updated, stored.revision);
360
+ }
361
+ /**
362
+ * Replaces an account's recovery codes.
363
+ * @param userId The account.
364
+ * @returns The new codes (shown once).
365
+ */
366
+ async regenerateRecoveryCodes(userId) {
367
+ const stored = await this.requireUser(userId);
368
+ const recovery = generateRecoveryCodes();
369
+ await this.store.putDocument("users", stored.id, Object.assign({}, stored.value, { recoveryCodeHashes: recovery.hashes }), stored.revision);
370
+ return recovery.codes;
371
+ }
372
+ /**
373
+ * Saves an account's console preferences.
374
+ * @param userId The account.
375
+ * @param preferences The preferences.
376
+ * @returns The stored account.
377
+ */
378
+ async updatePreferences(userId, preferences) {
379
+ const stored = await this.requireUser(userId);
380
+ return this.store.putDocument("users", stored.id, Object.assign({}, stored.value, { preferences: preferences }), stored.revision);
381
+ }
382
+ /**
383
+ * Clears an account's lock and failure count.
384
+ * @param userId The account.
385
+ */
386
+ async unlock(userId) {
387
+ const stored = await this.requireUser(userId);
388
+ await this.store.putDocument("users", stored.id, Object.assign({}, stored.value, { lockedUntil: null }), stored.revision);
389
+ await this.store.deleteDocument("signInFailures", accountFailureId(userId));
390
+ }
391
+ /**
392
+ * Removes an account's authenticator and recovery codes; its next sign-in enrols a new one.
393
+ * @param userId The account.
394
+ */
395
+ async resetSecondFactor(userId) {
396
+ const stored = await this.requireUser(userId);
397
+ await this.store.putDocument("users", stored.id, Object.assign({}, stored.value, { totpSecret: null, lastUsedTotpStep: 0, recoveryCodeHashes: [] }), stored.revision);
398
+ }
399
+ /**
400
+ * The console's view of an account (never its secrets).
401
+ * @param stored The stored account.
402
+ * @returns The view.
403
+ */
404
+ toUserView(stored) {
405
+ const user = stored.value;
406
+ return {
407
+ userId: user.userId,
408
+ username: user.username,
409
+ displayName: user.displayName,
410
+ email: user.email,
411
+ role: user.role,
412
+ disabled: user.disabled,
413
+ locked: this.isLocked(user),
414
+ hasSecondFactor: user.totpSecret != null,
415
+ createdAt: user.createdAt,
416
+ lastSignInAt: user.lastSignInAt,
417
+ preferences: user.preferences,
418
+ revision: stored.revision
419
+ };
420
+ }
421
+ /**
422
+ * Whether an account is locked now.
423
+ * @param user The account.
424
+ * @returns True while lockedUntil is in the future.
425
+ */
426
+ isLocked(user) {
427
+ return user.lockedUntil != null && Date.parse(user.lockedUntil) > this.clock.now();
428
+ }
429
+ /**
430
+ * Checks a second factor against an account.
431
+ * @param user The account.
432
+ * @param proof A code (its step must be newer than lastUsedTotpStep) or a recovery code (used up).
433
+ * @returns The account with the code recorded as used, or null when the proof is wrong.
434
+ */
435
+ applySecondFactor(user, proof) {
436
+ if ("recoveryCode" in proof) {
437
+ const remaining = consumeRecoveryCode(proof.recoveryCode, user.recoveryCodeHashes);
438
+ return (remaining != null) ? Object.assign({}, user, { recoveryCodeHashes: remaining }) : null;
439
+ }
440
+ if (user.totpSecret == null)
441
+ return null;
442
+ const check = verifyTotp(user.totpSecret, proof.code, user.lastUsedTotpStep, this.clock.now());
443
+ return check.ok ? Object.assign({}, user, { lastUsedTotpStep: check.step }) : null;
444
+ }
445
+ /**
446
+ * Ends a sign-in: stores the account, creates the session, deletes the pending record and clears the failures.
447
+ * @param pendingId The pending sign-in document id, or null for a sign-in without one.
448
+ * @param stored The account as read.
449
+ * @param updated The account with the used code recorded.
450
+ * @param context Where the sign-in comes from.
451
+ * @param recoveryCodes New recovery codes to show, or none.
452
+ * @returns The session.
453
+ */
454
+ async finishSignIn(pendingId, stored, updated, context, recoveryCodes) {
455
+ const signedIn = Object.assign({}, updated, { lastSignInAt: this.nowIso() });
456
+ // The revision check makes a code that two requests send at once work only once.
457
+ await this.store.putDocument("users", stored.id, signedIn, stored.revision);
458
+ const created = await this.sessions.create(signedIn, context);
459
+ if (pendingId != null)
460
+ await this.store.deleteDocument("pendingSignIns", pendingId);
461
+ await this.store.deleteDocument("signInFailures", accountFailureId(signedIn.userId));
462
+ return { session: created.record, cookieValue: created.cookieValue, recoveryCodes: recoveryCodes, user: signedIn };
463
+ }
464
+ /**
465
+ * Stores a sign-in waiting for its second factor.
466
+ * @param user The account.
467
+ * @param stage What it waits for.
468
+ * @param viaSingleSignOn True when the first factor was a single sign-on.
469
+ * @returns The step answer with the pending token.
470
+ */
471
+ async startPendingSignIn(user, stage, viaSingleSignOn = false) {
472
+ const pendingToken = randomToken(TOKEN_BYTES);
473
+ const record = { userId: user.userId, stage: stage, pendingTotpSecret: null, expiresAt: new Date(this.clock.now() + PENDING_SIGN_IN_LIFETIME_MS).toISOString(), viaSingleSignOn: viaSingleSignOn };
474
+ await this.store.putDocument("pendingSignIns", sha256Hex(pendingToken), record, 0);
475
+ return { stage: stage, pendingToken: pendingToken };
476
+ }
477
+ /**
478
+ * Reads a live pending sign-in of a stage.
479
+ * @param pendingToken The token.
480
+ * @param stage The stage the call needs, or null for either stage.
481
+ * @returns The stored pending sign-in.
482
+ * @throws AdminRequestError 401 when it is unknown, expired or at another stage.
483
+ */
484
+ async requirePendingSignIn(pendingToken, stage) {
485
+ const stored = await this.store.getDocument("pendingSignIns", sha256Hex(pendingToken));
486
+ const isAtStage = stored != null && (stage == null || stored.value.stage === stage);
487
+ const isLive = stored != null && isAtStage && Date.parse(stored.value.expiresAt) > this.clock.now();
488
+ if (stored == null || !isLive)
489
+ throw new AdminRequestError(HTTP_UNAUTHORIZED, SIGN_IN_EXPIRED_MESSAGE);
490
+ return stored;
491
+ }
492
+ /**
493
+ * Reads an account that must exist.
494
+ * @param userId The account id.
495
+ * @returns The stored account.
496
+ * @throws AdminRequestError 404 when it does not exist.
497
+ */
498
+ async requireUser(userId) {
499
+ const stored = await this.getUser(userId);
500
+ if (stored == null)
501
+ throw new AdminRequestError(HTTP_NOT_FOUND, "no such admin user");
502
+ return stored;
503
+ }
504
+ /**
505
+ * Reads an account that may sign in now.
506
+ * @param userId The account id.
507
+ * @returns The stored account.
508
+ * @throws AdminRequestError 404 when it does not exist, 401 when it is disabled, 423 while it is locked.
509
+ */
510
+ async requireUsableUser(userId) {
511
+ const stored = await this.requireUser(userId);
512
+ if (stored.value.disabled)
513
+ throw new AdminRequestError(HTTP_UNAUTHORIZED, "this account is disabled");
514
+ if (this.isLocked(stored.value))
515
+ throw new AdminRequestError(HTTP_LOCKED, LOCKED_MESSAGE);
516
+ return stored;
517
+ }
518
+ /**
519
+ * Counts a failed sign-in for the address and the account, locks the account at ACCOUNT_LOCKOUT_FAILURES, then waits
520
+ * the growing failure delay (DR §7.2).
521
+ * @param user The account, or null for an unknown username.
522
+ * @param clientAddress The client address.
523
+ */
524
+ async recordFailure(user, clientAddress) {
525
+ const addressFailures = await this.addFailure(addressFailureId(clientAddress));
526
+ let accountFailures = 0;
527
+ if (user != null) {
528
+ accountFailures = await this.addFailure(accountFailureId(user.userId));
529
+ if (accountFailures >= ACCOUNT_LOCKOUT_FAILURES)
530
+ await this.lock(user, clientAddress);
531
+ }
532
+ const recentFailures = Math.max(addressFailures, accountFailures);
533
+ await this.delay(Math.min(SIGN_IN_DELAY_STEP_MS * recentFailures, SIGN_IN_DELAY_MAX_MS));
534
+ }
535
+ /**
536
+ * Locks an account for ACCOUNT_LOCKOUT_WINDOW_MS and tells the lockout listener.
537
+ * @param user The account.
538
+ * @param clientAddress The address of the last failure.
539
+ */
540
+ async lock(user, clientAddress) {
541
+ const stored = await this.getUser(user.userId);
542
+ if (stored == null)
543
+ return;
544
+ const lockedUntil = new Date(this.clock.now() + ACCOUNT_LOCKOUT_WINDOW_MS).toISOString();
545
+ await this.store.putDocument("users", stored.id, Object.assign({}, stored.value, { lockedUntil: lockedUntil }), null);
546
+ await this.store.deleteDocument("signInFailures", accountFailureId(user.userId));
547
+ this.lockoutListener?.(stored.value, clientAddress);
548
+ }
549
+ /**
550
+ * Adds one failure to a counter, dropping failures older than the lockout window.
551
+ * @param failureId "account:<userId>" or "address:<address>".
552
+ * @returns The failures within the window, this one included.
553
+ */
554
+ async addFailure(failureId) {
555
+ const now = this.clock.now();
556
+ const stored = await this.store.getDocument("signInFailures", failureId);
557
+ const failures = this.withinWindow(stored?.value.failures ?? [], now).concat(new Date(now).toISOString());
558
+ await this.store.putDocument("signInFailures", failureId, { failures: failures }, null);
559
+ return failures.length;
560
+ }
561
+ /**
562
+ * The failures of a counter within the lockout window.
563
+ * @param failureId The counter.
564
+ * @returns How many.
565
+ */
566
+ async recentFailures(failureId) {
567
+ const stored = await this.store.getDocument("signInFailures", failureId);
568
+ return this.withinWindow(stored?.value.failures ?? [], this.clock.now()).length;
569
+ }
570
+ /**
571
+ * Keeps the failure times within ACCOUNT_LOCKOUT_WINDOW_MS.
572
+ * @param failures ISO times.
573
+ * @param now The current time, in milliseconds.
574
+ * @returns The recent ones.
575
+ */
576
+ withinWindow(failures, now) {
577
+ return failures.filter((failedAt) => now - Date.parse(failedAt) < ACCOUNT_LOCKOUT_WINDOW_MS);
578
+ }
579
+ /**
580
+ * Waits on the admin clock.
581
+ * @param milliseconds How long.
582
+ * @returns Resolves after the wait.
583
+ */
584
+ delay(milliseconds) {
585
+ return new Promise((resolve) => this.clock.setTimeout(resolve, milliseconds));
586
+ }
587
+ /**
588
+ * @returns The clock's now as an ISO time.
589
+ */
590
+ nowIso() {
591
+ return new Date(this.clock.now()).toISOString();
592
+ }
593
+ }
594
+ /**
595
+ * @param userId An account id.
596
+ * @returns Its failure counter id.
597
+ */
598
+ function accountFailureId(userId) {
599
+ return "account:" + userId;
600
+ }
601
+ /**
602
+ * @param clientAddress A client address.
603
+ * @returns Its failure counter id.
604
+ */
605
+ function addressFailureId(clientAddress) {
606
+ return "address:" + clientAddress;
607
+ }