@metaplay/metaplay-auth 1.4.2 → 1.5.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.
@@ -0,0 +1,307 @@
1
+ import { logger } from './logging.js'
2
+ import { dump } from 'js-yaml'
3
+ import { getUserinfo } from './auth.js'
4
+ import { isRunningUnderNpx } from './utils.js'
5
+ import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr'
6
+ import { defaultStackApiBaseUrl } from './stackapi.js'
7
+
8
+ interface AwsCredentialsResponse {
9
+ AccessKeyId: string
10
+ SecretAccessKey: string
11
+ SessionToken: string
12
+ Expiration: string
13
+ }
14
+
15
+ export interface DeploymentDetails {
16
+ server_hostname: string
17
+ kubernetes_namespace: string
18
+ ecr_repo: string
19
+ aws_region: string
20
+ }
21
+
22
+ export interface EnvironmentDetails {
23
+ deployment: DeploymentDetails
24
+ }
25
+
26
+ interface KubeConfig {
27
+ apiVersion?: string
28
+ kind: string
29
+ 'current-context': string
30
+ clusters: KubeConfigCluster[]
31
+ contexts: KubeConfigContext[]
32
+ users: KubeConfigUser[]
33
+ preferences?: any
34
+ }
35
+
36
+ interface KubeConfigCluster {
37
+ cluster: {
38
+ 'certificate-authority-data': string
39
+ server: string
40
+ }
41
+ name: string
42
+ }
43
+
44
+ interface KubeConfigContext {
45
+ context: {
46
+ cluster: string
47
+ user: string
48
+ namespace?: string
49
+ }
50
+ name: string
51
+ }
52
+
53
+ interface KubeConfigUser {
54
+ name: string
55
+ user: {
56
+ token?: string
57
+ exec?: {
58
+ command: string
59
+ args: string[]
60
+ apiVersion: string
61
+ }
62
+ }
63
+ }
64
+
65
+ interface KubeExecCredential {
66
+ apiVersion: string
67
+ kind: string
68
+ spec: {
69
+ cluster?: {
70
+ server: string
71
+ certificateAuthorityData: string
72
+ }
73
+ }
74
+ status: {
75
+ token: string
76
+ expirationTimestamp: string
77
+ }
78
+ }
79
+
80
+ export interface DockerCredentials {
81
+ username: string
82
+ password: string
83
+ registryUrl: string
84
+ }
85
+
86
+ export class TargetEnvironment {
87
+ private readonly accessToken: string
88
+ private readonly environmentDomain: string
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
+
95
+ constructor (accessToken: string, environmentDomain: string, stackApiBaseUrl: string, environmentApiBaseUrl: string, organization?: string, project?: string, environment?: string) {
96
+ this.accessToken = accessToken
97
+ this.environmentDomain = environmentDomain
98
+ this.stackApiBaseUrl = stackApiBaseUrl
99
+ this.environmentApiBaseUrl = environmentApiBaseUrl
100
+ this.organization = organization
101
+ this.project = project
102
+ this.environment = environment
103
+ }
104
+
105
+ async fetchJson<T> (url: string, method: string): Promise<T> {
106
+ const response = await fetch(url, {
107
+ method,
108
+ headers: {
109
+ Authorization: `Bearer ${this.accessToken}`,
110
+ 'Content-Type': 'application/json'
111
+ }
112
+ })
113
+
114
+ if (response.status !== 200) {
115
+ throw new Error(`Failed to fetch ${method} ${url}: ${response.statusText}, response code=${response.status}`)
116
+ }
117
+
118
+ return await response.json() as T
119
+ }
120
+
121
+ async fetchText (url: string, method: string): Promise<string> {
122
+ let response
123
+ try {
124
+ response = await fetch(url, {
125
+ method,
126
+ headers: {
127
+ Authorization: `Bearer ${this.accessToken}`,
128
+ 'Content-Type': 'application/json'
129
+ }
130
+ })
131
+ } catch (error: any) {
132
+ logger.error(`Failed to fetch ${method} ${url}:`, error)
133
+ 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?`)
135
+ }
136
+ throw new Error(`Failed to fetch ${url}: ${error}`)
137
+ }
138
+
139
+ if (response.status !== 200) {
140
+ throw new Error(`Failed to fetch ${method} ${url}: ${response.statusText}`)
141
+ }
142
+
143
+ return await response.text()
144
+ }
145
+
146
+ async getAwsCredentials (): Promise<AwsCredentialsResponse> {
147
+ const url = `${this.environmentApiBaseUrl}/credentials/aws`
148
+ logger.debug(`Fetching AWS credentials from ${url}...`)
149
+ return await this.fetchJson<AwsCredentialsResponse>(url, 'POST')
150
+ }
151
+
152
+ async getKubeConfig (): Promise<string> {
153
+ const url = `${this.environmentApiBaseUrl}/credentials/k8s`
154
+ logger.debug(`Getting KubeConfig from ${url}...`)
155
+ return await this.fetchText(url, 'POST')
156
+ }
157
+
158
+ /**
159
+ * Get a `kubeconfig` payload which invokes `metaplay-auth get-kubernetes-execcredential` to get the actual
160
+ * access credentials each time the kubeconfig is used.
161
+ * @returns The kubeconfig YAML.
162
+ */
163
+ async getKubeConfigExecCredential (): Promise<string> {
164
+ const url = `${this.environmentApiBaseUrl}/credentials/k8s?type=execcredential`
165
+ logger.debug(`Getting Kubernetes KubeConfig from ${url}...`)
166
+
167
+ // get the execcredential and morph it into a kubeconfig which calls metaplay-auth for the token
168
+ let kubeExecCredential: KubeExecCredential
169
+ try {
170
+ kubeExecCredential = await this.fetchJson<KubeExecCredential>(url, 'POST')
171
+ } catch {
172
+ throw new Error('Failed to fetch Kubernetes KubeConfig')
173
+ }
174
+
175
+ if (!kubeExecCredential.spec.cluster) {
176
+ throw new Error('Received kubeExecCredential with missing spec.cluster')
177
+ }
178
+
179
+ // Fetch environment namespace
180
+ const environment = await this.getEnvironmentDetails()
181
+ const namespace = environment.deployment?.kubernetes_namespace
182
+ if (!namespace) {
183
+ throw new Error('Environment details did not contain a valid Kubernetes namespace')
184
+ }
185
+
186
+ // Fetch user id
187
+ const userinfo = await getUserinfo(this.accessToken)
188
+ const userId = userinfo.email
189
+
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
+ // Resolve invocation for getting the kubeconfig exec credential
194
+ let execCmd = 'metaplay-auth'
195
+ let execArgs =
196
+ ['get-kubernetes-execcredential']
197
+ .concat(this.stackApiBaseUrl !== defaultStackApiBaseUrl ? ['--stack-api', this.stackApiBaseUrl] : [])
198
+ .concat([envId])
199
+
200
+ // If running under npx, use npx also for getting the kube exec credential
201
+ if (isRunningUnderNpx()) {
202
+ execCmd = 'npx'
203
+ execArgs = [
204
+ '--yes', // For NPX to silently install or update
205
+ '@metaplay/metaplay-auth@latest',
206
+ ...execArgs
207
+ ]
208
+ }
209
+
210
+ const kubeConfig: KubeConfig = {
211
+ apiVersion: 'v1',
212
+ kind: 'Config',
213
+ 'current-context': envId,
214
+ clusters: [
215
+ {
216
+ cluster: {
217
+ 'certificate-authority-data': kubeExecCredential.spec.cluster.certificateAuthorityData,
218
+ server: kubeExecCredential.spec.cluster.server
219
+ },
220
+ name: kubeExecCredential.spec.cluster.server
221
+ }
222
+ ],
223
+ contexts: [
224
+ {
225
+ context: {
226
+ cluster: kubeExecCredential.spec.cluster.server,
227
+ namespace,
228
+ user: userId
229
+ },
230
+ name: envId,
231
+ }
232
+ ],
233
+ users: [
234
+ {
235
+ name: userId,
236
+ user: {
237
+ exec: {
238
+ apiVersion: 'client.authentication.k8s.io/v1beta1',
239
+ command: execCmd,
240
+ args: execArgs
241
+ }
242
+ }
243
+ }
244
+ ],
245
+ }
246
+
247
+ // return as yaml for easier consumption
248
+ return dump(kubeConfig)
249
+ }
250
+
251
+ async getKubeExecCredential (): Promise<string> {
252
+ const url = `${this.environmentApiBaseUrl}/credentials/k8s?type=execcredential`
253
+ logger.debug(`Getting Kubernetes ExecCredential from ${url}...`)
254
+ return await this.fetchText(url, 'POST')
255
+ }
256
+
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
261
+ logger.debug(`Getting environment details from ${url}...`)
262
+ return await this.fetchJson<EnvironmentDetails>(url, 'GET')
263
+ }
264
+
265
+ async getDockerCredentials (): Promise<DockerCredentials> {
266
+ // Get environment info (for AWS region)
267
+ logger.debug('Get environment info')
268
+ const envInfo = await this.getEnvironmentDetails()
269
+
270
+ // Fetch AWS credentials from Metaplay cloud
271
+ logger.debug('Get AWS credentials from Metaplay')
272
+ const awsCredentials = await this.getAwsCredentials()
273
+
274
+ // Create ECR client with credentials
275
+ logger.debug('Create ECR client')
276
+ const client = new ECRClient({
277
+ credentials: {
278
+ accessKeyId: awsCredentials.AccessKeyId,
279
+ secretAccessKey: awsCredentials.SecretAccessKey,
280
+ sessionToken: awsCredentials.SessionToken
281
+ },
282
+ region: envInfo.deployment.aws_region
283
+ })
284
+
285
+ // Fetch the ECR docker authentication token
286
+ logger.debug('Fetch ECR login credentials from AWS')
287
+ const command = new GetAuthorizationTokenCommand({})
288
+ const response = await client.send(command)
289
+ if (!response.authorizationData || response.authorizationData.length === 0 || !response.authorizationData[0].authorizationToken || !response.authorizationData[0].proxyEndpoint) {
290
+ throw new Error('Received an empty authorization token response for ECR repository')
291
+ }
292
+
293
+ // Parse username and password from the response (separated by a ':')
294
+ logger.debug('Parse ECR response')
295
+ const registryUrl = response.authorizationData[0].proxyEndpoint
296
+ const authorization64 = response.authorizationData[0].authorizationToken
297
+ const authorization = Buffer.from(authorization64, 'base64').toString()
298
+ const [username, password] = authorization.split(':')
299
+ logger.debug(`ECR: username=${username}, proxyEndpoint=${registryUrl}`)
300
+
301
+ return {
302
+ username,
303
+ password,
304
+ registryUrl
305
+ } satisfies DockerCredentials
306
+ }
307
+ }
package/src/utils.ts CHANGED
@@ -1,8 +1,5 @@
1
1
  import { spawn } from 'child_process'
2
-
3
- export function stackApiBaseFromOptions (arg: string, options: Record<string, any>): string {
4
- return ''
5
- }
2
+ import path from 'path'
6
3
 
7
4
  /**
8
5
  * Checks if the given string is a fully qualified domain name (FQDN).
@@ -49,15 +46,27 @@ export function getGameserverAdminUrl (uri: string): string {
49
46
  return `${hostname}-admin.${domain}`
50
47
  }
51
48
 
52
- interface CommandResult {
53
- code: number | null
49
+ export interface ExecuteCommandResult {
50
+ exitCode: number | null
54
51
  stdout: any[]
55
52
  stderr: any[]
56
53
  }
57
54
 
58
- export async function executeCommand (command: string, args: string[]): Promise<CommandResult> {
55
+ /**
56
+ * Run a child process and return an awaitable Promise.
57
+ *
58
+ * @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.
62
+ * @returns Awaitable promise with the result of the execution.
63
+ */
64
+ export async function executeCommand (command: string, args: string[], inheritStdio: boolean, env?: NodeJS.ProcessEnv): Promise<ExecuteCommandResult> {
59
65
  return await new Promise((resolve, reject) => {
60
- const childProcess = spawn(command, args) //, { stdio: 'inherit' }) -- this pipes stdout & stderr to our output
66
+ const childProcess = spawn(command, args, {
67
+ stdio: inheritStdio ? 'inherit' : undefined,
68
+ env,
69
+ })
61
70
  let stdout: any[] = []
62
71
  let stderr: any[] = []
63
72
 
@@ -71,7 +80,7 @@ export async function executeCommand (command: string, args: string[]): Promise<
71
80
 
72
81
  // If program runs to completion, resolve promise with success
73
82
  childProcess.on('close', (code) => {
74
- resolve({ code, stdout, stderr })
83
+ resolve({ exitCode: code, stdout, stderr })
75
84
  })
76
85
 
77
86
  childProcess.on('error', (err) => {
@@ -83,3 +92,34 @@ export async function executeCommand (command: string, args: string[]): Promise<
83
92
  })
84
93
  })
85
94
  }
95
+
96
+ export function isRunningUnderNpx () {
97
+ // console.log('process.argv:', process.argv)
98
+ const matchBinary = (binaryPath: string, binaryName: string): boolean => {
99
+ const binary = path.basename(binaryPath, path.extname(binaryPath)) // drop path and extension
100
+ return binary === binaryName
101
+ }
102
+
103
+ // Check if 'npx' is the invoked binary (this check is not sufficient as it can also be 'node')
104
+ if (matchBinary(process.argv[0], 'npx')) {
105
+ return true
106
+ }
107
+
108
+ // Check if the script path contains a known npx temporary directory pattern
109
+ const scriptPath = process.argv[1]
110
+ if (scriptPath?.includes(`${path.sep}_npx${path.sep}`)) {
111
+ return true
112
+ }
113
+
114
+ // Check if the environment variable 'npm_execpath' is exactly 'npx'
115
+ if (process.env.npm_execpath && matchBinary(process.env.npm_execpath, 'npx')) {
116
+ return true
117
+ }
118
+
119
+ // Check if the environment variable '_' ends with 'npx' (specific to npx usage)
120
+ if (process.env._ && matchBinary(process.env._, 'npx')) {
121
+ return true
122
+ }
123
+
124
+ return false
125
+ }
package/src/version.ts ADDED
@@ -0,0 +1 @@
1
+ export const PACKAGE_VERSION = '1.5.0'