@metaplay/metaplay-auth 1.7.0 → 1.7.2

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/index.ts CHANGED
@@ -89,7 +89,7 @@ interface PortalEnvironmentInfo {
89
89
  * @param environment Environment slug.
90
90
  * @returns The portal's information about the environment.
91
91
  */
92
- // eslint-disable-next-line @typescript-eslint/max-params
92
+
93
93
  async function fetchManagedEnvironmentInfoWithSlugs(
94
94
  tokens: TokenSet,
95
95
  organization: string,
@@ -107,10 +107,7 @@ async function fetchManagedEnvironmentInfoWithSlugs(
107
107
  })
108
108
 
109
109
  // Throw on server errors (eg, forbidden)
110
- if (!response.ok) {
111
- const errorData = await response.json()
112
- throw new Error(`Failed to fetch environment details with error ${response.status}: ${JSON.stringify(errorData)}`)
113
- }
110
+ await checkEnvironmentInfoResponseError(response)
114
111
 
115
112
  // \todo Validate response?
116
113
  const portalEnvInfo = (await response.json()) as PortalEnvironmentInfo
@@ -130,20 +127,30 @@ async function fetchManageEnvironmentInfoWithHumanId(tokens: TokenSet, humanId:
130
127
  })
131
128
 
132
129
  // Throw on server errors (eg, forbidden)
133
- if (!response.ok) {
134
- const errorData = await response.json()
135
- throw new Error(`Failed to fetch environment details with error ${response.status}: ${JSON.stringify(errorData)}`)
136
- }
130
+ await checkEnvironmentInfoResponseError(response)
137
131
 
138
132
  // Return the result
139
133
  const portalEnvInfos = (await response.json()) as PortalEnvironmentInfo[]
140
134
  logger.debug(`Portal returned environment infos: ${JSON.stringify(portalEnvInfos, undefined, 2)}`)
141
135
  if (portalEnvInfos.length === 0) {
142
- throw new Error(`Failed to fetch details from portal for environment ${humanId}: no matching environment found`)
136
+ throw new Error(`Environment ${humanId} does not exist or your account does not have permissions to access this environment`)
143
137
  }
144
138
  return portalEnvInfos[0]
145
139
  }
146
140
 
141
+ async function checkEnvironmentInfoResponseError(response: Response): Promise<void> {
142
+ // Throw on server errors (eg, forbidden)
143
+ // Portal returns a helpful error message in 'message' field.
144
+ if (!response.ok) {
145
+ const errorData = await response.json()
146
+ if (typeof(errorData) === 'object' && errorData !== null && 'message' in errorData && typeof(errorData.message) === 'string') {
147
+ throw new Error(errorData.message)
148
+ } else {
149
+ throw new Error(`Failed to fetch environment details with error ${response.status}: ${JSON.stringify(errorData)}`)
150
+ }
151
+ }
152
+ }
153
+
147
154
  /**
148
155
  * Resolve the target environment based on (org, proj, env) tuple. We fetch the information
149
156
  * from the portal and then construct the `TargetEnvironment` class with the required information
@@ -154,7 +161,7 @@ async function fetchManageEnvironmentInfoWithHumanId(tokens: TokenSet, humanId:
154
161
  * @param environment Environment slug.
155
162
  * @returns The TargetEnvironment instance needed to operate with the environment.
156
163
  */
157
- // eslint-disable-next-line @typescript-eslint/max-params
164
+
158
165
  async function resolveTargetEnvironmentFromSlugs(
159
166
  tokens: TokenSet,
160
167
  organization: string,
@@ -251,8 +258,10 @@ program
251
258
  }
252
259
 
253
260
  // Store the portal base URL for accessing globally
254
- if (opts.portalBaseUrl) {
255
- setPortalBaseUrl(opts.portalBaseUrl as string)
261
+ const overridePortalUrl: string | undefined = opts.portalBaseUrl ?? process.env.AUTHCLI_PORTAL_BASEURL
262
+ if (overridePortalUrl) {
263
+ logger.debug(`Using portal URL override: ${overridePortalUrl}`)
264
+ setPortalBaseUrl(overridePortalUrl)
256
265
  }
257
266
 
258
267
  // Store the stack API base URL for accessing globally
@@ -309,6 +318,7 @@ program
309
318
  await removeTokens()
310
319
  console.log('Done! You are now logged out.')
311
320
  } catch (error) {
321
+ logger.debug('Invocation failed with error:', error)
312
322
  if (error instanceof Error) {
313
323
  console.error(`Error logging out: ${error.message}`)
314
324
  }
@@ -328,6 +338,7 @@ program
328
338
  const tokens = await loadTokens()
329
339
  console.log(JSON.stringify(tokens, undefined, 2))
330
340
  } catch (error) {
341
+ logger.debug('Invocation failed with error:', error)
331
342
  if (error instanceof Error) {
332
343
  console.error(`Error showing tokens: ${error.message}`)
333
344
  }
@@ -381,8 +392,9 @@ program
381
392
  console.log(kubeconfigPayload)
382
393
  }
383
394
  } catch (error) {
395
+ logger.debug('Invocation failed with error:', error)
384
396
  if (error instanceof Error) {
385
- console.error('Error getting KubeConfig:', error)
397
+ console.error('Error getting KubeConfig:', error.message)
386
398
  }
387
399
  exit(1)
388
400
  }
@@ -417,6 +429,7 @@ program
417
429
  const credentials = await targetEnv.getKubeExecCredential()
418
430
  console.log(credentials)
419
431
  } catch (error) {
432
+ logger.debug('Invocation failed with error:', error)
420
433
  if (error instanceof Error) {
421
434
  console.error(`Error getting Kubernetes ExecCredential: ${error.message}`)
422
435
  }
@@ -464,6 +477,7 @@ program
464
477
  )
465
478
  }
466
479
  } catch (error) {
480
+ logger.debug('Invocation failed with error:', error)
467
481
  if (error instanceof Error) {
468
482
  console.error(`Error getting AWS credentials: ${error.message}`)
469
483
  }
@@ -522,6 +536,7 @@ program
522
536
  )
523
537
  }
524
538
  } catch (error) {
539
+ logger.debug('Invocation failed with error:', error)
525
540
  if (error instanceof Error) {
526
541
  console.error(`Error getting docker login credentials: ${error.message}`)
527
542
  }
@@ -551,6 +566,7 @@ program
551
566
  const environment = await targetEnv.getEnvironmentDetails()
552
567
  console.log(JSON.stringify(environment, undefined, 2))
553
568
  } catch (error) {
569
+ logger.debug('Invocation failed with error:', error)
554
570
  if (error instanceof Error) {
555
571
  console.error(`Error getting environment details: ${error.message}`)
556
572
  }
@@ -630,7 +646,7 @@ program
630
646
  reject(error)
631
647
  } else {
632
648
  // result contains an array of all the progress objects
633
- logger.debug('Succesfully finished pushing docker image')
649
+ logger.debug('Successfully finished pushing docker image')
634
650
  resolve(result)
635
651
  }
636
652
  },
@@ -644,6 +660,7 @@ program
644
660
 
645
661
  console.log(`Successfully pushed docker image to ${dstImageName}!`)
646
662
  } catch (error) {
663
+ logger.debug('Invocation failed with error:', error)
647
664
  if (error instanceof Error) {
648
665
  console.error(`Failed to push docker image: ${error.message}`)
649
666
  }
@@ -749,7 +766,7 @@ program
749
766
  reject(error)
750
767
  } else {
751
768
  // result contains an array of all the progress objects
752
- logger.debug('Succesfully finished pulling image')
769
+ logger.debug('Successfully finished pulling image')
753
770
  resolve(result)
754
771
  }
755
772
  },
@@ -790,14 +807,16 @@ program
790
807
  options.helmChartRepo ?? imageLabels['io.metaplay.default_helm_repo'] ?? 'https://charts.metaplay.dev'
791
808
  )
792
809
 
810
+ // Warn about Helm chart version needing to be specified explicitly (unless using a local chart)
811
+ if (!options.helmChartVersion && !options.localChartPath) {
812
+ console.warn('Warning: You should specify the Helm chart version explicitly with --helm-chart-version=<version>!')
813
+ }
814
+
793
815
  // Resolve helmChartVersion, in order of precedence:
794
816
  // - Specified in the cli options
795
817
  // - Specified in the docker image label
796
818
  // - Unknown, error out!
797
819
  const helmChartVersionSpec = options.helmChartVersion ?? imageLabels['io.metaplay.default_server_chart_version']
798
- if (!options.helmChartVersion) {
799
- console.warn('Warning: You should specify the Helm chart version with --helm-chart-version=<version>!')
800
- }
801
820
  if (!helmChartVersionSpec) {
802
821
  throw new Error('No Helm chart version defined. With pre-R28 SDK versions, you must specify the Helm chart version explicitly with --helm-chart-version=<version>.')
803
822
  }
@@ -884,11 +903,13 @@ program
884
903
  console.log('Validating game server deployment...')
885
904
  await checkGameServerDeployment(envInfo, kubeconfig, imageTag)
886
905
  } catch (error) {
906
+ logger.debug('Invocation failed with error:', error)
887
907
  const errMessage = error instanceof Error ? error.message : String(error)
888
908
  console.error(`Failed to resolve game server deployment status: ${errMessage}`)
889
909
  exit(2)
890
910
  }
891
911
  } catch (error) {
912
+ logger.debug('Invocation failed with error:', error)
892
913
  if (error instanceof Error) {
893
914
  console.error(`Error deploying game server into target environment: ${error.message}`)
894
915
  }
@@ -933,6 +954,7 @@ program
933
954
  // \todo Get requiredImageTag from the Helm chart
934
955
  await checkGameServerDeployment(envInfo, kubeconfig, /* requiredImageTag: */ null)
935
956
  } catch (error: any) {
957
+ logger.debug('Invocation failed with error:', error)
936
958
  console.error(`Failed to check deployment status: ${error.message}`)
937
959
  exit(1)
938
960
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@metaplay/metaplay-auth",
3
3
  "description": "Utility CLI for authenticating with the Metaplay Auth and making authenticated calls to infrastructure endpoints.",
4
- "version": "1.7.0",
4
+ "version": "1.7.2",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "homepage": "https://metaplay.io",
@@ -18,22 +18,22 @@
18
18
  "devDependencies": {
19
19
  "@metaplay/eslint-config": "workspace:*",
20
20
  "@types/dockerode": "3.3.31",
21
- "@types/express": "4.17.21",
21
+ "@types/express": "5.0.0",
22
22
  "@types/js-yaml": "4.0.9",
23
23
  "@types/jsonwebtoken": "9.0.7",
24
24
  "@types/jwk-to-pem": "2.0.3",
25
- "@types/node": "20.16.1",
25
+ "@types/node": "20.16.10",
26
26
  "@types/semver": "7.5.8",
27
27
  "esbuild": "0.24.0",
28
- "tsx": "4.19.1",
29
- "typescript": "5.6.2",
30
- "vitest": "2.1.1",
31
- "@aws-sdk/client-ecr": "3.654.0",
32
- "@kubernetes/client-node": "1.0.0-rc6",
33
- "@ory/client": "1.15.4",
28
+ "tsx": "4.19.2",
29
+ "typescript": "5.6.3",
30
+ "vitest": "2.1.4",
31
+ "@aws-sdk/client-ecr": "3.682.0",
32
+ "@kubernetes/client-node": "1.0.0-rc7",
33
+ "@ory/client": "1.15.7",
34
34
  "commander": "12.1.0",
35
35
  "dockerode": "4.0.2",
36
- "h3": "1.12.0",
36
+ "h3": "1.13.0",
37
37
  "js-yaml": "4.1.0",
38
38
  "jsonwebtoken": "9.0.2",
39
39
  "jwk-to-pem": "2.0.6",
package/src/auth.ts CHANGED
@@ -1,4 +1,4 @@
1
- /* eslint-disable @typescript-eslint/no-misused-promises */
1
+
2
2
  import { toNodeListener, createApp, defineEventHandler, getQuery, sendError } from 'h3'
3
3
  import jwt from 'jsonwebtoken'
4
4
  import jwkToPem from 'jwk-to-pem'
@@ -324,7 +324,7 @@ async function extendCurrentSessionWithRefreshToken(
324
324
  * @param code
325
325
  * @returns
326
326
  */
327
- // eslint-disable-next-line @typescript-eslint/max-params
327
+
328
328
  async function getTokensWithAuthorizationCode(
329
329
  state: string,
330
330
  redirectUri: string,
@@ -356,7 +356,7 @@ async function getTokensWithAuthorizationCode(
356
356
  */
357
357
  export async function loadTokens(): Promise<TokenSet> {
358
358
  try {
359
- const tokens = (await getSecret('tokens')) as TokenSet
359
+ const tokens = (await getSecret('tokens')) as unknown as TokenSet
360
360
 
361
361
  if (!tokens) {
362
362
  throw new Error('Unable to load tokens. You need to login first.')
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Base URL to the Metaplay developer portal. Override with global flag --portal-base-url.
2
+ * Base URL to the Metaplay developer portal. Override with global flag --portal-base-url
3
+ * or environment variable `AUTHCLI_PORTAL_BASEURL'.
3
4
  */
4
5
  export let portalBaseUrl = 'https://portal.metaplay.dev'
5
6
 
package/src/deployment.ts CHANGED
@@ -3,7 +3,8 @@ import os from 'os'
3
3
  import path from 'path'
4
4
  import { exit } from 'process'
5
5
  import { EnvironmentDetails, TargetEnvironment } from 'targetenvironment.js'
6
- import * as net from 'net'
6
+ import { promises as dns } from 'dns'
7
+ import tls from 'tls'
7
8
 
8
9
  import {
9
10
  KubeConfig,
@@ -404,8 +405,8 @@ export async function waitForGameServerPodsToBeReady(
404
405
 
405
406
  // Handle state of the deployment
406
407
  if (anyPodsInPhase(podStatuses, GameServerPodPhase.Failed)) {
407
- logger.error(`Gameserver start failed with pod statuses: ${JSON.stringify(podStatuses, undefined, 2)}`)
408
- console.log('Gameserver failed to start due to the pods not starting properly! See above for details.')
408
+ logger.error(`Game server start failed with pod statuses: ${JSON.stringify(podStatuses, undefined, 2)}`)
409
+ console.log('Game server failed to start due to the pods not starting properly! See above for details.')
409
410
  for (let ndx = 0; ndx < pods.length; ndx += 1) {
410
411
  const status = podStatuses[ndx]
411
412
  const suffix = status.phase !== GameServerPodPhase.Ready ? ` -- ${status.message}` : ''
@@ -417,14 +418,14 @@ export async function waitForGameServerPodsToBeReady(
417
418
  anyPodsInPhase(podStatuses, GameServerPodPhase.Pending) ||
418
419
  anyPodsInPhase(podStatuses, GameServerPodPhase.Running)
419
420
  ) {
420
- console.log('Waiting for gameserver(s) to be ready...')
421
+ console.log('Waiting for pod(s) to be ready...')
421
422
  for (let ndx = 0; ndx < pods.length; ndx += 1) {
422
423
  const status = podStatuses[ndx]
423
424
  const suffix = status.phase !== GameServerPodPhase.Ready ? ` -- ${status.message}` : ''
424
425
  console.log(` ${pods[ndx].metadata?.name}: ${status.phase}${suffix}`)
425
426
  }
426
427
  } else if (allPodsInPhase(podStatuses, GameServerPodPhase.Ready)) {
427
- console.log('Gameserver is up and ready to serve!')
428
+ console.log('Game server pod(s) are up and ready to serve!')
428
429
  return
429
430
  } else {
430
431
  console.log('Deployment in inconsistent state, waiting...')
@@ -445,9 +446,34 @@ export async function waitForGameServerPodsToBeReady(
445
446
  }
446
447
  }
447
448
 
449
+ async function waitForDomainResolution(hostname: string): Promise<void> {
450
+ // Use 15min timeout -- DNS propagation can take a while
451
+ const timeoutAt = Date.now() + 15 * 60 * 1000
452
+
453
+ while (true) {
454
+ try {
455
+ const addresses = await dns.lookup(hostname)
456
+ console.log(`Successfully resolved domain ${hostname} to:`, addresses)
457
+ return
458
+ } catch (err) {
459
+ if (Date.now() > timeoutAt) {
460
+ throw new Error(`Could not resolve domain ${hostname} before timeout.`)
461
+ }
462
+
463
+ if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENOTFOUND') {
464
+ console.log(`Waiting for domain name ${hostname} to propagate... This can take up to 15 minutes on the first deploy.`)
465
+ } else {
466
+ console.log(`Failed to resolve ${hostname}: ${String(err)}. Retrying...`)
467
+ }
468
+ await delay(5000) // use long timeout as it can take ~5min for DNS to propagate
469
+ }
470
+ }
471
+ }
472
+
448
473
  /**
449
474
  * Wait until we can establish a client-simulating connection to the target
450
- * game server, or a timeout happens.
475
+ * game server and perform the TLS handshake and wait for the initial bytes
476
+ * to be received from the server, or a timeout happens.
451
477
  * @param hostname Hostname of the target server to connect to.
452
478
  * @param port Port to use for the connections (usually 9339).
453
479
  */
@@ -455,47 +481,49 @@ async function waitForGameServerClientEndpointToBeReady(
455
481
  hostname: string,
456
482
  port: number
457
483
  ): Promise<void> {
458
- const checkConnection = async (): Promise<boolean> => {
459
- return await new Promise((resolve) => {
460
- const socket = new net.Socket()
461
- // Set a short timeout for each attempt
462
- // Note: This does not include the DNS resolve time which can take a minute or so.
463
- // \todo Consider adding a DNS resolve step before trying to connect.
464
- socket.setTimeout(2000)
465
-
466
- socket.on('connect', () => {
467
- socket.destroy() // Clean up after success
468
- resolve(true)
469
- })
470
-
471
- socket.on('error', () => {
472
- socket.destroy() // Clean up after error
473
- resolve(false)
474
- })
475
-
476
- socket.on('timeout', () => {
477
- socket.destroy() // Clean up after timeout
478
- resolve(false)
479
- })
480
-
481
- socket.connect(port, hostname)
482
- })
483
- }
484
-
485
- // Try for 2 min before giving up
486
- const timeoutAt = Date.now() + 2 * 60 * 1000
484
+ // Try for a few minutes before giving up
485
+ const timeoutAt = Date.now() + 5 * 60 * 1000
487
486
 
488
487
  while (Date.now() < timeoutAt) {
489
- const connected = await checkConnection()
490
- if (connected) {
491
- console.log(`Successfully connected to ${hostname}:${port}`)
488
+ try {
489
+ await new Promise<void>((resolve, reject) => {
490
+ const socket: tls.TLSSocket = tls.connect({ host: hostname, port }, () => {
491
+ if (socket.authorized) {
492
+ console.log('TLS handshake completed, waiting to receive data from the server...')
493
+ // \todo This is enough with direct connection to the server but with the upcoming
494
+ // client-traffic-proxy, we may need to exchange the proper handshake messages
495
+ socket.once('data', (bytes: Buffer) => {
496
+ const hexBytes = Array.from(bytes).map(byte => byte.toString(16).padStart(2, '0'))
497
+ console.log(`Received ${bytes.length} bytes from server: ${hexBytes.join(' ')}`)
498
+ socket.end()
499
+ resolve()
500
+ })
501
+ } else {
502
+ socket.end()
503
+ reject(new Error(`TLS handshake failed: ${String(socket.authorizationError)}`))
504
+ }
505
+ })
506
+
507
+ socket.on('error', reject)
508
+
509
+ socket.setTimeout(5000, () => {
510
+ socket.end()
511
+ reject(new Error('Timeout while waiting for data after handshake'))
512
+ })
513
+ })
514
+
515
+ // Success
516
+ console.log(`Successfully connected to the target environment ${hostname}:${port}`)
492
517
  return
518
+ } catch (error) {
519
+ console.log(`Attempt failed, retrying: ${String(error)}`)
493
520
  }
494
- console.log(`Retrying connection to ${hostname}:${port}...`)
495
- await new Promise((resolve) => setTimeout(resolve, 1000))
521
+
522
+ // Wait a bit before trying again
523
+ await delay(1000)
496
524
  }
497
525
 
498
- throw new Error(`Timeout while trying to connect to ${hostname}:${port}.`)
526
+ throw new Error(`Timeout reached while waiting to establish connection to ${hostname}:${port}`)
499
527
  }
500
528
 
501
529
  /**
@@ -515,7 +543,10 @@ export async function checkGameServerDeployment(
515
543
  console.log('Waiting for game server pods to be ready...')
516
544
  await waitForGameServerPodsToBeReady(envInfo.deployment.kubernetes_namespace, kubeconfig, requiredImageTag)
517
545
 
518
- // \todo Add a separate step for a DNS check on the game server
546
+ // Resolve DNS for the target server
547
+ // \todo Use the -proxy address when client-traffic-proxy is in use
548
+ console.log(`Resolving the domain name '${envInfo.deployment.server_hostname}' of the server...`)
549
+ await waitForDomainResolution(envInfo.deployment.server_hostname)
519
550
 
520
551
  // Check that the game server accepts traffic on its client-facing endpoint
521
552
  const clientTrafficPort = 9339 // \todo check for other ports, too?
@@ -78,7 +78,7 @@ export async function setSecret(key: string, value: any): Promise<void> {
78
78
  await fs.writeFile(filePath, content)
79
79
  }
80
80
 
81
- export async function getSecret(key: string): Promise<any | undefined> {
81
+ export async function getSecret(key: string): Promise<string> {
82
82
  logger.debug(`Getting secret ${key}...`)
83
83
 
84
84
  const secrets = await loadSecrets()
@@ -85,6 +85,14 @@ export interface DockerCredentials {
85
85
  registryUrl: string
86
86
  }
87
87
 
88
+ class FetchJsonHttpError extends Error {
89
+ response: Response;
90
+ constructor(method: string, url: string, response: Response) {
91
+ super(`Failed to fetch ${method} ${url}: ${response.statusText}, response code=${response.status}`)
92
+ this.response = response;
93
+ }
94
+ }
95
+
88
96
  export class TargetEnvironment {
89
97
  private readonly accessToken: string
90
98
  private readonly humanId: string
@@ -106,14 +114,14 @@ export class TargetEnvironment {
106
114
  })
107
115
 
108
116
  if (response.status !== 200) {
109
- throw new Error(`Failed to fetch ${method} ${url}: ${response.statusText}, response code=${response.status}`)
117
+ throw new FetchJsonHttpError(method, url, response)
110
118
  }
111
119
 
112
120
  return (await response.json()) as T
113
121
  }
114
122
 
115
123
  async fetchText(url: string, method: string): Promise<string> {
116
- // eslint-disable-next-line @typescript-eslint/init-declarations
124
+
117
125
  let response
118
126
  try {
119
127
  response = await fetch(url, {
@@ -168,12 +176,21 @@ export class TargetEnvironment {
168
176
  logger.debug(`Getting Kubernetes KubeConfig from ${url}...`)
169
177
 
170
178
  // get the execcredential and morph it into a kubeconfig which calls metaplay-auth for the token
171
- // eslint-disable-next-line @typescript-eslint/init-declarations
179
+
172
180
  let kubeExecCredential: KubeExecCredential
173
181
  try {
174
182
  kubeExecCredential = await this.fetchJson<KubeExecCredential>(url, 'POST')
175
- } catch {
176
- throw new Error(`Failed to fetch Kubernetes KubeConfig from ${url}`)
183
+ } catch (error) {
184
+ // User-friendly error messages for well-known HTTP errors.
185
+ if (error instanceof FetchJsonHttpError) {
186
+ if (error.response.status === 404) {
187
+ throw new Error(`Environment ${this.humanId} does not exist.`)
188
+ } else if (error.response.status === 403) {
189
+ throw new Error(`Your account does not have permissions to access environment ${this.humanId}.`)
190
+ }
191
+ }
192
+ const errorMessage = (error instanceof Error) ? error.message : String(error)
193
+ throw new Error(`Failed to fetch Kubernetes KubeConfig: ${errorMessage}`)
177
194
  }
178
195
 
179
196
  if (!kubeExecCredential.spec.cluster) {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = "1.7.0"
1
+ export const PACKAGE_VERSION = "1.7.2"