├── .gitignore ├── .pylintrc ├── .travis.yml ├── LICENSE ├── README.md ├── RELEASE.md ├── lint.sh ├── okta_aws.sh ├── okta_aws ├── __init__.py ├── __main__.py ├── exceptions.py └── okta_aws.py ├── release.sh ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── data ├── .gitignore ├── okta_auth_password_expired.json ├── okta_auth_success.json ├── okta_aws.toml ├── saml_assertion.txt ├── saml_assertion_decoded.txt ├── saml_assertion_page.html └── sts_creds.json ├── test_aws_assume_role.py ├── test_friendly_interval.py ├── test_get_arns.py ├── test_get_saml_assertion.py └── test_log_in_to_okta.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # mkdocs documentation 97 | /site 98 | 99 | # pytest 100 | .pytest_cache 101 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | 3 | disable=no-self-use 4 | 5 | # Good variable names which should always be accepted, separated by a comma 6 | good-names=i, # Indices 7 | j, 8 | k, 9 | fh, # File handle 10 | m, # Regex match 11 | v, # Value (used with k for key) 12 | kv, # Key/Value pairs 13 | r, # Result from requests.get 14 | e, # exception 15 | _ 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | install: 5 | - pip install -e . 6 | - pip install pytest-datadir 7 | script: 8 | - pytest 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Okta AWS tool 2 | 3 | [![Build Status](https://travis-ci.org/chef/okta_aws.svg?branch=master)](https://travis-ci.org/chef/okta_aws) 4 | 5 | This tool is for accessing the AWS API for AWS accounts you normally log 6 | into via okta. Normally, when you log in to an account via okta, you are 7 | assigned an IAM role, and don't have an actual user within AWS. This means you 8 | don't have any API keys you can use to access the AWS API via the command 9 | line. 10 | 11 | This tool will prompt you for your Okta credentials, and generate temporary 12 | API keys you can use to access the AWS API with. 13 | 14 | ## Installation 15 | 16 | Okta_aws requires Python 3 to run. 17 | 18 | ### macOS 19 | 20 | If you have [Homebrew](https://brew.sh), you can use it to install `okta_aws`: 21 | 22 | brew tap chef/okta_aws 23 | brew install okta_aws 24 | 25 | To do the same thing in one step, you can run: 26 | 27 | brew install chef/okta_aws/okta_aws 28 | 29 | ### Linux (Ubuntu) 30 | 31 | Due to a [bug](https://github.com/aws/aws-cli/issues/3820) in the 32 | version of the AWS CLI found in Ubuntu packages, you will need to use 33 | the AWS CLI installed via `pip` instead (`okta_aws` currently 34 | interacts with the AWS API via the CLI). 35 | 36 | Once you have installed the newer AWS CLI, you can install `okta_aws` 37 | using `pip`: 38 | 39 | sudo apt-get remove awscli 40 | sudo apt-get install python3 python3-pip 41 | sudo python3 -m pip install --upgrade awscli 42 | sudo python3 -m pip install --upgrade okta_aws 43 | 44 | 45 | ### Pip 46 | 47 | Alternatively, you can install via `pip`: 48 | 49 | pip install okta_aws 50 | 51 | ### Source 52 | 53 | To install from source, clone this repository and run: 54 | 55 | python setup.py install 56 | 57 | ## Setup 58 | 59 | Make a file in your home directory called `.okta_aws.toml`: 60 | 61 | [general] 62 | username="yourusername" 63 | okta_server="yourcompany.okta.com" 64 | 65 | The values are as follows: 66 | 67 | * `username`: your okta username. 68 | * `okta_server`: the okta domain your company uses. It is usually something 69 | like `yourcompanyname.okta.com`. 70 | 71 | There are some optional settings too: 72 | 73 | * `short_profile_names` - okta_aws will fetch a list of AWS accounts you have 74 | been assigned directly from okta, and will use the name in okta as the 75 | profile name referred to by the AWS tools. However, the name of the 76 | application in okta is often verbose. With this option turned on (it 77 | defaults to true), the profile names will be shortened into something easier 78 | to type. For example 'MyCompany Engineering (all devs) AWS' will become 79 | `mycompany-engineering`. The exact rules are: 80 | * Any trailing 'AWS' suffix, if present, is removed 81 | * Anything in parentheses is stripped 82 | * Everything is converted to lowercaase 83 | * Spaces are stripped and replaced with dashes 84 | * `cookie_file` - the location where the okta session cookie is stored. This 85 | defaults to `~/.okta_aws_cookie`. 86 | * `session_duration` - How long to request that the AWS temporary credentials 87 | should be valid for. This defaults to `3600` (1 hour), but you can choose a 88 | shorter or longer value up to `43200` (12 hours). 89 | * Note: in order to choose a session length longer than 1 hour, you need to 90 | configure the role in AWS to allow longer sessions. In the IAM console, 91 | find the role and exit the `Maximum CLI/API session duration` setting. 92 | * `role_arn` - the ARN or name of the role to assume. This only needs to be 93 | set if you have more than one role and are prompted to select which role to 94 | assume when you run okta_aws. 95 | 96 | Each of these settings can be set per-profile. To do this, create a new 97 | section in the configuration file with the name of the profile, and put your 98 | per-profile settings here. For example, in order to use a longer session 99 | length for the `mycompany-dev` profile, add this to your `~/.okta_aws.toml` 100 | file: 101 | 102 | ``` 103 | [mycompany-dev] 104 | session_duration = 43200 105 | ``` 106 | 107 | Or, if you are prompted when logging into the `mycompany-staging` profile 108 | which role you want to use, then you can configure a default role as follows: 109 | 110 | ``` 111 | [mycompany-staging] 112 | role_arn = "Okta_PowerUser" 113 | # Alternatively you can specify the full ARN 114 | # role_arn = "arn:aws:iam::1234567890:role/Okta_PowerUser" 115 | ``` 116 | 117 | ### Profile aliases 118 | 119 | Sometimes, the profile name obtained from okta doesn't match the profile name 120 | you want to use in your AWS credentials file (for example, you might have a 121 | specific profile name hardcoded in scripts). In these cases, you can configure 122 | a profile name to be an _alias_ of another. To do this, add an `[aliases]` 123 | section to your `.okta_aws.toml` file. For example, if `okta_aws --list` shows 124 | an available profile of `companyname-engineering` but you have 125 | `engineering` configured as a profile name in your scripts, you can do: 126 | 127 | ``` 128 | [aliases] 129 | engineering = "companyname-engineering" 130 | ``` 131 | 132 | Then, you just set `AWS_PROFILE` to `engineering`, or pass `engineering` as an 133 | argument to okta_aws, and it will log in with the `companyname-engineering` 134 | profile, while storing the credentials in an `engineering` profile in your 135 | `~/.aws/credentials` file. 136 | 137 | If you want to configure profile specific settings for a profile that has an 138 | alias, you can configure them under either the profile name itself, or the 139 | alias. If you configure the settings under the 'real' name of the profile, 140 | then those settings will also be used if you refer to the profile by its 141 | alias. If you configure them under the alias, then they will only take effect 142 | if you refer to the profile by its alias. 143 | 144 | ### GovCloud 145 | 146 | If the profile name includes 'govcloud', then okta_aws will use the appropriate 147 | region for fetching govcloud credentials (us-gov-east-1). 148 | 149 | ## Usage 150 | 151 | Run `okta_aws PROFILENAME`, or run `okta_aws` without any arguments and 152 | okta_aws will use the `AWS_PROFILE` environment variable if you have it set. 153 | 154 | To fetch credentials for all profiles you have access to, run `okta_aws --all`. 155 | 156 | To list the available profiles, run `okta_aws --list`. 157 | 158 | The first time you run `okta_aws`, you will be prompted for your okta username 159 | and password. On subsequent runs, if you are still logged into okta and your 160 | session hasn't expired, then you won't have to log in again. 161 | 162 | Once you have entered your okta username and password, a temporary token will 163 | be obtained for you and stored in your AWS credentials file. You can then use 164 | the aws api as normal, passing in the profile name you gave to okta_aws. 165 | 166 | If you have been assigned multiple possible roles when the aws account was set 167 | up for you in okta, then you will be prompted which one of them you want to 168 | use (e.g. Okta_PowerUser or Okta_AdminAccess). Select which one you want from 169 | the menu if prompted. You can also configure a default role to assume in 170 | `~/.okta_aws.toml`. 171 | 172 | The AWS token you receive will only last for an hour. To get a new token, 173 | re-run okta_aws. 174 | 175 | ### Automatically refreshing the token 176 | 177 | You can run okta_aws a second time to retrieve a new token before the old one 178 | expires. If you wish, you can run one of the following to automatically refresh 179 | the token once every 55 minutes (allowing some grace period before the token 180 | expires): 181 | 182 | while true; do okta_aws PROFILENAME; sleep 3300; done 183 | while true; do okta_aws --all; sleep 3300; done 184 | 185 | okta_aws will run without prompting for anything one you have logged in and, 186 | if necessary, configured a default profile in `~/.okta_aws.toml`. 187 | 188 | Users of zsh may find this `oh-my-zsh` plugin useful for shell-integrated 189 | auto-refresh: [okta-aws plugin for zsh](https://gist.github.com/irvingpop/8e4e3bc63497be3432e695a52ef885f0) 190 | 191 | ## Troubleshooting 192 | 193 | If you're having issues running okta_aws, there's a few things to check: 194 | `okta_aws --version` - should match most recent 195 | `python --version` - should be 3.X 196 | 197 | We also recently saw someone where the error looked like this, user had environment variables set that put python 3.6 directories first in the PATH, when python 3.7 was installed. Removing those lines from `.bash_profile` fixed the issue: 198 | `-bash: /Library/Frameworks/Python.framework/Versions/3.6/bin/okta_aws: /usr/local/Cellar/okta_aws/0.5.3/libexec/bin/python3.7: bad interpreter: No such file or directory` 199 | 200 | ## Similar projects 201 | 202 | * [okta-aws-cli-assume-role](https://github.com/oktadeveloper/okta-aws-cli-assume-role) - Okta's own tool 203 | * [oktaauth](https://github.com/ThoughtWorksInc/oktaauth) - Python library for working with okta 204 | * [segmentio/aws-okta](https://github.com/segmentio/aws-okta) - uses assumerole 205 | to connect to multiple aws accounts while signing into a primary aws account 206 | in okta. 207 | * [aws-vault](https://github.com/99designs/aws-vault) - tool for securely storing aws credentials 208 | * [okta_aws_login](https://github.com/nimbusscale/okta_aws_login) 209 | * [okta-awscli](https://github.com/jmhale/okta-awscli) 210 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a new release 2 | 3 | ## Prerequisites 4 | 5 | * Create an account on and make sure you have access to the 6 | okta_aws package. Ask an existing maintainer for access if you don't have 7 | it. 8 | * Make sure you have push access to the `chef/okta_aws` and 9 | `chef/homebrew-okta_aws` repositories. 10 | * Install dependencies: python3, twine (`brew install python3; 11 | pip3 install twine`) 12 | 13 | ## Making the release 14 | 15 | Run `./release.sh NEW_VERSION_NUMBER`. This will: 16 | 17 | * Edit okta_aws and bump the version number at the top (in the `__VERSION__` 18 | variable) 19 | * Commit changes to git 20 | * Tag the commit `git tag v$(./okta_aws --version)` 21 | * Push the changes `git push --tags origin master` 22 | * Remove any existing builds `rm -rf dist` 23 | * Run `python ./setup.py sdist` to build a source package 24 | * Run `twine upload dist/*` to upload the package to pypi. 25 | 26 | To update the homebrew formula, clone 27 | and run the `release.sh` script in 28 | that repository. This will: 29 | 30 | * Install okta_aws and homebrew-pypi-poet to a virtualenv 31 | * Generate a formula with `poet -f okta_aws` 32 | * Patch the generated formula to add local customizations 33 | * Commit and push the changes to homebrew-okta-aws 34 | -------------------------------------------------------------------------------- /lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Quick script to lint okta_aws 3 | # Serves mostly as a reminder of which tools are used. 4 | 5 | # Installation of tools on a mac: 6 | # brew install flake8 7 | # pip3 install pylint 8 | 9 | flake8 10 | pylint okta_aws 11 | -------------------------------------------------------------------------------- /okta_aws.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to run okta_aws directly from the git repository for use during 3 | # development 4 | python3 -m okta_aws "$@" 5 | -------------------------------------------------------------------------------- /okta_aws/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2017 Chef Software 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | -------------------------------------------------------------------------------- /okta_aws/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2017 Chef Software 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """The main entry point for the program.""" 17 | 18 | from okta_aws import okta_aws 19 | 20 | 21 | def main(args=None): 22 | """The main entry point for the program.""" 23 | try: 24 | okta_aws.OktaAWS(args).run() 25 | except KeyboardInterrupt: 26 | print("Exiting...") 27 | 28 | 29 | if __name__ == "__main__": 30 | main() 31 | -------------------------------------------------------------------------------- /okta_aws/exceptions.py: -------------------------------------------------------------------------------- 1 | class Error(Exception): 2 | "Base exception class for okta_aws" 3 | pass 4 | 5 | 6 | class LoginError(Error): 7 | "Error logging in to okta" 8 | def __init__(self, message): 9 | self.message = message 10 | 11 | 12 | class AssumeRoleError(Error): 13 | "Error running aws assume-role-with-saml" 14 | def __init__(self, message): 15 | self.message = message 16 | -------------------------------------------------------------------------------- /okta_aws/okta_aws.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2017 Chef Software 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | import argparse 17 | import base64 18 | import getpass 19 | import html 20 | import logging 21 | import os 22 | import re 23 | import shutil 24 | import subprocess 25 | import sys 26 | import xml.etree.ElementTree as ET 27 | 28 | import requests 29 | import toml 30 | import boto3 31 | 32 | from okta_aws import exceptions 33 | 34 | 35 | __VERSION__ = '0.7.0' 36 | 37 | 38 | class OktaAWS(object): 39 | def __init__(self, argv=None): 40 | """Initialize the program 41 | 42 | argv - command line arguments (or None to use sys.argv) 43 | """ 44 | self.args = self.parse_args(argv) 45 | self.profile = self.args.profile 46 | 47 | def parse_args(self, argv): 48 | """Parses command line arguments using argparse 49 | 50 | argv - command line arguments (or None to use sys.argv) 51 | """ 52 | parser = argparse.ArgumentParser( 53 | description='Generates temporary AWS credentials for an AWS' 54 | ' account you access through okta.') 55 | parser.add_argument('profile', nargs='?', 56 | default=os.getenv("AWS_PROFILE") or "default", 57 | help='The AWS profile you want credentials for') 58 | parser.add_argument('--config', '-c', default='~/.okta_aws.toml', 59 | help='Path to the configuration file') 60 | parser.add_argument('--no-cookies', '-n', action='store_true', 61 | help="Don't use or save okta session cookie") 62 | parser.add_argument('--debug', '-d', action='store_true', 63 | help='Show debug output') 64 | parser.add_argument('--quiet', '-q', action='store_true', 65 | help='Only show error messages') 66 | parser.add_argument('--list', '-l', action='store_true', 67 | help="Don't assume a role, list assigned " 68 | "applications in okta") 69 | parser.add_argument('--all', '-a', action='store_true', 70 | help='Assume a role in all assigned accounts') 71 | parser.add_argument('--role_arn', '-r', 72 | help='Role name or ARN to assume') 73 | parser.add_argument('--version', '-v', action='version', 74 | version=__VERSION__, 75 | help='Show version of okta_aws and exit') 76 | parser.add_argument('--setup', '-s', action='store_true', 77 | help="Set up a config file for okta_aws") 78 | return parser.parse_args(argv) 79 | 80 | def setup_logging(self): 81 | """Sets up logging based on whether debugging is enabled or not""" 82 | if self.args.debug: 83 | logging.basicConfig( 84 | format='%(asctime)s %(levelname)s %(message)s', 85 | level=logging.DEBUG) 86 | elif self.args.quiet: 87 | logging.basicConfig(format='%(message)s', level=logging.ERROR) 88 | else: 89 | logging.basicConfig(format='%(message)s', level=logging.INFO) 90 | 91 | if not self.args.debug: 92 | logging.getLogger('boto3').setLevel(logging.ERROR) 93 | logging.getLogger('botocore').setLevel(logging.ERROR) 94 | 95 | def preflight_checks(self): 96 | """Performs some checks to ensure that the program can be run 97 | successfully. If these checks fail, an explanation is given as well as 98 | a hint for how the user can fix the problem. 99 | """ 100 | errors = [] 101 | # AWS cli 102 | if shutil.which("aws") is None: 103 | errors.append("The AWS CLI (the 'aws' command) cannot be found. " 104 | "see http://docs.aws.amazon.com/cli/latest/" 105 | "userguide/installing.html for information on " 106 | "installing it.") 107 | if errors: 108 | print("Preflight check failed") 109 | print("======================") 110 | for e in errors: 111 | print("* %s" % e) 112 | sys.exit(1) 113 | 114 | def interactive_setup(self, config_file): 115 | """Performs first-time setup for users who haven't set up a config 116 | file yet, by asking some simple questions. 117 | """ 118 | try: 119 | toml_config = toml.load(os.path.expanduser(config_file)) 120 | except FileNotFoundError: 121 | toml_config = {} 122 | toml_config.setdefault('general', {}) 123 | 124 | default_username = toml_config['general'].get( 125 | 'username', getpass.getuser()) 126 | default_server = toml_config['general'].get( 127 | 'okta_server', 'example.okta.com') 128 | 129 | print("Okta AWS initial setup") 130 | print("======================") 131 | print() 132 | username = input("Enter your okta username [%s]: " % default_username) 133 | if username == "": 134 | username = default_username 135 | 136 | okta_server = input("Enter your okta domain [%s]: " % default_server) 137 | if okta_server == "": 138 | okta_server = default_server 139 | 140 | print() 141 | print("Creating/updating %s" % config_file) 142 | toml_config['general']['username'] = username 143 | toml_config['general']['okta_server'] = okta_server 144 | with open(os.path.expanduser(config_file), "w") as fh: 145 | toml.dump(toml_config, fh) 146 | 147 | print("Setup complete. You can now log in with okta_aws PROFILENAME.") 148 | print("Hint: you can use 'okta_aws --list' to see which profiles " 149 | "you can use.") 150 | 151 | def load_config(self, config_file): 152 | """Loads the config file and returns a dictionary containing its 153 | contents. 154 | 155 | config_file - path to the configuration file to load 156 | """ 157 | try: 158 | config = toml.load(os.path.expanduser(config_file)) 159 | except FileNotFoundError: 160 | self.interactive_setup(config_file) 161 | sys.exit(0) 162 | 163 | config.setdefault('general', {}) 164 | config.setdefault('aliases', {}) 165 | 166 | required_config_options = [ 167 | 'username', 168 | 'okta_server' 169 | ] 170 | 171 | missing_options = [k for k in required_config_options 172 | if k not in config['general']] 173 | if missing_options: 174 | logging.error("Missing required configuration settings: %s", 175 | ', '.join(missing_options)) 176 | sys.exit(1) 177 | 178 | # Default configuration values 179 | config['general'].setdefault('cookie_file', '~/.okta_aws_cookie') 180 | config['general'].setdefault('short_profile_names', True) 181 | config['general'].setdefault('session_duration', 3600) 182 | 183 | config['general']['cookie_file'] = os.path.expanduser( 184 | config['general']['cookie_file']) 185 | 186 | return config 187 | 188 | def get_config(self, key, default=None): 189 | """Obtain a profile specific configuration value, falling back to the 190 | general config or a default value""" 191 | try: 192 | return self.config[self.profile][key] 193 | except KeyError: 194 | try: 195 | real_profile = self.config['aliases'][self.profile] 196 | return self.config[real_profile][key] 197 | except KeyError: 198 | return self.config['general'].get(key, default) 199 | 200 | def choose_from_menu(self, choices, prompt="Select an option: "): 201 | """Present an interactive menu of choices for the user to pick from. 202 | 203 | Returns the index into the list of the selected item. 204 | 205 | choices - a list of options to choose from 206 | prompt - the prompt to show to the user 207 | """ 208 | for idx, value in enumerate(choices): 209 | print("%2d) %s" % (idx + 1, value)) 210 | response = 0 211 | while response < 1 or response > len(choices): 212 | try: 213 | response = int(input(prompt)) 214 | except ValueError: 215 | # If we enter something invalid, just go through the 216 | # loop again. 217 | pass 218 | return response - 1 219 | 220 | def select_role(self, arns): 221 | """Returns the role to use from a list of principal/role arn pairs, 222 | based on either a configuration option, user selecting from a menu, 223 | or simply returning the only arn pair in the list if there is only 224 | one. 225 | 226 | arns - a list of arn pairs (each pair should be a princpal/role arn) 227 | """ 228 | selected = None 229 | if len(arns) > 1: 230 | # Get role via config, but allow commandline override 231 | role_arn = self.get_config('role_arn') or self.args.role_arn 232 | if role_arn is not None: 233 | # First check to see if we configured a default role 234 | logging.debug("Looking for configured role: %s", role_arn) 235 | for arn in arns: 236 | # Use endswith here so we can provide just a role name 237 | # instead of the full ARN. 238 | if arn[1].endswith(role_arn): 239 | selected = arn 240 | if selected is None: 241 | # We either didn't configure a default role or the configured 242 | # default role didn't match any available roles. Ask the user 243 | # to pick one. 244 | print("Available roles") 245 | response = self.choose_from_menu( 246 | [arn[1].split('/')[-1] for arn in arns], 247 | "Select role to log in with: ") 248 | selected = arns[response] 249 | else: 250 | selected = arns[0] 251 | return selected 252 | 253 | def get_arns(self, saml_assertion): 254 | """Extracts the available principal/role ARNS for a user given a 255 | base64 encoded SAML assertion returned by okta. 256 | 257 | saml_assertion - the saml asssertion given by okta, base64 encoded. 258 | """ 259 | parsed = ET.fromstring(base64.b64decode(saml_assertion)) 260 | # Horrible xpath expression to dig into the ARNs 261 | elems = parsed.findall( 262 | ".//{urn:oasis:names:tc:SAML:2.0:assertion}Attribute[" 263 | "@Name='https://aws.amazon.com/SAML/Attributes/Role']//*") 264 | # text contains Principal ARN, Role ARN separated by a comma 265 | arns = [e.text.split(",", 1) for e in elems] 266 | selected = self.select_role(arns) 267 | # Returns principal_arn, role_arn 268 | logging.debug("Principal ARN: %s", selected[0]) 269 | logging.debug("Role ARN: %s", selected[1]) 270 | return selected 271 | 272 | def aws_assume_role(self, principal_arn, role_arn, assertion, duration): 273 | """Gets temporary credentials from aws. Returns a dictionary 274 | containing the temporary credentials. 275 | 276 | principal_arn - the principal_arn (obtained from saml assertion) 277 | role_arn - the arn of the role to assume (obtained from saml 278 | assertion) 279 | assertion - the saml assertion itself (base64 encoded) 280 | duration - how long to request the credentials be valid for in 281 | seconds. This can't be longer than AWS allows (3600 by 282 | default, may be configured to be as long as 43200) 283 | """ 284 | 285 | # Override AWS_PROFILE so boto3 doesn't complain if we have it set 286 | # to a new profile that doesn't yet exist. This is needed because 287 | # boto3 will use environment variables if you don't pass in a profile, 288 | # but will complain if you do pass in a profile that doesn't exist. 289 | oldenv = os.environ 290 | if 'AWS_PROFILE' in os.environ: 291 | del os.environ['AWS_PROFILE'] 292 | if 'AWS_DEFAULT_PROFILE' in os.environ: 293 | del os.environ['AWS_DEFAULT_PROFILE'] 294 | if 'govcloud' in self.profile: 295 | region_name = 'us-gov-east-1' 296 | else: 297 | region_name = 'us-east-1' 298 | 299 | client = boto3.client('sts', region_name=region_name) 300 | 301 | # And restore them once more 302 | if 'AWS_PROFILE' in oldenv: 303 | os.environ['AWS_PROFILE'] = oldenv['AWS_PROFILE'] 304 | if 'AWS_DEFAULT_PROFILE' in oldenv: 305 | os.environ['AWS_DEFAULT_PROFILE'] = oldenv['AWS_DEFAULT_PROFILE'] 306 | 307 | aws_creds = client.assume_role_with_saml( 308 | RoleArn=role_arn, 309 | PrincipalArn=principal_arn, 310 | SAMLAssertion=assertion, 311 | DurationSeconds=duration 312 | ) 313 | 314 | if 'Credentials' not in aws_creds: 315 | logging.debug("aws_creds json is: %s" % aws_creds) 316 | raise exceptions.AssumeRoleError("Credentials key not in returned" 317 | " json") 318 | 319 | return aws_creds['Credentials'] 320 | 321 | def set_aws_config(self, profile, key, value): 322 | """Sets a single AWS configuration option. Used to store the temporary 323 | credentials in ~/.aws/credentials. 324 | 325 | profile - the profile to set the configuration option under 326 | key - the option to change 327 | value - the value to change it to 328 | """ 329 | # Override AWS_PROFILE so aws sts doesn't complain if we have it set 330 | # to a new profile that doesn't yet exist 331 | newenv = os.environ.copy() 332 | if 'AWS_PROFILE' in newenv: 333 | del newenv['AWS_PROFILE'] 334 | if 'AWS_DEFAULT_PROFILE' in newenv: 335 | del newenv['AWS_DEFAULT_PROFILE'] 336 | 337 | subprocess.call([shutil.which("aws"), "configure", "set", 338 | "--profile", profile, key, value], 339 | env=newenv) 340 | 341 | def store_aws_creds_in_profile(self, profile, aws_creds): 342 | """Stores the temporary AWS credentials in ~/.aws/credentials. 343 | 344 | profile - the profile to store the credentials under 345 | aws_creds - a dictionary containing the credentials returned from AWS 346 | """ 347 | self.set_aws_config(profile, "aws_access_key_id", 348 | aws_creds['AccessKeyId']) 349 | self.set_aws_config(profile, "aws_secret_access_key", 350 | aws_creds['SecretAccessKey']) 351 | self.set_aws_config(profile, "aws_session_token", 352 | aws_creds['SessionToken']) 353 | 354 | def is_logged_in(self, session_id): 355 | """Checks to see if a given okta session ID is still valid. Will return 356 | false if the session has expired and we are no longer logged in to 357 | okta. 358 | 359 | session_id - the session token that we are verifying 360 | """ 361 | logging.debug("Verifying if we are already logged in") 362 | r = requests.get("https://%s/api/v1/sessions/me" % 363 | self.get_config('okta_server'), 364 | cookies={"sid": session_id}) 365 | logged_in = r.status_code == 200 366 | logging.debug("Logged in: %s", logged_in) 367 | return logged_in 368 | 369 | def verify_totp_factor(self, url, statetoken): 370 | """Verifies the totp factor passcode, returning a single use session 371 | token that can be exchanged for a long lived session ID. 372 | 373 | url - the totp factor verification url 374 | statetoken - the state token provided when verifying totp factor 375 | """ 376 | passcode = input("Enter your passcode: ") 377 | r = requests.post(url, 378 | json={ 379 | "stateToken": statetoken, 380 | "passCode": passcode 381 | }) 382 | if r.status_code == 403: 383 | raise exceptions.LoginError("Incorrect passcode") 384 | if r.status_code != 200: 385 | logging.debug(r.text) 386 | raise exceptions.LoginError( 387 | "Login request returned HTTP status %s" % r.status_code) 388 | return r.json() 389 | 390 | def log_in_to_okta(self, password): 391 | """Logs in to okta using the authn API, returning a single use session 392 | token that can be exchanged for a long lived session ID. 393 | 394 | password - the user's okta password 395 | """ 396 | r = requests.post( 397 | "https://%s/api/v1/authn" % self.get_config('okta_server'), 398 | json={ 399 | "username": self.get_config('username'), 400 | "password": password 401 | }) 402 | if r.status_code == 401: 403 | raise exceptions.LoginError("Incorrect password") 404 | if r.status_code != 200: 405 | logging.debug(r.text) 406 | raise exceptions.LoginError( 407 | "Login request returned HTTP status %s" % r.status_code) 408 | session_data = r.json() 409 | if 'status' not in session_data: 410 | logging.error(session_data) 411 | raise exceptions.LoginError( 412 | "Unknown error (missing status field in response)") 413 | if session_data['status'] == 'MFA_REQUIRED': 414 | logging.debug('MFA Required') 415 | statetoken = session_data["stateToken"] 416 | for factor in session_data["_embedded"]["factors"]: 417 | # TODO - Add other factors 418 | if factor["factorType"] == "token:software:totp": 419 | url = factor["_links"]["verify"]["href"] 420 | session_data = self.verify_totp_factor(url, statetoken) 421 | if session_data['status'] != 'SUCCESS': 422 | raise exceptions.LoginError( 423 | session_data['status'].title().replace('_', ' ')) 424 | if 'sessionToken' not in session_data: 425 | logging.debug(session_data) 426 | raise exceptions.LoginError("Missing session token") 427 | return session_data['sessionToken'] 428 | 429 | def get_session_id(self, session_token): 430 | """Returns a (long lived) session ID given a (single use) session 431 | token. 432 | 433 | session_token - the single use token returned when logging in to okta 434 | """ 435 | r = requests.post( 436 | "https://%s/api/v1/sessions" % self.get_config('okta_server'), 437 | json={"sessionToken": session_token}) 438 | if r.status_code != 200: 439 | logging.debug(r.text) 440 | return None 441 | return r.json()['id'] 442 | 443 | def get_assigned_applications(self, session_id): 444 | """Queries okta to get a list of AWS applications that have been 445 | assigned to the user. Returns a dictionary mapping the profile names 446 | to log in URLs for each assigned application. 447 | 448 | session_id - the okta session ID needed to make api calls 449 | """ 450 | # TODO - proper pagination on this 451 | logging.debug("Getting assigned application links from okta") 452 | r = requests.get("https://%s/api/v1/users/me/appLinks?limit=1000" % 453 | self.get_config('okta_server'), 454 | cookies={"sid": session_id}) 455 | if r.status_code != 200: 456 | logging.error("Error getting assigned application list") 457 | logging.debug(r.text) 458 | return None 459 | applinks = {i['label']: i['linkUrl'] for i in r.json() 460 | if i['appName'] == 'amazon_aws'} 461 | return applinks 462 | 463 | def shorten_appnames(self, applinks): 464 | """Converts long application names such as 465 | 'Company Engineering AWS (dev use)' to something suitable for use in 466 | an aws profile such as 'company-engineering'. 467 | 468 | applinks - a dictionary mapping application names to application links. 469 | """ 470 | logging.debug("Shortening application names") 471 | newapplinks = {} 472 | for k, v in applinks.items(): 473 | newk = re.sub(" *AWS$", "", k) # Remove AWS suffix 474 | newk = re.sub(r" *\(.*\)", "", newk) # Remove anything in parens 475 | newk = newk.lower() 476 | newk = re.sub(" +", "-", newk) 477 | newapplinks[newk] = v 478 | logging.debug("%s => %s", k, newk) 479 | return newapplinks 480 | 481 | def get_saml_assertion(self, session_id, app_url): 482 | """Sends a request to the application link, and extracts a SAML 483 | assertion from the response. 484 | 485 | session_id - okta session ID needed to make api calls 486 | app_url - The URL used to log in to the okta application 487 | """ 488 | r = requests.post(app_url, cookies={"sid": session_id}) 489 | 490 | if r.status_code != 200: 491 | logging.error("Error getting saml assertion. HTML response %s", 492 | r.status_code) 493 | return None 494 | 495 | match = re.search(r'= 3600: 510 | return "%.2g hours" % (seconds / 3600.0) 511 | elif seconds == 60: 512 | return "1 minute" 513 | return "%.2g minutes" % (seconds / 60.0) 514 | 515 | def fetch_credentials(self, applinks, session_id): 516 | """Performs the various steps needed to actually get a set of 517 | temporary credentials and store them. Doesn't return anything, but 518 | temporary credentials should be stored in ~/.aws/credentials by the 519 | time this method has finished. 520 | 521 | applinks - a mapping of profile names to application links 522 | session_id - okta session ID needed to make API calls 523 | """ 524 | # Resolve any profile alias and store it in real_profile 525 | real_profile = self.config['aliases'].get(self.profile, self.profile) 526 | 527 | if real_profile not in applinks: 528 | alias_msg = "" 529 | if real_profile != self.profile: 530 | alias_msg = " (an alias that resolved to %s)" % real_profile 531 | print("ERROR: %s%s isn't a valid profile name" % ( 532 | self.profile, alias_msg)) 533 | print("Valid profiles:", ', '.join(list(applinks.keys()))) 534 | sys.exit(1) 535 | 536 | saml_assertion = self.get_saml_assertion( 537 | session_id, applinks[real_profile]) 538 | if saml_assertion is None: 539 | logging.error("Problem getting SAML assertion") 540 | sys.exit(1) 541 | 542 | principal_arn, role_arn = self.get_arns(saml_assertion) 543 | 544 | logging.info("Assuming AWS role %s...", role_arn.split("/")[-1]) 545 | session_duration = self.get_config('session_duration') 546 | try: 547 | aws_creds = self.aws_assume_role(principal_arn, role_arn, 548 | saml_assertion, session_duration) 549 | except exceptions.AssumeRoleError as e: 550 | logging.error("Unable to get temporary credentials: %s", e) 551 | sys.exit(1) 552 | self.store_aws_creds_in_profile(self.profile, aws_creds) 553 | logging.info("Temporary credentials stored in profile %s", 554 | self.profile) 555 | logging.info("Credentials expire in %s", 556 | self.friendly_interval(session_duration)) 557 | 558 | def run(self): 559 | """Main entry point for the application after parsing command line 560 | arguments.""" 561 | self.setup_logging() 562 | 563 | self.preflight_checks() 564 | 565 | if self.args.setup: 566 | self.interactive_setup(self.args.config) 567 | sys.exit(0) 568 | 569 | self.config = self.load_config(self.args.config) 570 | 571 | if not self.args.no_cookies: 572 | if os.path.exists(self.get_config('cookie_file')): 573 | logging.debug("Loading session ID from %s", 574 | self.get_config('cookie_file')) 575 | with open(self.get_config('cookie_file')) as fh: 576 | session_id = fh.read().rstrip("\n") 577 | # Support old cookie file format 578 | if session_id.startswith('#LWP-Cookies-2.0'): 579 | logging.debug("Converting old cookie file format") 580 | m = re.search(r'sid="([^"]*)"', session_id) 581 | if m: 582 | logging.debug("Found session ID in old cookies") 583 | session_id = m.group(1) 584 | else: 585 | logging.debug("Didn't find session ID in cookies") 586 | session_id = None 587 | if session_id is not None \ 588 | and not self.is_logged_in(session_id): 589 | session_id = None 590 | else: 591 | session_id = None 592 | else: 593 | session_id = None 594 | 595 | if session_id is None: 596 | print("Okta Username:", self.get_config('username')) 597 | password = "" 598 | while password == "": 599 | password = getpass.getpass("Okta Password: ") 600 | sys.stdout.flush() 601 | 602 | try: 603 | onetimetoken = self.log_in_to_okta(password) 604 | except exceptions.LoginError as e: 605 | logging.error("Error logging into okta: %s", e.message) 606 | sys.exit(1) 607 | 608 | session_id = self.get_session_id(onetimetoken) 609 | if not self.args.no_cookies: 610 | logging.debug("Saving session cookie to %s", 611 | self.get_config('cookie_file')) 612 | with open(self.get_config('cookie_file'), 'w') as fh: 613 | fh.write(session_id) 614 | 615 | applinks = self.get_assigned_applications(session_id) 616 | if self.get_config('short_profile_names'): 617 | applinks = self.shorten_appnames(applinks) 618 | 619 | if self.args.all: 620 | for profile in applinks.keys(): 621 | print("Fetching credentials for:", profile) 622 | self.profile = profile 623 | self.fetch_credentials(applinks, session_id) 624 | sys.exit(0) 625 | 626 | if self.args.list: 627 | print("Available profiles:") 628 | reverse_aliases = {} 629 | for k, v in self.config['aliases'].items(): 630 | reverse_aliases.setdefault(v, []).append(k) 631 | for profile in applinks.keys(): 632 | if profile in reverse_aliases: 633 | print("%s (Aliases: %s)" % (profile, ', '.join( 634 | reverse_aliases[profile]))) 635 | else: 636 | print(profile) 637 | 638 | sys.exit(0) 639 | 640 | self.fetch_credentials(applinks, session_id) 641 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | VERSION="$1" 3 | 4 | VERSION_FILE="okta_aws/okta_aws.py" 5 | 6 | if [[ -z $VERSION ]]; then 7 | echo "Usage: $0 VERSION" 8 | echo "Current version: $(grep ^__VERSION__ $VERSION_FILE | awk '{print $NF}')" 9 | exit 1 10 | fi 11 | 12 | # Make sure required commands are installed first 13 | for i in twine python git; do 14 | if ! command -v twine >/dev/null; then 15 | echo "You need to have $i installed. Please install it and try" 16 | echo "running this again. See RELEASE.md for more information" 17 | exit 1 18 | fi 19 | done 20 | 21 | echo "=> Bumping version in okta_aws to $VERSION" 22 | sed -i "" "s/^__VERSION__ = .*$/__VERSION__ = '$VERSION'/" "$VERSION_FILE" 23 | 24 | echo "=> Committing to git" 25 | git add "$VERSION_FILE" 26 | git commit -m "Release v$VERSION" 27 | 28 | echo "=> Tagging release" 29 | git tag "v$VERSION" 30 | 31 | echo "=> Building source distribution" 32 | rm -rf dist/ 33 | python ./setup.py sdist 34 | 35 | echo "=> Changes about to be pushed" 36 | git --no-pager show HEAD 37 | 38 | echo "About to push changes to git. Press Enter to continue, or ^C to quit" 39 | read -r 40 | 41 | echo "=> Pushing to git" 42 | git push --tags origin master 43 | 44 | echo "About to upload release to pypi. Press Enter to continue, or ^C to quit" 45 | read -r 46 | 47 | echo "=> Uploading release to pypi" 48 | twine upload dist/* 49 | 50 | echo "=> Done" 51 | 52 | echo "Don't forget to update the homebrew formula as well by running the" 53 | echo "release.sh script in the homebrew-okta_aws repo." 54 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from setuptools import setup 4 | 5 | 6 | def find_version(): 7 | with open(os.path.join(os.path.dirname(__file__), 8 | 'okta_aws/okta_aws.py')) as fh: 9 | content = fh.read() 10 | m = re.search(r"^__VERSION__ = ['\"]([^'\"]*)['\"]", content, re.M) 11 | if m: 12 | return m.group(1) 13 | raise RuntimeError("Unable to find version string.") 14 | 15 | 16 | setup( 17 | name='okta_aws', 18 | version=find_version(), 19 | description='Use the AWS API via an account using Okta', 20 | author='Mark Harrison', 21 | author_email='mharrison@chef.io', 22 | license='Apache-2.0', 23 | packages=['okta_aws'], 24 | entry_points={"console_scripts": ['okta_aws=okta_aws.__main__:main']}, 25 | url='https://github.com/chef/okta_aws', 26 | python_requires='>=3', 27 | setup_requires=['pytest-runner'], 28 | tests_require=['pytest', 'pytest_datadir'], 29 | install_requires=['requests>=2.21.0', 'toml>=0.10.0', 'boto3>=1.9.93'] 30 | ) 31 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chef/okta_aws/6baa3826435fc42751fd30ab5b77e0a9adfc493b/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from okta_aws import okta_aws 4 | 5 | 6 | @pytest.fixture 7 | def oa(): 8 | return okta_aws.OktaAWS([]) 9 | -------------------------------------------------------------------------------- /tests/data/.gitignore: -------------------------------------------------------------------------------- 1 | *_real.* 2 | -------------------------------------------------------------------------------- /tests/data/okta_auth_password_expired.json: -------------------------------------------------------------------------------- 1 | {"stateToken":"1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ123456","expiresAt":"2018-05-15T00:00:00.000Z","status":"PASSWORD_EXPIRED","_embedded":{"user":{"id":"00000000000000000000","profile":{"login":"fakey@chef.io","firstName":"Frederick","lastName":"Akey","locale":"en","timeZone":"America/Los_Angeles"}},"policy":{"complexity":{"minLength":8,"minLowerCase":1,"minUpperCase":1,"minNumber":1,"minSymbol":0,"excludeUsername":true},"age":{"minAgeMinutes":0,"historyCount":0}}},"_links":{"next":{"name":"changePassword","href":"https://chef.okta.com/api/v1/authn/credentials/change_password","hints":{"allow":["POST"]}},"cancel":{"href":"https://chef.okta.com/api/v1/authn/cancel","hints":{"allow":["POST"]}}}} 2 | -------------------------------------------------------------------------------- /tests/data/okta_auth_success.json: -------------------------------------------------------------------------------- 1 | {"expiresAt":"2018-05-15T00:00:00.000Z","status":"SUCCESS","sessionToken":"1234567890ABCDEFGHIJKLMNO","_embedded":{"user":{"id":"00000000000000000000","profile":{"login":"fakey","firstName":"Frederick","lastName":"Akey","locale":"en","timeZone":"America/Los_Angeles"}}}} 2 | -------------------------------------------------------------------------------- /tests/data/okta_aws.toml: -------------------------------------------------------------------------------- 1 | # Sample config file 2 | [general] 3 | username="fakey" 4 | okta_server="example.okta.com" 5 | -------------------------------------------------------------------------------- /tests/data/saml_assertion.txt: -------------------------------------------------------------------------------- 1 | PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHNhbWwycDpSZXNwb25zZSB4bWxuczpzYW1sMnA9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpwcm90b2NvbCIgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiBEZXN0aW5hdGlvbj0iaHR0cHM6Ly9zaWduaW4uYXdzLmFtYXpvbi5jb20vc2FtbCIgSUQ9ImlkMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NSIgSXNzdWVJbnN0YW50PSIyMDE4LTA0LTIxVDAwOjAwOjAwLjAwMFoiIFZlcnNpb249IjIuMCI+CiAgPHNhbWwyOklzc3VlciB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiIgRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6bmFtZWlkLWZvcm1hdDplbnRpdHkiPmh0dHA6Ly93d3cub2t0YS5jb20vRkFLRTwvc2FtbDI6SXNzdWVyPgogIDxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPgogICAgPGRzOlNpZ25lZEluZm8+CiAgICAgIDxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+CiAgICAgIDxkczpTaWduYXR1cmVNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNyc2Etc2hhMjU2Ii8+CiAgICAgIDxkczpSZWZlcmVuY2UgVVJJPSIjaWQxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ0Ij4KICAgICAgICA8ZHM6VHJhbnNmb3Jtcz4KICAgICAgICAgIDxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjZW52ZWxvcGVkLXNpZ25hdHVyZSIvPgogICAgICAgICAgPGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyI+CiAgICAgICAgICAgIDxlYzpJbmNsdXNpdmVOYW1lc3BhY2VzIHhtbG5zOmVjPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiIFByZWZpeExpc3Q9InhzIi8+CiAgICAgICAgICA8L2RzOlRyYW5zZm9ybT4KICAgICAgICA8L2RzOlRyYW5zZm9ybXM+CiAgICAgICAgPGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI3NoYTI1NiIvPgogICAgICAgIDxkczpEaWdlc3RWYWx1ZT5Sa0ZMUlFvPTwvZHM6RGlnZXN0VmFsdWU+CiAgICAgIDwvZHM6UmVmZXJlbmNlPgogICAgPC9kczpTaWduZWRJbmZvPgogICAgPGRzOlNpZ25hdHVyZVZhbHVlPlJrRkxSUW89PC9kczpTaWduYXR1cmVWYWx1ZT4KICAgIDxkczpLZXlJbmZvPgogICAgICA8ZHM6WDUwOURhdGE+CiAgICAgICAgPGRzOlg1MDlDZXJ0aWZpY2F0ZT4KICAgICAgICAgICAgUmtGTFJRbz0KICAgICAgICA8L2RzOlg1MDlDZXJ0aWZpY2F0ZT4KICAgICAgPC9kczpYNTA5RGF0YT4KICAgIDwvZHM6S2V5SW5mbz4KICA8L2RzOlNpZ25hdHVyZT4KICA8c2FtbDJwOlN0YXR1cyB4bWxuczpzYW1sMnA9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpwcm90b2NvbCI+CiAgICA8c2FtbDJwOlN0YXR1c0NvZGUgVmFsdWU9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpzdGF0dXM6U3VjY2VzcyIvPgogIDwvc2FtbDJwOlN0YXR1cz4KICA8c2FtbDI6QXNzZXJ0aW9uIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiB4bWxuczp4cz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIElEPSJpZDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDUiIElzc3VlSW5zdGFudD0iMjAxOC0wNC0yMVQwMDowMDowMC4wMDBaIiBWZXJzaW9uPSIyLjAiPgogICAgPHNhbWwyOklzc3VlciB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiIgRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6bmFtZWlkLWZvcm1hdDplbnRpdHkiPmh0dHA6Ly93d3cub2t0YS5jb20vRkFLRTwvc2FtbDI6SXNzdWVyPgogICAgPGRzOlNpZ25hdHVyZSB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI+CiAgICAgIDxkczpTaWduZWRJbmZvPgogICAgICAgIDxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+CiAgICAgICAgPGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZHNpZy1tb3JlI3JzYS1zaGEyNTYiLz4KICAgICAgICA8ZHM6UmVmZXJlbmNlIFVSST0iI2lkMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NSI+CiAgICAgICAgICA8ZHM6VHJhbnNmb3Jtcz4KICAgICAgICAgICAgPGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNlbnZlbG9wZWQtc2lnbmF0dXJlIi8+CiAgICAgICAgICAgIDxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiPgogICAgICAgICAgICAgIDxlYzpJbmNsdXNpdmVOYW1lc3BhY2VzIHhtbG5zOmVjPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiIFByZWZpeExpc3Q9InhzIi8+CiAgICAgICAgICAgIDwvZHM6VHJhbnNmb3JtPgogICAgICAgICAgPC9kczpUcmFuc2Zvcm1zPgogICAgICAgICAgPGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI3NoYTI1NiIvPgogICAgICAgICAgPGRzOkRpZ2VzdFZhbHVlPlJrRkxSUW89PC9kczpEaWdlc3RWYWx1ZT4KICAgICAgICA8L2RzOlJlZmVyZW5jZT4KICAgICAgPC9kczpTaWduZWRJbmZvPgogICAgICA8ZHM6U2lnbmF0dXJlVmFsdWU+UmtGTFJRbz08L2RzOlNpZ25hdHVyZVZhbHVlPgogICAgICA8ZHM6S2V5SW5mbz4KICAgICAgICA8ZHM6WDUwOURhdGE+CiAgICAgICAgICA8ZHM6WDUwOUNlcnRpZmljYXRlPlJrRkxSUW89PC9kczpYNTA5Q2VydGlmaWNhdGU+CiAgICAgICAgPC9kczpYNTA5RGF0YT4KICAgICAgPC9kczpLZXlJbmZvPgogICAgPC9kczpTaWduYXR1cmU+CiAgICA8c2FtbDI6U3ViamVjdCB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiI+CiAgICAgIDxzYW1sMjpOYW1lSUQgRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6bmFtZWlkLWZvcm1hdDp1bnNwZWNpZmllZCI+ZmFrZXVzZXI8L3NhbWwyOk5hbWVJRD4KICAgICAgPHNhbWwyOlN1YmplY3RDb25maXJtYXRpb24gTWV0aG9kPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6Y206YmVhcmVyIj4KICAgICAgICA8c2FtbDI6U3ViamVjdENvbmZpcm1hdGlvbkRhdGEgTm90T25PckFmdGVyPSIyMDE4LTA0LTIxVDAwOjAwOjAwLjAwMFoiIFJlY2lwaWVudD0iaHR0cHM6Ly9zaWduaW4uYXdzLmFtYXpvbi5jb20vc2FtbCIvPgogICAgICA8L3NhbWwyOlN1YmplY3RDb25maXJtYXRpb24+CiAgICA8L3NhbWwyOlN1YmplY3Q+CiAgICA8c2FtbDI6Q29uZGl0aW9ucyB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiIgTm90QmVmb3JlPSIyMDE4LTA0LTIxVDAwOjAwOjAwLjAwMFoiIE5vdE9uT3JBZnRlcj0iMjAxOC0wNC0yMVQwMDowMDowMC4wMDBaIj4KICAgICAgPHNhbWwyOkF1ZGllbmNlUmVzdHJpY3Rpb24+CiAgICAgICAgPHNhbWwyOkF1ZGllbmNlPnVybjphbWF6b246d2Vic2VydmljZXM8L3NhbWwyOkF1ZGllbmNlPgogICAgICA8L3NhbWwyOkF1ZGllbmNlUmVzdHJpY3Rpb24+CiAgICA8L3NhbWwyOkNvbmRpdGlvbnM+CiAgICA8c2FtbDI6QXV0aG5TdGF0ZW1lbnQgeG1sbnM6c2FtbDI9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIEF1dGhuSW5zdGFudD0iMjAxOC0wNC0yMVQwMDowMDowMC4wMDBaIiBTZXNzaW9uSW5kZXg9ImlkMTUyNDM1NDQ3Nzc0MC4xNTc2NjMzMDkxIj4KICAgICAgPHNhbWwyOkF1dGhuQ29udGV4dD4KICAgICAgICA8c2FtbDI6QXV0aG5Db250ZXh0Q2xhc3NSZWY+dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFjOmNsYXNzZXM6UGFzc3dvcmRQcm90ZWN0ZWRUcmFuc3BvcnQ8L3NhbWwyOkF1dGhuQ29udGV4dENsYXNzUmVmPgogICAgICA8L3NhbWwyOkF1dGhuQ29udGV4dD4KICAgIDwvc2FtbDI6QXV0aG5TdGF0ZW1lbnQ+CiAgICA8c2FtbDI6QXR0cmlidXRlU3RhdGVtZW50IHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj4KICAgICAgPHNhbWwyOkF0dHJpYnV0ZSBOYW1lPSJodHRwczovL2F3cy5hbWF6b24uY29tL1NBTUwvQXR0cmlidXRlcy9Sb2xlIiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OnVyaSI+CiAgICAgICAgPHNhbWwyOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOnN0cmluZyI+YXJuOmF3czppYW06OjAxMjM0NTY3ODkwMTpzYW1sLXByb3ZpZGVyL09LVEEsYXJuOmF3czppYW06OjAxMjM0NTY3ODkwMTpyb2xlL09rdGFfQWRtaW5pc3RyYXRvckFjY2Vzczwvc2FtbDI6QXR0cmlidXRlVmFsdWU+CiAgICAgIDwvc2FtbDI6QXR0cmlidXRlPgogICAgICA8c2FtbDI6QXR0cmlidXRlIE5hbWU9Imh0dHBzOi8vYXdzLmFtYXpvbi5jb20vU0FNTC9BdHRyaWJ1dGVzL1JvbGVTZXNzaW9uTmFtZSIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+CiAgICAgICAgPHNhbWwyOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOnN0cmluZyI+ZmFrZXVzZXI8L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPgogICAgICA8L3NhbWwyOkF0dHJpYnV0ZT4KICAgICAgPHNhbWwyOkF0dHJpYnV0ZSBOYW1lPSJodHRwczovL2F3cy5hbWF6b24uY29tL1NBTUwvQXR0cmlidXRlcy9TZXNzaW9uRHVyYXRpb24iIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6YmFzaWMiPgogICAgICAgIDxzYW1sMjpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4cz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhzaTp0eXBlPSJ4czpzdHJpbmciPjQzMjAwPC9zYW1sMjpBdHRyaWJ1dGVWYWx1ZT4KICAgICAgPC9zYW1sMjpBdHRyaWJ1dGU+CiAgICA8L3NhbWwyOkF0dHJpYnV0ZVN0YXRlbWVudD4KICA8L3NhbWwyOkFzc2VydGlvbj4KPC9zYW1sMnA6UmVzcG9uc2U+Cg== 2 | -------------------------------------------------------------------------------- /tests/data/saml_assertion_decoded.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | http://www.okta.com/FAKE 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | RkFLRQo= 17 | 18 | 19 | RkFLRQo= 20 | 21 | 22 | 23 | RkFLRQo= 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | http://www.okta.com/FAKE 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | RkFLRQo= 46 | 47 | 48 | RkFLRQo= 49 | 50 | 51 | RkFLRQo= 52 | 53 | 54 | 55 | 56 | fakeuser 57 | 58 | 59 | 60 | 61 | 62 | 63 | urn:amazon:webservices 64 | 65 | 66 | 67 | 68 | urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport 69 | 70 | 71 | 72 | 73 | arn:aws:iam::012345678901:saml-provider/OKTA,arn:aws:iam::012345678901:role/Okta_AdministratorAccess 74 | 75 | 76 | fakeuser 77 | 78 | 79 | 43200 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /tests/data/saml_assertion_page.html: -------------------------------------------------------------------------------- 1 | 2 | My Company - Signing in... 3 | 4 | 5 | 6 |
7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/data/sts_creds.json: -------------------------------------------------------------------------------- 1 | { 2 | "Credentials": { 3 | "AccessKeyId": "ASIAABCDEFG123456789", 4 | "SecretAccessKey": "hOUlRBNYBVlR05jXRBXbntDc/F56FkPsj+Gd/mzP", 5 | "SessionToken": "uMvpvBlJ2kL3JRsJiktoC3FJq3aVCmyKOcX+mtrJCc5UIkFsozZD7aGxISsAELQelXCEMVRhef37BG8dsI8cd2Khz+xIcWdwCxwOfFqQEWDFltsI3Sfbz0cdT9mtDG6DvmT3vTFB5DdeuRJzTvZFXCi7msKCGWmOpSpWc9PrZIOVV5ad84mgsa3N0ccvCEtJ/7rdzZL/y9cQfZLmb0+VbuVTL76Siyw9zt7sMIjDSOtGBOXf1wcgO2m7NU37wRK3Ux4nvttTtrKeZ7f3DuDzsH61OrLcg98sz0XDW3Brl31dhrqdVORIOv9SEpaRewDSX/wl74DVElH9VQ0fOHdsD6MNFnKl+NEAiYd4vZMjk1U8/e4GAWZIF/EHaQxgHU3NBsWvRYsTQUPombgR81BXm9Quh9aj", 6 | "Expiration": "2018-04-24T00:00:00Z" 7 | }, 8 | "AssumedRoleUser": { 9 | "AssumedRoleId": "ABCDEFGHIJ12345678901:fakeuser", 10 | "Arn": "arn:aws:sts::123456789012:assumed-role/Okta_AdministratorAccess/fakeuser" 11 | }, 12 | "Subject": "fakeuser", 13 | "SubjectType": "unspecified", 14 | "Issuer": "http://www.okta.com/abcdefghijklmnopq1d8", 15 | "Audience": "https://signin.aws.amazon.com/saml", 16 | "NameQualifier": "DNBr1wUt6enAZ9ia69kV8WW7ASY=" 17 | } 18 | -------------------------------------------------------------------------------- /tests/test_aws_assume_role.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=invalid-name,missing-docstring 2 | import pytest 3 | import json 4 | 5 | from okta_aws import exceptions 6 | 7 | from unittest.mock import patch 8 | 9 | 10 | @patch('boto3.client') 11 | def test_aws_assume_role(patched_client, oa, shared_datadir): 12 | with open("%s/sts_creds.json" % shared_datadir, 'rb') as fh: 13 | patched_client('sts').assume_role_with_saml.return_value = \ 14 | json.load(fh) 15 | 16 | with open("%s/saml_assertion.txt" % shared_datadir) as fh: 17 | assertion = fh.read() 18 | 19 | credentials = oa.aws_assume_role( 20 | 'arn:aws:iam::012345678901:saml-provider/OKTA', 21 | 'arn:aws:iam::012345678901:role/Okta_AdministratorAccess', 22 | assertion, 23 | 3600) 24 | 25 | assert credentials['AccessKeyId'] == 'ASIAABCDEFG123456789' 26 | assert credentials['SecretAccessKey'] == \ 27 | 'hOUlRBNYBVlR05jXRBXbntDc/F56FkPsj+Gd/mzP' 28 | assert credentials['SessionToken'] == \ 29 | 'uMvpvBlJ2kL3JRsJiktoC3FJq3aVCmyKOcX+mtrJCc5UIkFsozZD7aGxISsAE' \ 30 | 'LQelXCEMVRhef37BG8dsI8cd2Khz+xIcWdwCxwOfFqQEWDFltsI3Sfbz0cdT9' \ 31 | 'mtDG6DvmT3vTFB5DdeuRJzTvZFXCi7msKCGWmOpSpWc9PrZIOVV5ad84mgsa3' \ 32 | 'N0ccvCEtJ/7rdzZL/y9cQfZLmb0+VbuVTL76Siyw9zt7sMIjDSOtGBOXf1wcg' \ 33 | 'O2m7NU37wRK3Ux4nvttTtrKeZ7f3DuDzsH61OrLcg98sz0XDW3Brl31dhrqdV' \ 34 | 'ORIOv9SEpaRewDSX/wl74DVElH9VQ0fOHdsD6MNFnKl+NEAiYd4vZMjk1U8/e' \ 35 | '4GAWZIF/EHaQxgHU3NBsWvRYsTQUPombgR81BXm9Quh9aj' 36 | assert credentials['Expiration'] == '2018-04-24T00:00:00Z' 37 | 38 | 39 | @patch('boto3.client') 40 | def test_aws_assume_role_no_credentials(patched_client, oa, shared_datadir): 41 | patched_client('sts').assume_role_with_saml.return_value = {} 42 | 43 | with pytest.raises(exceptions.AssumeRoleError, 44 | match="Credentials key not in returned json"): 45 | oa.aws_assume_role( 46 | 'arn:aws:iam::012345678901:saml-provider/OKTA', 47 | 'arn:aws:iam::012345678901:role/Okta_AdministratorAccess', 48 | 'fake_assertion', 49 | 3600) 50 | -------------------------------------------------------------------------------- /tests/test_friendly_interval.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=invalid-name,missing-docstring 3 | 4 | 5 | def test_hour(oa): 6 | assert oa.friendly_interval(3600) == '1 hour' 7 | assert oa.friendly_interval(7200) == '2 hours' 8 | 9 | 10 | def test_minute(oa): 11 | assert oa.friendly_interval(600) == '10 minutes' 12 | assert oa.friendly_interval(60) == '1 minute' 13 | 14 | 15 | def test_fractions(oa): 16 | assert oa.friendly_interval(5400) == '1.5 hours' 17 | assert oa.friendly_interval(30) == '0.5 minutes' 18 | -------------------------------------------------------------------------------- /tests/test_get_arns.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=invalid-name,missing-docstring 3 | 4 | 5 | def test_get_arns(oa, shared_datadir): 6 | with open("%s/saml_assertion.txt" % shared_datadir) as fh: 7 | assertion = fh.read() 8 | arns = oa.get_arns(assertion) 9 | 10 | # Principal 11 | assert arns[0] == 'arn:aws:iam::012345678901:saml-provider/OKTA' 12 | # Role 13 | assert arns[1] == 'arn:aws:iam::012345678901:role/Okta_AdministratorAccess' 14 | -------------------------------------------------------------------------------- /tests/test_get_saml_assertion.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=invalid-name,missing-docstring 3 | from unittest.mock import patch 4 | 5 | 6 | def test_get_saml_assertion(oa, shared_datadir): 7 | with patch("requests.post") as patched_post: 8 | patched_post.return_value.status_code = 200 9 | with open("%s/saml_assertion_page.html" % shared_datadir) as fh: 10 | patched_post.return_value.text = fh.read() 11 | 12 | sa = oa.get_saml_assertion("session_id", "app_url") 13 | 14 | assert sa == "VGVzdGluZyAxLi4uMi4uLjMuLi4K" 15 | -------------------------------------------------------------------------------- /tests/test_log_in_to_okta.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=invalid-name,missing-docstring 2 | import json 3 | import pytest 4 | 5 | from okta_aws import exceptions 6 | 7 | from unittest.mock import patch 8 | 9 | 10 | def test_log_in_to_okta_success(oa, shared_datadir): 11 | oa.config = oa.load_config("%s/okta_aws.toml" % shared_datadir) 12 | with patch("requests.post") as patched_post: 13 | patched_post.return_value.status_code = 200 14 | with open("%s/okta_auth_success.json" % shared_datadir) as fh: 15 | text = fh.read() 16 | patched_post.return_value.text = text 17 | patched_post.return_value.json.return_value = json.loads(text) 18 | 19 | token = oa.log_in_to_okta('hunter2') 20 | 21 | assert token == "1234567890ABCDEFGHIJKLMNO" 22 | 23 | 24 | def test_log_in_to_okta_incorrect_password(oa, shared_datadir): 25 | oa.config = oa.load_config("%s/okta_aws.toml" % shared_datadir) 26 | with patch("requests.post") as patched_post: 27 | patched_post.return_value.status_code = 401 28 | 29 | with pytest.raises(exceptions.LoginError, match="Incorrect password"): 30 | oa.log_in_to_okta('hunter2') 31 | 32 | 33 | def test_log_in_to_okta_password_expired(oa, shared_datadir): 34 | oa.config = oa.load_config("%s/okta_aws.toml" % shared_datadir) 35 | with patch("requests.post") as patched_post: 36 | patched_post.return_value.status_code = 200 37 | with open("%s/okta_auth_password_expired.json" % shared_datadir) as fh: 38 | text = fh.read() 39 | patched_post.return_value.text = text 40 | patched_post.return_value.json.return_value = json.loads(text) 41 | 42 | with pytest.raises(exceptions.LoginError, match="Password Expired"): 43 | oa.log_in_to_okta('hunter2') 44 | --------------------------------------------------------------------------------