From 81640cecb6f8098521f3c03c6dac33cbb1c6ea12 Mon Sep 17 00:00:00 2001 From: ddimitrioglo Date: Sun, 11 Nov 2018 21:31:57 +0200 Subject: [PATCH] #AWSAML Major refactoring (w/ breacking changes) --- .saml.json | 9 -- README.md | 17 ++-- bin/cli.js | 74 +++++++-------- bin/configure.js | 42 --------- bin/login.js | 146 ----------------------------- cmd/alias.js | 41 ++++++++ cmd/configure.js | 32 +++++++ cmd/login.js | 139 +++++++++++++++++++++++++++ cmd/password.js | 38 ++++++++ help.txt | 11 +++ package.json | 9 +- src/command.js | 70 ++++++++++++++ src/config.js | 77 +++++++++++++++ {lib => src}/credentials-parser.js | 17 ++-- {lib => src}/extra-readline.js | 28 ++++-- {lib => src}/saml.js | 92 +++++++++++++----- src/ssh.js | 50 ++++++++++ 17 files changed, 602 insertions(+), 290 deletions(-) delete mode 100644 .saml.json delete mode 100755 bin/configure.js delete mode 100755 bin/login.js create mode 100755 cmd/alias.js create mode 100755 cmd/configure.js create mode 100755 cmd/login.js create mode 100755 cmd/password.js create mode 100644 help.txt create mode 100644 src/command.js create mode 100644 src/config.js rename {lib => src}/credentials-parser.js (77%) rename {lib => src}/extra-readline.js (59%) rename {lib => src}/saml.js (52%) create mode 100644 src/ssh.js diff --git a/.saml.json b/.saml.json deleted file mode 100644 index 0a9a066..0000000 --- a/.saml.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "profile": "saml", - "username": "john.doe", - "directoryDomain": "https://directory.example.com", - "accountMapping": { - "888888888888": "AccountId 1", - "999999999999": "AccountId 2" - } -} diff --git a/README.md b/README.md index f8fe509..a9336c3 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,16 @@ Inspired by [AWS CLI Access Using SAML 2.0][1] article. `aws-saml configure` -> Or manually edit `~/.aws/.saml.json` which will look like +> Or manually add/edit `~/.aws-saml/config.json` which should look like ```text { - "profile": "saml", - "username": "myusername", // or email: myusername@mycorp.com - "directoryDomain": "https://directory.mycorp.com", - "accountMapping": { - "888999888999": "Account A", + "profile": "saml", # AWS named profile [Required, default: "saml"] + "username": "myusername", # SSO username (login or email) [Required] + "password": false, # SSO password (encrypted with SSH keys) [Optional, default: false] + "directoryDomain": "https://directory.mycorp.com", # Identity provider (aka IdP) [Required] + "aliases": { # AWS accounts aliases [Optional, default: {}] + "888999888999": "workAccount", ... } } @@ -35,7 +36,7 @@ Inspired by [AWS CLI Access Using SAML 2.0][1] article. ### Usage * Run `aws-saml login` -* Enter a password +* Enter a username & password * Chose an account * Use your AWS CLI commands by adding `--profile saml` @@ -43,7 +44,7 @@ Inspired by [AWS CLI Access Using SAML 2.0][1] article. ### Help -`aws-saml --help` +To get familiar with all the features, just use `aws-saml --help` ### Improvements diff --git a/bin/cli.js b/bin/cli.js index 341b2f4..fba9d56 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -3,52 +3,44 @@ 'use strict'; const fs = require('fs'); -const os = require('os'); -const cli = require('commander'); const path = require('path'); -const login = require('./login'); -const configure = require('./configure'); +const parseArgs = require('minimist'); const { version } = require('../package'); -function Config() { - this.name = '.saml.json'; - this.path = path.join(os.homedir(), '.aws', this.name); - this.template = path.join(__dirname, '..', this.name); +const command = create(); - return { - name: this.name, - path: this.path, - template: this.template - }; -} - -const cfg = new Config(); - -cli - .version(version, '-v, --version') - .usage('aws-saml [action]'); - -cli - .command('configure') - .description('configure ~/.aws/.saml.json') - .action(() => { - configure(cfg); - }); - -cli - .command('login') - .description('login with SAML credentials') - .action(() => { - if (!fs.existsSync(cfg.path)) { - console.log('Please configure ~/.aws/.saml.json first'); - process.exit(1); +command + .validate() + .then(() => command.run()) + .then(message => { + if (message) { + console.info('✅', message); } - - login(cfg); + process.exit(0); + }) + .catch(err => { + console.error('❌', err.message || err || 'Error occurred'); + process.exit(1); }); -if (process.argv.length === 2) { - cli.help(); +/** + * Command factory + * @returns {Object} + */ +function create() { + const args = parseArgs(process.argv.slice(2)); + const command = args._.shift(); + + try { + const Command = require(path.join(__dirname, '../cmd', command)); + delete args._; + + return new Command(args); + } catch (e) { + const help = fs.readFileSync(path.join(__dirname, '../help.txt'), 'utf8'); + const output = args.hasOwnProperty('v') || args.hasOwnProperty('version') ? version : help; + + console.log(output); + process.exit(0); + } } - -cli.parse(process.argv); diff --git a/bin/configure.js b/bin/configure.js deleted file mode 100755 index e8a0d33..0000000 --- a/bin/configure.js +++ /dev/null @@ -1,42 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const rlex = require('../lib/extra-readline'); - -/** - * Configure action - * @param {Object} config - */ -function configure(config) { - let cfg = JSON.parse(fs.readFileSync(config.template, 'utf8')); - - if (fs.existsSync(config.path)) { - cfg = Object.assign(cfg, require(config.path)); - } - - Object.keys(cfg).reduce((prev, prop) => { - return prev.then(() => { - return new Promise(resolve => { - rlex.resume(); - const isObject = prop === 'accountMapping'; - const value = isObject ? JSON.stringify(cfg[prop]) : cfg[prop]; - - rlex.question(`${prop} (${value}): `, answer => { - if (answer) { - cfg[prop] = isObject ? JSON.parse(answer) : answer; - } - - rlex.pause(); - return resolve(cfg); - }); - }); - }) - }, Promise.resolve()).then(res => { - fs.writeFileSync(config.path, JSON.stringify(res, null, 2), 'utf8'); - - console.log('Done!'); - process.exit(0); - }); -} - -module.exports = configure; diff --git a/bin/login.js b/bin/login.js deleted file mode 100755 index 81da443..0000000 --- a/bin/login.js +++ /dev/null @@ -1,146 +0,0 @@ -'use strict'; - -const url = require('url'); -const sax = require('sax'); -const AWS = require('aws-sdk'); -const Saml = require('../lib/saml'); -const rlex = require('../lib/extra-readline'); -const CredentialsParser = require('../lib/credentials-parser'); - -/** - * Login action - * @param {Object} config - */ -function login(config) { - const { directoryDomain, username, profile, accountMapping } = require(config.path); - const idpEntryUrl = url.resolve(directoryDomain, 'adfs/ls/IdpInitiatedSignOn.aspx?loginToRp=urn:amazon:webservices'); - const saml = new Saml(idpEntryUrl); - const parser = new CredentialsParser(); - - let samlRawResponse = ''; - - Promise.all([ - askCredentials(username), - saml.getLoginPath() - ]).then(([credentials, loginPath]) => { - - return saml.getSamlResponse(loginPath, credentials.username, credentials.password); - }).then(samlResponse => { - samlRawResponse = samlResponse; - - return parseRoles(Saml.parseSamlResponse(samlRawResponse)); - }).then(roles => { - - return Promise.all(roles.map(role => { - return assumeRole(role.roleArn, role.principalArn, samlRawResponse); - })).then(results => { - return results.filter(Boolean); - }); - }).then(availableAccounts => { - - return chooseAccount(availableAccounts, accountMapping); - }).then(chosenAccount => { - - parser.updateProfile(profile, { - aws_access_key_id: chosenAccount.AccessKeyId, - aws_secret_access_key: chosenAccount.SecretAccessKey, - aws_session_token: chosenAccount.SessionToken - }).persist(); - - console.log('Done!'); - process.exit(0); - }).catch(err => { - console.error(`Failed with error: ${err.message.trim()}`); - process.exit(1); - }); -} - -module.exports = login; - -/** - * Ask user's credentials - * @returns {Promise} - */ -function askCredentials(username) { - return new Promise(resolve => { - rlex.resume(); - rlex.question(`Username (${username}): `, login => { - rlex.secretQuestion('Password: ', password => { - rlex.pause(); - - resolve({ username: login || username, password: password }); - }) - }); - }); -} - -/** - * Choose AWS account to login - * @param {Array} accounts - * @param {Object} mapping - * @returns {Promise} - */ -function chooseAccount(accounts, mapping) { - const accountsList = accounts.map((account, index) => { - let [, accountId] = account.Arn.match(/(\d+):role/); - - return `[ ${index} ] ${account.Arn} (${mapping[accountId] || accountId})`; - }); - - console.log(accountsList.join('\n')); - - return new Promise(resolve => { - rlex.resume(); - rlex.question('Choose account to login: ', index => { - rlex.close(); - - return resolve(accounts[index]); - }); - }); -} - -/** - * Parse role ARNs from xmlSamlResponse - * @param {String} xmlString - * @returns {Promise} - */ -function parseRoles(xmlString) { - return new Promise((resolve, reject) => { - let roles = []; - let parser = sax.parser(false); - - parser.ontext = text => { - if (/^arn:aws:iam::.*/.test(text)) { - const [ principalArn, roleArn ] = text.split(','); - - roles.push({ principalArn, roleArn }); - } - }; - - parser.onerror = err => reject(err); - parser.onend = () => resolve(roles); - parser.write(xmlString).close(); - }) -} - -/** - * Assume role (resolve false on fail) - * @param {String} roleArn - * @param {String} principalArn - * @param {String} samlResponse - * @returns {Promise} - */ -function assumeRole(roleArn, principalArn, samlResponse) { - const sts = new AWS.STS(); - const params = { RoleArn: roleArn, PrincipalArn: principalArn, SAMLAssertion: samlResponse }; - - return sts - .assumeRoleWithSAML(params) - .promise() - .then(data => { - return Promise.resolve( - Object.assign({ Arn: roleArn }, data.Credentials) - ); - }) - .catch(() => Promise.resolve(false)); -} diff --git a/cmd/alias.js b/cmd/alias.js new file mode 100755 index 0000000..9bcf32d --- /dev/null +++ b/cmd/alias.js @@ -0,0 +1,41 @@ +'use strict'; + +const rlex = require('../src/extra-readline'); +const Command = require('../src/command'); + +class AliasCommand extends Command { + /** + * @return {Promise} + */ + run() { + const config = this.getConfig(); + const aliases = this.getConfig('aliases'); + const isDelete = this.getOption('delete', 'd', false); + const accountId = this.getOption('account', 'a', false); + + if (!accountId || accountId.constructor === Boolean) { + return Promise.reject('Account ID is required'); + } + + if (isDelete) { + delete aliases[accountId]; + this.updateConfig(config); + + return Promise.resolve('Done!'); + } + + return rlex.promiseQuestion(`Alias for account ${accountId} (${aliases[accountId] || accountId}): `).then(answer => { + if (answer) { + aliases[accountId] = answer; + } + + return Promise.resolve(); + }).then(() => { + this.updateConfig(config); + + return Promise.resolve('Done!'); + }); + } +} + +module.exports = AliasCommand; diff --git a/cmd/configure.js b/cmd/configure.js new file mode 100755 index 0000000..ca9eb5c --- /dev/null +++ b/cmd/configure.js @@ -0,0 +1,32 @@ +'use strict'; + +const rlex = require('../src/extra-readline'); +const Command = require('../src/command'); + +class ConfigureCommand extends Command { + /** + * @return {Promise} + */ + run() { + const config = this.getConfig(); + const options = ['profile', 'username', 'directoryDomain']; + + return options.reduce((prev, prop) => { + return prev.then(() => { + return rlex.promiseQuestion(`${prop} (${config[prop]}): `).then(answer => { + if (answer) { + config[prop] = answer; + } + + return Promise.resolve(config); + }); + }); + }, Promise.resolve()).then(updated => { + this.updateConfig(updated); + + return Promise.resolve('Done!'); + }); + } +} + +module.exports = ConfigureCommand; diff --git a/cmd/login.js b/cmd/login.js new file mode 100755 index 0000000..9abdda7 --- /dev/null +++ b/cmd/login.js @@ -0,0 +1,139 @@ +'use strict'; + +const os = require('os'); +const SSH = require('../src/ssh'); +const Saml = require('../src/saml'); +const rlex = require('../src/extra-readline'); +const Command = require('../src/command'); +const CredentialsParser = require('../src/credentials-parser'); + +class LoginCommand extends Command { + /** + * @return {Promise} + */ + run() { + const domain = this.getConfig('directoryDomain'); + const saml = new Saml(domain); + const alias = this.getOption('alias', 'a', false); + const username = this.getConfig('username'); + const credentials = alias ? this.getCredentials() : this.askCredentials(); + + if (alias && alias.constructor === Boolean) { + return Promise.reject('Alias is not valid'); + } + + return credentials + .then(credentials => saml.getAvailableAccounts(credentials.username, credentials.password)) + .then(accounts => { + if (!accounts.length) { + return Promise.reject(`No accounts found for ${username}`); + } + + return alias ? this.autoSelectAccount(accounts, alias) : this.selectAccount(accounts); + }) + .then(selected => { + this.signIn(selected); + + return Promise.resolve('Done!'); + }); + } + + /** + * Get credentials from config + * @return {Promise} + */ + getCredentials() { + const password = this.getConfig('password'); + const ssh = new SSH(this.getConfigPath()); + + if (!password) { + return Promise.reject('Please configure your password first (run: `aws-saml password`)'); + } + + return Promise.resolve({ + username: this.getConfig('username'), + password: ssh.decrypt(password) + }); + } + + /** + * Ask user's credentials + * @return {Promise} + */ + askCredentials() { + const username = this.getConfig('username'); + + return rlex.promiseQuestion(`Username (${username}): `).then(login => { + return rlex.promiseQuestion('Password: ', true).then(password => { + return Promise.resolve({ + username: login || username, + password: password + }); + }); + }); + } + + /** + * Select AWS account to login + * @param {Array} accounts + * @returns {Promise} + */ + selectAccount(accounts) { + const mapping = this.getConfig('aliases'); + const accountsList = accounts.map((account, index) => { + let [, accountId] = account.Arn.match(/(\d+):role/); + + return `[ ${index + 1} ] ${account.Arn} (${mapping[accountId] || accountId})`; + }); + + console.log(accountsList.join(os.EOL)); + + return rlex + .promiseQuestion('Choose account to login: ') + .then(selected => Promise.resolve(accounts[selected - 1])); + } + + /** + * Auto-select account by ID or alias + * @param {Array} accounts + * @param {String} alias + * @return {Promise} + */ + autoSelectAccount(accounts, alias) { + const mapping = this.getConfig('aliases'); + const accountId = Object.keys(mapping).find(it => mapping[it] === alias) || alias; + + if (!/^[0-9]{12}$/.test(accountId)) { + return Promise.reject( + `'${accountId}' is neither valid account ID nor predefined alias (run 'aws-saml alias' to add)` + ); + } + + return Promise.resolve(accounts.find(account => { + const regExp = new RegExp(`${accountId}:role`); + + return regExp.test(account.Arn); + })); + } + + /** + * Sign in method + * @param {Object} account + * @return {void} + */ + signIn(account) { + const parser = new CredentialsParser(); + const profile = this.getConfig('profile'); + const patch = { + aws_access_key_id: account.AccessKeyId, + aws_session_token: account.SessionToken, + aws_secret_access_key: account.SecretAccessKey + }; + + parser + .updateProfile(profile, patch) + .persist(); + } +} + +module.exports = LoginCommand; diff --git a/cmd/password.js b/cmd/password.js new file mode 100755 index 0000000..fb0be74 --- /dev/null +++ b/cmd/password.js @@ -0,0 +1,38 @@ +'use strict'; + +const SSH = require('../src/ssh'); +const rlex = require('../src/extra-readline'); +const Command = require('../src/command'); + +class PasswordCommand extends Command { + /** + * @return {Promise} + */ + run() { + const config = this.getConfig(); + const isDelete = this.getOption('delete', 'd', false); + const configPath = this.getConfigPath(); + + if (isDelete) { + config.password = false; + this.updateConfig(config); + + return Promise.resolve('Done!'); + } + + return rlex.promiseQuestion('Password (will be encoded): ', true).then(answer => { + if (answer) { + const ssh = new SSH(configPath); + config.password = ssh.encrypt(answer); + } + + return Promise.resolve(); + }).then(() => { + this.updateConfig(config); + + return Promise.resolve('Done!'); + }); + } +} + +module.exports = PasswordCommand; diff --git a/help.txt b/help.txt new file mode 100644 index 0000000..c7287ad --- /dev/null +++ b/help.txt @@ -0,0 +1,11 @@ +Usage: aws-saml [command] [options] + +Commands: + alias --account [--delete] Manage AWS account's aliases + configure Configure aws-saml config + login [--alias ] SSO login (interactive or auto-login) + password [--delete] Manage SSO password + +Options: + -v, --version Print aws-saml version + -h, --help Print help diff --git a/package.json b/package.json index 1b4975d..1869508 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aws-saml", - "version": "1.0.2", + "version": "1.1.0", "description": "AWS SAML login", "keywords": [ "aws", @@ -14,9 +14,10 @@ "license": "MIT", "preferGlobal": true, "dependencies": { - "aws-sdk": "^2.211.1", - "commander": "^2.15.1", - "request": "^2.85.0", + "aws-sdk": "^2.353.0", + "keypair": "^1.0.1", + "minimist": "^1.2.0", + "request": "^2.88.0", "sax": "^1.2.4" }, "homepage": "https://github.com/ddimitrioglo/aws-saml#readme", diff --git a/src/command.js b/src/command.js new file mode 100644 index 0000000..fc48e4b --- /dev/null +++ b/src/command.js @@ -0,0 +1,70 @@ +'use strict'; + +const Config = require('./config'); + +class Command { + /** + * @param options + */ + constructor(options) { + this.options = options; + this.config = new Config(); + + this.initialize(); + } + + /** + * Initialization + */ + initialize() {} + + /** + * @return {Promise} + */ + validate() { + return Promise.resolve(); + } + + /** + * @param {String} full + * @param {String} short + * @param {*} defaultVal + * @return {*} + */ + getOption(full, short, defaultVal) { + return this.options[full] || this.options[short] || defaultVal; + } + + /** + * @param {String} path + * @return {*} + */ + getConfig(path = '') { + return this.config.getConfig(path); + } + + /** + * @return {String} + */ + getConfigPath() { + return this.config.path; + } + + /** + * @param {Object} config + * @return {Object} + */ + updateConfig(config) { + return this.config.saveConfig(config); + } + + /** + * @abstract + * @return {Promise} + */ + run() { + return Promise.reject('`run()` is not implemented'); + } +} + +module.exports = Command; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..fa95d36 --- /dev/null +++ b/src/config.js @@ -0,0 +1,77 @@ +'use strict'; + +const os = require('os'); +const fs = require('fs'); +const path = require('path'); + +class Config { + /** + * Constructor + */ + constructor() { + this.name = 'config.json'; + this.path = path.join(os.homedir(), '.aws-saml'); + this.file = path.join(this.path, this.name); + this.config = this._getConfig(); + } + + /** + * Default configuration + * @return {Object} + */ + static defaults() { + return { + profile: 'saml', + username: 'john.doe', + password: false, + directoryDomain: 'https://directory.example.com', + aliases: {} + }; + } + + /** + * Get current config + * @return {Object} + */ + _getConfig() { + return fs.existsSync(this.file) ? require(this.file) : this.saveConfig(Config.defaults()); + } + + /** + * Save config + * @param {Object} config + * @return {Object} + */ + saveConfig(config) { + if (!fs.existsSync(this.path)){ + fs.mkdirSync(this.path); + } + + fs.writeFileSync(this.file, JSON.stringify(config, null, 2)); + + return config; + } + + /** + * Get config by comma-separated path + * @param {String} path + * @return {*} + */ + getConfig(path = '') { + const parts = path.split('.').filter(Boolean); + + if (!parts.length) { + return this.config; + } + + return parts.reduce((result, part) => { + if (!result || !result.hasOwnProperty(part)) { + return null; + } + + return result[part]; + }, this.config); + } +} + +module.exports = Config; diff --git a/lib/credentials-parser.js b/src/credentials-parser.js similarity index 77% rename from lib/credentials-parser.js rename to src/credentials-parser.js index 689b478..8a89de1 100644 --- a/lib/credentials-parser.js +++ b/src/credentials-parser.js @@ -4,6 +4,12 @@ const os = require('os'); const fs = require('fs'); const path = require('path'); +/** + * @info: + * AWS_CREDENTIAL_PROFILES_FILE environment variable to the location of your AWS credentials file + * AWS_SHARED_CREDENTIALS_FILE – Change the location of the file that the AWS CLI uses to store access keys + * AWS_CONFIG_FILE – Change the location of the file that the AWS CLI uses to store configuration profiles + */ class CredentialsParser { /** * Constructor @@ -11,11 +17,6 @@ class CredentialsParser { constructor() { this._path = process.env.AWS_CREDENTIAL_PROFILES_FILE || path.join(os.homedir(), '.aws', 'credentials'); this._profiles = this._parse(); - - // @todo investigate different use cases - // AWS_SHARED_CREDENTIALS_FILE – Change the location of the file that the AWS CLI uses to store access keys. - // AWS_CONFIG_FILE – Change the location of the file that the AWS CLI uses to store configuration profiles. - // AWS_CREDENTIAL_PROFILES_FILE environment variable to the location of your AWS credentials file. } /** @@ -35,8 +36,8 @@ class CredentialsParser { profile = found[1]; result[profile] = {}; } else { - let [property, value] = line.split(/=[^$]/).map(x => x.trim()); - result[profile][property] = value; + let [property, ...value] = line.split('=').map(x => x.trim()); + result[profile][property] = value.join('='); } }); @@ -61,7 +62,7 @@ class CredentialsParser { getProfile(name) { let result = [`[${name}]`]; let profile = this._profiles[name]; - + Object.keys(profile).forEach(property => { result.push(`${property} = ${profile[property]}`) }); diff --git a/lib/extra-readline.js b/src/extra-readline.js similarity index 59% rename from lib/extra-readline.js rename to src/extra-readline.js index 4b4f97f..b670880 100644 --- a/lib/extra-readline.js +++ b/src/extra-readline.js @@ -1,6 +1,5 @@ 'use strict'; -const os = require('os'); const ReadLine = require('readline'); const secretSign = '*'; @@ -24,7 +23,7 @@ rl._writeToOutput = string => { if (rl.output !== null && rl.output !== undefined) { let result = string; - if (rl.secretQuery) { + if (rl.secretQuery && string.charCodeAt(0) !== 13) { let regExp = new RegExp(`^(${rl.secretQuery})(.*)$`); result = regExp.test(string) @@ -37,16 +36,25 @@ rl._writeToOutput = string => { }; /** - * Add secret question method + * Extra question method * @param {String} query - * @param {Function} cb + * @param {Boolean} isSecret + * @return {Promise} */ -rl.constructor.prototype.secretQuestion = (query, cb) => { - rl.secretQuery = query; - rl.question(query, answer => { - rl.secretQuery = null; - rl.output.write(os.EOL); - cb(answer); +rl.constructor.prototype.promiseQuestion = (query, isSecret = false) => { + if (isSecret) { + // Escape the regular expression special characters + rl.secretQuery = query.replace(/[\^$\\.*+?()[\]{}|]/g, '\\$&'); + } + + return new Promise(resolve => { + rl.question(query, (answer) => { + if (isSecret) { + rl.secretQuery = null; + } + + return resolve(answer.trim()); + }); }); }; diff --git a/lib/saml.js b/src/saml.js similarity index 52% rename from lib/saml.js rename to src/saml.js index 485f33c..2b060c4 100644 --- a/lib/saml.js +++ b/src/saml.js @@ -2,31 +2,29 @@ const url = require('url'); const sax = require('sax'); +const AWS = require('aws-sdk'); const request = require('request').defaults({ jar: true }); class Saml { /** - * @param {String} idpUrl + * @param {String} idpDomain */ - constructor(idpUrl) { - let idp = url.parse(idpUrl); - + constructor(idpDomain) { + this._domain = idpDomain; this._parser = sax.parser(false, { lowercase: true }); - this._domain = `${idp.protocol}//${idp.host}`; - this._entryPath = idp.path; - this._samlResponse = null; } /** * Get login path (w/o domain) * @returns {Promise} + * @private */ - getLoginPath() { - return new Promise((resolve, reject) => { - let loginPath = ''; - let initUrl = url.resolve(this._domain, this._entryPath); + _getLoginPath() { + let loginPath = ''; + let idpUrl = url.resolve(this._domain, 'adfs/ls/IdpInitiatedSignOn.aspx?loginToRp=urn:amazon:webservices'); - request.get({ url: initUrl, rejectUnauthorized: false }, (err, res, body) => { + return new Promise((resolve, reject) => { + request.get({ url: idpUrl, rejectUnauthorized: false }, (err, res, body) => { if (err) { return reject(err); } @@ -100,29 +98,79 @@ class Saml { /** * Get login SAML Response as base64 string - * @param {String} loginPath * @param {String} username * @param {String} password * @returns {Promise} */ - getSamlResponse(loginPath, username, password) { - if (this._samlResponse) { - return Promise.resolve(this._samlResponse); - } + getSamlResponse(username, password) { + return this._getLoginPath().then(loginPath => this._login(loginPath, username, password)); + } + + /** + * Login and get list of available roles + * @param {String} username + * @param {String} password + * @return {Promise} + */ + getAvailableAccounts(username, password) { + return this.getSamlResponse(username, password).then(samlBase64Response => { + const samlXml = Saml.parseResponse(samlBase64Response); + + return this._getAllRoles(samlXml) + .then(roles => Promise.all(roles.map(it => this._assumeRole(it.roleArn, it.principalArn, samlBase64Response)))) + .then(results => results.filter(Boolean)); + }); + } + + /** + * Parse role ARNs from xmlSamlResponse + * @param {String} xmlString + * @returns {Promise} + * @private + */ + _getAllRoles(xmlString) { + return new Promise((resolve, reject) => { + let roles = []; + + this._parser.ontext = text => { + if (/^arn:aws:iam::.*/.test(text)) { + const [ principalArn, roleArn ] = text.split(','); - return this._login(loginPath, username, password).then(samlResponse => { - this._samlResponse = samlResponse; + roles.push({ principalArn, roleArn }); + } + }; - return samlResponse; + this._parser.onerror = err => reject(err); + this._parser.onend = () => resolve(roles); + this._parser.write(xmlString).close(); }); } /** - * Parse SAML Response + * Assume role (resolve false on fail) + * @param {String} roleArn + * @param {String} principalArn + * @param {String} samlResponse + * @returns {Promise} + * @private + */ + _assumeRole(roleArn, principalArn, samlResponse) { + const sts = new AWS.STS(); + const params = { RoleArn: roleArn, PrincipalArn: principalArn, SAMLAssertion: samlResponse }; + + return sts + .assumeRoleWithSAML(params) + .promise() + .then(data => Promise.resolve(Object.assign({ Arn: roleArn }, data.Credentials))) + .catch(() => Promise.resolve(false)); + } + + /** + * Parse SAML Response (base64 => xml) * @param {String} samlResponse * @returns {String} */ - static parseSamlResponse(samlResponse) { + static parseResponse(samlResponse) { return Buffer.from(samlResponse, 'base64').toString('utf8'); } } diff --git a/src/ssh.js b/src/ssh.js new file mode 100644 index 0000000..8f553a0 --- /dev/null +++ b/src/ssh.js @@ -0,0 +1,50 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const keypair = require('keypair'); + +class SSH { + /** + * @param {String} destPath + */ + constructor(destPath) { + const publicPath = path.join(destPath, 'rsa.pubk'); + const privatePath = path.join(destPath, 'rsa.pk'); + + if (!fs.existsSync(privatePath)) { + const rsaPair = keypair(); + + fs.writeFileSync(publicPath, rsaPair['public']); + fs.writeFileSync(privatePath, rsaPair['private']); + } + + this.public = fs.readFileSync(publicPath, 'utf8'); + this.private = fs.readFileSync(privatePath, 'utf8'); + } + + /** + * Encrypt message and encode to base64 + * @param {String} message + * @return {*} + */ + encrypt(message) { + return crypto + .publicEncrypt(this.public, Buffer.from(message)) + .toString('base64'); + } + + /** + * Decode base64 and decrypt message + * @param {String} string + * @return {*} + */ + decrypt(string) { + return crypto + .privateDecrypt(this.private, Buffer.from(string, 'base64')) + .toString('utf8'); + } +} + +module.exports = SSH;