├── .dockerignore ├── .eslintrc ├── .gitignore ├── CODEOWNERS ├── Dockerfile ├── README.md ├── cfg └── config.example.json ├── docker-compose.yml ├── docker-entrypoint.sh ├── docs ├── aws-sts-token-generator.gif └── providers.md ├── example ├── Dockerfile ├── aws-token ├── build.sh └── config.json ├── package.json ├── src ├── index.js ├── providers │ ├── mock.js │ ├── okta-helpers.js │ ├── okta-mfa.js │ └── okta.js └── token-getter.js └── test ├── .eslintrc └── providers └── okta.test.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2017 4 | }, 5 | "env": { 6 | "es6": true 7 | }, 8 | "extends": "@earnest/eslint-config" 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .debug 3 | node_modules 4 | aws-token.sh 5 | config.json* 6 | !example/config.json -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @meetearnest/tech-leads 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8-slim 2 | 3 | WORKDIR /usr/src/app 4 | 5 | RUN set -ex \ 6 | && apt-get update -qq \ 7 | && apt-get install -qq -y \ 8 | libgtk2.0-0 \ 9 | libgconf-2-4 \ 10 | libasound2 \ 11 | libxtst6 \ 12 | libxss1 \ 13 | libnss3 \ 14 | xvfb \ 15 | libnotify-dev \ 16 | && rm -rf /var/lib/apt/lists/* 17 | 18 | COPY package.json ./ 19 | RUN npm install \ 20 | && mkdir -p cfg 21 | COPY src ./src 22 | COPY docker-entrypoint.sh . 23 | 24 | VOLUME "/root/.aws" 25 | 26 | ONBUILD COPY config.json /usr/src/app/cfg/ 27 | 28 | ENTRYPOINT ["./docker-entrypoint.sh"] 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS STS Token Generator 2 | 3 | Single Sign on within AWS removes the ability to generate long-lived access tokens for AWS. Instead, the 4 | [Amazon Security Token Service](http://docs.aws.amazon.com/STS/latest/APIReference/Welcome.html) is used to generate 5 | short-lived tokens. 6 | 7 | This command line utility can be used to authenticate with an SSO provider (ex: Okta) and generate access token credentials. 8 | It supports assuming an AWS role and will automatically update your AWS CLI credentials file with the new credentials. 9 | 10 | For ease of use, the token generator is packaged as a docker container. Your team will not need to clone this repository 11 | or install anything. Token can be generated via a single `docker run` command. A helper script is also included to encapsulate 12 | the arguments of the docker command. 13 | 14 | ## Installation 15 | 16 | The token generator runs as a docker container which can bind-mount to your AWS credentials file to save temporary credentials. 17 | Installation is as simple as downloading the [`aws-token`](./example/aws-token) script and saving it to your prefered PATH location e.g. `/usr/local/bin/aws-token`. Then execute that file every time 18 | you need a new token. 19 | 20 | ``` 21 | $> aws-token 22 | ``` 23 | 24 | ### Bonus: exporting the credentials as ENV vars 25 | 26 | ``` 27 | export AWS_PROFILE= 28 | export AWS_ACCESS_KEY_ID=$(aws configure get $AWS_PROFILE.aws_access_key_id) 29 | export AWS_SECRET_ACCESS_KEY=$(aws configure get $AWS_PROFILE.aws_secret_access_key) 30 | export AWS_SESSION_TOKEN=$(aws configure get $AWS_PROFILE.aws_session_token) 31 | export AWS_DEFAULT_REGION=us-east-1 32 | ``` 33 | 34 | ## Usage 35 | 36 | ````` 37 | $> aws-token --help 38 | usage: index.js [-h] [-v] [--username USERNAME] [--password PASSWORD] 39 | [--role ROLE] 40 | [--account {staging,development}] 41 | [--profile PROFILE] 42 | 43 | 44 | AWS STS Token Generator 45 | 46 | Optional arguments: 47 | -h, --help Show this help message and exit. 48 | -v, --version Show program's version number and exit. 49 | --username USERNAME Okta username (ex. user@domain.com) 50 | --password PASSWORD Okta password 51 | --role ROLE Name of SAML role to assume 52 | --account {staging,development} 53 | Name of account to switch to. Defaults to "staging". 54 | --profile PROFILE Profile name that the AWS credentials should be saved 55 | as. Defaults to the name of the account specified. 56 | ````` 57 | 58 | ![Image of Generator in Action](https://raw.githubusercontent.com/meetearnest/aws-sts/master/docs/aws-sts-token-generator.gif) 59 | 60 | ## How it Works 61 | 62 | The process of authenticating with Okta (and many SAML SSO providers) is only possible via form-based authentication. 63 | We're using headless browser automation to emulate a form-based sign-on. This is similar to the [solution proposed by Amazon](https://blogs.aws.amazon.com/security/post/Tx1LDN0UBGJJ26Q/How-to-Implement-Federated-API-and-CLI-Access-Using-SAML-2-0-and-AD-FS). 64 | 65 | 1. Prompt user for SSO-provider username and password 66 | 2. Use a headless browser to navigate to the login page and submit the credentials 67 | 3. Prompt for a TOTP token 68 | 4. Use the headless browser to submit the TOTP token 69 | 5. Parse the response from Amazon to extract the SAML assertion 70 | 6. Present accessible roles to the user (if more than one) and allow them to select the role to assume 71 | 7. Use the STS API to [assume the role](http://docs.aws.amazon.com/cli/latest/reference/sts/assume-role-with-saml.html) 72 | 8. Save the token information to the [AWS credentials file](https://blogs.aws.amazon.com/security/post/Tx3D6U6WSFGOK2H/A-New-and-Standardized-Way-to-Manage-Credentials-in-the-AWS-SDKs) 73 | 74 | 75 | ## Setting up the AWS Token Generator for your Organization 76 | 77 | We recommend the following steps for use in your organization, see `example`: 78 | 79 | 1. Create a fresh git repository/dir 80 | 2. Copy [`config.example.json`](./cfg/config.example.json) to the root of your repository/dir as `config.json` and edit it for your organization 81 | 3. Create a Dockerfile for your token generator. The following should suffice: 82 | 83 | ``` 84 | FROM $ORG/aws-sts:config 85 | ``` 86 | 87 | 4. Build and publish the docker image for use in your organization 88 | 5. Copy the [`aws-token`](./example/aws-token) script to your prefered PATH location e.g. `/usr/local/bin/aws-token` 89 | 6. in your terminal do a `$ aws-token` to generate a temporal AWS token 90 | 91 | ## Configuration 92 | 93 | Configuration is done by creating a config.json file in the root of your repository. An [example template](./cfg/config.example.json) is provided. 94 | 95 | ``` 96 | awsConfigPath: Path to the user AWS CLI credential file. The recommended path is the path to the 97 | Docker container's credential path. 98 | outputFormat: Output format of AWS access token credentials 99 | region: Region used for AWS API calls 100 | provider: Name of the SAML provider to use for authentication 101 | idPEntryUrl: URL to access the form-based authentication login for the provider 102 | defaultAccount: Default AWS account to use when one is not specified via the command line 103 | accounts: Map of accountName/account-objects for accounts which can be switched to 104 | once initially authenticated 105 | account: 106 | accountNumber: AWS account number 107 | idpEntryUrl: URL to access the form-based authentication login for the provider. If not defined will 108 | fallback to the global idPEntryUrl 109 | ``` 110 | 111 | ## Building 112 | 113 | Once configured, build a docker container so that folks on your team can easily generate tokens without setup or configuration. 114 | 115 | ``` 116 | docker-compose build 117 | docker-compose push 118 | ``` 119 | 120 | ## Found a bug? 121 | 122 | File it [HERE](https://github.com/meetearnest/aws-sts/issues/new) 123 | 124 | ## Troubleshooting 125 | 126 | Sometimes, you might run into a timeout when you think all the required params are entered correctly. When that happens, it's useful to turn of headless browsing to see what's going on. 127 | 128 | ``` 129 | $ npm run start-debug 130 | ``` 131 | 132 | ## Limitations 133 | 134 | * All functionality is executed inside a Docker container. Docker must be available in order for the application to work. 135 | * At the moment, only Okta authentication is supported. We welcome Pull Requests for additional providers. 136 | -------------------------------------------------------------------------------- /cfg/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "awsConfigPath": "/.aws/credentials", 3 | "outputFormat": "json", 4 | "region": "us-east-1", 5 | "provider": "okta", 6 | "accounts": { 7 | "development": { 8 | "accountNumber": "aws-account-id", 9 | "idpEntryUrl": "https://xxx.okta.com/home/amazon_aws/xxx" 10 | }, 11 | "staging": { 12 | "accountNumber": "aws-account-id", 13 | "idpEntryUrl": "https://xxx.okta.com/home/amazon_aws/xxx" 14 | }, 15 | "production": { 16 | "accountNumber": "aws-account-id", 17 | "idpEntryUrl": "https://xxx.okta.com/home/amazon_aws/xxx" 18 | } 19 | }, 20 | "defaultAccount": "staging" 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | image: aws-sts:base 6 | build: . 7 | environment: 8 | - DEBUG=nightmare* 9 | volumes: 10 | - ./cfg:/usr/src/app/cfg 11 | - ./src:/usr/src/app/src 12 | - ./test:/usr/src/app/test 13 | - ./.debug:/usr/src/app/.debug 14 | - $HOME/.aws/:/root/.aws/ 15 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | xvfb-run --server-args="-screen 0 1280x2000x24" node src/index.js "$@" 4 | -------------------------------------------------------------------------------- /docs/aws-sts-token-generator.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Earnest-Labs/aws-sts/c4885a1ecc4be7699b844682b1d45ce731f98538/docs/aws-sts-token-generator.gif -------------------------------------------------------------------------------- /docs/providers.md: -------------------------------------------------------------------------------- 1 | # SSO Providers 2 | 3 | Creating a provider is done by adding a new file in `src/providers` and implementing the expected interface. 4 | In your `cfg/config.js` file update the `provider` key to reference the filename of the new provider. 5 | 6 | Authentication is done via form-based authentication as described in the Amazon blog post, 7 | [How to Implement a General Solution for Federated API CLI Access Using SAML 2.0](http://blogs.aws.amazon.com/security/post/TxU0AVUS9J00FP/How-to-Implement-a-General-Solution-for-Federated-API-CLI-Access-Using-SAML-2-0). 8 | 9 | ### Example Provider 10 | 11 | ``` 12 | const Mock = { 13 | /** 14 | * Name of the provider 15 | */ 16 | name: 'Mock', 17 | 18 | /** 19 | * Login method used to generate a valid SAML assertion 20 | * @param idpEntryUrl URL to start the login process 21 | * @param username Username at the SSO provider 22 | * @param password Password for the SSO provider 23 | * @returns Base64 encoded SAML assertion from the SSO provider 24 | */ 25 | login: function *(idpEntryUrl, username, password) { 26 | // ... authenticate 27 | return 'base 64 encoded SAML assertion'; 28 | } 29 | }; 30 | ``` 31 | 32 | ## Okta 33 | 34 | The Okta form-based authentication provider uses [Nightmare](http://www.nightmarejs.org/) for headless-browser automation. It can successfully 35 | authenticate via the Okta login and handles a TOTP multifactor challenge. Other multifactor challenges are not 36 | currently supported. 37 | 38 | To enable debugging, set the `DEBUG` environment variable prior to running: 39 | 40 | `DEBUG=1 node src/index.js` 41 | -------------------------------------------------------------------------------- /example/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ${ORG}/aws-sts:base 2 | -------------------------------------------------------------------------------- /example/aws-token: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ORG="YOUR_DOCKER_ORG_NAME_HERE" 4 | 5 | docker run \ 6 | -it --rm \ 7 | -v $HOME/.aws/:/root/.aws/ \ 8 | $ORG/aws-sts:config \ 9 | "$@" 10 | -------------------------------------------------------------------------------- /example/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ORG="YOUR_DOCKER_ORG_NAME_HERE" 4 | 5 | docker build -t $ORG/aws-sts:config . -------------------------------------------------------------------------------- /example/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "awsConfigPath": "/.aws/credentials", 3 | "outputFormat": "json", 4 | "region": "us-east-1", 5 | "provider": "okta", 6 | "accounts": { 7 | "development": { 8 | "accountNumber": "747722827777", 9 | "idpEntryUrl": "https://dev-777777.okta.com/home/amazon_aws/XXXXXXXXXXXXXX/272" 10 | }, 11 | "staging": { 12 | "accountNumber": "747722827777", 13 | "idpEntryUrl": "https://dev-777777.okta.com/home/amazon_aws/XXXXXXXXXXXXXX/272" 14 | }, 15 | "production": { 16 | "accountNumber": "747722827777", 17 | "idpEntryUrl": "https://dev-777777.okta.com/home/amazon_aws/XXXXXXXXXXXXXX/272" 18 | } 19 | }, 20 | "defaultAccount": "development" 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@earnest/aws-sts", 3 | "version": "1.0.1", 4 | "description": "Generation of AWS STS tokens via SAML authentication.", 5 | "main": "src/index.js", 6 | "engines": { 7 | "node": ">= 4.0.0" 8 | }, 9 | "scripts": { 10 | "lint": "./node_modules/.bin/eslint .", 11 | "lint-changed": "git diff --name-only --cached --relative | grep '\\.js$' | xargs ./node_modules/.bin/eslint", 12 | "start": "node src/index.js", 13 | "start-debug": "DEBUG=true node src/index.js", 14 | "start-docker": "docker-compose run --rm app", 15 | "test": "./node_modules/.bin/mocha --recursive test", 16 | "test-docker": "docker-compose run --rm --entrypoint ./node_modules/.bin/mocha app --recursive test", 17 | "docker-shell": "docker-compose run --rm --entrypoint bash app" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/meetearnest/aws-sts.git" 22 | }, 23 | "keywords": [ 24 | "aws", 25 | "sts", 26 | "saml" 27 | ], 28 | "author": "Brian Romanko", 29 | "license": "ISC", 30 | "bugs": { 31 | "url": "https://github.com/meetearnest/aws-sts/issues" 32 | }, 33 | "homepage": "https://github.com/meetearnest/aws-sts#readme", 34 | "dependencies": { 35 | "argparse": "^1.0.9", 36 | "aws-sdk": "^2.478.0", 37 | "clui": "^0.3.1", 38 | "co": "^4.6.0", 39 | "coinquirer": "0.0.5", 40 | "colors": "^1.1.2", 41 | "ini": "^1.3.4", 42 | "mkdirp": "^0.5.1", 43 | "nightmare": "^2.10.0", 44 | "thunkify": "^2.1.2", 45 | "xml2js": "^0.4.17" 46 | }, 47 | "devDependencies": { 48 | "@earnest/eslint-config": "latest", 49 | "eslint": "~5.3.0", 50 | "eslint-plugin-mocha": "~5.3.0", 51 | "mocha": "~6.1.4" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint no-console: 0 */ 4 | 5 | const ArgumentParser = require('argparse').ArgumentParser; 6 | const pkg = require('../package.json'); 7 | const co = require('co'); 8 | const coinquirer = require('coinquirer'); 9 | const xml2js = require('xml2js'); 10 | const thunkify = require('thunkify'); 11 | const mkdirp = require('mkdirp'); 12 | const os = require('os'); 13 | const fs = require('fs'); 14 | const ini = require('ini'); 15 | const path = require('path'); 16 | const TokenGetter = require('./token-getter'); 17 | require('colors'); 18 | 19 | const config = require('../cfg/config'); 20 | const arnRegExp = /^arn:aws:iam::(\d+):([^\/]+)\/(.+)$/; 21 | 22 | co(function *() { 23 | console.log('Earnest AWS Token Generator\n'.green.bold); 24 | const provider = require(`./providers/${config.provider}`); 25 | const args = parseArgs(provider.name); 26 | const tokenGetter = new TokenGetter(config); 27 | const account = config.accounts[args.account]; 28 | const idpEntryUrl = account.idpEntryUrl ? account.idpEntryUrl : config.idpEntryUrl; 29 | account.name = args.account; 30 | 31 | const samlAssertion = yield provider.login(idpEntryUrl, args.username, args.password, args.otp); 32 | const role = yield selectRole(samlAssertion, args.role); 33 | const token = yield tokenGetter.getToken(samlAssertion, account, role); 34 | const profileName = buildProfileName(role, account.name, args.profile); 35 | yield writeTokenToConfig(token, profileName); 36 | 37 | console.log('\n\n----------------------------------------------------------------'); 38 | console.log('Your new access key pair has been stored in the AWS configuration file ' + 39 | '~%s'.green.bold + ' under the ' + '%s'.green.bold + 40 | ' profile.', config.awsConfigPath, profileName); 41 | console.log('Note that it will expire at ' + '%s'.yellow.bold + '.', 42 | token.Credentials.Expiration); 43 | console.log('After this time, you may safely rerun this script to refresh your access key pair.'); 44 | console.log('To use this credential, call the AWS CLI with the --profile option (e.g. ' + 45 | 'aws --profile %s ec2 describe-instances'.italic.grey + ').', profileName); 46 | console.log('----------------------------------------------------------------\n\n'); 47 | }) 48 | .catch(function (err) { 49 | if (err instanceof Error) { 50 | console.error(err.message); 51 | console.error(err.stack); 52 | } else { 53 | console.error(err); 54 | } 55 | process.exit(-1); 56 | }); 57 | 58 | 59 | function parseArgs(providerName) { 60 | let parser = new ArgumentParser({ 61 | addHelp: true, 62 | description: pkg.description, 63 | version: pkg.version 64 | }); 65 | parser.addArgument(['--username'], { 66 | help: `${providerName} username (ex. user@domain.com)` 67 | }); 68 | parser.addArgument(['--password'], { 69 | help: `${providerName} password` 70 | }); 71 | parser.addArgument(['--otp'], { 72 | help: `${providerName} otp (2FA)` 73 | }); 74 | parser.addArgument(['--role'], { 75 | help: 'Name of SAML role to assume' 76 | }); 77 | parser.addArgument(['--account'], { 78 | defaultValue: config.defaultAccount, 79 | help: 'Name of account to switch to. Defaults to "' + config.defaultAccount + '".', 80 | choices: config.accounts 81 | }); 82 | parser.addArgument(['--profile'], { 83 | help: 'Profile name that the AWS credentials should be saved as. ' + 84 | 'Defaults to the name of the account specified.' 85 | }); 86 | return parser.parseArgs(); 87 | } 88 | 89 | function *selectRole(samlAssertion, roleName) { 90 | let buf = new Buffer(samlAssertion, 'base64'); 91 | let saml = yield thunkify(xml2js.parseString)( 92 | buf, 93 | {tagNameProcessors: [xml2js.processors.stripPrefix], xmlns: true}); 94 | 95 | // Extract SAML roles 96 | let roles; 97 | let attributes = saml.Response.Assertion[0].AttributeStatement[0].Attribute; 98 | for (let attribute of attributes) { 99 | if (attribute.$.Name.value === 'https://aws.amazon.com/SAML/Attributes/Role') { 100 | roles = attribute.AttributeValue.map(function (role) { 101 | return parseRoleAttributeValue(role._); 102 | }); 103 | } 104 | } 105 | 106 | if (!roles || roles.length <= 0) { 107 | throw new Error('No roles are assigned to your SAML account. Please contact Ops.'); 108 | } 109 | 110 | let accountIds = []; 111 | roles.forEach(function (role) { 112 | if (accountIds.indexOf(role.accountId) === -1) { 113 | accountIds.push(role.accountId); 114 | } 115 | }); 116 | let multipleAccounts = accountIds.length > 1; 117 | 118 | // Set the default role if one was passed 119 | let role = roles.find(r => r.name === roleName); 120 | if (!role) { 121 | role = roles[0]; // Couldn't find that role, default to the first one 122 | } 123 | 124 | if (roles.length > 1 && !roleName) { 125 | let ci = new coinquirer(); 126 | role = yield ci.prompt({ 127 | type: 'list', 128 | message: 'Please select a role:', 129 | choices: roles.map(r => { 130 | let name = r.name; 131 | if (multipleAccounts) { 132 | name += ' (' + r.accountId + ')'; 133 | } 134 | 135 | return { 136 | name: name, 137 | value: r 138 | }; 139 | }) 140 | }); 141 | } 142 | 143 | return role; 144 | } 145 | 146 | function parseRoleAttributeValue(attributeValue) { 147 | let arns = attributeValue.split(',').map(function (arn) { 148 | let match = arnRegExp.exec(arn); 149 | if (!match) { 150 | throw new Error('Unable to parse role ARN: ' + arn); 151 | } 152 | 153 | return { 154 | arn: arn, 155 | accountId: match[1], 156 | type: match[2], 157 | value: match[3] 158 | }; 159 | }); 160 | 161 | let provider = arns.find(arn => arn.type === 'saml-provider'); 162 | let role = arns.find(arn => arn.type === 'role'); 163 | 164 | return { 165 | name: role.value, 166 | accountId: role.accountId, 167 | roleArn: role.arn, 168 | principalArn: provider.arn 169 | }; 170 | } 171 | 172 | function *writeTokenToConfig(token, label) { 173 | let configFile = path.join(os.homedir(), config.awsConfigPath); 174 | yield thunkify(mkdirp)(path.dirname(configFile)); 175 | 176 | try { 177 | fs.accessSync(configFile); 178 | } catch (err) { 179 | fs.writeFileSync(configFile, ''); 180 | } 181 | 182 | const iniCfg = ini.parse(fs.readFileSync(configFile).toString()); 183 | 184 | if (!iniCfg.hasOwnProperty(label)) { 185 | iniCfg[label] = {}; 186 | } 187 | 188 | iniCfg[label].output = config.outputFormat; 189 | iniCfg[label].region = config.region; 190 | iniCfg[label].aws_access_key_id = token.Credentials.AccessKeyId; 191 | iniCfg[label].aws_secret_access_key = token.Credentials.SecretAccessKey; 192 | iniCfg[label].aws_session_token = token.Credentials.SessionToken; 193 | iniCfg[label].aws_security_token = token.Credentials.SessionToken; 194 | 195 | fs.writeFileSync(configFile, ini.encode(iniCfg)); 196 | } 197 | 198 | function buildProfileName(role, accountName, overrideName) { 199 | if (overrideName) { return overrideName; } 200 | 201 | return `${accountName}-${role.name}`; 202 | } 203 | -------------------------------------------------------------------------------- /src/providers/mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Mock = { 4 | /** 5 | * Name of the provider 6 | */ 7 | name: 'Mock', 8 | 9 | /** 10 | * Login method used to generate a valid SAML assertion 11 | * @param idpEntryUrl URL to start the login process 12 | * @param username Username at the SSO provider 13 | * @param password Password for the SSO provider 14 | * @returns Base64 encoded SAML assertion from the SSO provider 15 | */ 16 | login: function *(idpEntryUrl, username, password) { 17 | // ... authenticate 18 | return 'base 64 encoded SAML assertion'; 19 | } 20 | }; 21 | 22 | module.exports = Mock; 23 | -------------------------------------------------------------------------------- /src/providers/okta-helpers.js: -------------------------------------------------------------------------------- 1 | const Helpers = { 2 | waitAndEmitSAMLResponse: function () { 3 | /* eslint-disable */ 4 | 5 | // Stop waiting if an error was found 6 | var errEl = document.querySelector('.o-form-has-errors'); 7 | if (errEl) return true; 8 | 9 | // Keep waiting if no error or SAMLResponse detected 10 | var samlEl = document.querySelector('input[name="SAMLResponse"]'); 11 | if (!samlEl) return false; 12 | 13 | // SAMLResponse found, send it out to Node via console.log 14 | console.log(JSON.stringify({ 15 | SAMLResponse: samlEl.value 16 | })); 17 | return true; 18 | /* eslint-enable */ 19 | } 20 | }; 21 | 22 | module.exports = Helpers; 23 | -------------------------------------------------------------------------------- /src/providers/okta-mfa.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const clui = require('clui'); 4 | const OktaHelpers = require('./okta-helpers'); 5 | 6 | const GoogleAuthenticator = { 7 | detect: function *(nightmare) { 8 | return yield nightmare.visible('.mfa-verify-totp'); 9 | }, 10 | 11 | prompt: function *(ci) { 12 | return yield ci.prompt({ 13 | type: 'input', 14 | message: 'Google Authenticator code:' 15 | }); 16 | }, 17 | 18 | verify: function *(mfaPrompt, nightmare) { 19 | const spinner = new clui.Spinner('Verifying MFA...'); 20 | spinner.start(); 21 | 22 | yield nightmare 23 | .visible('.mfa-verify-totp') 24 | .wait(300) 25 | .type('input[name="answer"]', mfaPrompt) 26 | .wait(1000) 27 | .click('input[type="submit"]') 28 | .wait(OktaHelpers.waitAndEmitSAMLResponse); 29 | spinner.stop(); 30 | 31 | const hasError = yield nightmare 32 | .exists('.o-form-has-errors'); 33 | 34 | if (hasError) { 35 | let errMsg = yield nightmare.evaluate(function () { 36 | return document.querySelector('.o-form-has-errors').innerText; 37 | }); 38 | throw new Error(errMsg); 39 | } 40 | } 41 | }; 42 | 43 | const OktaVerify = { 44 | detect: function *(nightmare) { 45 | return yield nightmare.visible('.mfa-verify-push'); 46 | }, 47 | 48 | prompt: function *() { 49 | // None needed - will send a push 50 | }, 51 | 52 | verify: function *(mfaPrompt, nightmare) { 53 | const spinner = new clui.Spinner('Sending Okta Verify push notification...'); 54 | spinner.start(); 55 | 56 | yield nightmare 57 | .click('.mfa-verify-push input[type="submit"]') 58 | .wait(OktaHelpers.waitAndEmitSAMLResponse); 59 | spinner.stop(); 60 | 61 | const hasError = yield nightmare 62 | .exists('.o-form-has-errors'); 63 | 64 | if (hasError) { 65 | let errMsg = yield nightmare.evaluate(function () { 66 | return document.querySelector('.o-form-has-errors').innerText; 67 | }); 68 | throw new Error(errMsg); 69 | } 70 | } 71 | }; 72 | 73 | module.exports = [GoogleAuthenticator, OktaVerify]; 74 | -------------------------------------------------------------------------------- /src/providers/okta.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Nightmare = require('nightmare'); 4 | const clui = require('clui'); 5 | const coinquirer = require('coinquirer'); 6 | const pkg = require('../../package.json'); 7 | const path = require('path'); 8 | const MfaProviders = require('./okta-mfa'); 9 | const OktaHelpers = require('./okta-helpers'); 10 | 11 | const Okta = { 12 | name: 'Okta', 13 | 14 | login: function *(idpEntryUrl, username, password, otp) { 15 | let spinner = new clui.Spinner('Logging in...'); 16 | 17 | let ci = new coinquirer(); 18 | username = username ? username : yield ci.prompt({ 19 | type: 'input', 20 | message: 'Okta username (ex. user@domain.com):' 21 | }); 22 | password = password ? password : yield ci.prompt({ 23 | type: 'password', 24 | message: 'Okta password:' 25 | }); 26 | 27 | spinner.start(); 28 | 29 | let samlAssertion = undefined; 30 | let nightmare = Nightmare({ 31 | show: process.env.DEBUG, 32 | openDevTools: true, 33 | typeInterval: 5, 34 | pollInterval: 10, 35 | waitTimeout: 30 * 1000 36 | }); 37 | let hasError = yield nightmare 38 | .on('console', function (type, message) { 39 | // After authentication, Okta will create an interstitial page with a form including 40 | // a hidden input with the SAML response data. Javascript then immediately submits the 41 | // form once the page loads. This means there is limited time to extract the SAML response. 42 | // Each `wait` call is capable of detecting the SAML response. However, then executing an 43 | // `evaluate` call takes long enough for the SAML response to no longer be available. 44 | // So, we need to capture the SAML response in the `wait` call. The easiest mechanism to 45 | // get data from the browser back to Node is via the console logger. This event handler 46 | // looks for a log including the SAML response. 47 | // 48 | // This event handler is so early in the chain because it must be attached prior to calling 49 | // `goto`. 50 | if (type === 'log' && message && message.indexOf('SAMLResponse') >= 0) { 51 | samlAssertion = JSON.parse(message).SAMLResponse; 52 | } 53 | }) 54 | .useragent(pkg.description + ' v.' + pkg.version) 55 | .goto(idpEntryUrl) 56 | .visible('.primary-auth-form') 57 | .wait('input[type="submit"]') // Form is loaded via AJAX 58 | .wait(300) 59 | .type('input[name="username"]', username) 60 | .click('input[type="submit"]') // Submit form 61 | .wait('.o-form-input-name-password') 62 | .type('input[name="password"]', password) 63 | .click('input[type="submit"]') // Submit form 64 | .wait('.o-form-has-errors, .mfa-verify') // Wait for error or success 65 | .exists('.o-form-has-errors'); 66 | spinner.stop(); 67 | 68 | if (hasError) { 69 | let errMsg = yield nightmare.evaluate(function () { 70 | return document.querySelector('.o-form-has-errors').innerText; 71 | }); 72 | yield fail(nightmare, errMsg); 73 | } 74 | 75 | for (let i = 0; i < MfaProviders.length; i++) { 76 | const mfaProvider = MfaProviders[i]; 77 | const good = yield mfaProvider.detect(nightmare); 78 | if (good) { 79 | try { 80 | const prompt = otp ? otp : yield mfaProvider.prompt(ci); 81 | yield mfaProvider.verify(prompt, nightmare); 82 | } catch (err) { 83 | yield fail(nightmare, err.message); 84 | } 85 | break; 86 | } 87 | } 88 | 89 | if (!samlAssertion) { 90 | yield nightmare.wait(OktaHelpers.waitAndEmitSAMLResponse); 91 | } 92 | yield nightmare.end(); 93 | spinner.stop(); 94 | 95 | if (!samlAssertion) { 96 | throw new Error('SAML Assertion was never found.'); 97 | } 98 | return samlAssertion; 99 | } 100 | }; 101 | 102 | function *fail(nightmare, errMsg) { 103 | if (process.env.DEBUG) { 104 | yield nightmare 105 | .screenshot(path.join(process.cwd(), '.debug', 'error.png')) 106 | .html(path.join(process.cwd(), '.debug', 'error.html'), 'HTMLComplete'); 107 | } 108 | 109 | throw new Error(errMsg); 110 | } 111 | 112 | module.exports = Okta; 113 | -------------------------------------------------------------------------------- /src/token-getter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const AWS = require('aws-sdk'); 3 | const clui = require('clui'); 4 | 5 | // Not thread safe! 6 | class TokenGetter { 7 | constructor(config) { 8 | this.spinner = new clui.Spinner('Getting token...'); 9 | this.sts = new AWS.STS({region: config.region}); 10 | this.defaultAccount = config.defaultAccount; 11 | } 12 | 13 | async getToken(samlAssertion, account, role) { 14 | this.samlAssertion = samlAssertion; 15 | this.account = account; 16 | this.accountNumber = this.account.accountNumber; 17 | this.role = role; 18 | 19 | try { 20 | this.spinner.start(); 21 | const token = await this.getSTSToken(); 22 | 23 | // if the account has an IDP field we don't care about the default account 24 | // because we loged in directly to the final account 25 | if (this.isDefaultAccount() || this.hasAccountIDP()) { 26 | this.spinner.stop(); 27 | return token; 28 | } 29 | 30 | // need to switch roles to the other account 31 | const assumedToken = await this.getAssumeRoleToken(token); 32 | this.spinner.stop(); 33 | return assumedToken; 34 | } catch (e) { 35 | console.log(e.stack); // eslint-disable-line no-console 36 | throw new Error('error getting token: ' + e.message); 37 | } 38 | } 39 | 40 | isDefaultAccount() { 41 | return this.account.name === this.defaultAccount; 42 | } 43 | 44 | hasAccountIDP() { 45 | return 'idpEntryUrl' in this.account; 46 | } 47 | 48 | async getSTSToken() { 49 | const request = this.sts.assumeRoleWithSAML({ 50 | PrincipalArn: this.role.principalArn, 51 | RoleArn: this.role.roleArn, 52 | SAMLAssertion: this.samlAssertion 53 | }); 54 | return await request.promise(); 55 | } 56 | 57 | async getAssumeRoleToken(originalToken) { 58 | this.sts.config.credentials = new AWS.Credentials( 59 | originalToken.Credentials.AccessKeyId, 60 | originalToken.Credentials.SecretAccessKey, 61 | originalToken.Credentials.SessionToken); 62 | const roleArn = this.role.roleArn.replace(/::(\d+)/, `::${this.accountNumber}`); 63 | const splitArn = originalToken.AssumedRoleUser.Arn.split('/'); 64 | 65 | return await this.sts.assumeRole({ 66 | RoleArn: roleArn, 67 | RoleSessionName: splitArn[splitArn.length - 1] 68 | }).promise(); 69 | } 70 | } 71 | 72 | module.exports = TokenGetter; 73 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | {"extends": "@earnest/eslint-config/mocha"} 2 | -------------------------------------------------------------------------------- /test/providers/okta.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const Okta = require('../../src/providers/okta'); 5 | 6 | describe('Okta Provider', function () { 7 | it('should conform to provider interface', function () { 8 | assert.hasOwnProperty('name', Okta); 9 | 10 | assert.equal(Okta.name, 'Okta'); 11 | }); 12 | }); 13 | --------------------------------------------------------------------------------