@babylonjs-toolkit/server 0.0.0-stage → 9.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CUSTOM.md +1690 -0
- package/DEPLOY.md +2012 -0
- package/README.md +1081 -2
- package/lib/AbuseLimits.d.ts +64 -0
- package/lib/AbuseLimits.js +121 -0
- package/lib/Authentication.d.ts +86 -0
- package/lib/Authentication.js +272 -0
- package/lib/BootSessionOwner.d.ts +86 -0
- package/lib/BootSessionOwner.js +116 -0
- package/lib/ColyseusLoggerAdapter.d.ts +32 -0
- package/lib/ColyseusLoggerAdapter.js +73 -0
- package/lib/DedicatedSceneRegistry.d.ts +52 -0
- package/lib/DedicatedSceneRegistry.js +74 -0
- package/lib/DedicatedSession.d.ts +152 -0
- package/lib/DedicatedSession.js +173 -0
- package/lib/DualTransport.d.ts +112 -0
- package/lib/DualTransport.js +197 -0
- package/lib/GameServer.d.ts +64 -0
- package/lib/GameServer.js +647 -0
- package/lib/GameServerConfig.d.ts +387 -0
- package/lib/GameServerConfig.js +1059 -0
- package/lib/GameServerLobbyRoom.d.ts +33 -0
- package/lib/GameServerLobbyRoom.js +56 -0
- package/lib/GameServerLogger.d.ts +111 -0
- package/lib/GameServerLogger.js +190 -0
- package/lib/GameServerMetrics.d.ts +158 -0
- package/lib/GameServerMetrics.js +276 -0
- package/lib/GameServerState.d.ts +164 -0
- package/lib/GameServerState.js +26 -0
- package/lib/HeadlessRuntime.d.ts +316 -0
- package/lib/HeadlessRuntime.js +380 -0
- package/lib/JoinGate.d.ts +34 -0
- package/lib/JoinGate.js +221 -0
- package/lib/NetworkAddresses.d.ts +33 -0
- package/lib/NetworkAddresses.js +90 -0
- package/lib/PlayerTokens.d.ts +17 -0
- package/lib/PlayerTokens.js +24 -0
- package/lib/RoomServerTransport.d.ts +73 -0
- package/lib/RoomServerTransport.js +90 -0
- package/lib/ServerEndpoints.d.ts +133 -0
- package/lib/ServerEndpoints.js +247 -0
- package/lib/ServerInit.d.ts +28 -0
- package/lib/ServerInit.js +113 -0
- package/lib/SessionRoom.d.ts +391 -0
- package/lib/SessionRoom.js +930 -0
- package/lib/TlsCertificates.d.ts +35 -0
- package/lib/TlsCertificates.js +130 -0
- package/lib/admin/AccountRoutes.d.ts +8 -0
- package/lib/admin/AccountRoutes.js +502 -0
- package/lib/admin/AdminAccounts.d.ts +329 -0
- package/lib/admin/AdminAccounts.js +607 -0
- package/lib/admin/AdminApiRouter.d.ts +202 -0
- package/lib/admin/AdminApiRouter.js +465 -0
- package/lib/admin/AdminApiTypes.d.ts +1782 -0
- package/lib/admin/AdminApiTypes.js +4 -0
- package/lib/admin/AdminAudit.d.ts +113 -0
- package/lib/admin/AdminAudit.js +266 -0
- package/lib/admin/AdminCli.d.ts +7 -0
- package/lib/admin/AdminCli.js +351 -0
- package/lib/admin/AdminClock.d.ts +51 -0
- package/lib/admin/AdminClock.js +10 -0
- package/lib/admin/AdminConsole.d.ts +39 -0
- package/lib/admin/AdminConsole.js +325 -0
- package/lib/admin/AdminConstants.d.ts +147 -0
- package/lib/admin/AdminConstants.js +167 -0
- package/lib/admin/AdminCrypto.d.ts +19 -0
- package/lib/admin/AdminCrypto.js +29 -0
- package/lib/admin/AdminExtensionsHost.d.ts +176 -0
- package/lib/admin/AdminExtensionsHost.js +307 -0
- package/lib/admin/AdminPasswords.d.ts +33 -0
- package/lib/admin/AdminPasswords.js +92 -0
- package/lib/admin/AdminPermissions.d.ts +28 -0
- package/lib/admin/AdminPermissions.js +74 -0
- package/lib/admin/AdminRecords.d.ts +196 -0
- package/lib/admin/AdminRecords.js +3 -0
- package/lib/admin/AdminRequestBodies.d.ts +47 -0
- package/lib/admin/AdminRequestBodies.js +84 -0
- package/lib/admin/AdminRequestGate.d.ts +38 -0
- package/lib/admin/AdminRequestGate.js +59 -0
- package/lib/admin/AdminResponses.d.ts +103 -0
- package/lib/admin/AdminResponses.js +164 -0
- package/lib/admin/AdminServices.d.ts +99 -0
- package/lib/admin/AdminServices.js +4 -0
- package/lib/admin/AdminStaticFiles.d.ts +29 -0
- package/lib/admin/AdminStaticFiles.js +97 -0
- package/lib/admin/AdminStore.d.ts +152 -0
- package/lib/admin/AdminStore.js +46 -0
- package/lib/admin/AlertChannels.d.ts +43 -0
- package/lib/admin/AlertChannels.js +120 -0
- package/lib/admin/AlertEngine.d.ts +231 -0
- package/lib/admin/AlertEngine.js +681 -0
- package/lib/admin/AlertRoutes.d.ts +8 -0
- package/lib/admin/AlertRoutes.js +180 -0
- package/lib/admin/ApiKeyRoutes.d.ts +8 -0
- package/lib/admin/ApiKeyRoutes.js +53 -0
- package/lib/admin/ApiKeys.d.ts +63 -0
- package/lib/admin/ApiKeys.js +167 -0
- package/lib/admin/AuditRoutes.d.ts +8 -0
- package/lib/admin/AuditRoutes.js +106 -0
- package/lib/admin/AuthToolRoutes.d.ts +13 -0
- package/lib/admin/AuthToolRoutes.js +164 -0
- package/lib/admin/BanList.d.ts +87 -0
- package/lib/admin/BanList.js +152 -0
- package/lib/admin/CborDecoder.d.ts +18 -0
- package/lib/admin/CborDecoder.js +201 -0
- package/lib/admin/ColyseusCloudClient.d.ts +313 -0
- package/lib/admin/ColyseusCloudClient.js +272 -0
- package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
- package/lib/admin/ColyseusCloudRoutes.js +91 -0
- package/lib/admin/ColyseusCloudService.d.ts +180 -0
- package/lib/admin/ColyseusCloudService.js +476 -0
- package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
- package/lib/admin/ColyseusCloudSettings.js +29 -0
- package/lib/admin/ColyseusCloudViews.d.ts +105 -0
- package/lib/admin/ColyseusCloudViews.js +371 -0
- package/lib/admin/CommonPasswords.d.ts +6 -0
- package/lib/admin/CommonPasswords.js +19 -0
- package/lib/admin/ConfigOverrides.d.ts +174 -0
- package/lib/admin/ConfigOverrides.js +406 -0
- package/lib/admin/ConfigRoutes.d.ts +8 -0
- package/lib/admin/ConfigRoutes.js +91 -0
- package/lib/admin/ConsoleSessions.d.ts +112 -0
- package/lib/admin/ConsoleSessions.js +201 -0
- package/lib/admin/DedicatedRoutes.d.ts +21 -0
- package/lib/admin/DedicatedRoutes.js +254 -0
- package/lib/admin/ExtensionRoutes.d.ts +8 -0
- package/lib/admin/ExtensionRoutes.js +145 -0
- package/lib/admin/FileAdminStore.d.ts +157 -0
- package/lib/admin/FileAdminStore.js +349 -0
- package/lib/admin/FleetDirectory.d.ts +323 -0
- package/lib/admin/FleetDirectory.js +489 -0
- package/lib/admin/FleetLeader.d.ts +43 -0
- package/lib/admin/FleetLeader.js +89 -0
- package/lib/admin/FleetRoutes.d.ts +55 -0
- package/lib/admin/FleetRoutes.js +465 -0
- package/lib/admin/HealthWarnings.d.ts +10 -0
- package/lib/admin/HealthWarnings.js +95 -0
- package/lib/admin/LeaderboardRoutes.d.ts +8 -0
- package/lib/admin/LeaderboardRoutes.js +569 -0
- package/lib/admin/LogBuffer.d.ts +60 -0
- package/lib/admin/LogBuffer.js +92 -0
- package/lib/admin/LogRedaction.d.ts +15 -0
- package/lib/admin/LogRedaction.js +72 -0
- package/lib/admin/LogRoutes.d.ts +45 -0
- package/lib/admin/LogRoutes.js +307 -0
- package/lib/admin/MaintenanceMode.d.ts +41 -0
- package/lib/admin/MaintenanceMode.js +74 -0
- package/lib/admin/MaintenanceWindows.d.ts +91 -0
- package/lib/admin/MaintenanceWindows.js +207 -0
- package/lib/admin/MetricsRoutes.d.ts +8 -0
- package/lib/admin/MetricsRoutes.js +209 -0
- package/lib/admin/MetricsSampler.d.ts +124 -0
- package/lib/admin/MetricsSampler.js +211 -0
- package/lib/admin/OneTimeCodes.d.ts +60 -0
- package/lib/admin/OneTimeCodes.js +162 -0
- package/lib/admin/OpenIdConnect.d.ts +146 -0
- package/lib/admin/OpenIdConnect.js +335 -0
- package/lib/admin/Passkeys.d.ts +130 -0
- package/lib/admin/Passkeys.js +432 -0
- package/lib/admin/PlayerRoutes.d.ts +20 -0
- package/lib/admin/PlayerRoutes.js +676 -0
- package/lib/admin/RedisAdminStore.d.ts +114 -0
- package/lib/admin/RedisAdminStore.js +258 -0
- package/lib/admin/RolloutCoordinator.d.ts +99 -0
- package/lib/admin/RolloutCoordinator.js +321 -0
- package/lib/admin/ServerEvents.d.ts +57 -0
- package/lib/admin/ServerEvents.js +89 -0
- package/lib/admin/SessionRoutes.d.ts +113 -0
- package/lib/admin/SessionRoutes.js +386 -0
- package/lib/admin/UserRoutes.d.ts +8 -0
- package/lib/admin/UserRoutes.js +214 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +221 -0
- package/lib/console/assets/index-CI5N7c-T.js +21 -0
- package/lib/console/assets/index-DPitvzLp.css +1 -0
- package/lib/console/favicon.svg +1 -0
- package/lib/console/index.html +16 -0
- package/lib/discord/DiscordOAuth.d.ts +57 -0
- package/lib/discord/DiscordOAuth.js +127 -0
- package/lib/discord/DiscordSessions.d.ts +79 -0
- package/lib/discord/DiscordSessions.js +135 -0
- package/lib/discord/DiscordSignInConstants.d.ts +54 -0
- package/lib/discord/DiscordSignInConstants.js +55 -0
- package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
- package/lib/discord/DiscordSignInRoutes.js +359 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +18 -0
- package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
- package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
- package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
- package/lib/leaderboards/LeaderboardConstants.js +70 -0
- package/lib/leaderboards/LeaderboardError.d.ts +15 -0
- package/lib/leaderboards/LeaderboardError.js +20 -0
- package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
- package/lib/leaderboards/LeaderboardIdentity.js +72 -0
- package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
- package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
- package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
- package/lib/leaderboards/LeaderboardRankStore.js +18 -0
- package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
- package/lib/leaderboards/LeaderboardRules.js +472 -0
- package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
- package/lib/leaderboards/LeaderboardScheduler.js +103 -0
- package/lib/leaderboards/LeaderboardService.d.ts +669 -0
- package/lib/leaderboards/LeaderboardService.js +1667 -0
- package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
- package/lib/leaderboards/LeaderboardTransfer.js +580 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
- package/package.json +64 -4
- package/templates/init/Caddyfile +6 -0
- package/templates/init/DEPLOY.md +233 -0
- package/templates/init/Dockerfile +12 -0
- package/templates/init/app.json +21 -0
- package/templates/init/aws/README.md +118 -0
- package/templates/init/aws/ecs-fargate-alb.yml +207 -0
- package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
- package/templates/init/babylon-game-server.config.mjs +158 -0
- package/templates/init/colyseus-cloud/README.md +133 -0
- package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
- package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
- package/templates/init/colyseus-cloud/gitignore +6 -0
- package/templates/init/colyseus-cloud/package.json +15 -0
- package/templates/init/digitalocean-app.yaml +30 -0
- package/templates/init/docker-compose.full.yml +45 -0
- package/templates/init/docker-compose.websocket.yml +41 -0
- package/templates/init/fly.toml +56 -0
- package/templates/init/heroku.yml +11 -0
- package/templates/init/kubernetes/deployment.yaml +71 -0
- package/templates/init/kubernetes/redis.yaml +73 -0
- package/templates/init/kubernetes/service.yaml +24 -0
- package/templates/init/monitoring/grafana-dashboard.json +539 -0
- package/templates/init/monitoring/prometheus-alerts.yml +51 -0
- package/templates/init/monitoring/prometheus-scrape.yml +17 -0
- package/templates/init/railway.json +13 -0
- package/templates/init/render.yaml +19 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { TlsConfig } from "./GameServerConfig.js";
|
|
2
|
+
/** A certificate and its private key, as PEM text. */
|
|
3
|
+
export interface TlsMaterial {
|
|
4
|
+
/** The certificate chain (PEM). */
|
|
5
|
+
cert: string;
|
|
6
|
+
/** The private key (PEM). */
|
|
7
|
+
key: string;
|
|
8
|
+
}
|
|
9
|
+
/** Milliseconds between checks of the certificate files for a renewal (D9). */
|
|
10
|
+
export declare const TLS_RELOAD_POLL_MS: number;
|
|
11
|
+
/**
|
|
12
|
+
* The HTTPS certificate and key the server uses (D9): the PEM pair, else the files, else the development certificate
|
|
13
|
+
* made by mkcert. With UDP on, a missing mkcert is not an error: the HTTP/3 transport's own pinned development
|
|
14
|
+
* certificate then serves HTTPS too (null).
|
|
15
|
+
* @param tls The TLS settings.
|
|
16
|
+
* @param udpEnabled True when the HTTP/3 transport runs.
|
|
17
|
+
* @returns The material, or null when TLS is off or (UDP on, no mkcert) the HTTP/3 certificate serves HTTPS.
|
|
18
|
+
* @throws Error(MKCERT_MISSING_MESSAGE) when the development certificate needs mkcert and it is not installed.
|
|
19
|
+
*/
|
|
20
|
+
export declare function loadTlsMaterial(tls: TlsConfig, udpEnabled: boolean): Promise<TlsMaterial | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Polls the certificate files for a renewal every TLS_RELOAD_POLL_MS and hands changed material to a callback (D9).
|
|
23
|
+
* Only file-based TLS is watched; PEM text and the development certificate never change while running.
|
|
24
|
+
* @param tls The TLS settings.
|
|
25
|
+
* @param onChange Receives the renewed certificate and key.
|
|
26
|
+
*/
|
|
27
|
+
export declare function watchTlsFiles(tls: TlsConfig, onChange: (material: TlsMaterial) => void): void;
|
|
28
|
+
/**
|
|
29
|
+
* Re-reads the watched certificate files now and hands them to the watcher's callback when they changed (tests and
|
|
30
|
+
* the file poll call it).
|
|
31
|
+
* @returns True when the material changed and the callback ran.
|
|
32
|
+
*/
|
|
33
|
+
export declare function reloadTlsNow(): Promise<boolean>;
|
|
34
|
+
/** Stops polling the certificate files and forgets the callback (close). */
|
|
35
|
+
export declare function stopWatchingTlsFiles(): void;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unwatchFile, watchFile } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { getLogger } from "./GameServerLogger.js";
|
|
8
|
+
/** Milliseconds between checks of the certificate files for a renewal (D9). */
|
|
9
|
+
export const TLS_RELOAD_POLL_MS = 30000;
|
|
10
|
+
/** Folder under the OS temp folder the development certificate is cached in (D9). */
|
|
11
|
+
const DEVELOPMENT_CERTIFICATE_FOLDER = "babylon-game-server-dev-cert";
|
|
12
|
+
/** The development certificate's file name. */
|
|
13
|
+
const DEVELOPMENT_CERT_FILE = "cert.pem";
|
|
14
|
+
/** The development certificate's key file name. */
|
|
15
|
+
const DEVELOPMENT_KEY_FILE = "key.pem";
|
|
16
|
+
/** Names the development certificate is valid for. */
|
|
17
|
+
const DEVELOPMENT_CERTIFICATE_NAMES = ["localhost", "127.0.0.1", "::1"];
|
|
18
|
+
/** The mkcert executable. */
|
|
19
|
+
const MKCERT_COMMAND = "mkcert";
|
|
20
|
+
/** Error code of a command that is not installed. */
|
|
21
|
+
const COMMAND_NOT_FOUND_CODE = "ENOENT";
|
|
22
|
+
/** The startup error when the development certificate needs mkcert and it is missing (D9). */
|
|
23
|
+
const MKCERT_MISSING_MESSAGE = "install mkcert (brew install mkcert && mkcert -install) or set tls.certFile/keyFile";
|
|
24
|
+
/** Runs a command and resolves its output. */
|
|
25
|
+
const runCommand = promisify(execFile);
|
|
26
|
+
/** The TLS settings whose files are being watched, or null. */
|
|
27
|
+
let watchedTls = null;
|
|
28
|
+
/** What watchTlsFiles registered to receive renewed material, or null. */
|
|
29
|
+
let reloadCallback = null;
|
|
30
|
+
/** The material last loaded from the watched files. */
|
|
31
|
+
let lastMaterial = null;
|
|
32
|
+
/**
|
|
33
|
+
* The HTTPS certificate and key the server uses (D9): the PEM pair, else the files, else the development certificate
|
|
34
|
+
* made by mkcert. With UDP on, a missing mkcert is not an error: the HTTP/3 transport's own pinned development
|
|
35
|
+
* certificate then serves HTTPS too (null).
|
|
36
|
+
* @param tls The TLS settings.
|
|
37
|
+
* @param udpEnabled True when the HTTP/3 transport runs.
|
|
38
|
+
* @returns The material, or null when TLS is off or (UDP on, no mkcert) the HTTP/3 certificate serves HTTPS.
|
|
39
|
+
* @throws Error(MKCERT_MISSING_MESSAGE) when the development certificate needs mkcert and it is not installed.
|
|
40
|
+
*/
|
|
41
|
+
export async function loadTlsMaterial(tls, udpEnabled) {
|
|
42
|
+
if (!tls.enabled)
|
|
43
|
+
return null;
|
|
44
|
+
if (tls.certPem != null && tls.keyPem != null)
|
|
45
|
+
return { cert: tls.certPem, key: tls.keyPem };
|
|
46
|
+
if (tls.certFile != null && tls.keyFile != null)
|
|
47
|
+
return { cert: await readFile(tls.certFile, "utf8"), key: await readFile(tls.keyFile, "utf8") };
|
|
48
|
+
if (!tls.developmentCertificate)
|
|
49
|
+
return null;
|
|
50
|
+
try {
|
|
51
|
+
return await loadDevelopmentCertificate();
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const mkcertMissing = error.code === COMMAND_NOT_FOUND_CODE;
|
|
55
|
+
if (mkcertMissing && udpEnabled)
|
|
56
|
+
return null;
|
|
57
|
+
if (mkcertMissing)
|
|
58
|
+
throw new Error(MKCERT_MISSING_MESSAGE);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Polls the certificate files for a renewal every TLS_RELOAD_POLL_MS and hands changed material to a callback (D9).
|
|
64
|
+
* Only file-based TLS is watched; PEM text and the development certificate never change while running.
|
|
65
|
+
* @param tls The TLS settings.
|
|
66
|
+
* @param onChange Receives the renewed certificate and key.
|
|
67
|
+
*/
|
|
68
|
+
export function watchTlsFiles(tls, onChange) {
|
|
69
|
+
stopWatchingTlsFiles();
|
|
70
|
+
if (!tls.enabled || tls.certFile == null || tls.keyFile == null)
|
|
71
|
+
return;
|
|
72
|
+
watchedTls = tls;
|
|
73
|
+
reloadCallback = onChange;
|
|
74
|
+
lastMaterial = { cert: readFileSync(tls.certFile, "utf8"), key: readFileSync(tls.keyFile, "utf8") };
|
|
75
|
+
for (const filePath of [tls.certFile, tls.keyFile])
|
|
76
|
+
watchFile(filePath, { interval: TLS_RELOAD_POLL_MS }, reloadAfterFileChange);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Re-reads the watched certificate files now and hands them to the watcher's callback when they changed (tests and
|
|
80
|
+
* the file poll call it).
|
|
81
|
+
* @returns True when the material changed and the callback ran.
|
|
82
|
+
*/
|
|
83
|
+
export async function reloadTlsNow() {
|
|
84
|
+
if (watchedTls == null || reloadCallback == null)
|
|
85
|
+
return false;
|
|
86
|
+
const material = await loadTlsMaterial(watchedTls, false);
|
|
87
|
+
if (material == null || isSameMaterial(material, lastMaterial))
|
|
88
|
+
return false;
|
|
89
|
+
lastMaterial = material;
|
|
90
|
+
reloadCallback(material);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
/** Stops polling the certificate files and forgets the callback (close). */
|
|
94
|
+
export function stopWatchingTlsFiles() {
|
|
95
|
+
if (watchedTls != null && watchedTls.certFile != null && watchedTls.keyFile != null) {
|
|
96
|
+
unwatchFile(watchedTls.certFile, reloadAfterFileChange);
|
|
97
|
+
unwatchFile(watchedTls.keyFile, reloadAfterFileChange);
|
|
98
|
+
}
|
|
99
|
+
watchedTls = null;
|
|
100
|
+
reloadCallback = null;
|
|
101
|
+
lastMaterial = null;
|
|
102
|
+
}
|
|
103
|
+
/** The file poll's listener: reloads, logging a failure (a half-written renewal is retried at the next poll). */
|
|
104
|
+
function reloadAfterFileChange() {
|
|
105
|
+
reloadTlsNow().catch((error) => getLogger().warn("certificate reload failed", { error: String(error) }));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether two certificate/key pairs are the same text.
|
|
109
|
+
* @param material The new pair.
|
|
110
|
+
* @param previous The previous pair, or null.
|
|
111
|
+
* @returns True when both the certificate and the key are unchanged.
|
|
112
|
+
*/
|
|
113
|
+
function isSameMaterial(material, previous) {
|
|
114
|
+
return previous != null && material.cert === previous.cert && material.key === previous.key;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The mkcert development certificate for localhost, made once into the OS temp folder and reused while both files exist.
|
|
118
|
+
* @returns The material.
|
|
119
|
+
* @throws The mkcert error (code ENOENT when mkcert is not installed).
|
|
120
|
+
*/
|
|
121
|
+
async function loadDevelopmentCertificate() {
|
|
122
|
+
const folder = join(tmpdir(), DEVELOPMENT_CERTIFICATE_FOLDER);
|
|
123
|
+
const certPath = join(folder, DEVELOPMENT_CERT_FILE);
|
|
124
|
+
const keyPath = join(folder, DEVELOPMENT_KEY_FILE);
|
|
125
|
+
if (!existsSync(certPath) || !existsSync(keyPath)) {
|
|
126
|
+
mkdirSync(folder, { recursive: true });
|
|
127
|
+
await runCommand(MKCERT_COMMAND, ["-cert-file", certPath, "-key-file", keyPath].concat(DEVELOPMENT_CERTIFICATE_NAMES));
|
|
128
|
+
}
|
|
129
|
+
return { cert: await readFile(certPath, "utf8"), key: await readFile(keyPath, "utf8") };
|
|
130
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type AdminApiRouter } from "./AdminApiRouter.js";
|
|
2
|
+
import type { AdminServices } from "./AdminServices.js";
|
|
3
|
+
/**
|
|
4
|
+
* Registers every auth/*, setup/* and account/* route, passkeys (D31) and single sign-on (D26) included.
|
|
5
|
+
* @param router The admin API router.
|
|
6
|
+
* @param services The shared services.
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerAccountRoutes(router: AdminApiRouter, services: AdminServices): void;
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// The sign-in, setup and account routes of the admin API (game-server-admin-console DR §6.11 auth/*, setup/*,
|
|
2
|
+
// account/* rows; D8, D10, D30). None of them may be called with an API key (R8).
|
|
3
|
+
import { CONSOLE_BASE_PATH } from "./AdminConstants.js";
|
|
4
|
+
import { getLogger } from "../GameServerLogger.js";
|
|
5
|
+
import { AdminRequestError } from "./AdminApiRouter.js";
|
|
6
|
+
import { ROLE_PERMISSIONS } from "./AdminPermissions.js";
|
|
7
|
+
import { bodyObject, optionalStringField, stringField } from "./AdminRequestBodies.js";
|
|
8
|
+
import { buildClearingCookie, buildSessionCookie } from "./AdminResponses.js";
|
|
9
|
+
import { colyseusCloudFeatureOf } from "./ColyseusCloudSettings.js";
|
|
10
|
+
/** HTTP status of a successful answer. */
|
|
11
|
+
const HTTP_OK = 200;
|
|
12
|
+
/** HTTP status of a refused request body. */
|
|
13
|
+
const HTTP_BAD_REQUEST = 400;
|
|
14
|
+
/** HTTP status of a caller without a session. */
|
|
15
|
+
const HTTP_UNAUTHORIZED = 401;
|
|
16
|
+
/** HTTP status of a refused sign-in method. */
|
|
17
|
+
const HTTP_FORBIDDEN = 403;
|
|
18
|
+
/** HTTP status of a missing account or session. */
|
|
19
|
+
const HTTP_NOT_FOUND = 404;
|
|
20
|
+
/** The console page a finished single sign-on lands on. */
|
|
21
|
+
const SIGNED_IN_PAGE = CONSOLE_BASE_PATH + "#/";
|
|
22
|
+
/** The console sign-in page a single sign-on hands a second factor or a refusal to. */
|
|
23
|
+
const SIGN_IN_PAGE = CONSOLE_BASE_PATH + "#/sign-in";
|
|
24
|
+
/** The answer of an action with nothing else to say. */
|
|
25
|
+
const OK_BODY = { ok: true };
|
|
26
|
+
/** Allowed console themes. */
|
|
27
|
+
const THEMES = new Set(["system", "light", "dark"]);
|
|
28
|
+
/** Allowed console time zones. */
|
|
29
|
+
const TIME_ZONES = new Set(["local", "utc"]);
|
|
30
|
+
/**
|
|
31
|
+
* Registers every auth/*, setup/* and account/* route, passkeys (D31) and single sign-on (D26) included.
|
|
32
|
+
* @param router The admin API router.
|
|
33
|
+
* @param services The shared services.
|
|
34
|
+
*/
|
|
35
|
+
export function registerAccountRoutes(router, services) {
|
|
36
|
+
const routes = [
|
|
37
|
+
publicRoute("GET", "auth/methods", null, async () => ok(await signInMethods(services))),
|
|
38
|
+
publicRoute("POST", "setup/owner", "setup.owner", async (request) => createOwner(services, request)),
|
|
39
|
+
publicRoute("POST", "auth/sign-in", "auth.signIn", async (request) => ok(await beginSignIn(services, request))),
|
|
40
|
+
publicRoute("POST", "auth/enrol", null, async (request) => ok(await beginEnrolment(services, request))),
|
|
41
|
+
publicRoute("POST", "auth/enrol/confirm", "auth.enrol", async (request) => confirmEnrolment(services, request)),
|
|
42
|
+
publicRoute("POST", "auth/second-factor", "auth.secondFactor", async (request) => completeSecondFactor(services, request)),
|
|
43
|
+
signedInRoute("GET", "auth/session", false, null, async (request) => ok(await sessionView(services, requireSession(request)))),
|
|
44
|
+
signedInRoute("POST", "auth/step-up", false, "auth.stepUp", async (request) => stepUp(services, request)),
|
|
45
|
+
signedInRoute("POST", "auth/sign-out", false, "auth.signOut", async (request) => signOut(services, request, false)),
|
|
46
|
+
signedInRoute("POST", "auth/sign-out-everywhere", false, "auth.signOutEverywhere", async (request) => signOut(services, request, true)),
|
|
47
|
+
signedInRoute("GET", "account/sessions", false, null, async (request) => ok(await accountSessions(services, request))),
|
|
48
|
+
signedInRoute("DELETE", "account/sessions/:sessionId", false, "account.endSession", async (request) => endAccountSession(services, request)),
|
|
49
|
+
signedInRoute("PUT", "account/password", true, "account.password", async (request) => changePassword(services, request)),
|
|
50
|
+
signedInRoute("POST", "account/recovery-codes", true, "account.recoveryCodes", async (request) => ok({ recoveryCodes: await services.accounts.regenerateRecoveryCodes(requireSession(request).user.userId) })),
|
|
51
|
+
signedInRoute("PUT", "account/preferences", false, null, async (request) => updatePreferences(services, request)),
|
|
52
|
+
publicRoute("POST", "auth/passkey/options", null, async (request) => ok(await passkeyRequestOptions(services, request))),
|
|
53
|
+
publicRoute("POST", "auth/passkey/verify", "auth.passkey", async (request) => verifyPasskey(services, request)),
|
|
54
|
+
signedInRoute("POST", "account/passkeys/options", true, null, async (request) => ok(await services.passkeys.creationOptions(requirePasskeys(services, request).user, request.consoleOrigin))),
|
|
55
|
+
signedInRoute("POST", "account/passkeys", true, "account.passkeyAdd", async (request) => addPasskey(services, request)),
|
|
56
|
+
signedInRoute("GET", "account/passkeys", false, null, async (request) => ok(await services.passkeys.list(requireSession(request).user.userId))),
|
|
57
|
+
signedInRoute("DELETE", "account/passkeys/:passkeyId", true, "account.passkeyRemove", async (request) => removePasskey(services, request)),
|
|
58
|
+
publicRoute("GET", "auth/oidc/start", null, async (request) => startSingleSignOn(services, request)),
|
|
59
|
+
publicRoute("GET", "auth/oidc/callback", "auth.oidc", async (request) => finishSingleSignOn(services, request))
|
|
60
|
+
];
|
|
61
|
+
for (const route of routes)
|
|
62
|
+
router.register(route);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A route anyone may call (sign-in steps, setup).
|
|
66
|
+
* @param method The HTTP method.
|
|
67
|
+
* @param pattern The pattern.
|
|
68
|
+
* @param auditAction The audit action, or null.
|
|
69
|
+
* @param handler The handler.
|
|
70
|
+
* @returns The route.
|
|
71
|
+
*/
|
|
72
|
+
function publicRoute(method, pattern, auditAction, handler) {
|
|
73
|
+
return { method: method, pattern: pattern, permission: "public", requiresStepUp: false, auditAction: auditAction, allowsApiKeys: false, handler: handler };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A route of the signed-in admin's own account.
|
|
77
|
+
* @param method The HTTP method.
|
|
78
|
+
* @param pattern The pattern.
|
|
79
|
+
* @param requiresStepUp True for dangerous changes.
|
|
80
|
+
* @param auditAction The audit action, or null.
|
|
81
|
+
* @param handler The handler.
|
|
82
|
+
* @returns The route.
|
|
83
|
+
*/
|
|
84
|
+
function signedInRoute(method, pattern, requiresStepUp, auditAction, handler) {
|
|
85
|
+
return { method: method, pattern: pattern, permission: "signedIn", requiresStepUp: requiresStepUp, auditAction: auditAction, allowsApiKeys: false, handler: handler };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A 200 answer.
|
|
89
|
+
* @param body The JSON body.
|
|
90
|
+
* @returns The result.
|
|
91
|
+
*/
|
|
92
|
+
function ok(body) {
|
|
93
|
+
return { status: HTTP_OK, body: body };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* What the sign-in page offers. A process's own setup link stays pending after another process created the first owner,
|
|
97
|
+
* so the store decides whether setup is still needed.
|
|
98
|
+
* @param services The shared services.
|
|
99
|
+
* @returns The methods.
|
|
100
|
+
*/
|
|
101
|
+
async function signInMethods(services) {
|
|
102
|
+
const oidc = services.openId.isEnabled ? { label: services.openId.label } : null;
|
|
103
|
+
const needsSetup = services.accounts.hasPendingSetup() && !await services.accounts.hasUsers();
|
|
104
|
+
return { local: isLocalSignInAllowed(services), oidc: oidc, passkeys: services.passkeys.isEnabled, needsSetup: needsSetup };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Whether username and password sign-in is offered: always, unless single sign-on is on with allowLocalSignIn false
|
|
108
|
+
* (the CLI stays the recovery path).
|
|
109
|
+
* @param services The shared services.
|
|
110
|
+
* @returns True when local sign-in is allowed.
|
|
111
|
+
*/
|
|
112
|
+
function isLocalSignInAllowed(services) {
|
|
113
|
+
return !(services.openId.isEnabled && !services.config.admin.oidc.allowLocalSignIn);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* POST setup/owner: audited as the new owner, the account the request created.
|
|
117
|
+
* @param services The shared services.
|
|
118
|
+
* @param request The request.
|
|
119
|
+
* @returns The owner's enrolment step.
|
|
120
|
+
*/
|
|
121
|
+
async function createOwner(services, request) {
|
|
122
|
+
const body = bodyObject(request.body);
|
|
123
|
+
const username = stringField(body, "username");
|
|
124
|
+
const step = await services.accounts.createOwnerFromSetup({
|
|
125
|
+
setupToken: stringField(body, "setupToken"),
|
|
126
|
+
username: username,
|
|
127
|
+
displayName: optionalStringField(body, "displayName") ?? "",
|
|
128
|
+
password: stringField(body, "password")
|
|
129
|
+
});
|
|
130
|
+
const owner = await services.accounts.findByUsername(username);
|
|
131
|
+
const audit = (owner != null) ? { target: owner.value.username, parameters: {}, actor: userActorOf(owner.value) } : undefined;
|
|
132
|
+
return { status: HTTP_OK, body: step, audit: audit };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* POST auth/sign-in.
|
|
136
|
+
* @param services The shared services.
|
|
137
|
+
* @param request The request.
|
|
138
|
+
* @returns The next step.
|
|
139
|
+
*/
|
|
140
|
+
function beginSignIn(services, request) {
|
|
141
|
+
if (!isLocalSignInAllowed(services))
|
|
142
|
+
throw new AdminRequestError(HTTP_FORBIDDEN, "sign in with single sign-on");
|
|
143
|
+
const body = bodyObject(request.body);
|
|
144
|
+
return services.accounts.beginSignIn({ username: stringField(body, "username"), password: stringField(body, "password") }, signInContextOf(request));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* POST auth/enrol.
|
|
148
|
+
* @param services The shared services.
|
|
149
|
+
* @param request The request.
|
|
150
|
+
* @returns The secret to enrol.
|
|
151
|
+
*/
|
|
152
|
+
function beginEnrolment(services, request) {
|
|
153
|
+
return services.accounts.beginEnrolment(stringField(bodyObject(request.body), "pendingToken"));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* POST auth/enrol/confirm.
|
|
157
|
+
* @param services The shared services.
|
|
158
|
+
* @param request The request.
|
|
159
|
+
* @returns The signed-in answer with the session cookie.
|
|
160
|
+
*/
|
|
161
|
+
async function confirmEnrolment(services, request) {
|
|
162
|
+
const body = bodyObject(request.body);
|
|
163
|
+
const result = await services.accounts.completeEnrolment(stringField(body, "pendingToken"), stringField(body, "code"), signInContextOf(request));
|
|
164
|
+
return signedInResult(services, request, result);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* POST auth/second-factor.
|
|
168
|
+
* @param services The shared services.
|
|
169
|
+
* @param request The request.
|
|
170
|
+
* @returns The signed-in answer with the session cookie.
|
|
171
|
+
*/
|
|
172
|
+
async function completeSecondFactor(services, request) {
|
|
173
|
+
const body = bodyObject(request.body);
|
|
174
|
+
const pendingToken = stringField(body, "pendingToken");
|
|
175
|
+
const result = await services.accounts.completeSecondFactor(pendingToken, secondFactorProofOf(body), signInContextOf(request));
|
|
176
|
+
return signedInResult(services, request, result);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* POST auth/step-up.
|
|
180
|
+
* @param services The shared services.
|
|
181
|
+
* @param request The request.
|
|
182
|
+
* @returns The session with its fresh secondFactorAt.
|
|
183
|
+
*/
|
|
184
|
+
async function stepUp(services, request) {
|
|
185
|
+
const caller = requireCaller(request);
|
|
186
|
+
const session = await services.accounts.stepUp(caller, secondFactorProofOf(bodyObject(request.body)));
|
|
187
|
+
const user = requireSession(request).user;
|
|
188
|
+
return ok(await sessionView(services, { session: session, user: user }));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* POST auth/sign-out and auth/sign-out-everywhere.
|
|
192
|
+
* @param services The shared services.
|
|
193
|
+
* @param request The request.
|
|
194
|
+
* @param everywhere True to end every session of the account.
|
|
195
|
+
* @returns ok with a clearing cookie.
|
|
196
|
+
*/
|
|
197
|
+
async function signOut(services, request, everywhere) {
|
|
198
|
+
const signedIn = requireSession(request);
|
|
199
|
+
if (everywhere)
|
|
200
|
+
await services.sessions.endAllFor(signedIn.user.userId);
|
|
201
|
+
else
|
|
202
|
+
await services.sessions.end(signedIn.session.sessionId);
|
|
203
|
+
return { status: HTTP_OK, body: OK_BODY, setCookies: [buildClearingCookie(request.isSecure)], audit: { target: signedIn.user.username, parameters: {} } };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* GET account/sessions.
|
|
207
|
+
* @param services The shared services.
|
|
208
|
+
* @param request The request.
|
|
209
|
+
* @returns The caller's sessions.
|
|
210
|
+
*/
|
|
211
|
+
async function accountSessions(services, request) {
|
|
212
|
+
const signedIn = requireSession(request);
|
|
213
|
+
const sessions = await services.sessions.listFor(signedIn.user.userId);
|
|
214
|
+
return sessions.map((record) => ({
|
|
215
|
+
sessionId: record.sessionId,
|
|
216
|
+
createdAt: record.createdAt,
|
|
217
|
+
lastSeenAt: record.lastSeenAt,
|
|
218
|
+
clientAddress: record.clientAddress,
|
|
219
|
+
userAgent: record.userAgent,
|
|
220
|
+
current: record.sessionId === signedIn.session.sessionId
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* DELETE account/sessions/:sessionId.
|
|
225
|
+
* @param services The shared services.
|
|
226
|
+
* @param request The request.
|
|
227
|
+
* @returns ok.
|
|
228
|
+
* @throws AdminRequestError 404 when the session is not the caller's.
|
|
229
|
+
*/
|
|
230
|
+
async function endAccountSession(services, request) {
|
|
231
|
+
const signedIn = requireSession(request);
|
|
232
|
+
const sessionId = request.params.sessionId;
|
|
233
|
+
const owned = await services.sessions.listFor(signedIn.user.userId);
|
|
234
|
+
if (!owned.some((record) => record.sessionId === sessionId))
|
|
235
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "no such session");
|
|
236
|
+
await services.sessions.end(sessionId);
|
|
237
|
+
return { status: HTTP_OK, body: OK_BODY, audit: { target: sessionId, parameters: {} } };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* PUT account/password.
|
|
241
|
+
* @param services The shared services.
|
|
242
|
+
* @param request The request.
|
|
243
|
+
* @returns ok.
|
|
244
|
+
*/
|
|
245
|
+
async function changePassword(services, request) {
|
|
246
|
+
const body = bodyObject(request.body);
|
|
247
|
+
const user = requireSession(request).user;
|
|
248
|
+
await services.accounts.changePassword(user.userId, stringField(body, "currentPassword"), stringField(body, "newPassword"));
|
|
249
|
+
return { status: HTTP_OK, body: OK_BODY, audit: { target: user.username, parameters: {} } };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* PUT account/preferences.
|
|
253
|
+
* @param services The shared services.
|
|
254
|
+
* @param request The request.
|
|
255
|
+
* @returns The updated account view.
|
|
256
|
+
* @throws AdminRequestError 400 for an unknown theme or time zone.
|
|
257
|
+
*/
|
|
258
|
+
async function updatePreferences(services, request) {
|
|
259
|
+
const body = bodyObject(request.body);
|
|
260
|
+
const theme = stringField(body, "theme");
|
|
261
|
+
const timeZone = stringField(body, "timeZone");
|
|
262
|
+
if (!THEMES.has(theme) || !TIME_ZONES.has(timeZone))
|
|
263
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "theme must be system, light or dark and timeZone local or utc");
|
|
264
|
+
const preferences = { theme: theme, timeZone: timeZone };
|
|
265
|
+
const stored = await services.accounts.updatePreferences(requireSession(request).user.userId, preferences);
|
|
266
|
+
return ok(services.accounts.toUserView(stored));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The answer of a finished sign-in: the SignedInView, the session cookie and the audit target.
|
|
270
|
+
* @param services The shared services.
|
|
271
|
+
* @param request The request.
|
|
272
|
+
* @param result The sign-in.
|
|
273
|
+
* @returns The result.
|
|
274
|
+
*/
|
|
275
|
+
async function signedInResult(services, request, result) {
|
|
276
|
+
const view = { session: await sessionView(services, { session: result.session, user: result.user }), recoveryCodes: result.recoveryCodes };
|
|
277
|
+
const cookie = buildSessionCookie(result.cookieValue, request.isSecure, services.sessions.cookieMaxAgeSeconds);
|
|
278
|
+
return { status: HTTP_OK, body: view, setCookies: [cookie], audit: { target: result.user.username, parameters: {}, actor: userActorOf(result.user) } };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The audit actor of an account that just signed in or was just created on a public route.
|
|
282
|
+
* @param user The account.
|
|
283
|
+
* @returns The actor.
|
|
284
|
+
*/
|
|
285
|
+
function userActorOf(user) {
|
|
286
|
+
return { kind: "user", id: user.userId, name: user.displayName };
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The ConsoleSessionView of a session.
|
|
290
|
+
* @param services The shared services.
|
|
291
|
+
* @param signedIn The session and its account.
|
|
292
|
+
* @returns The view.
|
|
293
|
+
*/
|
|
294
|
+
async function sessionView(services, signedIn) {
|
|
295
|
+
const stored = await services.accounts.getUser(signedIn.user.userId);
|
|
296
|
+
if (stored == null)
|
|
297
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "no such admin user");
|
|
298
|
+
return {
|
|
299
|
+
user: services.accounts.toUserView(stored),
|
|
300
|
+
permissions: Array.from(ROLE_PERMISSIONS[stored.value.role]),
|
|
301
|
+
csrfToken: signedIn.session.csrfToken,
|
|
302
|
+
secondFactorAt: signedIn.session.secondFactorAt,
|
|
303
|
+
expiresAt: services.sessions.expiresAt(signedIn.session),
|
|
304
|
+
features: { colyseusCloud: colyseusCloudFeatureOf(services.config, process.env) }
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The second factor of a body: a code or a recovery code.
|
|
309
|
+
* @param body The body object.
|
|
310
|
+
* @returns The proof.
|
|
311
|
+
* @throws AdminRequestError 400 when neither is given.
|
|
312
|
+
*/
|
|
313
|
+
function secondFactorProofOf(body) {
|
|
314
|
+
const code = optionalStringField(body, "code");
|
|
315
|
+
if (code != null)
|
|
316
|
+
return { code: code };
|
|
317
|
+
const recoveryCode = optionalStringField(body, "recoveryCode");
|
|
318
|
+
if (recoveryCode != null)
|
|
319
|
+
return { recoveryCode: recoveryCode };
|
|
320
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "send a code or a recovery code");
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Where a sign-in comes from.
|
|
324
|
+
* @param request The request.
|
|
325
|
+
* @returns The address and user agent.
|
|
326
|
+
*/
|
|
327
|
+
function signInContextOf(request) {
|
|
328
|
+
return { clientAddress: request.clientAddress, userAgent: request.headers.get("user-agent") ?? "" };
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The caller of a signedIn route.
|
|
332
|
+
* @param request The request.
|
|
333
|
+
* @returns The caller.
|
|
334
|
+
* @throws AdminRequestError 401 without one (the router already refuses that).
|
|
335
|
+
*/
|
|
336
|
+
function requireCaller(request) {
|
|
337
|
+
if (request.caller == null)
|
|
338
|
+
throw new AdminRequestError(HTTP_UNAUTHORIZED, "sign in first");
|
|
339
|
+
return request.caller;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The console session and account of a signedIn route's caller.
|
|
343
|
+
* @param request The request.
|
|
344
|
+
* @returns The session and the account.
|
|
345
|
+
* @throws AdminRequestError 401 for a caller without a session.
|
|
346
|
+
*/
|
|
347
|
+
function requireSession(request) {
|
|
348
|
+
const caller = requireCaller(request);
|
|
349
|
+
if (caller.session == null || caller.user == null)
|
|
350
|
+
throw new AdminRequestError(HTTP_UNAUTHORIZED, "sign in first");
|
|
351
|
+
return { session: caller.session, user: caller.user };
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* The signed-in account of a passkey route, when passkeys are on.
|
|
355
|
+
* @param services The shared services.
|
|
356
|
+
* @param request The request.
|
|
357
|
+
* @returns The session and the account.
|
|
358
|
+
* @throws AdminRequestError 404 while passkeys are off.
|
|
359
|
+
*/
|
|
360
|
+
function requirePasskeys(services, request) {
|
|
361
|
+
if (!services.passkeys.isEnabled)
|
|
362
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "passkeys are off");
|
|
363
|
+
return requireSession(request);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* POST auth/passkey/options.
|
|
367
|
+
* @param services The shared services.
|
|
368
|
+
* @param request The request.
|
|
369
|
+
* @returns The options of navigator.credentials.get.
|
|
370
|
+
* @throws AdminRequestError 404 while passkeys are off.
|
|
371
|
+
*/
|
|
372
|
+
async function passkeyRequestOptions(services, request) {
|
|
373
|
+
if (!services.passkeys.isEnabled)
|
|
374
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "passkeys are off");
|
|
375
|
+
return services.passkeys.requestOptions(optionalStringField(bodyObject(request.body), "username"), request.consoleOrigin);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* POST auth/passkey/verify: a passkey as the second factor of a password sign-in (with its pendingToken), or a
|
|
379
|
+
* password-less sign-in when the authenticator verified the user (D31).
|
|
380
|
+
* @param services The shared services.
|
|
381
|
+
* @param request The request.
|
|
382
|
+
* @returns The signed-in answer with the session cookie.
|
|
383
|
+
* @throws AdminRequestError 404 while passkeys are off, 401 for a refused passkey or a password-less sign-in without
|
|
384
|
+
* user verification.
|
|
385
|
+
*/
|
|
386
|
+
async function verifyPasskey(services, request) {
|
|
387
|
+
if (!services.passkeys.isEnabled)
|
|
388
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "passkeys are off");
|
|
389
|
+
const body = bodyObject(request.body);
|
|
390
|
+
const pendingToken = optionalStringField(body, "pendingToken");
|
|
391
|
+
const verified = await services.passkeys.verifyAssertion(assertionOf(body.credential), request.consoleOrigin);
|
|
392
|
+
if (pendingToken != null)
|
|
393
|
+
return signedInResult(services, request, await services.accounts.completeSecondFactorWithPasskey(pendingToken, verified.user.userId, signInContextOf(request)));
|
|
394
|
+
if (!verified.userVerified)
|
|
395
|
+
throw new AdminRequestError(HTTP_UNAUTHORIZED, "this passkey did not verify you; sign in with your password first");
|
|
396
|
+
return signedInResult(services, request, await services.accounts.signInVerified(verified.user.userId, signInContextOf(request)));
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* POST account/passkeys: registers a passkey for the signed-in admin.
|
|
400
|
+
* @param services The shared services.
|
|
401
|
+
* @param request The request.
|
|
402
|
+
* @returns The passkey.
|
|
403
|
+
*/
|
|
404
|
+
async function addPasskey(services, request) {
|
|
405
|
+
const signedIn = requirePasskeys(services, request);
|
|
406
|
+
const body = bodyObject(request.body);
|
|
407
|
+
const name = optionalStringField(body, "name") ?? "";
|
|
408
|
+
const record = await services.passkeys.register(signedIn.user, name, attestationOf(body.credential), request.consoleOrigin);
|
|
409
|
+
const view = { passkeyId: record.passkeyId, name: record.name, createdAt: record.createdAt, lastUsedAt: record.lastUsedAt };
|
|
410
|
+
return { status: HTTP_OK, body: view, audit: { target: signedIn.user.username, parameters: { passkeyId: record.passkeyId, name: record.name } } };
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* DELETE account/passkeys/:passkeyId.
|
|
414
|
+
* @param services The shared services.
|
|
415
|
+
* @param request The request.
|
|
416
|
+
* @returns ok.
|
|
417
|
+
*/
|
|
418
|
+
async function removePasskey(services, request) {
|
|
419
|
+
const signedIn = requireSession(request);
|
|
420
|
+
const passkeyId = request.params.passkeyId;
|
|
421
|
+
await services.passkeys.remove(signedIn.user.userId, passkeyId);
|
|
422
|
+
return { status: HTTP_OK, body: OK_BODY, audit: { target: signedIn.user.username, parameters: { passkeyId: passkeyId } } };
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* GET auth/oidc/start: a redirect to the provider (or back to the sign-in page when it cannot be reached).
|
|
426
|
+
* @param services The shared services.
|
|
427
|
+
* @param request The request.
|
|
428
|
+
* @returns The redirect.
|
|
429
|
+
* @throws AdminRequestError 404 while single sign-on is off.
|
|
430
|
+
*/
|
|
431
|
+
async function startSingleSignOn(services, request) {
|
|
432
|
+
if (!services.openId.isEnabled)
|
|
433
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "single sign-on is off");
|
|
434
|
+
try {
|
|
435
|
+
const start = await services.openId.startUrl(request.consoleOrigin);
|
|
436
|
+
return { status: HTTP_OK, body: null, redirectTo: start.location };
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
getLogger().warn("the OpenID provider could not be reached", { error: String(error) });
|
|
440
|
+
return { status: HTTP_OK, body: null, redirectTo: SIGN_IN_PAGE + "?error=sign-in-failed" };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* GET auth/oidc/callback (DR §7.10): signs in (cookie, then the console), hands a second factor to the sign-in page,
|
|
445
|
+
* or sends a refusal reason there. A refusal is audited as a failure.
|
|
446
|
+
* @param services The shared services.
|
|
447
|
+
* @param request The request.
|
|
448
|
+
* @returns The redirect.
|
|
449
|
+
* @throws AdminRequestError 404 while single sign-on is off.
|
|
450
|
+
*/
|
|
451
|
+
async function finishSingleSignOn(services, request) {
|
|
452
|
+
if (!services.openId.isEnabled)
|
|
453
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "single sign-on is off");
|
|
454
|
+
const outcome = await services.openId.complete(request.query);
|
|
455
|
+
if (outcome.kind === "refused") {
|
|
456
|
+
return { status: HTTP_FORBIDDEN, body: { error: "single sign-on refused", detail: outcome.reason }, redirectTo: SIGN_IN_PAGE + "?error=" + outcome.reason };
|
|
457
|
+
}
|
|
458
|
+
if (outcome.kind === "needsSecondFactor") {
|
|
459
|
+
const location = SIGN_IN_PAGE + "?second-factor=" + encodeURIComponent(outcome.pendingToken) + "&stage=" + outcome.stage;
|
|
460
|
+
return { status: HTTP_OK, body: null, redirectTo: location, audit: { target: outcome.user.username, parameters: { secondFactor: "asked", note: outcome.note } } };
|
|
461
|
+
}
|
|
462
|
+
const result = await services.accounts.signInVerified(outcome.user.userId, signInContextOf(request));
|
|
463
|
+
const cookie = buildSessionCookie(result.cookieValue, request.isSecure, services.sessions.cookieMaxAgeSeconds);
|
|
464
|
+
return { status: HTTP_OK, body: null, redirectTo: SIGNED_IN_PAGE, setCookies: [cookie], audit: { target: result.user.username, parameters: { secondFactor: "provider", note: outcome.note }, actor: userActorOf(result.user) } };
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* A navigator.credentials.get result from a request body.
|
|
468
|
+
* @param value body.credential.
|
|
469
|
+
* @returns The assertion.
|
|
470
|
+
* @throws AdminRequestError 400 when a field is missing.
|
|
471
|
+
*/
|
|
472
|
+
function assertionOf(value) {
|
|
473
|
+
const credential = bodyObject(value);
|
|
474
|
+
const response = bodyObject(credential.response);
|
|
475
|
+
return {
|
|
476
|
+
id: stringField(credential, "id"),
|
|
477
|
+
rawId: stringField(credential, "rawId"),
|
|
478
|
+
type: "public-key",
|
|
479
|
+
response: {
|
|
480
|
+
clientDataJSON: stringField(response, "clientDataJSON"),
|
|
481
|
+
authenticatorData: stringField(response, "authenticatorData"),
|
|
482
|
+
signature: stringField(response, "signature"),
|
|
483
|
+
userHandle: optionalStringField(response, "userHandle")
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* A navigator.credentials.create result from a request body.
|
|
489
|
+
* @param value body.credential.
|
|
490
|
+
* @returns The attestation.
|
|
491
|
+
* @throws AdminRequestError 400 when a field is missing.
|
|
492
|
+
*/
|
|
493
|
+
function attestationOf(value) {
|
|
494
|
+
const credential = bodyObject(value);
|
|
495
|
+
const response = bodyObject(credential.response);
|
|
496
|
+
return {
|
|
497
|
+
id: stringField(credential, "id"),
|
|
498
|
+
rawId: stringField(credential, "rawId"),
|
|
499
|
+
type: "public-key",
|
|
500
|
+
response: { clientDataJSON: stringField(response, "clientDataJSON"), attestationObject: stringField(response, "attestationObject") }
|
|
501
|
+
};
|
|
502
|
+
}
|