forked from ruimarinho/gsts
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.js
executable file
·455 lines (353 loc) · 13.3 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
#!/usr/bin/env node
/**
* Module dependencies.
*/
const { parse } = require('querystring');
const AWS = require('aws-sdk');
const Logger = require('./logger')
const Saml = require('libsaml');
const childProcess = require('child_process');
const fs = require('fs').promises;
const homedir = require('os').homedir();
const ini = require('ini');
const open = require('open');
const path = require('path')
const paths = require('env-paths')('gsts', { suffix: '' });
const plist = require('plist');
const puppeteer = require('puppeteer-extra');
const stealth = require('puppeteer-extra-plugin-stealth');
const trash = require('trash');
// Default session duration, as states on AWS documentation.
// See https://aws.amazon.com/blogs/security/enable-federated-api-access-to-your-aws-resources-for-up-to-12-hours-using-iam-roles/.
const DEFAULT_SESSION_DURATION = 3600 // 1 hour
// Delta (in ms) between exact expiration date and current date to avoid requests
// on the same second to fail.
const EXPIRATION_DELTA = 30e3; // 30 seconds
// Regex pattern for Role.
const REGEX_PATTERN_ROLE = /arn:aws:iam:[^:]*:[0-9]+:role\/[^,]+/i;
// Regex pattern for Principal (SAML Provider).
const REGEX_PATTERN_PRINCIPAL = /arn:aws:iam:[^:]*:[0-9]+:saml-provider\/[^,]+/i;
// Project namespace to be used for plist generation.
const PROJECT_NAMESPACE = 'io.github.ruimarinho.gsts';
// LaunchAgents plist path.
const MACOS_LAUNCH_AGENT_HELPER_PATH = path.join(process.env.HOME, 'Library', 'LaunchAgents', `${PROJECT_NAMESPACE}.plist`)
// Parse command line arguments.
const argv = require('yargs')
.usage('gsts')
.env()
.command('console')
.count('verbose')
.alias('v', 'verbose')
.options({
'aws-profile': {
description: 'AWS profile name for storing credentials',
default: 'sts'
},
'aws-role-arn': {
description: 'AWS role ARN to authenticate with'
},
'aws-shared-credentials-file': {
description: 'AWS shared credentials file',
default: path.join(homedir, '.aws', 'credentials')
},
'clean': {
boolean: false,
description: 'Start authorization from a clean session state'
},
'daemon': {
boolean: false,
description: 'Install daemon service (only on macOS for now)'
},
'daemon-out-log-path': {
description: `Path for storing the output log of the daemon`,
default: '/usr/local/var/log/gsts.stdout.log'
},
'daemon-error-log-path': {
description: `Path for storing the error log of the daemon`,
default: '/usr/local/var/log/gsts.stderr.log'
},
'force': {
boolean: false,
description: 'Force re-authorization even with valid session'
},
'headful': {
boolean: false,
description: 'headful',
hidden: true
},
'idp-id': {
alias: 'google-idp-id',
description: 'Google Identity Provider ID (IDP IP)',
required: true
},
'sp-id': {
alias: 'google-sp-id',
description: 'Google Service Provider ID (SP ID)',
required: true
},
'username': {
alias: 'google-username',
description: 'Google username to auto pre-fill during login'
},
'verbose': {
description: 'Log verbose output'
}
})
.strictCommands()
.argv;
/**
* The SAML URL to be used for authentication.
*/
const SAML_URL = `https://accounts.google.com/o/saml2/initsso?idpid=${argv.googleIdpId}&spid=${argv.googleSpId}&forceauthn=false`;
/**
* Custom logger instance to support `-v` or `--verbose` output and non-TTY
* detailed logging with timestamps.
*/
const logger = new Logger(process.stdout, process.stderr, argv.verbose);
/**
* Process a SAML response and extract all relevant data to be exchanged for an
* STS token.
*/
async function processSamlResponse(response, credentialsPath, profile, role) {
const samlAssertion = unescape(parse(response).SAMLResponse);
const saml = new Saml(samlAssertion);
logger.debug('Parsed SAML assertion %O', saml.parsedSaml);
const attribute = saml.getAttribute('https://aws.amazon.com/SAML/Attributes/Role')[0];
const roleArn = role || attribute.match(REGEX_PATTERN_ROLE)[0];
const principalArn = attribute.match(REGEX_PATTERN_PRINCIPAL)[0];
let sessionDuration = DEFAULT_SESSION_DURATION;
if (saml.parsedSaml.attributes) {
for (const attribute of saml.parsedSaml.attributes) {
if (attribute.name === 'https://aws.amazon.com/SAML/Attributes/SessionDuration') {
sessionDuration = attribute.value[0];
logger.debug('Found SessionDuration attribute %s', sessionDuration);
}
}
}
logger.debug('Found Role ARN %s', roleArn);
logger.debug('Found Principal ARN %s', principalArn);
const roleResponse = await (new AWS.STS).assumeRoleWithSAML({
DurationSeconds: sessionDuration,
PrincipalArn: principalArn,
RoleArn: roleArn,
SAMLAssertion: samlAssertion
}).promise();
logger.debug('Role has been assumed %O', roleResponse);
await saveCredentials(credentialsPath, profile, {
accessKeyId: roleResponse.Credentials.AccessKeyId,
secretAccessKey: roleResponse.Credentials.SecretAccessKey,
expiration: roleResponse.Credentials.Expiration,
sessionToken: roleResponse.Credentials.SessionToken
});
}
/**
* Load AWS credentials from the user home preferences.
* Optionally accepts a AWS profile (usually a name representing
* a section on the .ini-like file).
*/
async function loadCredentials(path, profile) {
let credentials;
try {
credentials = await fs.readFile(path, 'utf-8')
} catch (e) {
if (e.code === 'ENOENT') {
logger.debug('Credentials file does not exist at %s', path)
return;
}
throw e;
}
const config = ini.parse(credentials);
if (profile) {
return config[profile];
}
return config;
}
/**
* Save AWS credentials to a profile section.
*/
async function saveCredentials(path, profile, { accessKeyId, secretAccessKey, expiration, sessionToken }) {
// The config file may have other profiles configured, so parse existing data instead of writing a new file instead.
let credentials = await loadCredentials(path);
if (!credentials) {
credentials = {};
}
credentials[profile] = {};
credentials[profile].aws_access_key_id = accessKeyId;
credentials[profile].aws_secret_access_key = secretAccessKey;
credentials[profile].aws_session_expiration = expiration.toISOString();
credentials[profile].aws_session_token = sessionToken;
await fs.writeFile(path, ini.encode(credentials))
logger.debug('Config file %O', credentials);
}
/**
* Extract session expiration from AWS credentials file for a given profile.
* The constant EXPIRATION_DELTA represents a safety buffer to avoid requests
* failing at the exact time of expiration.
*/
async function getSessionExpirationForProfileCredentials(credentialsPath, profile) {
logger.debug('Attempting to retrieve session expiration credentials');
const credentials = await loadCredentials(credentialsPath, profile);
if (!credentials) {
return { isValid: false, expiresAt: null };
}
if (!credentials.aws_session_expiration) {
logger.debug('Session expiration date not found');
return { isValid: false, expiresAt: null };
}
if (new Date(credentials.aws_session_expiration).getTime() - EXPIRATION_DELTA > Date.now()) {
logger.debug('Session is expected to be valid until %s minus expiration delta of %d seconds', credentials.aws_session_expiration, EXPIRATION_DELTA / 1e3);
return { isValid: true, expiresAt: new Date(credentials.aws_session_expiration).getTime() - EXPIRATION_DELTA };
}
logger.debug('Session has expired on %s', credentials.aws_session_expiration);
return { isValid: false, expiresAt: new Date(credentials.aws_session_expiration).getTime() - EXPIRATION_DELTA };
}
/**
* Remove a directory by trashing it (as opposed to permanently deleting it).
*/
async function cleanDirectory(path) {
logger.debug('Cleaning session data directory %s', path)
return await trash(path);
}
/**
* Generate a launch agent plist based on dynamic values.
*/
function generateLaunchAgentPlist(idpId, spId, username, outLogPath, errorLogPath) {
const programArguments = ['/usr/local/bin/gsts', `--idp-id=${idpId}`, `--sp-id=${spId}`]
if (username) {
programArguments.push(`--username=${username}`);
}
const payload = {
Label: PROJECT_NAMESPACE,
EnvironmentVariables: {
PATH: '/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin'
},
RunAtLoad: true,
StartInterval: 600,
StandardErrorPath: errorLogPath,
StandardOutPath: outLogPath,
ProgramArguments: programArguments
};
return plist.build(payload);
}
/**
* Only available for macOS: generates dynamic plist and attempts to install and load a launch agent
* from the user's home directory.
*/
async function installDaemon(platform, googleIdpId, googleSpId, username, daemonOutLogPath, daemonErrorLogPath) {
if (platform !== 'darwin') {
return logger.error('Sorry, this feature is only available on macOS at this time');
}
logger.debug('Unloading potentially existing launch agent at %s', MACOS_LAUNCH_AGENT_HELPER_PATH);
await childProcess.execFile('launchctl', ['unload', MACOS_LAUNCH_AGENT_HELPER_PATH], (error, stdout, stderr) => {
if (!error) {
return;
}
if (stderr) {
logger.error('Result from stderr while attempting to unload agent was "%s"', stderr);
}
if (stdout) {
logger.info('Result from stdout while attempting to unload agent was "%s"', stdout);
}
logger.error(error);
});
const plist = generateLaunchAgentPlist(googleIdpId, googleSpId, username, daemonOutLogPath, daemonErrorLogPath).toString();
logger.debug('Generated launch agent plist file %s', plist);
await fs.writeFile(MACOS_LAUNCH_AGENT_HELPER_PATH, plist);
logger.debug('Successfully wrote the launch agent plist to %s', MACOS_LAUNCH_AGENT_HELPER_PATH);
await childProcess.execFile('launchctl', ['load', MACOS_LAUNCH_AGENT_HELPER_PATH], (error, stdout, stderr) => {
if (error) {
logger.error(error);
return;
}
if (stderr) {
logger.error('Result from stderr while attempting to load agent was "%s"', stderr);
}
if (stdout) {
logger.info('Result from stdout while attempting to load agent was "%s"', stdout);
} else {
logger.info('Daemon installed successfully at %s', MACOS_LAUNCH_AGENT_HELPER_PATH)
}
});
}
/**
* Open the given url on the user's default browser window.
*/
async function openConsole(url) {
logger.debug('Opening url %s', url);
return await open(url);
}
/**
* Main execution routine which handles command-line flags.
*/
(async () => {
if (argv._[0] === 'console') {
return await openConsole(SAML_URL);
}
if (argv.daemon) {
return await installDaemon(process.platform, argv.googleIdpId, argv.googleSpId, argv.username, argv.daemonOutLogPath, argv.daemonErrorLogPath);
}
if (argv.clean) {
logger.debug('Cleaning directory %s', paths.data);
await cleanDirectory(paths.data);
}
let isAuthenticated = false;
let { isValid: isSessionValid, expiresAt: sessionExpiresAt } = await getSessionExpirationForProfileCredentials(argv.awsSharedCredentialsFile, argv.awsProfile);
if (!argv.clean && !argv.force && isSessionValid) {
logger.info('Skipping re-authorization as session is valid until %s. Use --force to ignore.', new Date(sessionExpiresAt));
isAuthenticated = true;
return;
}
puppeteer.use(stealth());
const browser = await puppeteer.launch({
headless: !argv.headful,
userDataDir: paths.data
});
const page = await browser.newPage();
await page.setRequestInterception(true);
await page.setDefaultTimeout(0);
page.on('request', async request => {
if (request.url() === 'https://signin.aws.amazon.com/saml') {
isAuthenticated = true;
await processSamlResponse(request._postData, argv.awsSharedCredentialsFile, argv.awsProfile, argv.awsRoleArn);
logger.info(`Login successful${ argv.verbose ? ` stored in ${argv.awsSharedCredentialsFile} with AWS profile "${argv.awsProfile}" and ARN role ${argv.awsRoleArn}` : '!' }`);
request.continue();
return;
}
if (/google|gstatic|youtube|googleusercontent|googleapis|gvt1/.test(request.url())) {
request.continue();
return;
}
request.abort();
});
await page.goto(`https://accounts.google.com/o/saml2/initsso?idpid=${argv.googleIdpId}&spid=${argv.googleSpId}&forceauthn=false`);
if (argv.headful) {
try {
await page.waitFor('input[type=email]');
const selector = await page.$('input[type=email]');
if (argv.username) {
logger.debug('Pre-filling email with %s', argv.username);
await selector.type(argv.username);
}
await page.waitForResponse('https://signin.aws.amazon.com/saml');
} catch (e) {
if (/Target closed/.test(e.message)) {
logger.error('Browser closed outside running context, exiting');
return;
}
logger.error(e);
}
}
if (!isAuthenticated && !argv.headful) {
logger.info('User is not authenticated, spawning headful instance');
const args = ['--headful'];
if (argv.force) {
args.push('--force');
}
if (argv.clean) {
args.push('--clean');
}
const ui = childProcess.spawn('gsts', args, { stdio: 'inherit' });
ui.on('close', code => logger.debug(`Headful instance has exited with code ${code}`))
}
await browser.close();
})();