@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,335 @@
|
|
|
1
|
+
// OpenID Connect single sign-on of the admin console (game-server-admin-console D26, DR §7.10): authorization code flow
|
|
2
|
+
// with PKCE (S256), the ID token verified with jose against the provider's JWKS, the state and nonce checked, accounts
|
|
3
|
+
// matched by verified email or subject, roles mapped from a claim, and the provider's multi-factor proof honoured.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
6
|
+
import { ADMIN_API_PREFIX, CHALLENGE_LIFETIME_MS, OIDC_DISCOVERY_CACHE_MS } from "./AdminConstants.js";
|
|
7
|
+
import { randomToken, sha256Hex } from "./AdminCrypto.js";
|
|
8
|
+
import { ADMIN_ROLES } from "./AdminPermissions.js";
|
|
9
|
+
import { getLogger } from "../GameServerLogger.js";
|
|
10
|
+
/** The provider's discovery document path below the issuer. */
|
|
11
|
+
const DISCOVERY_PATH = "/.well-known/openid-configuration";
|
|
12
|
+
/** The API path of the callback, below the console origin. */
|
|
13
|
+
const CALLBACK_PATH = ADMIN_API_PREFIX + "auth/oidc/callback";
|
|
14
|
+
/** The scopes the console asks for. */
|
|
15
|
+
const SCOPES = "openid email profile";
|
|
16
|
+
/** Random bytes of the state, the nonce and the PKCE verifier. */
|
|
17
|
+
const RANDOM_VALUE_BYTES = 32;
|
|
18
|
+
/** The id prefix of OIDC sign-ins in pendingSignIns. */
|
|
19
|
+
const CHALLENGE_ID_PREFIX = "oidc:";
|
|
20
|
+
/** amr values that count as a multi-factor proof (D26). */
|
|
21
|
+
const MULTI_FACTOR_METHODS = new Set(["mfa", "otp", "hwk", "swk"]);
|
|
22
|
+
/** Usernames: lower case letters, digits, dot, underscore and dash, 3 to 32 characters (AdminAccounts' rule). */
|
|
23
|
+
const USERNAME_MAX_LENGTH = 32;
|
|
24
|
+
const USERNAME_MIN_LENGTH = 3;
|
|
25
|
+
/** Characters a derived username may not contain. */
|
|
26
|
+
const USERNAME_INVALID_CHARACTERS = /[^a-z0-9._-]/g;
|
|
27
|
+
/** The fallback base of a derived username. */
|
|
28
|
+
const DEFAULT_USERNAME_BASE = "sso-user";
|
|
29
|
+
/** Claims an ID token must carry besides those jwtVerify checks by value. */
|
|
30
|
+
const REQUIRED_CLAIMS = ["exp", "iat"];
|
|
31
|
+
/** The owner role, which the last enabled owner never loses (DR §5). */
|
|
32
|
+
const OWNER_ROLE = "owner";
|
|
33
|
+
/** Request timeout of provider calls, in milliseconds. */
|
|
34
|
+
const PROVIDER_TIMEOUT_MS = 10000;
|
|
35
|
+
/** The console's OpenID Connect client. */
|
|
36
|
+
export class OpenIdConnectClient {
|
|
37
|
+
/** admin.oidc. */
|
|
38
|
+
config;
|
|
39
|
+
/** The admin store (pending sign-ins, accounts). */
|
|
40
|
+
store;
|
|
41
|
+
/** The clock of the discovery cache and challenge expiries. */
|
|
42
|
+
clock;
|
|
43
|
+
/** The admin accounts. */
|
|
44
|
+
accounts;
|
|
45
|
+
/** The cached discovery document and when it was fetched, or null. */
|
|
46
|
+
cachedDiscovery = null;
|
|
47
|
+
/** The provider's key set, per jwks_uri (jose caches the keys). */
|
|
48
|
+
keySet = null;
|
|
49
|
+
/**
|
|
50
|
+
* Creates the client.
|
|
51
|
+
* @param config admin.oidc.
|
|
52
|
+
* @param store The admin store.
|
|
53
|
+
* @param clock The clock.
|
|
54
|
+
* @param accounts The admin accounts.
|
|
55
|
+
*/
|
|
56
|
+
constructor(config, store, clock, accounts) {
|
|
57
|
+
this.config = config;
|
|
58
|
+
this.store = store;
|
|
59
|
+
this.clock = clock;
|
|
60
|
+
this.accounts = accounts;
|
|
61
|
+
}
|
|
62
|
+
/** True when admin.oidc.enabled. */
|
|
63
|
+
get isEnabled() {
|
|
64
|
+
return this.config.enabled;
|
|
65
|
+
}
|
|
66
|
+
/** The sign-in button label. */
|
|
67
|
+
get label() {
|
|
68
|
+
return this.config.label;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The provider's discovery document, cached for OIDC_DISCOVERY_CACHE_MS.
|
|
72
|
+
* @returns The document.
|
|
73
|
+
* @throws Error when the provider does not answer with one.
|
|
74
|
+
*/
|
|
75
|
+
async discovery() {
|
|
76
|
+
const now = this.clock.now();
|
|
77
|
+
if (this.cachedDiscovery != null && now - this.cachedDiscovery.fetchedAt < OIDC_DISCOVERY_CACHE_MS)
|
|
78
|
+
return this.cachedDiscovery.document;
|
|
79
|
+
const issuer = (this.config.issuer ?? "").replace(/\/+$/, "");
|
|
80
|
+
const answer = await fetch(issuer + DISCOVERY_PATH, { signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS) });
|
|
81
|
+
if (!answer.ok)
|
|
82
|
+
throw new Error("the OpenID provider's discovery document answered " + answer.status);
|
|
83
|
+
const document = await answer.json();
|
|
84
|
+
const isComplete = [document.issuer, document.authorization_endpoint, document.token_endpoint, document.jwks_uri].every((field) => typeof field === "string");
|
|
85
|
+
if (!isComplete)
|
|
86
|
+
throw new Error("the OpenID provider's discovery document lacks issuer, endpoints or jwks_uri");
|
|
87
|
+
this.cachedDiscovery = { document: document, fetchedAt: now };
|
|
88
|
+
return this.cachedDiscovery.document;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Starts a sign-in (GET auth/oidc/start): stores the state, nonce and PKCE verifier for CHALLENGE_LIFETIME_MS and
|
|
92
|
+
* builds the provider's authorization URL.
|
|
93
|
+
* @param consoleOrigin The console origin (the redirect URI is below it).
|
|
94
|
+
* @returns Where to send the browser.
|
|
95
|
+
*/
|
|
96
|
+
async startUrl(consoleOrigin) {
|
|
97
|
+
const provider = await this.discovery();
|
|
98
|
+
const state = randomToken(RANDOM_VALUE_BYTES);
|
|
99
|
+
const record = {
|
|
100
|
+
kind: "oidc",
|
|
101
|
+
nonce: randomToken(RANDOM_VALUE_BYTES),
|
|
102
|
+
codeVerifier: randomToken(RANDOM_VALUE_BYTES),
|
|
103
|
+
redirectUri: consoleOrigin + CALLBACK_PATH,
|
|
104
|
+
expiresAt: new Date(this.clock.now() + CHALLENGE_LIFETIME_MS).toISOString()
|
|
105
|
+
};
|
|
106
|
+
await this.store.putDocument("pendingSignIns", CHALLENGE_ID_PREFIX + sha256Hex(state), record, 0);
|
|
107
|
+
const location = new URL(provider.authorization_endpoint);
|
|
108
|
+
const parameters = {
|
|
109
|
+
response_type: "code",
|
|
110
|
+
client_id: this.config.clientId ?? "",
|
|
111
|
+
redirect_uri: record.redirectUri,
|
|
112
|
+
scope: SCOPES,
|
|
113
|
+
state: state,
|
|
114
|
+
nonce: record.nonce,
|
|
115
|
+
code_challenge: createHash("sha256").update(record.codeVerifier).digest("base64url"),
|
|
116
|
+
code_challenge_method: "S256"
|
|
117
|
+
};
|
|
118
|
+
for (const [name, value] of Object.entries(parameters))
|
|
119
|
+
location.searchParams.set(name, value);
|
|
120
|
+
return { location: location.toString() };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Finishes a sign-in (GET auth/oidc/callback): checks the state, exchanges the code, verifies the ID token (issuer,
|
|
124
|
+
* audience, signature, nonce), then matches or creates the account and applies the two-factor rule (D26).
|
|
125
|
+
* @param query The callback's query.
|
|
126
|
+
* @returns The outcome.
|
|
127
|
+
*/
|
|
128
|
+
async complete(query) {
|
|
129
|
+
const challenge = await this.consumeChallenge(query.get("state"));
|
|
130
|
+
const code = query.get("code");
|
|
131
|
+
if (challenge == null || code == null || query.has("error"))
|
|
132
|
+
return { kind: "refused", reason: "sign-in-failed" };
|
|
133
|
+
let claims;
|
|
134
|
+
try {
|
|
135
|
+
claims = await this.verifiedClaims(await this.exchangeCode(code, challenge), challenge.nonce);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
getLogger().warn("an admin single sign-on was refused", { error: String(error) });
|
|
139
|
+
return { kind: "refused", reason: "sign-in-failed" };
|
|
140
|
+
}
|
|
141
|
+
return this.signInWithClaims(claims);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Matches or creates the account of verified claims and decides the second factor.
|
|
145
|
+
* @param claims The verified ID token claims.
|
|
146
|
+
* @returns The outcome.
|
|
147
|
+
*/
|
|
148
|
+
async signInWithClaims(claims) {
|
|
149
|
+
const mappedRole = this.mappedRoleOf(claims);
|
|
150
|
+
const existing = await this.findAccount(claims);
|
|
151
|
+
if (existing == null && mappedRole == null)
|
|
152
|
+
return { kind: "refused", reason: "no-role" };
|
|
153
|
+
const matched = (existing != null)
|
|
154
|
+
? await this.updateAccount(existing, claims, mappedRole)
|
|
155
|
+
: { user: await this.createAccount(claims, mappedRole), note: null };
|
|
156
|
+
const user = matched.user;
|
|
157
|
+
if (user.disabled || this.accounts.isLocked(user))
|
|
158
|
+
return { kind: "refused", reason: "account-disabled" };
|
|
159
|
+
const methods = claims.amr;
|
|
160
|
+
const hasMultiFactorProof = Array.isArray(methods) && methods.some((method) => typeof method === "string" && MULTI_FACTOR_METHODS.has(method));
|
|
161
|
+
if (hasMultiFactorProof)
|
|
162
|
+
return { kind: "signedIn", user: user, note: matched.note };
|
|
163
|
+
const step = await this.accounts.startSingleSignOnSecondFactor(user);
|
|
164
|
+
return { kind: "needsSecondFactor", pendingToken: step.pendingToken, stage: step.stage, user: user, note: matched.note };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Takes a started sign-in out of the store (single use).
|
|
168
|
+
* @param state The callback's state, or null.
|
|
169
|
+
* @returns The live record, or null for a missing, unknown, used or expired state.
|
|
170
|
+
*/
|
|
171
|
+
async consumeChallenge(state) {
|
|
172
|
+
if (state == null || state === "")
|
|
173
|
+
return null;
|
|
174
|
+
const documentId = CHALLENGE_ID_PREFIX + sha256Hex(state);
|
|
175
|
+
const stored = await this.store.getDocument("pendingSignIns", documentId);
|
|
176
|
+
if (stored == null || !(await this.store.deleteDocument("pendingSignIns", documentId)))
|
|
177
|
+
return null;
|
|
178
|
+
const isLive = stored.value.kind === "oidc" && Date.parse(stored.value.expiresAt) > this.clock.now();
|
|
179
|
+
return isLive ? stored.value : null;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Exchanges the authorization code at the token endpoint (client_secret_post, PKCE verifier).
|
|
183
|
+
* @param code The authorization code.
|
|
184
|
+
* @param challenge The started sign-in.
|
|
185
|
+
* @returns The ID token.
|
|
186
|
+
* @throws Error when the provider refuses or answers without an ID token.
|
|
187
|
+
*/
|
|
188
|
+
async exchangeCode(code, challenge) {
|
|
189
|
+
const provider = await this.discovery();
|
|
190
|
+
const form = new URLSearchParams({
|
|
191
|
+
grant_type: "authorization_code",
|
|
192
|
+
code: code,
|
|
193
|
+
redirect_uri: challenge.redirectUri,
|
|
194
|
+
client_id: this.config.clientId ?? "",
|
|
195
|
+
code_verifier: challenge.codeVerifier
|
|
196
|
+
});
|
|
197
|
+
if (this.config.clientSecret != null)
|
|
198
|
+
form.set("client_secret", this.config.clientSecret);
|
|
199
|
+
const answer = await fetch(provider.token_endpoint, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
202
|
+
body: form.toString(),
|
|
203
|
+
signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS)
|
|
204
|
+
});
|
|
205
|
+
const tokens = await answer.json();
|
|
206
|
+
if (!answer.ok || typeof tokens.id_token !== "string")
|
|
207
|
+
throw new Error("the token endpoint answered " + answer.status + " without an id_token");
|
|
208
|
+
return tokens.id_token;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Verifies an ID token: signature against the provider's JWKS, issuer, audience (the client id) and nonce.
|
|
212
|
+
* @param idToken The ID token.
|
|
213
|
+
* @param expectedNonce The nonce of the started sign-in.
|
|
214
|
+
* @returns The claims.
|
|
215
|
+
* @throws Error when any check fails.
|
|
216
|
+
*/
|
|
217
|
+
async verifiedClaims(idToken, expectedNonce) {
|
|
218
|
+
const provider = await this.discovery();
|
|
219
|
+
const verified = await jwtVerify(idToken, this.keysOf(provider.jwks_uri), { issuer: provider.issuer, audience: this.config.clientId ?? "", requiredClaims: REQUIRED_CLAIMS });
|
|
220
|
+
if (verified.payload.nonce !== expectedNonce)
|
|
221
|
+
throw new Error("the ID token's nonce does not match");
|
|
222
|
+
if (typeof verified.payload.sub !== "string" || verified.payload.sub === "")
|
|
223
|
+
throw new Error("the ID token has no subject");
|
|
224
|
+
return verified.payload;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The provider's key set (one per jwks_uri; jose fetches and caches the keys).
|
|
228
|
+
* @param jwksUri The provider's jwks_uri.
|
|
229
|
+
* @returns The key resolver.
|
|
230
|
+
*/
|
|
231
|
+
keysOf(jwksUri) {
|
|
232
|
+
if (this.keySet == null || this.keySet.jwksUri !== jwksUri)
|
|
233
|
+
this.keySet = { jwksUri: jwksUri, getKey: createRemoteJWKSet(new URL(jwksUri)) };
|
|
234
|
+
return this.keySet.getKey;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The most powerful console role the claim maps to (admin.oidc.roleMapping "value=role").
|
|
238
|
+
* @param claims The verified claims.
|
|
239
|
+
* @returns The role, or null when no value maps.
|
|
240
|
+
*/
|
|
241
|
+
mappedRoleOf(claims) {
|
|
242
|
+
const claim = claims[this.config.roleClaim];
|
|
243
|
+
const values = (Array.isArray(claim) ? claim : [claim]).filter((value) => typeof value === "string");
|
|
244
|
+
const roles = [];
|
|
245
|
+
for (const entry of this.config.roleMapping) {
|
|
246
|
+
const separatorIndex = entry.lastIndexOf("=");
|
|
247
|
+
if (values.includes(entry.substring(0, separatorIndex)))
|
|
248
|
+
roles.push(entry.substring(separatorIndex + 1));
|
|
249
|
+
}
|
|
250
|
+
// ADMIN_ROLES lists the roles from most to least powerful.
|
|
251
|
+
return ADMIN_ROLES.find((role) => roles.includes(role)) ?? null;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The account of the claims: the one with this subject, else the one with this verified email.
|
|
255
|
+
* @param claims The verified claims.
|
|
256
|
+
* @returns The stored account, or null.
|
|
257
|
+
*/
|
|
258
|
+
async findAccount(claims) {
|
|
259
|
+
const users = await this.store.listDocuments("users");
|
|
260
|
+
const bySubject = users.find((stored) => stored.value.oidcSubject === claims.sub);
|
|
261
|
+
if (bySubject != null)
|
|
262
|
+
return bySubject;
|
|
263
|
+
const email = verifiedEmailOf(claims);
|
|
264
|
+
if (email == null)
|
|
265
|
+
return null;
|
|
266
|
+
return users.find((stored) => stored.value.email != null && stored.value.email.toLowerCase() === email) ?? null;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Links an existing account to the subject and applies a mapped role, except that the last enabled owner keeps the
|
|
270
|
+
* owner role (DR §5); that refusal is logged and returned as a note for the audit record.
|
|
271
|
+
* @param stored The account.
|
|
272
|
+
* @param claims The verified claims.
|
|
273
|
+
* @param mappedRole The mapped role, or null to keep the account's role.
|
|
274
|
+
* @returns The account as stored now, and the note.
|
|
275
|
+
*/
|
|
276
|
+
async updateAccount(stored, claims, mappedRole) {
|
|
277
|
+
const keepsLastOwner = stored.value.role === OWNER_ROLE && mappedRole != null && mappedRole !== OWNER_ROLE && !(await this.accounts.hasAnotherEnabledOwner(stored.id));
|
|
278
|
+
const note = keepsLastOwner ? "the last owner keeps the owner role; the role mapping asked for " + mappedRole : null;
|
|
279
|
+
if (keepsLastOwner)
|
|
280
|
+
getLogger().warn("a single sign-on role mapping would demote the last owner; the owner role is kept", { username: stored.value.username, mappedRole: mappedRole });
|
|
281
|
+
const role = keepsLastOwner ? OWNER_ROLE : (mappedRole ?? stored.value.role);
|
|
282
|
+
const subject = String(claims.sub);
|
|
283
|
+
if (stored.value.oidcSubject === subject && stored.value.role === role)
|
|
284
|
+
return { user: stored.value, note: note };
|
|
285
|
+
const updated = Object.assign({}, stored.value, { oidcSubject: subject, role: role });
|
|
286
|
+
await this.store.putDocument("users", stored.id, updated, stored.revision);
|
|
287
|
+
return { user: updated, note: note };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Creates the account of a first single sign-on (no password: SSO only).
|
|
291
|
+
* @param claims The verified claims.
|
|
292
|
+
* @param role The mapped role.
|
|
293
|
+
* @returns The new account.
|
|
294
|
+
*/
|
|
295
|
+
async createAccount(claims, role) {
|
|
296
|
+
const email = verifiedEmailOf(claims);
|
|
297
|
+
const username = await this.freeUsername(usernameBaseOf(claims, email));
|
|
298
|
+
const displayName = (typeof claims.name === "string") ? claims.name : username;
|
|
299
|
+
return this.accounts.createUser({ username: username, displayName: displayName, email: email, role: role, password: null, oidcSubject: String(claims.sub) });
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* A username not taken yet: the base, else the base with a number.
|
|
303
|
+
* @param base A valid username base.
|
|
304
|
+
* @returns The username.
|
|
305
|
+
*/
|
|
306
|
+
async freeUsername(base) {
|
|
307
|
+
if ((await this.accounts.findByUsername(base)) == null)
|
|
308
|
+
return base;
|
|
309
|
+
for (let suffix = 2;; suffix++) {
|
|
310
|
+
const candidate = base.substring(0, USERNAME_MAX_LENGTH - String(suffix).length - 1) + "-" + suffix;
|
|
311
|
+
if ((await this.accounts.findByUsername(candidate)) == null)
|
|
312
|
+
return candidate;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The email of the claims when the provider verified it.
|
|
318
|
+
* @param claims The verified claims.
|
|
319
|
+
* @returns The lower-case email, or null.
|
|
320
|
+
*/
|
|
321
|
+
function verifiedEmailOf(claims) {
|
|
322
|
+
return (typeof claims.email === "string" && claims.email_verified === true) ? claims.email.toLowerCase() : null;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* A username base from preferred_username, the email's local part or a default, cut to the username rule.
|
|
326
|
+
* @param claims The verified claims.
|
|
327
|
+
* @param email The verified email, or null.
|
|
328
|
+
* @returns The base.
|
|
329
|
+
*/
|
|
330
|
+
function usernameBaseOf(claims, email) {
|
|
331
|
+
const preferred = (typeof claims.preferred_username === "string") ? claims.preferred_username : null;
|
|
332
|
+
const raw = preferred ?? (email != null ? email.split("@")[0] : DEFAULT_USERNAME_BASE);
|
|
333
|
+
const cleaned = raw.toLowerCase().replace(USERNAME_INVALID_CHARACTERS, "-").substring(0, USERNAME_MAX_LENGTH);
|
|
334
|
+
return (cleaned.length >= USERNAME_MIN_LENGTH) ? cleaned : DEFAULT_USERNAME_BASE;
|
|
335
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { AdminAccounts } from "./AdminAccounts.js";
|
|
2
|
+
import type { PasskeyAlgorithm, PasskeyAssertionView, PasskeyAttestationView, PasskeyCreationOptionsView, PasskeyRequestOptionsView, PasskeyView } from "./AdminApiTypes.js";
|
|
3
|
+
import type { AdminClock } from "./AdminClock.js";
|
|
4
|
+
import type { AdminUserRecord, PasskeyRecord } from "./AdminRecords.js";
|
|
5
|
+
import type { AdminStore } from "./AdminStore.js";
|
|
6
|
+
import type { AdminPasskeyConfig } from "../GameServerConfig.js";
|
|
7
|
+
/** Registers passkeys and verifies passkey sign-ins. */
|
|
8
|
+
export declare class Passkeys {
|
|
9
|
+
/** The admin store. */
|
|
10
|
+
private readonly store;
|
|
11
|
+
/** The clock of challenge expiries and use times. */
|
|
12
|
+
private readonly clock;
|
|
13
|
+
/** The passkey settings. */
|
|
14
|
+
private readonly config;
|
|
15
|
+
/** The admin accounts. */
|
|
16
|
+
private readonly accounts;
|
|
17
|
+
/**
|
|
18
|
+
* Creates the passkey service.
|
|
19
|
+
* @param store The admin store.
|
|
20
|
+
* @param clock The clock.
|
|
21
|
+
* @param config admin.passkeys.
|
|
22
|
+
* @param accounts The admin accounts.
|
|
23
|
+
*/
|
|
24
|
+
constructor(store: AdminStore, clock: AdminClock, config: AdminPasskeyConfig, accounts: AdminAccounts);
|
|
25
|
+
/** True when admin.passkeys.enabled. */
|
|
26
|
+
get isEnabled(): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Options of navigator.credentials.create for a signed-in admin.
|
|
29
|
+
* @param user The account.
|
|
30
|
+
* @param consoleOrigin The console origin (its host is the default relying party id).
|
|
31
|
+
* @returns The options.
|
|
32
|
+
*/
|
|
33
|
+
creationOptions(user: AdminUserRecord, consoleOrigin: string): Promise<PasskeyCreationOptionsView>;
|
|
34
|
+
/**
|
|
35
|
+
* Registers a passkey from navigator.credentials.create's result (attestation "none" only).
|
|
36
|
+
* @param user The signed-in account.
|
|
37
|
+
* @param name The passkey's name.
|
|
38
|
+
* @param attestation The credential.
|
|
39
|
+
* @param consoleOrigin The console origin.
|
|
40
|
+
* @returns The stored passkey.
|
|
41
|
+
* @throws AdminRequestError 400 for a refused credential, 409 for a passkey already registered.
|
|
42
|
+
*/
|
|
43
|
+
register(user: AdminUserRecord, name: string, attestation: PasskeyAttestationView, consoleOrigin: string): Promise<PasskeyRecord>;
|
|
44
|
+
/**
|
|
45
|
+
* Options of navigator.credentials.get. With a username, its passkeys are listed; an unknown username gets an empty
|
|
46
|
+
* list like a discoverable sign-in, so the answer never reveals which usernames exist.
|
|
47
|
+
* @param username The username, or null for any account's discoverable passkey.
|
|
48
|
+
* @param consoleOrigin The console origin (its host is the default relying party id).
|
|
49
|
+
* @returns The options.
|
|
50
|
+
*/
|
|
51
|
+
requestOptions(username: string | null, consoleOrigin: string): Promise<PasskeyRequestOptionsView>;
|
|
52
|
+
/**
|
|
53
|
+
* Verifies navigator.credentials.get's result (DR §7.11): client data, relying party, presence, signature and sign
|
|
54
|
+
* count, then records the use.
|
|
55
|
+
* @param assertion The assertion.
|
|
56
|
+
* @param consoleOrigin The console origin.
|
|
57
|
+
* @returns The account and whether the authenticator verified the user.
|
|
58
|
+
* @throws AdminRequestError 401 for a refused assertion.
|
|
59
|
+
*/
|
|
60
|
+
verifyAssertion(assertion: PasskeyAssertionView, consoleOrigin: string): Promise<{
|
|
61
|
+
user: AdminUserRecord;
|
|
62
|
+
userVerified: boolean;
|
|
63
|
+
}>;
|
|
64
|
+
/**
|
|
65
|
+
* An account's passkeys, oldest first.
|
|
66
|
+
* @param userId The account.
|
|
67
|
+
* @returns The passkeys.
|
|
68
|
+
*/
|
|
69
|
+
list(userId: string): Promise<PasskeyView[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Removes one of an account's passkeys.
|
|
72
|
+
* @param userId The account.
|
|
73
|
+
* @param passkeyId The passkey.
|
|
74
|
+
* @throws AdminRequestError 404 when the account has no such passkey.
|
|
75
|
+
*/
|
|
76
|
+
remove(userId: string, passkeyId: string): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* The relying party id: admin.passkeys.rpId, or the console's host name.
|
|
79
|
+
* @param consoleOrigin The console origin.
|
|
80
|
+
* @returns The id.
|
|
81
|
+
*/
|
|
82
|
+
private rpIdOf;
|
|
83
|
+
/**
|
|
84
|
+
* Checks authenticator data against the relying party and the user-present flag. User verification is checked by
|
|
85
|
+
* the caller, since only a password-less sign-in needs it.
|
|
86
|
+
* @param authenticatorData The parsed data.
|
|
87
|
+
* @param consoleOrigin The console origin.
|
|
88
|
+
* @param status The status of a refusal.
|
|
89
|
+
* @throws AdminRequestError with the status on a mismatch.
|
|
90
|
+
*/
|
|
91
|
+
private checkAuthenticatorData;
|
|
92
|
+
/**
|
|
93
|
+
* Creates and stores a challenge for CHALLENGE_LIFETIME_MS.
|
|
94
|
+
* @param kind Registration or sign-in.
|
|
95
|
+
* @param userId The account it is for, or null.
|
|
96
|
+
* @returns The challenge, base64url.
|
|
97
|
+
*/
|
|
98
|
+
private createChallenge;
|
|
99
|
+
/**
|
|
100
|
+
* Takes a live challenge of a kind out of the store (single use).
|
|
101
|
+
* @param challenge The challenge from clientDataJSON.
|
|
102
|
+
* @param kind The kind the call needs.
|
|
103
|
+
* @param status The status of a refusal.
|
|
104
|
+
* @returns The challenge record.
|
|
105
|
+
* @throws AdminRequestError with the status when it is unknown, expired, used or of another kind.
|
|
106
|
+
*/
|
|
107
|
+
private consumeChallenge;
|
|
108
|
+
/**
|
|
109
|
+
* An account's stored passkeys.
|
|
110
|
+
* @param userId The account.
|
|
111
|
+
* @returns The records.
|
|
112
|
+
*/
|
|
113
|
+
private passkeysOf;
|
|
114
|
+
/**
|
|
115
|
+
* The passkey with a credential id.
|
|
116
|
+
* @param credentialId The base64url credential id.
|
|
117
|
+
* @returns The stored passkey, or null.
|
|
118
|
+
*/
|
|
119
|
+
private findByCredentialId;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Turns a COSE public key into a JWK: EC2 P-256 (x -2, y -3), RSA (n -1, e -2) or OKP Ed25519 (x -2).
|
|
123
|
+
* @param coseKey The decoded COSE key.
|
|
124
|
+
* @returns The JWK and its algorithm.
|
|
125
|
+
* @throws AdminRequestError 400 for another key type, curve or algorithm.
|
|
126
|
+
*/
|
|
127
|
+
export declare function coseKeyToJwk(coseKey: Map<unknown, unknown>): {
|
|
128
|
+
jwk: JsonWebKey;
|
|
129
|
+
algorithm: PasskeyAlgorithm;
|
|
130
|
+
};
|