@metaplay/metaplay-auth 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,6 @@ import { dump } from 'js-yaml'
3
3
  import { getUserinfo } from './auth.js'
4
4
  import { isRunningUnderNpx } from './utils.js'
5
5
  import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr'
6
- import { defaultStackApiBaseUrl } from './stackapi.js'
7
6
 
8
7
  interface AwsCredentialsResponse {
9
8
  AccessKeyId: string
@@ -58,6 +57,7 @@ interface KubeConfigUser {
58
57
  command: string
59
58
  args: string[]
60
59
  apiVersion: string
60
+ interactiveMode: string
61
61
  }
62
62
  }
63
63
  }
@@ -85,53 +85,48 @@ export interface DockerCredentials {
85
85
 
86
86
  export class TargetEnvironment {
87
87
  private readonly accessToken: string
88
- private readonly environmentDomain: string
88
+ private readonly humanId: string
89
89
  private readonly stackApiBaseUrl: string
90
- private readonly environmentApiBaseUrl: string
91
- private readonly organization?: string
92
- private readonly project?: string
93
- private readonly environment?: string
94
90
 
95
- constructor (accessToken: string, environmentDomain: string, stackApiBaseUrl: string, environmentApiBaseUrl: string, organization?: string, project?: string, environment?: string) {
91
+ constructor(accessToken: string, humanId: string, stackApiBaseUrl: string) {
96
92
  this.accessToken = accessToken
97
- this.environmentDomain = environmentDomain
93
+ this.humanId = humanId
98
94
  this.stackApiBaseUrl = stackApiBaseUrl
99
- this.environmentApiBaseUrl = environmentApiBaseUrl
100
- this.organization = organization
101
- this.project = project
102
- this.environment = environment
103
95
  }
104
96
 
105
- async fetchJson<T> (url: string, method: string): Promise<T> {
97
+ async fetchJson<T>(url: string, method: string): Promise<T> {
106
98
  const response = await fetch(url, {
107
99
  method,
108
100
  headers: {
109
101
  Authorization: `Bearer ${this.accessToken}`,
110
- 'Content-Type': 'application/json'
111
- }
102
+ 'Content-Type': 'application/json',
103
+ },
112
104
  })
113
105
 
114
106
  if (response.status !== 200) {
115
107
  throw new Error(`Failed to fetch ${method} ${url}: ${response.statusText}, response code=${response.status}`)
116
108
  }
117
109
 
118
- return await response.json() as T
110
+ return (await response.json()) as T
119
111
  }
120
112
 
121
- async fetchText (url: string, method: string): Promise<string> {
113
+ async fetchText(url: string, method: string): Promise<string> {
114
+ // eslint-disable-next-line @typescript-eslint/init-declarations
122
115
  let response
123
116
  try {
124
117
  response = await fetch(url, {
125
118
  method,
126
119
  headers: {
127
120
  Authorization: `Bearer ${this.accessToken}`,
128
- 'Content-Type': 'application/json'
129
- }
121
+ 'Content-Type': 'application/json',
122
+ },
130
123
  })
131
124
  } catch (error: any) {
132
125
  logger.error(`Failed to fetch ${method} ${url}:`, error)
133
126
  if (error.cause?.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
134
- throw new Error(`Failed to to fetch ${url}: SSL certificate validation failed. Is someone trying to tamper with your internet connection?`)
127
+ throw new Error(
128
+ `Failed to to fetch ${url}: SSL certificate validation failed. Is someone trying to tamper with your internet connection?`
129
+ )
135
130
  }
136
131
  throw new Error(`Failed to fetch ${url}: ${error}`)
137
132
  }
@@ -143,14 +138,22 @@ export class TargetEnvironment {
143
138
  return await response.text()
144
139
  }
145
140
 
146
- async getAwsCredentials (): Promise<AwsCredentialsResponse> {
147
- const url = `${this.environmentApiBaseUrl}/credentials/aws`
141
+ /**
142
+ * Get AWS credentials for the target environment from Metaplay cloud.
143
+ * @returns The AWS credentials.
144
+ */
145
+ async getAwsCredentials(): Promise<AwsCredentialsResponse> {
146
+ const url = `${this.stackApiBaseUrl}/v0/credentials/${this.humanId}/aws`
148
147
  logger.debug(`Fetching AWS credentials from ${url}...`)
149
148
  return await this.fetchJson<AwsCredentialsResponse>(url, 'POST')
150
149
  }
151
150
 
152
- async getKubeConfig (): Promise<string> {
153
- const url = `${this.environmentApiBaseUrl}/credentials/k8s`
151
+ /**
152
+ * Get a short-lived kubeconfig with the access credentials embedded in the kubeconfig file.
153
+ * @returns The kubeconfig YAML.
154
+ */
155
+ async getKubeConfigWithEmbeddedCredentials(): Promise<string> {
156
+ const url = `${this.stackApiBaseUrl}/v0/credentials/${this.humanId}/k8s`
154
157
  logger.debug(`Getting KubeConfig from ${url}...`)
155
158
  return await this.fetchText(url, 'POST')
156
159
  }
@@ -160,16 +163,17 @@ export class TargetEnvironment {
160
163
  * access credentials each time the kubeconfig is used.
161
164
  * @returns The kubeconfig YAML.
162
165
  */
163
- async getKubeConfigExecCredential (): Promise<string> {
164
- const url = `${this.environmentApiBaseUrl}/credentials/k8s?type=execcredential`
166
+ async getKubeConfigWithExecCredential(): Promise<string> {
167
+ const url = `${this.stackApiBaseUrl}/v0/credentials/${this.humanId}/k8s?type=execcredential`
165
168
  logger.debug(`Getting Kubernetes KubeConfig from ${url}...`)
166
169
 
167
170
  // 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
168
172
  let kubeExecCredential: KubeExecCredential
169
173
  try {
170
174
  kubeExecCredential = await this.fetchJson<KubeExecCredential>(url, 'POST')
171
175
  } catch {
172
- throw new Error('Failed to fetch Kubernetes KubeConfig')
176
+ throw new Error(`Failed to fetch Kubernetes KubeConfig from ${url}`)
173
177
  }
174
178
 
175
179
  if (!kubeExecCredential.spec.cluster) {
@@ -187,15 +191,11 @@ export class TargetEnvironment {
187
191
  const userinfo = await getUserinfo(this.accessToken)
188
192
  const userId = userinfo.email
189
193
 
190
- // Resolve the environment identity (prefer org-proj-env or fall back to FQDN)
191
- const envId = this.organization ? `${this.organization}-${this.project}-${this.environment}` : this.environmentDomain
192
-
193
194
  // Resolve invocation for getting the kubeconfig exec credential
194
195
  let execCmd = 'metaplay-auth'
195
- let execArgs =
196
- ['get-kubernetes-execcredential']
197
- .concat(this.stackApiBaseUrl !== defaultStackApiBaseUrl ? ['--stack-api', this.stackApiBaseUrl] : [])
198
- .concat([envId])
196
+ let execArgs = ['get-kubernetes-execcredential']
197
+ .concat(['--stack-api', this.stackApiBaseUrl]) // include URL to avoid lookups from portal
198
+ .concat([this.humanId]) // use the immutable humanId to tolerate slug renaming
199
199
 
200
200
  // If running under npx, use npx also for getting the kube exec credential
201
201
  if (isRunningUnderNpx()) {
@@ -203,32 +203,32 @@ export class TargetEnvironment {
203
203
  execArgs = [
204
204
  '--yes', // For NPX to silently install or update
205
205
  '@metaplay/metaplay-auth@latest',
206
- ...execArgs
206
+ ...execArgs,
207
207
  ]
208
208
  }
209
209
 
210
210
  const kubeConfig: KubeConfig = {
211
211
  apiVersion: 'v1',
212
212
  kind: 'Config',
213
- 'current-context': envId,
213
+ 'current-context': this.humanId, // use immutable humanId
214
214
  clusters: [
215
215
  {
216
216
  cluster: {
217
217
  'certificate-authority-data': kubeExecCredential.spec.cluster.certificateAuthorityData,
218
- server: kubeExecCredential.spec.cluster.server
218
+ server: kubeExecCredential.spec.cluster.server,
219
219
  },
220
- name: kubeExecCredential.spec.cluster.server
221
- }
220
+ name: kubeExecCredential.spec.cluster.server,
221
+ },
222
222
  ],
223
223
  contexts: [
224
224
  {
225
225
  context: {
226
226
  cluster: kubeExecCredential.spec.cluster.server,
227
227
  namespace,
228
- user: userId
228
+ user: userId,
229
229
  },
230
- name: envId,
231
- }
230
+ name: this.humanId, // use immutable humanId
231
+ },
232
232
  ],
233
233
  users: [
234
234
  {
@@ -237,10 +237,11 @@ export class TargetEnvironment {
237
237
  exec: {
238
238
  apiVersion: 'client.authentication.k8s.io/v1beta1',
239
239
  command: execCmd,
240
- args: execArgs
241
- }
242
- }
243
- }
240
+ args: execArgs,
241
+ interactiveMode: 'Never',
242
+ },
243
+ },
244
+ },
244
245
  ],
245
246
  }
246
247
 
@@ -248,21 +249,19 @@ export class TargetEnvironment {
248
249
  return dump(kubeConfig)
249
250
  }
250
251
 
251
- async getKubeExecCredential (): Promise<string> {
252
- const url = `${this.environmentApiBaseUrl}/credentials/k8s?type=execcredential`
252
+ async getKubeExecCredential(): Promise<string> {
253
+ const url = `${this.stackApiBaseUrl}/v0/credentials/${this.humanId}/k8s?type=execcredential`
253
254
  logger.debug(`Getting Kubernetes ExecCredential from ${url}...`)
254
255
  return await this.fetchText(url, 'POST')
255
256
  }
256
257
 
257
- async getEnvironmentDetails (): Promise<EnvironmentDetails> {
258
- // \note If invoking StackAPI via the https://<environment>/.infra, we need to add '/environment' to the path
259
- const urlSuffix = this.environmentApiBaseUrl.endsWith('.infra') ? '/environment' : ''
260
- const url = this.environmentApiBaseUrl + urlSuffix
258
+ async getEnvironmentDetails(): Promise<EnvironmentDetails> {
259
+ const url = `${this.stackApiBaseUrl}/v0/deployments/${this.humanId}`
261
260
  logger.debug(`Getting environment details from ${url}...`)
262
261
  return await this.fetchJson<EnvironmentDetails>(url, 'GET')
263
262
  }
264
263
 
265
- async getDockerCredentials (): Promise<DockerCredentials> {
264
+ async getDockerCredentials(): Promise<DockerCredentials> {
266
265
  // Get environment info (for AWS region)
267
266
  logger.debug('Get environment info')
268
267
  const envInfo = await this.getEnvironmentDetails()
@@ -277,16 +276,21 @@ export class TargetEnvironment {
277
276
  credentials: {
278
277
  accessKeyId: awsCredentials.AccessKeyId,
279
278
  secretAccessKey: awsCredentials.SecretAccessKey,
280
- sessionToken: awsCredentials.SessionToken
279
+ sessionToken: awsCredentials.SessionToken,
281
280
  },
282
- region: envInfo.deployment.aws_region
281
+ region: envInfo.deployment.aws_region,
283
282
  })
284
283
 
285
284
  // Fetch the ECR docker authentication token
286
285
  logger.debug('Fetch ECR login credentials from AWS')
287
286
  const command = new GetAuthorizationTokenCommand({})
288
287
  const response = await client.send(command)
289
- if (!response.authorizationData || response.authorizationData.length === 0 || !response.authorizationData[0].authorizationToken || !response.authorizationData[0].proxyEndpoint) {
288
+ if (
289
+ !response.authorizationData ||
290
+ response.authorizationData.length === 0 ||
291
+ !response.authorizationData[0].authorizationToken ||
292
+ !response.authorizationData[0].proxyEndpoint
293
+ ) {
290
294
  throw new Error('Received an empty authorization token response for ECR repository')
291
295
  }
292
296
 
@@ -301,7 +305,7 @@ export class TargetEnvironment {
301
305
  return {
302
306
  username,
303
307
  password,
304
- registryUrl
308
+ registryUrl,
305
309
  } satisfies DockerCredentials
306
310
  }
307
311
  }
package/src/utils.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { spawn } from 'child_process'
2
+ import { logger } from './logging.js'
2
3
  import path from 'path'
4
+ import yaml from 'js-yaml'
5
+ import * as semver from 'semver'
3
6
 
4
7
  /**
5
8
  * Checks if the given string is a fully qualified domain name (FQDN).
@@ -7,7 +10,7 @@ import path from 'path'
7
10
  * @param domain The domain name to check.
8
11
  * @returns true if the domain is a valid FQDN, false otherwise.
9
12
  */
10
- export function isValidFQDN (domain: string): boolean {
13
+ export function isValidFQDN(domain: string): boolean {
11
14
  const fqdnRegex = /^(?=.{1,253}$)(([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,})$/
12
15
 
13
16
  return fqdnRegex.test(domain)
@@ -19,7 +22,12 @@ export function isValidFQDN (domain: string): boolean {
19
22
  * @param urlString The HTTP URL string to be split.
20
23
  * @returns An object containing the scheme, hostname, port, and subpaths.
21
24
  */
22
- export function splitUrlComponents (urlString: string): { scheme: string, hostname: string, port: string | null, subpaths: string | null } {
25
+ export function splitUrlComponents(urlString: string): {
26
+ scheme: string
27
+ hostname: string
28
+ port: string | null
29
+ subpaths: string | null
30
+ } {
23
31
  try {
24
32
  const url = new URL(urlString)
25
33
 
@@ -32,50 +40,55 @@ export function splitUrlComponents (urlString: string): { scheme: string, hostna
32
40
  const subpaths = url.pathname.slice(1) ?? null
33
41
 
34
42
  return { scheme, hostname, port, subpaths }
43
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
35
44
  } catch (error) {
36
45
  throw new Error('Invalid URL')
37
46
  }
38
47
  }
39
48
 
40
- export function getGameserverAdminUrl (uri: string): string {
41
- const parts = uri.split('.')
42
-
43
- const hostname = parts[0]
44
- const domain = uri.substring(hostname.length + 1)
45
-
46
- return `${hostname}-admin.${domain}`
47
- }
48
-
49
49
  export interface ExecuteCommandResult {
50
50
  exitCode: number | null
51
51
  stdout: any[]
52
52
  stderr: any[]
53
53
  }
54
54
 
55
+ export interface ExecuteCommandOptions {
56
+ /**
57
+ * Should the stdin/stdout/stderr be inherited from the parent process? If false, the outputs are buffered for reading later.
58
+ */
59
+ inheritStdio?: boolean
60
+ /**
61
+ * Environment variables to pass in. Does not inherit parent process env. Use current process env if not specified.
62
+ */
63
+ env?: NodeJS.ProcessEnv
64
+ }
65
+
55
66
  /**
56
67
  * Run a child process and return an awaitable Promise.
57
68
  *
58
69
  * @param command Name of the command/binary to execute.
59
- * @param args List of arugments to pass to the child process.
60
- * @param inheritStdio Should the stdin/stdout/stderr be inherited from the parent process? If false, the outputs are buffered for reading later.
61
- * @param env Environment variables to pass in. Does not inherit parent process env. Use current process env if not specified.
70
+ * @param args List of arguments to pass to the child process.
62
71
  * @returns Awaitable promise with the result of the execution.
63
72
  */
64
- export async function executeCommand (command: string, args: string[], inheritStdio: boolean, env?: NodeJS.ProcessEnv): Promise<ExecuteCommandResult> {
73
+ export async function executeCommand(
74
+ command: string,
75
+ args: string[],
76
+ options?: { inheritStdio?: boolean; env?: NodeJS.ProcessEnv }
77
+ ): Promise<ExecuteCommandResult> {
65
78
  return await new Promise((resolve, reject) => {
66
79
  const childProcess = spawn(command, args, {
67
- stdio: inheritStdio ? 'inherit' : undefined,
68
- env,
80
+ stdio: options?.inheritStdio ? 'inherit' : undefined,
81
+ env: options?.env,
69
82
  })
70
- let stdout: any[] = []
71
- let stderr: any[] = []
83
+ const stdout: string[] = []
84
+ const stderr: string[] = []
72
85
 
73
- childProcess.stdout?.on('data', (data) => {
74
- stdout += data
86
+ childProcess.stdout?.on('data', (data: string) => {
87
+ stdout.push(data)
75
88
  })
76
89
 
77
- childProcess.stderr?.on('data', (data) => {
78
- stderr += data
90
+ childProcess.stderr?.on('data', (data: string) => {
91
+ stderr.push(data)
79
92
  })
80
93
 
81
94
  // If program runs to completion, resolve promise with success
@@ -84,16 +97,12 @@ export async function executeCommand (command: string, args: string[], inheritSt
84
97
  })
85
98
 
86
99
  childProcess.on('error', (err) => {
87
- reject(
88
- new Error(
89
- `Failed to execute command '${command} ${args.join(' ')}': ${err.message}`
90
- )
91
- )
100
+ reject(new Error(`Failed to execute command '${command} ${args.join(' ')}': ${err.message}`))
92
101
  })
93
102
  })
94
103
  }
95
104
 
96
- export function isRunningUnderNpx () {
105
+ export function isRunningUnderNpx(): boolean {
97
106
  // console.log('process.argv:', process.argv)
98
107
  const matchBinary = (binaryPath: string, binaryName: string): boolean => {
99
108
  const binary = path.basename(binaryPath, path.extname(binaryPath)) // drop path and extension
@@ -123,3 +132,85 @@ export function isRunningUnderNpx () {
123
132
 
124
133
  return false
125
134
  }
135
+
136
+ /**
137
+ * Join a set of path segments and return result with Unix path separators ('/').
138
+ * @param paths Individual path segments to join
139
+ * @returns Joined path
140
+ */
141
+ export function pathJoin(...paths: string[]): string {
142
+ return path.join(...paths).replaceAll('\\', '/')
143
+ }
144
+
145
+ /**
146
+ * Remove a trailing slash from a URL.
147
+ * @param url URL to remove the trailing slash from.
148
+ * @returns URL without a trailing slash.
149
+ */
150
+ export function removeTrailingSlash(url: string): string {
151
+ return url.replace(/\/+$/, '')
152
+ }
153
+
154
+ /**
155
+ * Entry in the Helm chart repository index.
156
+ */
157
+ interface HelmChartEntry {
158
+ name: string
159
+ version: string
160
+ description?: string
161
+ apiVersion?: string
162
+ appVersion?: string
163
+ urls: string[]
164
+ }
165
+
166
+ /**
167
+ * The index of the Helm chart repository. Contains an entry for each of published chart version.
168
+ */
169
+ interface HelmChartRepoIndex {
170
+ apiVersion: string
171
+ entries: Record<string, HelmChartEntry[]>
172
+ generated: string
173
+ }
174
+
175
+ /**
176
+ * Fetch the available versions of a specific Helm chart from a repository.
177
+ * @param repository URL of the Helm chart repository. No trailing slash allowed.
178
+ * @param chartName Name of the Helm chart.
179
+ * @returns List of available Helm chart versions.
180
+ */
181
+ export async function fetchHelmChartVersions(repository: string, chartName: string): Promise<string[]> {
182
+ // Fetch the index.yaml file from the repository
183
+ logger.debug(`Fetching Helm chart versions from '${repository}'...`)
184
+ const response: Response = await fetch(`${repository}/index.yaml`)
185
+ const indexYaml: string = await response.text()
186
+
187
+ // Parse the YAML index file
188
+ const repoIndex: HelmChartRepoIndex = yaml.load(indexYaml) as HelmChartRepoIndex
189
+
190
+ // Grab all versions >= 0.5.0 -- older are considered legacy
191
+ const chartVersions = repoIndex.entries[chartName]
192
+ .map((entry) => entry.version)
193
+ .filter((version) => semver.gte(version, '0.5.0'))
194
+ return chartVersions
195
+ }
196
+
197
+ /**
198
+ * Resolve the best matching version from a list of versions that satisfy a semver range.
199
+ * @param versions List of versions to resolve from.
200
+ * @param range Semver range to satisfy.
201
+ * @returns The best (latest) matching version or null if no versions satisfy the range.
202
+ */
203
+ export function resolveBestMatchingVersion(versions: string[], range: semver.Range | null): string | null {
204
+ // Filter versions that satisfy the range -- range==null means 'latest' == include all versions
205
+ const satisfyingVersions = range ? versions.filter((version) => semver.satisfies(version, range)) : versions
206
+ logger.debug(`Satisfying versions: ${satisfyingVersions.join(', ')}`)
207
+
208
+ // If no versions satisfy the range, return null
209
+ if (satisfyingVersions.length === 0) {
210
+ return null
211
+ }
212
+
213
+ // Sort the versions to get the highest one that matches the range
214
+ const sortedVersions = satisfyingVersions.sort(semver.rcompare)
215
+ return sortedVersions[0]
216
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = '1.5.0'
1
+ export const PACKAGE_VERSION = "1.6.0"