@metaplay/metaplay-auth 1.1.6 → 1.2.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.
@@ -1,18 +1,13 @@
1
1
  import { isValidFQDN, getGameserverAdminUrl } from './utils.js';
2
2
  import { logger } from './logging.js';
3
3
  export class StackAPI {
4
- id_token;
5
- access_token;
4
+ accessToken;
6
5
  _stack_api_base_uri;
7
- constructor(idToken, accessToken) {
8
- if (idToken == null) {
9
- throw new Error('id_token is missing');
10
- }
6
+ constructor(accessToken) {
11
7
  if (accessToken == null) {
12
- throw new Error('access_token is missing');
8
+ throw new Error('accessToken must be provided');
13
9
  }
14
- this.id_token = idToken;
15
- this.access_token = accessToken;
10
+ this.accessToken = accessToken;
16
11
  this._stack_api_base_uri = 'https://infra.p1.metaplay.io/stackapi';
17
12
  }
18
13
  get stack_api_base_uri() {
@@ -43,12 +38,12 @@ export class StackAPI {
43
38
  const response = await fetch(url, {
44
39
  method: 'POST',
45
40
  headers: {
46
- Authorization: `Bearer ${this.id_token}`,
41
+ Authorization: `Bearer ${this.accessToken}`,
47
42
  'Content-Type': 'application/json'
48
43
  }
49
44
  });
50
45
  if (response.status !== 200) {
51
- throw new Error(`Failed to fetch AWS credetials: ${response.statusText}`);
46
+ throw new Error(`Failed to fetch AWS credentials: ${response.statusText}, response code=${response.status}`);
52
47
  }
53
48
  return await response.json();
54
49
  }
@@ -73,7 +68,7 @@ export class StackAPI {
73
68
  const response = await fetch(url, {
74
69
  method: 'POST',
75
70
  headers: {
76
- Authorization: `Bearer ${this.id_token}`,
71
+ Authorization: `Bearer ${this.accessToken}`,
77
72
  'Content-Type': 'application/json'
78
73
  }
79
74
  });
@@ -103,7 +98,7 @@ export class StackAPI {
103
98
  const response = await fetch(url, {
104
99
  method: 'GET',
105
100
  headers: {
106
- Authorization: `Bearer ${this.id_token}`,
101
+ Authorization: `Bearer ${this.accessToken}`,
107
102
  'Content-Type': 'application/json'
108
103
  }
109
104
  });
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stackapi.js","sourceRoot":"","sources":["../../src/stackapi.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,uBAAuB,CAAE,GAAW,EAAE,OAA4B;IAChF,OAAO,EAAE,CAAA;AACX,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAE,MAAc;IACzC,MAAM,SAAS,GAAG,+EAA+E,CAAA;IAEjG,OAAO,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;AAC/B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAE,SAAiB;IACnD,IAAI;QACF,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,SAAS,CAAC,CAAA;QAE9B,oEAAoE;QACpE,MAAM,MAAM,GAAG,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA,CAAC,2BAA2B;QACpE,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,CAAA;QAC7B,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,IAAI,IAAI,CAAA;QAE7B,yDAAyD;QACzD,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,IAAI,CAAA;QAE9C,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAA;KAC5C;IAAC,OAAO,KAAK,EAAE;QACd,MAAM,IAAI,KAAK,CAAC,aAAa,CAAC,CAAA;KAC/B;AACH,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAE,GAAW;IAChD,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IAE5B,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,CAAC,CAAA;IACzB,MAAM,MAAM,GAAG,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IAEjD,OAAO,GAAG,QAAQ,UAAU,MAAM,EAAE,CAAA;AACtC,CAAC"}
@@ -0,0 +1,18 @@
1
+ import { expect, test, describe } from 'vitest';
2
+ import { isValidFQDN } from '../src/utils.js';
3
+ describe('isValidFQDN', () => {
4
+ test('should return true for valid FQDNs', () => {
5
+ expect(isValidFQDN('www.example.com')).toBe(true);
6
+ expect(isValidFQDN('subdomain.example.com')).toBe(true);
7
+ expect(isValidFQDN('example.com')).toBe(true);
8
+ expect(isValidFQDN('example.co.uk')).toBe(true);
9
+ });
10
+ test('should return false for invalid FQDNs', () => {
11
+ expect(isValidFQDN('example')).toBe(false);
12
+ expect(isValidFQDN('www.example..com')).toBe(false);
13
+ expect(isValidFQDN('www.-example.com')).toBe(false);
14
+ expect(isValidFQDN('www.example-.com')).toBe(false);
15
+ expect(isValidFQDN('www.example.com-')).toBe(false);
16
+ });
17
+ });
18
+ //# sourceMappingURL=utils.spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.spec.js","sourceRoot":"","sources":["../../tests/utils.spec.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAA;AAE/C,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAE7C,QAAQ,CAAC,aAAa,EAAE,GAAG,EAAE;IAC3B,IAAI,CAAC,oCAAoC,EAAE,GAAG,EAAE;QAC9C,MAAM,CAAC,WAAW,CAAC,iBAAiB,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACjD,MAAM,CAAC,WAAW,CAAC,uBAAuB,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACvD,MAAM,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAC7C,MAAM,CAAC,WAAW,CAAC,eAAe,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACjD,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,uCAAuC,EAAE,GAAG,EAAE;QACjD,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QAC1C,MAAM,CAAC,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACnD,MAAM,CAAC,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACnD,MAAM,CAAC,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACnD,MAAM,CAAC,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACrD,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander'
3
- import { loginAndSaveTokens, extendCurrentSession, loadTokens, saveTokens, removeTokens } from './auth.js'
4
- import { StackAPI } from './stackapi.js'
5
- import { checkGameServerDeployment } from './deployment.js'
6
- import { setLogLevel } from './logging.js'
3
+ import { loginAndSaveTokens, machineLoginAndSaveTokens, extendCurrentSession, loadTokens, removeTokens } from './src/auth.js'
4
+ import { StackAPI } from './src/stackapi.js'
5
+ import { checkGameServerDeployment } from './src/deployment.js'
6
+ import { logger, setLogLevel } from './src/logging.js'
7
7
  import { exit } from 'process'
8
+ import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr'
8
9
 
9
10
  const program = new Command()
10
11
 
11
12
  program
12
13
  .name('metaplay-auth')
13
14
  .description('Authenticate with Metaplay and get AWS and Kubernetes credentials for game servers.')
14
- .version('1.1.6')
15
+ .version('1.2.0')
15
16
  .option('-d, --debug', 'enable debug output')
16
17
  .hook('preAction', (thisCommand) => {
17
18
  // Handle debug flag for all commands.
@@ -30,26 +31,31 @@ program.command('login')
30
31
  })
31
32
 
32
33
  program.command('machine-login')
33
- .description('[experimental] login to the Metaplay cloud using a machine account (using credentials in environment variable METAPLAY_CREDENTIALS)')
34
- .action(async () => {
35
- const credentials = process.env.METAPLAY_CREDENTIALS
36
- if (!credentials || credentials === '') {
37
- throw new Error('Unable to find the credentials, the environment variable METAPLAY_CREDENTIALS is not defined!')
34
+ .description('login to the Metaplay cloud using a machine account (using credentials in environment variable METAPLAY_CREDENTIALS)')
35
+ .option('--dev-credentials', 'machine user credentials to use, only for dev purposes, use METAPLAY_CREDENTIALS env variable for better safety!')
36
+ .action(async (options) => {
37
+ // Get credentials from command line or from METAPLAY_CREDENTIALS environment variable
38
+ let credentials
39
+ if (options.devCredentials) {
40
+ credentials = options.devCredentials
41
+ } else {
42
+ credentials = process.env.METAPLAY_CREDENTIALS
43
+ if (!credentials || credentials === '') {
44
+ throw new Error('Unable to find the credentials, the environment variable METAPLAY_CREDENTIALS is not defined!')
45
+ }
38
46
  }
39
47
 
40
- // Exchange the credentials for tokens. For now, as we don't have proper M2M accounts implemented, we're simulating it by ingesting the raw tokens JSON instead.
41
- // \todo Replace (or extend) this with exchanging the credentials to usable tokens
42
- let tokens
43
- try {
44
- tokens = JSON.parse(credentials)
45
- } catch (error) {
46
- throw new Error('Unable to parse METAPLAY_CREDENTIALS: not valid JSON. Expecting a JSON blob with the access tokens, as outputted by `metaplay-auth show-tokens`.')
48
+ // Parse the clientId and clientSecret from the credentials (separate by a '+' character)
49
+ // \note We can't be certain that the secret does not contain pluses so split at the first occurrence
50
+ const splitOffset = credentials.indexOf('+')
51
+ if (splitOffset === -1) {
52
+ throw new Error('Invalid format for credentials, you should copy-paste the value from the developer portal verbatim')
47
53
  }
54
+ const clientId = credentials.substring(0, splitOffset)
55
+ const clientSecret = credentials.substring(splitOffset + 1)
48
56
 
49
- // Save tokens
50
- await saveTokens(tokens)
51
-
52
- console.log('Successfully logged in to Metaplay cloud using machine account!')
57
+ // Login with machine user and save the tokens
58
+ await machineLoginAndSaveTokens(clientId, clientSecret)
53
59
  })
54
60
 
55
61
  program.command('logout')
@@ -71,23 +77,14 @@ program.command('logout')
71
77
 
72
78
  program.command('show-tokens')
73
79
  .description('show loaded tokens')
74
- .option('-f, --format <format>', 'output format (json or pretty)', 'json')
75
80
  .hook('preAction', async () => {
76
81
  await extendCurrentSession()
77
82
  })
78
83
  .action(async (options) => {
79
84
  try {
80
- if (options.format !== 'json' && options.format !== 'pretty') {
81
- throw new Error('Invalid format; must be one of json or pretty')
82
- }
83
-
84
85
  // TODO: Could detect if not logged in and fail more gracefully?
85
86
  const tokens = await loadTokens()
86
- if (options.format === 'json') {
87
- console.log(JSON.stringify(tokens))
88
- } else {
89
- console.log(tokens)
90
- }
87
+ console.log(tokens)
91
88
  } catch (error) {
92
89
  if (error instanceof Error) {
93
90
  console.error(`Error showing tokens: ${error.message}`)
@@ -114,7 +111,7 @@ program.command('get-kubeconfig')
114
111
  throw new Error('Could not determine a deployment to fetch the KubeConfigs from. You need to specify either a gameserver or an organization, project, and environment')
115
112
  }
116
113
 
117
- const stackApi = new StackAPI(tokens.id_token, tokens.access_token)
114
+ const stackApi = new StackAPI(tokens.access_token)
118
115
  if (options.stackApi) {
119
116
  stackApi.stack_api_base_uri = options.stackApi
120
117
  }
@@ -154,7 +151,7 @@ program.command('get-aws-credentials')
154
151
  throw new Error('Could not determine a deployment to fetch the AWS credentials from. You need to specify either a gameserver or an organization, project, and environment')
155
152
  }
156
153
 
157
- const stackApi = new StackAPI(tokens.id_token, tokens.access_token)
154
+ const stackApi = new StackAPI(tokens.access_token)
158
155
  if (options.stackApi) {
159
156
  stackApi.stack_api_base_uri = options.stackApi
160
157
  }
@@ -181,6 +178,89 @@ program.command('get-aws-credentials')
181
178
  }
182
179
  })
183
180
 
181
+ program.command('get-docker-login')
182
+ .description('get docker login credentials for pushing the server image')
183
+ .argument('[gameserver]', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
184
+ .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
185
+ .option('-p, --project <project>', 'project name (e.g. idler)')
186
+ .option('-e, --environment <environment>', 'environment name (e.g. develop)')
187
+ .option('-f, --format <format>', 'output format (json or env)', 'json')
188
+ .hook('preAction', async () => {
189
+ await extendCurrentSession()
190
+ })
191
+ .action(async (gameserver, options) => {
192
+ try {
193
+ if (options.format !== 'json' && options.format !== 'env') {
194
+ throw new Error('Invalid format; must be one of json or env')
195
+ }
196
+
197
+ const tokens = await loadTokens()
198
+
199
+ if (!gameserver && !(options.organization && options.project && options.environment)) {
200
+ throw new Error('Could not determine a game server deployment. You need to specify either a gameserver or an organization, project, and environment')
201
+ }
202
+
203
+ const stackApi = new StackAPI(tokens.access_token)
204
+ if (options.stackApi) {
205
+ stackApi.stack_api_base_uri = options.stackApi
206
+ }
207
+
208
+ // Fetch AWS credentials from Metaplay cloud
209
+ logger.debug('Get AWS credentials from Metaplay')
210
+ const payload = gameserver ? { gameserver } : { organization: options.organization, project: options.project, environment: options.environment }
211
+ const credentials = await stackApi.getAwsCredentials(payload)
212
+
213
+ // Get environment info (region is needed for ECR)
214
+ logger.debug('Get environment info')
215
+ const environment = await stackApi.getEnvironmentDetails(payload)
216
+ const awsRegion = environment.deployment.aws_region
217
+ const dockerRepo = environment.deployment.ecr_repo
218
+
219
+ // Create ECR client with credentials
220
+ logger.debug('Create ECR client')
221
+ const client = new ECRClient({
222
+ credentials: {
223
+ accessKeyId: credentials.AccessKeyId,
224
+ secretAccessKey: credentials.SecretAccessKey,
225
+ sessionToken: credentials.SessionToken
226
+ },
227
+ region: awsRegion
228
+ })
229
+
230
+ // Fetch the ECR docker authentication token
231
+ logger.debug('Fetch ECR login credentials from AWS')
232
+ const command = new GetAuthorizationTokenCommand({})
233
+ const response = await client.send(command)
234
+ if (!response.authorizationData || response.authorizationData.length === 0 || !response.authorizationData[0].authorizationToken) {
235
+ throw new Error('Received an empty authorization token response for ECR repository')
236
+ }
237
+
238
+ // Parse username and password from the response (separated by a ':')
239
+ logger.debug('Parse ECR response')
240
+ const authorization64 = response.authorizationData[0].authorizationToken
241
+ const authorization = Buffer.from(authorization64, 'base64').toString()
242
+ const [username, password] = authorization.split(':')
243
+
244
+ // Output the docker repo & credentials
245
+ if (options.format === 'env') {
246
+ console.log(`export DOCKER_REPO=${dockerRepo}`)
247
+ console.log(`export DOCKER_USERNAME=${username}`)
248
+ console.log(`export DOCKER_PASSWORD=${password}`)
249
+ } else {
250
+ console.log(JSON.stringify({
251
+ dockerRepo,
252
+ username,
253
+ password
254
+ }))
255
+ }
256
+ } catch (error) {
257
+ if (error instanceof Error) {
258
+ console.error(`Error getting docker login credentials: ${error.message}`)
259
+ }
260
+ exit(1)
261
+ }
262
+ })
263
+
184
264
  program.command('get-environment')
185
265
  .description('get environment details for deployment')
186
266
  .argument('[gameserver]', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
@@ -199,7 +279,7 @@ program.command('get-environment')
199
279
  throw new Error('Could not determine a deployment to fetch environment details from. You need to specify either a gameserver or an organization, project, and environment')
200
280
  }
201
281
 
202
- const stackApi = new StackAPI(tokens.id_token, tokens.access_token)
282
+ const stackApi = new StackAPI(tokens.access_token)
203
283
  if (options.stackApi) {
204
284
  stackApi.stack_api_base_uri = options.stackApi
205
285
  }
@@ -219,15 +299,21 @@ program.command('get-environment')
219
299
  program.command('check-deployment')
220
300
  .description('[experimental] check that a game server was successfully deployed, or print out useful error messages in case of failure')
221
301
  .argument('[namespace]', 'kubernetes namespace of the deployment')
222
- .hook('preAction', async () => {
223
- await extendCurrentSession()
224
- })
225
- .action(async (namespace: string, options) => {
302
+ .action(async (namespace: string) => {
226
303
  setLogLevel(0)
227
304
 
228
- // Run the checks and exit with success/failure exitCode depending on result
229
- const exitCode = await checkGameServerDeployment(namespace)
230
- exit(exitCode)
305
+ try {
306
+ if (!namespace) {
307
+ throw new Error('Must specify value for argument "namespace"')
308
+ }
309
+
310
+ // Run the checks and exit with success/failure exitCode depending on result
311
+ const exitCode = await checkGameServerDeployment(namespace)
312
+ exit(exitCode)
313
+ } catch (error) {
314
+ console.error(`Failed to check deployment status: ${error}`)
315
+ exit(1)
316
+ }
231
317
  })
232
318
 
233
319
  void program.parseAsync()
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.1.6",
4
+ "version": "1.2.0",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "homepage": "https://metaplay.io",
@@ -13,24 +13,26 @@
13
13
  "@types/express": "^4.17.21",
14
14
  "@types/jsonwebtoken": "^9.0.5",
15
15
  "@types/jwk-to-pem": "^2.0.3",
16
- "@types/node": "^20.11.20",
16
+ "@types/node": "^20.11.28",
17
17
  "tsx": "^4.7.1",
18
18
  "typescript": "^5.1.6",
19
+ "vitest": "^1.3.1",
19
20
  "@metaplay/eslint-config": "1.0.0",
20
21
  "@metaplay/typescript-config": "1.0.0"
21
22
  },
22
23
  "dependencies": {
24
+ "@aws-sdk/client-ecr": "^3.535.0",
25
+ "@kubernetes/client-node": "^0.20.0",
23
26
  "@ory/client": "^1.6.2",
24
27
  "commander": "^12.0.0",
25
28
  "h3": "^1.10.2",
26
29
  "jsonwebtoken": "^9.0.2",
27
30
  "jwk-to-pem": "^2.0.5",
28
31
  "open": "^10.0.2",
29
- "tslog": "^4.9.2",
30
- "@kubernetes/client-node": "^0.20.0"
32
+ "tslog": "^4.9.2"
31
33
  },
32
34
  "scripts": {
33
- "dev": "tsx src/index.ts",
35
+ "dev": "tsx index.ts",
34
36
  "prepublish": "tsc"
35
37
  }
36
38
  }
package/src/auth.ts CHANGED
@@ -20,7 +20,6 @@ const tokenEndpoint = `${baseURL}/oauth2/token`
20
20
  const wellknownApi = new WellknownApi(new Configuration({
21
21
  basePath: baseURL,
22
22
  }))
23
- const tokenList: string[] = ['id_token', 'access_token', 'refresh_token'] // List of compulsory tokens
24
23
 
25
24
  /**
26
25
  * A helper function which generates a code verifier and challenge for exchaning code from Ory server.
@@ -111,7 +110,9 @@ export async function loginAndSaveTokens () {
111
110
  try {
112
111
  logger.debug(`Received callback request with code ${code}. Preparing to exchange for tokens...`)
113
112
  const tokens = await getTokensWithAuthorizationCode(state, redirectUri, verifier, code)
114
- await saveTokens(tokens)
113
+
114
+ // Only save access_token, id_token, and refresh_token
115
+ await saveTokens({ access_token: tokens.access_token, id_token: tokens.id_token, refresh_token: tokens.refresh_token })
115
116
 
116
117
  console.log('You are now logged in and can call the other commands.')
117
118
 
@@ -140,6 +141,40 @@ export async function loginAndSaveTokens () {
140
141
  void open(authorizationUrl)
141
142
  }
142
143
 
144
+ export async function machineLoginAndSaveTokens (clientId: string, clientSecret: string) {
145
+ // Get a fresh access token from Metaplay Auth.
146
+ const params = new URLSearchParams()
147
+ params.set('grant_type', 'client_credentials')
148
+ params.set('client_id', clientId)
149
+ params.set('client_secret', clientSecret)
150
+ params.set('scope', 'openid email profile offline_access')
151
+
152
+ const res = await fetch(tokenEndpoint, {
153
+ method: 'POST',
154
+ headers: {
155
+ 'Content-Type': 'application/x-www-form-urlencoded',
156
+ },
157
+ body: params.toString(),
158
+ })
159
+
160
+ // Return type checked manually by Teemu on 2024-3-7.
161
+ const tokens = await res.json() as { access_token: string, token_type: string, expires_in: number, scope: string }
162
+
163
+ logger.debug('Received machine authentication tokens, saving them for future use...')
164
+
165
+ await saveTokens({ access_token: tokens.access_token })
166
+
167
+ const userInfoResponse = await fetch('https://portal.metaplay.dev/api/external/userinfo', {
168
+ headers: {
169
+ Authorization: `Bearer ${tokens.access_token}`
170
+ },
171
+ })
172
+
173
+ const userInfo = await userInfoResponse.json() as { given_name: string, family_name: string }
174
+
175
+ console.log(`You are now logged in with machine user ${userInfo.given_name} ${userInfo.family_name} (clientId=${clientId}) and can execute the other commands.`)
176
+ }
177
+
143
178
  /**
144
179
  * Refresh access and ID token with a refresh token.
145
180
  */
@@ -147,18 +182,22 @@ export async function extendCurrentSession (): Promise<void> {
147
182
  try {
148
183
  const tokens = await loadTokens()
149
184
 
150
- logger.debug('Validating access token...')
185
+ logger.debug('Check if access token still valid...')
151
186
  if (await validateToken(tokens.access_token)) {
152
187
  // Access token is not expired, return to the caller
153
- logger.debug('Access token is valid, return to the caller.')
188
+ logger.debug('Access token is valid, no need to refresh.')
154
189
  return
155
190
  }
156
191
 
157
- logger.debug('Access token is no longer valid, trying to extend the current session with a refresh token.')
192
+ // Check that the refresh_token exists (machine users don't have it)
193
+ if (!tokens.refresh_token) {
194
+ throw new Error('Cannot refresh an access_token without a refresh_token. With machine users, should just login again instead.')
195
+ }
158
196
 
197
+ logger.debug('Access token is no longer valid, trying to extend the current session with a refresh token.')
159
198
  const refreshedTokens = await extendCurrentSessionWithRefreshToken(tokens.refresh_token)
160
199
 
161
- await saveTokens(refreshedTokens)
200
+ await saveTokens({ access_token: refreshedTokens.access_token, id_token: refreshedTokens.id_token, refresh_token: refreshedTokens.refresh_token })
162
201
  } catch (error) {
163
202
  if (error instanceof Error) {
164
203
  console.error(error.message)
@@ -173,7 +212,7 @@ export async function extendCurrentSession (): Promise<void> {
173
212
  * @returns A promise that resolves to a new set of tokens.
174
213
  */
175
214
  async function extendCurrentSessionWithRefreshToken (refreshToken: string): Promise<{ id_token: string, access_token: string, refresh_token: string }> {
176
- // TODO: similiar to the todo task in getTokensWithAuthorizationCode, http request can be handled by ory/client.
215
+ // TODO: similar to the todo task in getTokensWithAuthorizationCode, http request can be handled by ory/client.
177
216
  const params = new URLSearchParams({
178
217
  grant_type: 'refresh_token',
179
218
  refresh_token: refreshToken,
@@ -218,7 +257,7 @@ async function extendCurrentSessionWithRefreshToken (refreshToken: string): Prom
218
257
  * @param code
219
258
  * @returns
220
259
  */
221
- async function getTokensWithAuthorizationCode (state: string, redirectUri: string, verifier: string, code: string): Promise<{ id_token: string, access_token: string, refresh_token: string } | {}> {
260
+ async function getTokensWithAuthorizationCode (state: string, redirectUri: string, verifier: string, code: string): Promise<{ id_token: string, access_token: string, refresh_token: string }> {
222
261
  // TODO: the authorication code exchange flow might be better to be handled by ory/client, could check if there's any useful toosl there.
223
262
  try {
224
263
  const response = await fetch(tokenEndpoint, {
@@ -234,29 +273,22 @@ async function getTokensWithAuthorizationCode (state: string, redirectUri: strin
234
273
  if (error instanceof Error) {
235
274
  logger.error(`Error exchanging code for tokens: ${error.message}`)
236
275
  }
237
-
238
- return {}
276
+ throw error
239
277
  }
240
278
  }
241
279
 
242
280
  /**
243
281
  * Load tokens from the local secret store.
244
282
  */
245
- export async function loadTokens (): Promise<{ id_token: string, access_token: string, refresh_token: string }> {
283
+ export async function loadTokens (): Promise<{ id_token?: string, access_token: string, refresh_token?: string }> {
246
284
  try {
247
- const idToken = await getSecret('id_token')
248
- const accessToken = await getSecret('access_token')
249
- const refreshToken = await getSecret('refresh_token')
285
+ const tokens = await getSecret('tokens') as { id_token?: string, access_token: string, refresh_token?: string }
250
286
 
251
- if (idToken == null || accessToken == null || refreshToken == null) {
252
- throw new Error('Metaplay tokens not found. Are you logged in?')
287
+ if (!tokens) {
288
+ throw new Error('Unable to load tokens. You need to login first.')
253
289
  }
254
290
 
255
- return {
256
- id_token: idToken,
257
- access_token: accessToken,
258
- refresh_token: refreshToken
259
- }
291
+ return tokens
260
292
  } catch (error) {
261
293
  if (error instanceof Error) {
262
294
  throw new Error(`Error loading tokens: ${error.message}`)
@@ -273,23 +305,18 @@ export async function saveTokens (tokens: Record<string, string>): Promise<void>
273
305
  try {
274
306
  logger.debug('Received new tokens, verifying...')
275
307
 
276
- // Check if all tokens are present
277
- for (const tokenName of tokenList) {
278
- if (tokens[tokenName] === undefined) {
279
- throw new Error(`Metaplay token ${tokenName} not found. Please log in again and make sure all checkboxes of permissions are selected before proceeding.`)
280
- }
308
+ // All tokens must have an access_token (machine users only have it)
309
+ if (!tokens.access_token) {
310
+ throw new Error('Metaplay token has no access_token. Please log in again and make sure all checkboxes of permissions are selected before proceeding.')
281
311
  }
282
312
 
283
313
  logger.debug('Token verification completed, storing tokens...')
284
314
 
285
- await setSecret('id_token', tokens.id_token)
286
- await setSecret('access_token', tokens.access_token)
287
- await setSecret('refresh_token', tokens.refresh_token)
315
+ await setSecret('tokens', tokens)
288
316
 
289
317
  logger.debug('Tokens successfully stored.')
290
318
 
291
319
  await showTokenInfo(tokens.access_token)
292
- // await showTokenInfo(tokens.id_token)
293
320
  } catch (error) {
294
321
  if (error instanceof Error) {
295
322
  throw new Error(`Failed to save tokens: ${error.message}`)
@@ -303,12 +330,8 @@ export async function saveTokens (tokens: Record<string, string>): Promise<void>
303
330
  */
304
331
  export async function removeTokens (): Promise<void> {
305
332
  try {
306
- await removeSecret('id_token')
307
- logger.debug('Removed id_token.')
308
- await removeSecret('access_token')
309
- logger.debug('Removed access_token.')
310
- await removeSecret('refresh_token')
311
- logger.debug('Removed refresh_token.')
333
+ await removeSecret('tokens')
334
+ logger.debug('Removed tokens.')
312
335
  } catch (error) {
313
336
  if (error instanceof Error) {
314
337
  throw new Error(`Error removing tokens: ${error.message}`)
package/src/deployment.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { promises as fs, existsSync } from 'fs'
1
2
  import { KubeConfig, CoreV1Api, V1Pod } from '@kubernetes/client-node'
2
3
  import { logger } from './logging.js'
3
4
  import { error } from 'console'
@@ -202,11 +203,22 @@ async function delay (ms: number): Promise<void> {
202
203
  }
203
204
 
204
205
  export async function checkGameServerDeployment (namespace: string): Promise<number> {
205
- logger.info(`Validating deployment: namespace=${namespace}, ..`)
206
+ logger.info(`Validating game server deployment in namespace ${namespace}`)
207
+
208
+ // Check that the KUBECONFIG environment variable exists
209
+ const kubeconfigPath = process.env.KUBECONFIG
210
+ if (!kubeconfigPath) {
211
+ throw new Error('The KUBECONFIG environment variable must be specified')
212
+ }
213
+
214
+ // Check that the kubeconfig file exists
215
+ if (!await existsSync(kubeconfigPath)) {
216
+ throw new Error(`The environment variable KUBECONFIG points to a file '${kubeconfigPath}' that doesn't exist`)
217
+ }
206
218
 
207
219
  // Create Kubernetes API instance (with default kubeconfig)
208
220
  const kc = new KubeConfig()
209
- kc.loadFromDefault()
221
+ kc.loadFromFile(kubeconfigPath)
210
222
  const k8sApi = kc.makeApiClient(CoreV1Api)
211
223
 
212
224
  // Figure out when to stop
@@ -220,21 +232,21 @@ export async function checkGameServerDeployment (namespace: string): Promise<num
220
232
 
221
233
  switch (podStatus.phase) {
222
234
  case GameServerPodPhase.Ready:
223
- logger.error('Gameserver successfully started')
235
+ console.log('Gameserver successfully started')
224
236
  // \todo add further readiness checks -- ping endpoint, ping dashboard, other checks?
225
237
  return 0
226
238
 
227
239
  case GameServerPodPhase.Failed:
228
- logger.error('Gameserver failed to start')
240
+ console.log('Gameserver failed to start')
229
241
  return 1
230
242
 
231
243
  case GameServerPodPhase.Pending:
232
- logger.error('Gameserver still starting')
244
+ console.log('Gameserver still starting')
233
245
  break
234
246
 
235
247
  case GameServerPodPhase.Unknown:
236
248
  default:
237
- logger.error('Gameserver in unknown state')
249
+ console.log('Gameserver in unknown state')
238
250
  break
239
251
  }
240
252
 
@@ -65,7 +65,7 @@ function decrypt (text: string): string {
65
65
  return decrypted.toString()
66
66
  }
67
67
 
68
- export async function setSecret (key: string, value: string): Promise<void> {
68
+ export async function setSecret (key: string, value: any): Promise<void> {
69
69
  logger.debug(`Setting secret ${key}...`)
70
70
 
71
71
  const secrets = await loadSecrets()
@@ -75,7 +75,7 @@ export async function setSecret (key: string, value: string): Promise<void> {
75
75
  await fs.writeFile(filePath, content)
76
76
  }
77
77
 
78
- export async function getSecret (key: string): Promise<string | null> {
78
+ export async function getSecret (key: string): Promise<any | undefined> {
79
79
  logger.debug(`Getting secret ${key}...`)
80
80
 
81
81
  const secrets = await loadSecrets()