├── .dockerignore ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── cicd.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── NOTICE ├── README.md ├── bin ├── gimme-aws-creds └── gimme-aws-creds.cmd ├── gimme_aws_creds ├── __init__.py ├── aws.py ├── common.py ├── config.py ├── default.py ├── duo.py ├── errors.py ├── main.py ├── okta.py ├── registered_authenticators.py ├── u2f.py ├── ui.py └── webauthn.py ├── lambda ├── README.md └── lambda_handler.py ├── requirements.txt ├── requirements_dev.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── test_aws_resolver.py ├── test_config.py ├── test_main.py ├── test_okta_client.py ├── test_registered_authenticators.py └── user_interface_mock.py /.dockerignore: -------------------------------------------------------------------------------- 1 | CONTRIBUTING.md 2 | Makefile 3 | README.md -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Expected Behavior 4 | 5 | 6 | 7 | ## Current Behavior 8 | 9 | 10 | 11 | ## Possible Solution 12 | 13 | 14 | 15 | ## Steps to Reproduce (for bugs) 16 | 17 | 18 | 1. 19 | 2. 20 | 3. 21 | 4. 22 | 23 | 24 | 25 | ## Context 26 | 27 | 28 | 29 | ## Your Environment 30 | 31 | * App Version used: 32 | * Environment name and version: 33 | * Operating System and version: 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Related Issue 7 | 8 | 9 | 10 | 11 | 12 | ## Motivation and Context 13 | 14 | 15 | ## How Has This Been Tested? 16 | 17 | 18 | 19 | 20 | ## Screenshots (if appropriate): 21 | 22 | ## Types of changes 23 | 24 | - [ ] Bug fix (non-breaking change which fixes an issue) 25 | - [ ] New feature (non-breaking change which adds functionality) 26 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 27 | 28 | ## Checklist: 29 | 30 | 31 | - [ ] My code follows the code style of this project. 32 | - [ ] My change requires a change to the documentation. 33 | - [ ] I have updated the documentation accordingly. 34 | - [ ] I have read the **CONTRIBUTING** document. 35 | - [ ] I have added tests to cover my changes. 36 | - [ ] All new and existing tests passed. 37 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yml: -------------------------------------------------------------------------------- 1 | name: CICD 2 | 3 | on: 4 | push: 5 | branches: [ '*' ] 6 | pull_request: 7 | branches: [ '*' ] 8 | release: 9 | types: [ created ] 10 | 11 | jobs: 12 | test: 13 | name: Unit Tests 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [ 3.6, 3.7, 3.8, 3.9 ] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | python -m pip install -r requirements_dev.txt 29 | - name: Run Tests 30 | run: make test 31 | 32 | code-scan: 33 | name: Static Code Analysis 34 | runs-on: ubuntu-latest 35 | 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | language: [ 'python' ] 40 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 41 | # Learn more: 42 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 43 | 44 | steps: 45 | - name: Checkout repository 46 | uses: actions/checkout@v2 47 | 48 | # Initializes the CodeQL tools for scanning. 49 | - name: Initialize CodeQL 50 | uses: github/codeql-action/init@v1 51 | with: 52 | languages: ${{ matrix.language }} 53 | # If you wish to specify custom queries, you can do so here or in a config file. 54 | # By default, queries listed here will override any specified in a config file. 55 | # Prefix the list here with "+" to use these queries and those in the config file. 56 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v1 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 https://git.io/JvXDl 65 | 66 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 67 | # and modify them (or add more) to build your code if your project 68 | # uses a compiled language 69 | 70 | #- run: | 71 | # make bootstrap 72 | # make release 73 | 74 | - name: Perform CodeQL Analysis 75 | uses: github/codeql-action/analyze@v1 76 | 77 | deploy: 78 | name: Deploy to PyPi 79 | runs-on: ubuntu-latest 80 | needs: 81 | - test 82 | - code-scan 83 | if: github.event_name == 'release' 84 | steps: 85 | - uses: actions/checkout@v2 86 | - name: Set up Python 87 | uses: actions/setup-python@v2 88 | with: 89 | python-version: '3.x' 90 | - name: Install dependencies 91 | run: | 92 | python -m pip install --upgrade pip 93 | pip install setuptools wheel twine 94 | - name: Build and publish 95 | env: 96 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 97 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 98 | run: | 99 | python setup.py sdist bdist_wheel 100 | twine upload dist/* 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | covhtml/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # IntelliJ IDE 99 | .idea/ 100 | 101 | # Vim swap files 102 | *.swp 103 | *.swo 104 | *~ 105 | 106 | # VSCODE 107 | .vscode 108 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | There are a few guidelines that we need contributors to follow so that we are able to process requests as efficiently as possible. If you have any questions or concerns please feel free to contact us at [opensource@nike.com](mailto:opensource@nike.com). 4 | 5 | ## Getting Started 6 | 7 | * Review our [Code of Conduct](https://github.com/Nike-Inc/nike-inc.github.io/blob/master/CONDUCT.md) 8 | * Submit the [Individual Contributor License Agreement](https://www.clahub.com/agreements/Nike-Inc/fastbreak) 9 | * Make sure you have a [GitHub account](https://github.com/signup/free) 10 | * Submit a ticket for your issue, assuming one does not already exist. 11 | * Clearly describe the issue including steps to reproduce when it is a bug. 12 | * Make sure you fill in the earliest version that you know has the issue. 13 | * Fork the repository on GitHub 14 | 15 | ## Making Changes 16 | 17 | * Create a topic branch off of `master` before you start your work. 18 | * Please avoid working directly on the `master` branch. 19 | * Make commits of logical units. 20 | * You may be asked to squash unnecessary commits down to logical units. 21 | * Check for unnecessary whitespace with `git diff --check` before committing. 22 | * Write meaningful, descriptive commit messages. 23 | * Please follow existing code conventions when working on a file. 24 | 25 | ## Submitting Changes 26 | 27 | * Push your changes to a topic branch in your fork of the repository. 28 | * Submit a pull request to the repository in the Nike-Inc organization. 29 | * After feedback has been given we expect responses within two weeks. After two weeks we may close the pull request if it isn't showing any activity. 30 | * Bug fixes or features that lack appropriate tests may not be considered for merge. 31 | * Changes that lower test coverage may not be considered for merge. 32 | 33 | # Additional Resources 34 | 35 | * [General GitHub documentation](https://help.github.com/) 36 | * [GitHub pull request documentation](https://help.github.com/send-pull-requests/) 37 | * [Nike's Code of Conduct](https://github.com/Nike-Inc/nike-inc.github.io/blob/master/CONDUCT.md) 38 | * [Nike's Individual Contributor License Agreement](https://www.clahub.com/agreements/Nike-Inc/fastbreak) 39 | * [Nike OSS](https://nike-inc.github.io/) -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-alpine 2 | 3 | WORKDIR /opt/gimme-aws-creds 4 | 5 | COPY . . 6 | 7 | ENV PACKAGES="gcc musl-dev python3-dev libffi-dev openssl-dev cargo" 8 | 9 | RUN apk --update add $PACKAGES \ 10 | && pip install --upgrade pip setuptools-rust \ 11 | && pip install futures \ 12 | && python setup.py install \ 13 | && apk del --purge $PACKAGES 14 | 15 | ENTRYPOINT ["/usr/local/bin/gimme-aws-creds"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2016 Nike, Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.txt 3 | include requirements.txt 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | pip3 install -r requirements_dev.txt 3 | 4 | docker-build: 5 | docker build -t gimme-aws-creds . 6 | 7 | test: docker-build 8 | nosetests -vv tests 9 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | gimme-aws-creds 2 | Copyright 2017-present, Nike, Inc. (http://nike.com) 3 | All rights reserved. 4 | The Nike gimme-aws-creds software is licensed under the Apache-2.0 license found in the LICENSE.txt file included in the root directory of the project source tree. 5 | 6 | ** Free and Open-Source Software Notices: 7 | The Nike gimme-aws-creds software incorporates, depends upon, interacts with, or was developed using the free and open-source software components listed below. Please see the linked component sites for additional licensing, dependency, and use information and component source code: 8 | 9 | * AWS Okta Keyman 10 | project homepage/download site: https://nathanv.com/ 11 | https://github.com/nathan-v/aws_okta_keyman 12 | project licensing notices: 13 | /README.md: 14 | License 15 | Copyright 2019 Nathan V 16 | Copyright 2018 Nextdoor.com, Inc 17 | Licensed under the Apache License, Version 2.0. See LICENSE.txt file for details. 18 | Some code in aws_okta_keyman/okta.py, aws_okta_keyman/aws.py, aws_okta_keyman/aws_saml.py, and aws_okta_keyman/test/aws_saml_test.py is distributed under MIT license. See the source files for details. A copy of the license is in the LICENSE_MIT.txt file. 19 | /LICENSE.txt: 20 | Apache License 21 | Version 2.0, January 2004 22 | http://www.apache.org/licenses/ 23 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 24 | 1. Definitions. 25 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 26 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 27 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 28 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 29 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 30 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 31 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 32 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 33 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 34 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 35 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 36 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 37 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 38 | You must give any other recipients of the Work or Derivative Works a copy of this License; and 39 | You must cause any modified files to carry prominent notices stating that You changed the files; and 40 | You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 41 | If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 42 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 43 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 44 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 45 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 46 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 47 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 48 | END OF TERMS AND CONDITIONS 49 | /LICENSE_MIT.txt 50 | Copyright (c) 2015, Peter Gillard-Moss 51 | All rights reserved. 52 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 53 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gimme AWS Creds 2 | 3 | [![][license img]][license] 4 | [![Build Status](https://travis-ci.org/Nike-Inc/gimme-aws-creds.svg?branch=master)](https://travis-ci.org/Nike-Inc/gimme-aws-creds) 5 | 6 | gimme-aws-creds is a CLI that utilizes an [Okta](https://www.okta.com/) IdP via SAML to acquire temporary AWS credentials via AWS STS. 7 | 8 | Okta is a SAML identity provider (IdP), that can be easily set-up to do SSO to your AWS console. Okta does offer an [OSS java CLI]((https://github.com/oktadeveloper/okta-aws-cli-assume-role)) tool to obtain temporary AWS credentials, but I found it needs more information than the average Okta user would have and doesn't scale well if have more than one Okta App. 9 | 10 | With gimme-aws-creds all you need to know is your username, password, Okta url and MFA token, if MFA is enabled. gimme-aws-creds gives you the option to select which Okta AWS application and role you want credentials for. Alternatively, you can pre-configure the app and role name by passing -c or editing the config file. This is all covered in the usage section. 11 | 12 | ## Prerequisites 13 | 14 | [Okta SAML integration to AWS using the AWS App](https://help.okta.com/en/prod/Content/Topics/Miscellaneous/References/OktaAWSMulti-AccountConfigurationGuide.pdf) 15 | 16 | Python 3.6+ 17 | 18 | ### Optional 19 | 20 | [Gimme-creds-lambda](https://github.com/Nike-Inc/gimme-aws-creds/tree/master/lambda) can be used as a proxy to the Okta APIs needed by gimme-aws-creds. This removes the requirement of an Okta API key. Gimme-aws-creds authenticates to gimme-creds-lambda using OpenID Connect and the lambda handles all interactions with the Okta APIs. Alternately, you can set the `OKTA_API_KEY` environment variable and the `gimme_creds_server` configuration value to 'internal' to call the Okta APIs directly from gimme-aws-creds. 21 | 22 | ## Installation 23 | 24 | This is a Python 3 project. 25 | 26 | Install/Upgrade from PyPi: 27 | 28 | ```bash 29 | pip3 install --upgrade gimme-aws-creds 30 | ``` 31 | 32 | __OR__ 33 | 34 | Install/Upgrade the latest gimme-aws-creds package direct from GitHub: 35 | 36 | ```bash 37 | pip3 install --upgrade git+git://github.com/Nike-Inc/gimme-aws-creds.git 38 | ``` 39 | 40 | __OR__ 41 | 42 | Install the gimme-aws-creds package if you have already cloned the source: 43 | 44 | ```bash 45 | python3 setup.py install 46 | ``` 47 | 48 | __OR__ 49 | 50 | Use homebrew 51 | 52 | ```bash 53 | brew install gimme-aws-creds 54 | ``` 55 | 56 | __OR__ 57 | 58 | Build the docker image locally: 59 | 60 | ```bash 61 | docker build -t gimme-aws-creds . 62 | ``` 63 | 64 | To make it easier you can also create an alias for the gimme-aws-creds command with docker: 65 | 66 | ```bash 67 | # make sure you have the "~/.okta_aws_login_config" locally first! 68 | touch ~/.okta_aws_login_config && \ 69 | alias gimme-aws-creds="docker run -it --rm \ 70 | -v ~/.aws/credentials:/root/.aws/credentials \ 71 | -v ~/.okta_aws_login_config:/root/.okta_aws_login_config \ 72 | gimme-aws-creds" 73 | ``` 74 | 75 | With this config, you will be able to run further commands seamlessly! 76 | 77 | ## Configuration 78 | 79 | To set-up the configuration run: 80 | 81 | ```bash 82 | gimme-aws-creds --action-configure 83 | ``` 84 | 85 | You can also set up different Okta configuration profiles, this useful if you have multiple Okta accounts or environments you need credentials for. You can use the configuration wizard or run: 86 | 87 | ```bash 88 | gimme-aws-creds --action-configure --profile profileName 89 | ``` 90 | 91 | A configuration wizard will prompt you to enter the necessary configuration parameters for the tool to run, the only one that is required is the `okta_org_url`. The configuration file is written to `~/.okta_aws_login_config`, but you can change the location with the environment variable `OKTA_CONFIG`. 92 | 93 | - conf_profile - This sets the Okta configuration profile name, the default is DEFAULT. 94 | - okta_org_url - This is your Okta organization url, which is typically something like `https://companyname.okta.com`. 95 | - okta_auth_server - [Okta API Authorization Server](https://help.okta.com/en/prev/Content/Topics/Security/API_Access.htm) used for OpenID Connect authentication for gimme-creds-lambda 96 | - client_id - OAuth client ID for gimme-creds-lambda 97 | - gimme_creds_server 98 | - URL for gimme-creds-lambda 99 | - 'internal' for direct interaction with the Okta APIs (`OKTA_API_KEY` environment variable required) 100 | - 'appurl' to set an aws application link url. This setting removes the need of an OKTA API key. 101 | - write_aws_creds - True or False - If True, the AWS credentials will be written to `~/.aws/credentials` otherwise it will be written to stdout. 102 | - cred_profile - If writing to the AWS cred file, this sets the name of the AWS credential profile. 103 | - The reserved word `role` will use the name component of the role arn as the profile name. i.e. arn:aws:iam::123456789012:role/okta-1234-role becomes section [okta-1234-role] in the aws credentials file 104 | - The reserved word `acc-role` will use the name component of the role arn prepended with account number (or alias if `resolve_aws_alias` is set to y) to avoid collisions, i.e. arn:aws:iam::123456789012:role/okta-1234-role becomes section [123456789012-okta-1234-role], or if `resolve_aws_alias` [-okta-1234-role] in the aws credentials file 105 | - If set to `default` then the temp creds will be stored in the default profile 106 | - Note: if there are multiple roles, and `default` is selected it will be overwritten multiple times and last role wins. The same happens when `role` is selected and you have many accounts with the same role names. Consider using `acc-role` if this happens. 107 | - aws_appname - This is optional. The Okta AWS App name, which has the role you want to assume. 108 | - aws_rolename - This is optional. The ARN of the role you want temporary AWS credentials for. The reserved word 'all' can be used to get and store credentials for every role the user is permissioned for. 109 | - aws_default_duration = This is optional. Lifetime for temporary credentials, in seconds. Defaults to 1 hour (3600) 110 | - app_url - If using 'appurl' setting for gimme_creds_server, this sets the url to the aws application configured in Okta. It is typically something like 111 | - okta_username - use this username to authenticate 112 | - preferred_mfa_type - automatically select a particular device when prompted for MFA: 113 | - push - Okta Verify App push or DUO push (depends on okta supplied provider type) 114 | - token:software:totp - OTP using the Okta Verify App 115 | - token:hardware - OTP using hardware like Yubikey 116 | - call - OTP via Voice call 117 | - sms - OTP via SMS message 118 | - web - DUO uses localhost webbrowser to support push|call|passcode 119 | - passcode - DUO uses `OKTA_MFA_CODE` or `--mfa-code` if set, or prompts user for passcode(OTP). 120 | 121 | - resolve_aws_alias - y or n. If yes, gimme-aws-creds will try to resolve AWS account ids with respective alias names (default: n). This option can also be set interactively in the command line using `-r` or `--resolve` parameter 122 | - include_path - (optional) Includes full role path to the role name in AWS credential profile name. (default: n). If `y`: `-/some/path/administrator`. If `n`: `-administrator` 123 | - remember_device - y or n. If yes, the MFA device will be remembered by Okta service for a limited time. This option can also be set interactively in the command line using `-m` or `--remember-device` 124 | - output_format - `json` or `export`, determines default credential output format, can be also specified by `--output-format FORMAT` and `-o FORMAT`. 125 | 126 | ## Configuration File 127 | 128 | The config file follows a [configfile](https://docs.python.org/3/library/configparser.html) format. 129 | By default, it is located in $HOME/.okta_aws_login_config 130 | 131 | Example file: 132 | 133 | ```ini 134 | [myprofile] 135 | client_id = myclient_id 136 | ``` 137 | 138 | Configurations can inherit from other configurations to share common configuration parameters. 139 | 140 | ```ini 141 | [my-base-profile] 142 | client_id = myclient_id 143 | [myprofile] 144 | inherits = my-base-profile 145 | aws_rolename = my-role 146 | ``` 147 | 148 | ## Usage 149 | 150 | **If you are not using gimme-creds-lambda nor using appurl settings, make sure you set the OKTA_API_KEY environment variable.** 151 | 152 | After running --action-configure, just run gimme-aws-creds. You will be prompted for the necessary information. 153 | 154 | ```bash 155 | $ ./gimme-aws-creds 156 | Username: user@domain.com 157 | Password for user@domain.com: 158 | Authentication Success! Calling Gimme-Creds Server... 159 | Pick an app: 160 | [ 0 ] AWS Test Account 161 | [ 1 ] AWS Prod Account 162 | Selection: 1 163 | Pick a role: 164 | [ 0 ]: OktaAWSAdminRole 165 | [ 1 ]: OktaAWSReadOnlyRole 166 | Selection: 1 167 | Multi-factor Authentication required. 168 | Pick a factor: 169 | [ 0 ] Okta Verify App: SmartPhone_IPhone: iPhone 170 | [ 1 ] token:software:totp: user@domain.com 171 | Selection: 0 172 | Okta Verify push sent... 173 | export AWS_ACCESS_KEY_ID=AQWERTYUIOP 174 | export AWS_SECRET_ACCESS_KEY=T!#$JFLOJlsoddop1029405-P 175 | ``` 176 | 177 | You can automate the environment variable creation by running `$(gimme-aws-creds)` on linux or `iex (gimme-aws-creds)` using Windows Powershell 178 | 179 | You can run a specific configuration profile with the `--profile` parameter: 180 | 181 | ```bash 182 | ./gimme-aws-creds --profile profileName 183 | ``` 184 | 185 | The username and password you are prompted for are the ones you login to Okta with. You can predefine your username by setting the `OKTA_USERNAME` environment variable or using the `-u username` parameter. 186 | 187 | If you have not configured an Okta App or Role, you will prompted to select one. 188 | 189 | If all goes well you will get your temporary AWS access, secret key and token, these will either be written to stdout or `~/.aws/credentials`. 190 | 191 | You can always run `gimme-aws-creds --help` for all the available options. 192 | 193 | Alternatively, you can overwrite values in the config section with environment variables for instances where say you may want to change the duration of your token. 194 | A list of values of to change with environment variables are: 195 | 196 | - `AWS_DEFAULT_DURATION` - corresponds to `aws_default_duration` configuration 197 | - `AWS_SHARED_CREDENTIALS_FILE` - file to write credentials to, points to `~/.aws/credentials` by default 198 | - `GIMME_AWS_CREDS_CLIENT_ID` - corresponds to `client_id` configuration 199 | - `GIMME_AWS_CREDS_CRED_PROFILE` - corresponds to `cred_profile` configuration 200 | - `GIMME_AWS_CREDS_OUTPUT_FORMAT` - corresponds to `output_format` configuration and `--output-format` CLI option 201 | - `OKTA_AUTH_SERVER` - corresponds to `okta_auth_server` configuration 202 | - `OKTA_DEVICE_TOKEN` - corresponds to `device_token` configuration, can be used in CI 203 | - `OKTA_MFA_CODE` - corresponds to `--mfa-code` CLI option 204 | - `OKTA_PASSWORD` - provides password during authentication, can be used in CI 205 | - `OKTA_USERNAME` - corresponds to `okta_username` configuration and `--username` CLI option 206 | 207 | Example: `GIMME_AWS_CREDS_CLIENT_ID='foobar' AWS_DEFAULT_DURATION=12345 gimme-aws-creds` 208 | 209 | For changing variables outside of this, you'd need to create a separate profile altogether with `gimme-aws-creds --action-configure --profile profileName` 210 | 211 | ### Viewing Profiles 212 | 213 | `gimme-aws-creds --action-list-profiles` will go to your okta config file and print out all profiles created and their settings. 214 | 215 | ### Viewing roles 216 | 217 | `gimme-aws-creds --action-list-roles` will print all available roles to STDOUT without retrieving their credentials. 218 | 219 | ### Generate credentials as json 220 | 221 | `gimme-aws-creds -o json` will print out credentials in JSON format - 1 entry per line 222 | 223 | ### Store credentials from json 224 | 225 | `gimme-aws-creds --action-store-json-creds` will store JSON formatted credentials from `stdin` to 226 | aws credentials file, eg: `gimme-aws-creds -o json | gimme-aws-creds --action-store-json-creds`. 227 | Data can be modified by scripts on the way. 228 | 229 | ### Usage in python code 230 | 231 | Configuration and interactions can be configured using [`gimme_aws_creds.ui`](./gimme_aws_creds/ui.py), 232 | UserInterfaces support all kind of interactions within library including: asking for input, `sys.argv` and `os.environ` 233 | overrides. 234 | 235 | ```python 236 | import sys 237 | import gimme_aws_creds.main 238 | import gimme_aws_creds.ui 239 | 240 | account_ids = sys.argv[1:] or [ 241 | '123456789012', 242 | '120123456789', 243 | ] 244 | 245 | pattern = "|".join(sorted(set(account_ids))) 246 | pattern = '/:({}):/'.format(pattern) 247 | ui = gimme_aws_creds.ui.CLIUserInterface(argv=[sys.argv[0], '--roles', pattern]) 248 | creds = gimme_aws_creds.main.GimmeAWSCreds(ui=ui) 249 | 250 | # Print out all selected roles: 251 | for role in creds.aws_selected_roles: 252 | print(role) 253 | 254 | # Generate credentials overriding profile name with `okta-` 255 | for data in creds.iter_selected_aws_credentials(): 256 | arn = data['role']['arn'] 257 | account_id = None 258 | for piece in arn.split(':'): 259 | if len(piece) == 12 and piece.isdigit(): 260 | account_id = piece 261 | break 262 | 263 | if account_id is None: 264 | raise ValueError("Didn't find aws_account_id (12 digits) in {}".format(arn)) 265 | 266 | data['profile']['name'] = 'okta-{}'.format(account_id) 267 | creds.write_aws_creds_from_data(data) 268 | 269 | ``` 270 | 271 | ## MFA security keys support 272 | 273 | gimme-aws-creds works both on FIDO1 enabled org and WebAuthN enabled org 274 | 275 | Note that FIDO1 will probably be deprecated in the near future as standards moves forward to WebAuthN 276 | 277 | WebAuthN support is available for usb security keys (gimme-aws-creds relies on the yubico fido2 lib). 278 | 279 | To use your local machine as an authenticator, along with Touch ID or Windows Hello, if available, 280 | you must register a new authenticator via gimme-aws-creds, using: 281 | ```bash 282 | gimme-aws-creds --action-setup-fido-authenticator 283 | ``` 284 | 285 | Then, you can choose the newly registered authenticator from the factors list. 286 | 287 | ## Running Tests 288 | 289 | You can run all the unit tests using nosetests. Most of the tests are mocked. 290 | 291 | ```bash 292 | nosetests --verbosity=2 tests/ 293 | ``` 294 | 295 | ## Maintenance 296 | 297 | This project is maintained by [Ann Wallace](https://github.com/anners), [Eric Pierce](https://github.com/epierce), and [Justin Wiley](https://github.com/sector95). 298 | 299 | ## Thanks and Credit 300 | 301 | I came across [okta_aws_login](https://github.com/nimbusscale/okta_aws_login) written by Joe Keegan, when I was searching for a CLI tool that generates AWS tokens via Okta. Unfortunately it hasn't been updated since 2015 and didn't seem to work with the current Okta version. But there was still some great code I was able to reuse under the MIT license for gimme-aws-creds. I have noted in the comments where I used his code, to make sure he receives proper credit. 302 | 303 | ## Etc 304 | 305 | [Okta's Java tool](https://github.com/oktadeveloper/okta-aws-cli-assume-role) 306 | 307 | [AWS - How to Implement Federated API and CLI Access Using SAML 2.0 and AD FS](https://aws.amazon.com/blogs/security/how-to-implement-federated-api-and-cli-access-using-saml-2-0-and-ad-fs/) 308 | 309 | ## [Contributing](https://github.com/Nike-Inc/gimme-aws-creds/blob/master/CONTRIBUTING.md) 310 | 311 | ## License 312 | 313 | Gimme AWS Creds is released under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) 314 | 315 | [license]:LICENSE 316 | [license img]:https://img.shields.io/badge/License-Apache%202-blue.svg 317 | -------------------------------------------------------------------------------- /bin/gimme-aws-creds: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Copyright 2016-present Nike, Inc. 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 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and* limitations under the License.* 12 | """ 13 | from gimme_aws_creds.main import GimmeAWSCreds 14 | 15 | if __name__ == '__main__': 16 | try: 17 | GimmeAWSCreds().run() 18 | except KeyboardInterrupt: 19 | exit(130) 20 | -------------------------------------------------------------------------------- /bin/gimme-aws-creds.cmd: -------------------------------------------------------------------------------- 1 | @echo OFF 2 | REM=""" 3 | setlocal 4 | set PythonExe="" 5 | set PythonExeFlags= 6 | 7 | for %%i in (cmd bat exe) do ( 8 | for %%j in (python3.%%i) do ( 9 | call :SetPythonExe "%%~$PATH:j" 10 | ) 11 | ) 12 | for /f "tokens=2 delims==" %%i in ('assoc .py') do ( 13 | for /f "tokens=2 delims==" %%j in ('ftype %%i') do ( 14 | for /f "tokens=1" %%k in ("%%j") do ( 15 | call :SetPythonExe %%k 16 | ) 17 | ) 18 | ) 19 | for %%i in (cmd bat exe) do ( 20 | for %%j in (python.%%i) do ( 21 | call :SetPythonExe "%%~$PATH:j" 22 | ) 23 | ) 24 | %PythonExe% -x %PythonExeFlags% "%~f0" %* 25 | exit /B %ERRORLEVEL% 26 | goto :EOF 27 | 28 | :SetPythonExe 29 | if not ["%~1"]==[""] ( 30 | if [%PythonExe%]==[""] ( 31 | set PythonExe="%~1" 32 | ) 33 | ) 34 | goto :EOF 35 | """ 36 | 37 | #!/usr/bin/env python3 38 | # Copyright 2016-present Nike, Inc. 39 | # Licensed under the Apache License, Version 2.0 (the "License"); 40 | # You may not use this file except in compliance with the License. 41 | # You may obtain a copy of the License at 42 | # http://www.apache.org/licenses/LICENSE-2.0 43 | # Unless required by applicable law or agreed to in writing, software 44 | # distributed under the License is distributed on an "AS IS" BASIS, 45 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 46 | # See the License for the specific language governing permissions and* limitations under the License.* 47 | 48 | from gimme_aws_creds.main import GimmeAWSCreds 49 | 50 | if __name__ == '__main__': 51 | try: 52 | GimmeAWSCreds().run() 53 | except KeyboardInterrupt: 54 | exit(130) 55 | -------------------------------------------------------------------------------- /gimme_aws_creds/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['config', 'okta', 'main', 'ui'] 2 | version = '2.4.3' 3 | -------------------------------------------------------------------------------- /gimme_aws_creds/aws.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2018-present Engie SA. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | You may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and* limitations under the License.* 11 | """ 12 | import base64 13 | import xml.etree.ElementTree as ET 14 | 15 | import requests 16 | from bs4 import BeautifulSoup 17 | from requests.adapters import HTTPAdapter 18 | from requests.packages.urllib3.util.retry import Retry 19 | 20 | import gimme_aws_creds.common as commondef 21 | from . import errors 22 | 23 | 24 | class AwsResolver(object): 25 | """ 26 | The Aws Client Class performes post request on AWS sign-in page 27 | to fetch friendly names/alias for account and IAM roles 28 | """ 29 | 30 | def __init__(self, verify_ssl_certs=True): 31 | """ 32 | :param verify_ssl_certs: Enable/disable SSL verification 33 | """ 34 | self._verify_ssl_certs = verify_ssl_certs 35 | 36 | if verify_ssl_certs is False: 37 | requests.packages.urllib3.disable_warnings() 38 | 39 | # Allow up to 5 retries on requests to AWS in case we have network issues 40 | self._http_client = requests.Session() 41 | retries = Retry(total=5, backoff_factor=1, 42 | method_whitelist=['POST']) 43 | self._http_client.mount('https://', HTTPAdapter(max_retries=retries)) 44 | 45 | def get_signinpage(self, saml_token, saml_target_url): 46 | """ Post SAML token to aws sign in page and get back html result""" 47 | payload = { 48 | 'SAMLResponse': saml_token, 49 | 'RelayState': '' 50 | } 51 | 52 | response = self._http_client.post( 53 | saml_target_url, 54 | data=payload, 55 | verify=self._verify_ssl_certs 56 | ) 57 | return response.text 58 | 59 | def _enumerate_saml_roles(self, assertion, saml_target_url): 60 | signin_page = self.get_signinpage(assertion, saml_target_url) 61 | 62 | """ using the assertion to fetch aws sign-in page, parse it and return aws sts creds """ 63 | role_pairs = [] 64 | root = ET.fromstring(base64.b64decode(assertion)) 65 | for saml2_attribute in root.iter('{urn:oasis:names:tc:SAML:2.0:assertion}Attribute'): 66 | if saml2_attribute.get('Name') == 'https://aws.amazon.com/SAML/Attributes/Role': 67 | for saml2_attribute_value in saml2_attribute.iter('{urn:oasis:names:tc:SAML:2.0:assertion}AttributeValue'): 68 | role_pairs.append(saml2_attribute_value.text) 69 | 70 | # build a temp hash table 71 | table = {} 72 | for role_pair in role_pairs: 73 | idp, role = None, None 74 | for field in role_pair.split(','): 75 | if 'saml-provider' in field: 76 | idp = field 77 | elif 'role' in field: 78 | role = field 79 | if not idp or not role: 80 | raise errors.GimmeAWSCredsError('Parsing error on {}'.format(role_pair)) 81 | else: 82 | table[role] = idp 83 | 84 | # init parser 85 | soup = BeautifulSoup(signin_page, 'html.parser') 86 | 87 | # find all roles 88 | roles = soup.find_all("div", attrs={"class": "saml-role"}) 89 | # Normalize pieces of string; 90 | result = [] 91 | 92 | # Return role if no Roles are present 93 | if not roles: 94 | role = next(iter(table)) 95 | idp = table[role] 96 | result.append(commondef.RoleSet(idp=idp, role=role, friendly_account_name='SingleAccountName', friendly_role_name='SingleRole')) 97 | return result 98 | 99 | for role_item in roles: 100 | idp, role, friendly_account_name, friendly_role_name = None, None, None, None 101 | role = role_item.label['for'] 102 | idp = table[role] 103 | friendly_account_name = role_item.parent.parent.find("div").find("div").get_text() 104 | friendly_role_name = role_item.label.get_text() 105 | result.append(commondef.RoleSet(idp=idp, role=role, friendly_account_name=friendly_account_name, friendly_role_name=friendly_role_name)) 106 | return result 107 | 108 | @staticmethod 109 | def _display_role(roles): 110 | """ gets a list of available roles and 111 | asks the user to select the role they want to assume 112 | """ 113 | # Gather the roles available to the user. 114 | role_strs = [] 115 | last_account = None 116 | for i, role in enumerate(roles): 117 | if not role: 118 | continue 119 | current_account = role.friendly_account_name 120 | if not current_account == last_account: 121 | role_strs.append(current_account) 122 | last_account = current_account 123 | 124 | role_strs.append(' [ {} ]: {}'.format(i, role.friendly_role_name)) 125 | 126 | return role_strs 127 | -------------------------------------------------------------------------------- /gimme_aws_creds/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2018-present SYNETIS. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | You may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and* limitations under the License.* 11 | """ 12 | from collections import namedtuple 13 | 14 | RoleSet = namedtuple('RoleSet', 'idp, role, friendly_account_name, friendly_role_name') 15 | -------------------------------------------------------------------------------- /gimme_aws_creds/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2016-present Nike, Inc. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | You may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and* limitations under the License.* 11 | """ 12 | import argparse 13 | import configparser 14 | import os 15 | from urllib.parse import urlparse 16 | 17 | from . import errors, ui, version 18 | 19 | 20 | class Config(object): 21 | """ 22 | The Config Class gets the CLI arguments, writes out the okta config file, 23 | gets and returns username and password and the Okta API key. 24 | 25 | A lot of this code is modified from https://github.com/nimbusscale/okta_aws_login 26 | under the MIT license. 27 | """ 28 | 29 | def __init__(self, gac_ui, create_config=True): 30 | """ 31 | :type gac_ui: ui.UserInterface 32 | """ 33 | self.ui = gac_ui 34 | self.FILE_ROOT = self.ui.HOME 35 | self.OKTA_CONFIG = self.ui.environ.get( 36 | 'OKTA_CONFIG', 37 | os.path.join(self.FILE_ROOT, '.okta_aws_login_config') 38 | ) 39 | self.action_register_device = False 40 | self.username = None 41 | self.api_key = None 42 | self.conf_profile = 'DEFAULT' 43 | self.verify_ssl_certs = True 44 | self.app_url = None 45 | self.resolve = False 46 | self.mfa_code = None 47 | self.remember_device = False 48 | self.aws_default_duration = 3600 49 | self.device_token = None 50 | self.action_configure = False 51 | self.action_list_profiles = False 52 | self.action_list_roles = False 53 | self.action_store_json_creds = False 54 | self.action_setup_fido_authenticator = False 55 | self.action_output_format = False 56 | self.output_format = 'export' 57 | self.roles = [] 58 | 59 | if self.ui.environ.get("OKTA_USERNAME") is not None: 60 | self.username = self.ui.environ.get("OKTA_USERNAME") 61 | 62 | if self.ui.environ.get("OKTA_API_KEY") is not None: 63 | self.api_key = self.ui.environ.get("OKTA_API_KEY") 64 | 65 | if create_config and not os.path.isfile(self.OKTA_CONFIG): 66 | self.ui.notify('No gimme-aws-creds configuration file found, starting first-time configuration...') 67 | self.update_config_file() 68 | 69 | def get_args(self): 70 | """Get the CLI args""" 71 | parser = argparse.ArgumentParser( 72 | description="Gets a STS token to use for AWS CLI based on a SAML assertion from Okta" 73 | ) 74 | parser.add_argument( 75 | '--username', '-u', 76 | help="The username to use when logging into Okta. The username can " 77 | "also be set via the OKTA_USERNAME env variable. If not provided " 78 | "you will be prompted to enter a username." 79 | ) 80 | parser.add_argument( 81 | '--action-configure', '--configure', '-c', 82 | action='store_true', 83 | help="If set, will prompt user for configuration parameters and then exit." 84 | ) 85 | parser.add_argument( 86 | '--action-register-device', 87 | '--register-device', 88 | '--register_device', 89 | action='store_true', 90 | help='Download a device token from Okta and add it to the configuration file.' 91 | ) 92 | parser.add_argument( 93 | '--output-format', '-o', 94 | choices=['export', 'json'], 95 | help='Output credentials as either list of shell exports or lines of structured JSON.' 96 | ) 97 | parser.add_argument( 98 | '--profile', '-p', 99 | help='If set, the specified configuration profile will be used instead of the default.' 100 | ) 101 | parser.add_argument( 102 | '--roles', 103 | help='If set, the specified role will be used instead of the aws_rolename in the profile, ' 104 | 'can be specified as a comma separated list, ' 105 | 'can be regex in format //. ' 106 | 'for example: arn:aws:iam::123456789012:role/Admin,/:210987654321:/ ' 107 | 'would match both account 123456789012 by ARN and 210987654321 by regexp' 108 | ) 109 | parser.add_argument( 110 | '--resolve', '-r', 111 | action='store_true', 112 | help='If set, perfom alias resolution.' 113 | ) 114 | parser.add_argument( 115 | '--insecure', '-k', 116 | action='store_true', 117 | help='Allow connections to SSL sites without cert verification.' 118 | ) 119 | parser.add_argument( 120 | '--mfa-code', 121 | help="The MFA verification code to be used with SMS or TOTP authentication methods. " 122 | "If not provided you will be prompted to enter an MFA verification code. " 123 | "Can be read from OKTA_MFA_CODE environment variable" 124 | ) 125 | parser.add_argument( 126 | '--remember-device', '-m', 127 | action='store_true', 128 | help="The MFA device will be remembered by Okta service for a limited time, " 129 | "otherwise, you will be prompted for it every time." 130 | ) 131 | parser.add_argument( 132 | '--version', action='version', 133 | version='%(prog)s {}'.format(version), 134 | help='gimme-aws-creds version') 135 | parser.add_argument( 136 | '--action-list-profiles', '--list-profiles', action='store_true', 137 | help='List all the profiles under .okta_aws_login_config') 138 | parser.add_argument( 139 | '--action-list-roles', action='store_true', 140 | help='List all the roles in the selected profile') 141 | parser.add_argument( 142 | '--action-store-json-creds', action='store_true', 143 | help='Read credentials from stdin (in json format) and store them in ~/.aws/credentials file') 144 | parser.add_argument( 145 | '--action-setup-fido-authenticator', action='store_true', 146 | help='Sets up a new FIDO WebAuthn authenticator in Okta' 147 | ) 148 | args = parser.parse_args(self.ui.args) 149 | 150 | self.action_configure = args.action_configure 151 | self.action_list_profiles = args.action_list_profiles 152 | self.action_list_roles = args.action_list_roles 153 | self.action_store_json_creds = args.action_store_json_creds 154 | self.action_register_device = args.action_register_device 155 | self.action_setup_fido_authenticator = args.action_setup_fido_authenticator 156 | 157 | if args.insecure is True: 158 | ui.default.warning("Warning: SSL certificate validation is disabled!") 159 | self.verify_ssl_certs = False 160 | else: 161 | self.verify_ssl_certs = True 162 | 163 | if args.username is not None: 164 | self.username = args.username 165 | if args.mfa_code is not None: 166 | self.mfa_code = args.mfa_code 167 | if args.remember_device: 168 | self.remember_device = True 169 | if args.resolve is True: 170 | self.resolve = True 171 | if args.output_format is not None: 172 | self.action_output_format = args.output_format 173 | self.output_format = args.output_format 174 | if args.roles is not None: 175 | self.roles = [role.strip() for role in args.roles.split(',') if role.strip()] 176 | self.conf_profile = args.profile or 'DEFAULT' 177 | 178 | def _handle_config(self, config, profile_config, include_inherits = True): 179 | if "inherits" in profile_config.keys() and include_inherits: 180 | self.ui.message("Using inherited config: " + profile_config["inherits"]) 181 | if profile_config["inherits"] not in config: 182 | raise errors.GimmeAWSCredsError(self.conf_profile + " inherits from " + profile_config["inherits"] + ", but could not find " + profile_config["inherits"]) 183 | combined_config = { 184 | **self._handle_config(config, dict(config[profile_config["inherits"]])), 185 | **profile_config, 186 | } 187 | del combined_config["inherits"] 188 | return combined_config 189 | else: 190 | return profile_config 191 | 192 | def get_config_dict(self, include_inherits = True): 193 | """returns the conf dict from the okta config file""" 194 | # Check to see if config file exists, if not complain and exit 195 | # If config file does exist return config dict from file 196 | if os.path.isfile(self.OKTA_CONFIG): 197 | config = configparser.ConfigParser() 198 | config.read(self.OKTA_CONFIG) 199 | 200 | try: 201 | profile_config = dict(config[self.conf_profile]) 202 | self.fail_if_profile_not_found(profile_config, self.conf_profile, config.default_section) 203 | return self._handle_config(config, profile_config, include_inherits) 204 | except KeyError: 205 | if self.action_configure: 206 | return {} 207 | raise errors.GimmeAWSCredsError( 208 | 'Configuration profile not found! Use the --action-configure flag to generate the profile.') 209 | raise errors.GimmeAWSCredsError('Configuration file not found! Use the --action-configure flag to generate file.') 210 | 211 | def update_config_file(self): 212 | """ 213 | Prompts user for config details for the okta_aws_login tool. 214 | Either updates existing config file or creates new one. 215 | Config Options: 216 | okta_org_url = Okta URL 217 | gimme_creds_server = URL of the gimme-creds-server or 'internal' for local processing or 'appurl' when app url available 218 | client_id = OAuth Client id for the gimme-creds-server 219 | okta_auth_server = Server ID for the OAuth authorization server used by gimme-creds-server 220 | write_aws_creds = Option to write creds to ~/.aws/credentials 221 | cred_profile = Use DEFAULT or Role-based name as the profile in ~/.aws/credentials 222 | aws_appname = (optional) Okta AWS App Name 223 | aws_rolename = (optional) Okta Role ARN 224 | okta_username = Okta username 225 | aws_default_duration = Default AWS session duration (3600) 226 | preferred_mfa_type = Select this MFA device type automatically 227 | include_path - (optional) includes that full role path to the role name for profile 228 | 229 | """ 230 | config = configparser.ConfigParser() 231 | if self.action_configure: 232 | self.conf_profile = self._get_conf_profile_name(self.conf_profile) 233 | 234 | defaults = { 235 | 'okta_org_url': '', 236 | 'okta_auth_server': '', 237 | 'client_id': '', 238 | 'gimme_creds_server': 'appurl', 239 | 'aws_appname': '', 240 | 'aws_rolename': ','.join(self.roles), 241 | 'write_aws_creds': '', 242 | 'cred_profile': 'role', 243 | 'okta_username': '', 244 | 'app_url': '', 245 | 'resolve_aws_alias': 'n', 246 | 'include_path': 'n', 247 | 'preferred_mfa_type': '', 248 | 'remember_device': 'n', 249 | 'aws_default_duration': '3600', 250 | 'device_token': '', 251 | 'output_format': 'export', 252 | } 253 | 254 | # See if a config file already exists. 255 | # If so, use current values as defaults 256 | if os.path.isfile(self.OKTA_CONFIG): 257 | config.read(self.OKTA_CONFIG) 258 | 259 | if self.conf_profile in config: 260 | profile = config[self.conf_profile] 261 | 262 | for default in defaults: 263 | defaults[default] = profile.get(default, defaults[default]) 264 | 265 | # Prompt user for config details and store in config_dict 266 | config_dict = defaults 267 | config_dict['okta_org_url'] = self._get_org_url_entry(defaults['okta_org_url']) 268 | config_dict['gimme_creds_server'] = self._get_gimme_creds_server_entry(defaults['gimme_creds_server']) 269 | 270 | if config_dict['gimme_creds_server'] == 'appurl': 271 | config_dict['app_url'] = self._get_appurl_entry(defaults['app_url']) 272 | elif config_dict['gimme_creds_server'] != 'internal': 273 | config_dict['client_id'] = self._get_client_id_entry(defaults['client_id']) 274 | config_dict['okta_auth_server'] = self._get_auth_server_entry(defaults['okta_auth_server']) 275 | 276 | config_dict['write_aws_creds'] = self._get_write_aws_creds(defaults['write_aws_creds']) 277 | if config_dict['gimme_creds_server'] != 'appurl': 278 | config_dict['aws_appname'] = self._get_aws_appname(defaults['aws_appname']) 279 | config_dict['resolve_aws_alias'] = self._get_resolve_aws_alias(defaults['resolve_aws_alias']) 280 | config_dict['include_path'] = self._get_include_path(defaults['include_path']) 281 | config_dict['aws_rolename'] = self._get_aws_rolename(defaults['aws_rolename']) 282 | config_dict['okta_username'] = self._get_okta_username(defaults['okta_username']) 283 | config_dict['aws_default_duration'] = self._get_aws_default_duration(defaults['aws_default_duration']) 284 | config_dict['preferred_mfa_type'] = self._get_preferred_mfa_type(defaults['preferred_mfa_type']) 285 | config_dict['remember_device'] = self._get_remember_device(defaults['remember_device']) 286 | config_dict["output_format"] = '' 287 | if not config_dict["write_aws_creds"]: 288 | config_dict['output_format'] = self._get_output_format(defaults['output_format']) 289 | 290 | # If write_aws_creds is True get the profile name 291 | if config_dict['write_aws_creds'] is True: 292 | config_dict['cred_profile'] = self._get_cred_profile(defaults['cred_profile']) 293 | else: 294 | config_dict['cred_profile'] = defaults['cred_profile'] 295 | 296 | self.write_config_file(config_dict) 297 | 298 | def write_config_file(self, config_dict): 299 | config = configparser.ConfigParser() 300 | config.read(self.OKTA_CONFIG) 301 | config[self.conf_profile] = config_dict 302 | 303 | # write out the conf file 304 | with open(self.OKTA_CONFIG, 'w') as configfile: 305 | config.write(configfile) 306 | 307 | def _get_org_url_entry(self, default_entry): 308 | """ Get and validate okta_org_url """ 309 | ui.default.info("Enter the Okta URL for your organization. This is https://something.okta[preview].com") 310 | okta_org_url_valid = False 311 | okta_org_url = default_entry 312 | 313 | while okta_org_url_valid is False: 314 | okta_org_url = self._get_user_input("Okta URL for your organization", default_entry).strip('/') 315 | # Validate that okta_org_url is a well formed okta URL 316 | url_parse_results = urlparse(okta_org_url) 317 | 318 | if url_parse_results.scheme == "https" and "okta.com" or "oktapreview.com" or "okta-emea.com" in okta_org_url: 319 | okta_org_url_valid = True 320 | else: 321 | ui.default.error( 322 | "Okta organization URL must be HTTPS URL for okta.com or oktapreview.com or okta-emea.com domain") 323 | 324 | self._okta_org_url = okta_org_url 325 | 326 | return okta_org_url 327 | 328 | def _get_auth_server_entry(self, default_entry): 329 | """ Get and validate okta_auth_server """ 330 | ui.default.message( 331 | "Enter the OAuth authorization server for the gimme-creds-server. If you do not know this value, contact your Okta admin") 332 | 333 | okta_auth_server = self._get_user_input("Authorization server", default_entry) 334 | self._okta_auth_server = okta_auth_server 335 | 336 | return okta_auth_server 337 | 338 | def _get_client_id_entry(self, default_entry): 339 | """ Get and validate client_id """ 340 | ui.default.message( 341 | "Enter the OAuth client id for the gimme-creds-server. If you do not know this value, contact your Okta admin") 342 | 343 | client_id = self._get_user_input("Client ID", default_entry) 344 | self._client_id = client_id 345 | 346 | return client_id 347 | 348 | def _get_appurl_entry(self, default_entry): 349 | """ Get and validate app_url """ 350 | ui.default.message( 351 | "Enter the application link. This is https://something.okta[preview].com/home/amazon_aws//something") 352 | okta_org_url_valid = False 353 | app_url = default_entry 354 | 355 | while okta_org_url_valid is False: 356 | app_url = self._get_user_input("Application url", default_entry) 357 | url_parse_results = urlparse(app_url) 358 | 359 | if url_parse_results.scheme == "https" and "okta.com" or "oktapreview.com" or "okta-emea.com" in app_url: 360 | okta_org_url_valid = True 361 | else: 362 | ui.default.warning( 363 | "Okta organization URL must be HTTPS URL for okta.com or oktapreview.com or okta-emea.com domain") 364 | 365 | self._app_url = app_url 366 | 367 | return app_url 368 | 369 | def _get_gimme_creds_server_entry(self, default_entry): 370 | """ Get gimme_creds_server """ 371 | ui.default.message("Enter the URL for the gimme-creds-server or 'internal' for handling Okta APIs locally.") 372 | gimme_creds_server_valid = False 373 | gimme_creds_server = default_entry 374 | 375 | while gimme_creds_server_valid is False: 376 | gimme_creds_server = self._get_user_input( 377 | "URL for gimme-creds-server", default_entry) 378 | if gimme_creds_server == "internal": 379 | gimme_creds_server_valid = True 380 | elif gimme_creds_server == "appurl": 381 | gimme_creds_server_valid = True 382 | else: 383 | url_parse_results = urlparse(gimme_creds_server) 384 | 385 | if url_parse_results.scheme == "https": 386 | gimme_creds_server_valid = True 387 | else: 388 | ui.default.warning("The gimme-creds-server must be a HTTPS URL") 389 | 390 | return gimme_creds_server 391 | 392 | def _get_write_aws_creds(self, default_entry): 393 | """ Option to write to the ~/.aws/credentials or to stdour""" 394 | ui.default.message( 395 | "Do you want to write the temporary AWS to ~/.aws/credentials?" 396 | "\nIf no, the credentials will be written to stdout." 397 | "\nPlease answer y or n.") 398 | 399 | while True: 400 | try: 401 | return self._get_user_input_yes_no("Write AWS Credentials", default_entry) 402 | except ValueError: 403 | ui.default.warning("Write AWS Credentials must be either y or n.") 404 | 405 | def _get_include_path(self, default_entry): 406 | """ Option to include path from rolename """ 407 | 408 | ui.default.message( 409 | "Do you want to include full role path to the role name in AWS credential profile name?" 410 | "\nPlease answer y or n.") 411 | 412 | while True: 413 | try: 414 | return self._get_user_input_yes_no("Include Path", default_entry) 415 | except ValueError: 416 | ui.default.warning("Include Path must be either y or n.") 417 | 418 | def _get_resolve_aws_alias(self, default_entry): 419 | """ Option to resolve account id to alias """ 420 | ui.default.message( 421 | "Do you want to resolve aws account id to aws alias ?" 422 | "\nPlease answer y or n.") 423 | while True: 424 | try: 425 | return self._get_user_input_yes_no("Resolve AWS alias", default_entry) 426 | except ValueError: 427 | ui.default.warning("Resolve AWS alias must be either y or n.") 428 | 429 | def _get_cred_profile(self, default_entry): 430 | """sets the aws credential profile name""" 431 | ui.default.message( 432 | "The AWS credential profile defines which profile is used to store the temp AWS creds.\n" 433 | "If set to 'role' then a new profile will be created matching the role name assumed by the user.\n" 434 | "If set to 'acc-role' then a new profile will be created matching the role name assumed by the user, but prefixed with account number to avoid collisions.\n" 435 | "If set to 'default' then the temp creds will be stored in the default profile\n" 436 | "If set to any other value, the name of the profile will match that value." 437 | ) 438 | 439 | cred_profile = self._get_user_input( 440 | "AWS Credential Profile", default_entry) 441 | 442 | if cred_profile.lower() in ['default', 'role', 'acc-role']: 443 | cred_profile = cred_profile.lower() 444 | 445 | return cred_profile 446 | 447 | def _get_aws_appname(self, default_entry): 448 | """ Get Okta AWS App name """ 449 | ui.default.message( 450 | "Enter the AWS Okta App Name." 451 | "\nThis is optional, you can select the App when you run the CLI.") 452 | aws_appname = self._get_user_input("AWS App Name", default_entry) 453 | return aws_appname 454 | 455 | def _get_aws_rolename(self, default_entry): 456 | """ Get the AWS Role ARN""" 457 | ui.default.message( 458 | "Enter the ARN for the AWS role you want credentials for. 'all' will retrieve all roles." 459 | "\nThis is optional, you can select the role when you run the CLI.") 460 | aws_rolename = self._get_user_input("AWS Role ARN", default_entry) 461 | return aws_rolename 462 | 463 | def _get_conf_profile_name(self, default_entry): 464 | """Get and validate configuration profile name. [Optional]""" 465 | ui.default.message( 466 | "If you'd like to assign the Okta configuration to a specific profile\n" 467 | "instead of to the default profile, specify the name of the profile.\n" 468 | "This is optional.") 469 | conf_profile = self._get_user_input( 470 | "Okta Configuration Profile Name", default_entry) 471 | return conf_profile 472 | 473 | def _get_okta_username(self, default_entry): 474 | """Get and validate okta username. [Optional]""" 475 | ui.default.message( 476 | "If you'd like to set your okta username in the config file, specify the username\n." 477 | "This is optional.") 478 | okta_username = self._get_user_input( 479 | "Okta User Name", default_entry) 480 | return okta_username 481 | 482 | def _get_aws_default_duration(self, default_entry): 483 | """Get and validate the aws default session duration. [Optional]""" 484 | ui.default.message( 485 | "If you'd like to set the default session duration, specify it (in seconds).\n" 486 | "This is optional.") 487 | aws_default_duration = self._get_user_input( 488 | "AWS Default Session Duration", default_entry) 489 | return aws_default_duration 490 | 491 | def _get_preferred_mfa_type(self, default_entry): 492 | """Get the user's preferred MFA device [Optional]""" 493 | ui.default.message( 494 | "If you'd like to set a preferred device type to use for MFA, enter it here.\n" 495 | "This is optional. valid devices types:\n" 496 | """ 497 | - push - Okta Verify App push or DUO push (depends on okta supplied provider type) 498 | - token:software:totp - OTP using the Okta Verify App 499 | - token:hardware - OTP using hardware like Yubikey 500 | - call - OTP via Voice call 501 | - sms - OTP via SMS message 502 | - web - DUO uses localhost webbrowser to support push|call|passcode 503 | - passcode - DUO uses `OKTA_MFA_CODE` or `--mfa-code` if set, or prompts user for passcode(OTP). 504 | """ 505 | ) 506 | okta_username = self._get_user_input( 507 | "Preferred MFA Device Type", default_entry) 508 | return okta_username 509 | 510 | def _get_output_format(self, default_entry): 511 | """Get the user's preferred output format [Optional]""" 512 | ui.default.message("Set the tools' output format:[export, json]") 513 | output_format = None 514 | while output_format not in ('export', 'json'): 515 | output_format = self._get_user_input( 516 | "Preferred output format", default_entry) 517 | return output_format 518 | 519 | def _get_remember_device(self, default_entry): 520 | """Option to remember the MFA device""" 521 | ui.default.message( 522 | "Do you want the MFA device be remembered?\n" 523 | "Please answer y or n.") 524 | while True: 525 | try: 526 | return self._get_user_input_yes_no( 527 | "Remember device", default_entry) 528 | except ValueError: 529 | ui.default.warning("Remember the MFA device must be either y or n.") 530 | 531 | def _get_user_input(self, message, default=None): 532 | """formats message to include default and then prompts user for input 533 | via keyboard with message. Returns user's input or if user doesn't 534 | enter input will return the default.""" 535 | if default and default != '': 536 | prompt_message = message + " [{}]: ".format(default) 537 | else: 538 | prompt_message = message + ': ' 539 | 540 | # print the prompt with print() rather than input() as input prompts on stderr 541 | user_input = self.ui.input(prompt_message) 542 | if user_input: 543 | return user_input 544 | return default 545 | 546 | def _get_user_input_yes_no(self, message, default=None): 547 | """works like _get_user_input, but either: return bool or 548 | raises ValueError""" 549 | if isinstance(default, str): 550 | default = default.lower() 551 | 552 | if default in ('y', 'true', True): 553 | default = 'y' 554 | else: 555 | default = 'n' 556 | 557 | answer = self._get_user_input(message, default=default) 558 | answer = answer.lower() 559 | 560 | if answer == 'y': 561 | return True 562 | if answer == 'n': 563 | return False 564 | 565 | raise ValueError('Invalid answer: %s' % answer) 566 | 567 | def clean_up(self): 568 | """ clean up secret stuff""" 569 | del self.username 570 | del self.api_key 571 | 572 | def fail_if_profile_not_found(self, profile_config, conf_profile, default_section): 573 | """ 574 | When a users profile does not have a profile named 'DEFAULT' configparser fails to throw 575 | an exception. This will raise an exception that handles this case and provide better messaging 576 | to the user why the failure occurred. 577 | Ensure that whichever profile is set as the default exists in the end users okta config 578 | """ 579 | if not profile_config and conf_profile == default_section: 580 | raise errors.GimmeAWSCredsError( 581 | 'DEFAULT profile is missing! This is profile is required when not using --profile') -------------------------------------------------------------------------------- /gimme_aws_creds/default.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2018-present Engie SA / Synetis. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | You may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and* limitations under the License.* 11 | """ 12 | import base64 13 | import xml.etree.ElementTree as ET 14 | 15 | import gimme_aws_creds.common as commondef 16 | from . import errors 17 | 18 | 19 | class DefaultResolver(object): 20 | """ 21 | The Aws Client Class performs post request on AWS sign-in page 22 | to fetch friendly names/alias for account and IAM roles 23 | """ 24 | 25 | def __init__(self, verify_ssl_certs=True): 26 | return 27 | 28 | def _enumerate_saml_roles(self, assertion, saml_target_url): 29 | """ using the assertion to fetch aws sign-in page, parse it and return aws sts creds """ 30 | role_pairs = [] 31 | root = ET.fromstring(base64.b64decode(assertion)) 32 | for saml2_attribute in root.iter('{urn:oasis:names:tc:SAML:2.0:assertion}Attribute'): 33 | if saml2_attribute.get('Name') == 'https://aws.amazon.com/SAML/Attributes/Role': 34 | for saml2_attribute_value in saml2_attribute.iter('{urn:oasis:names:tc:SAML:2.0:assertion}AttributeValue'): 35 | role_pairs.append(saml2_attribute_value.text) 36 | 37 | # Normalize pieces of string; order may vary per AWS sample 38 | result = [] 39 | for role_pair in role_pairs: 40 | idp, role = None, None 41 | for field in role_pair.split(','): 42 | if 'saml-provider' in field: 43 | idp = field 44 | elif 'role' in field: 45 | role = field 46 | if not idp or not role: 47 | raise errors.GimmeAWSCredsError('Parsing error on {}'.format(role_pair)) 48 | else: 49 | result.append(commondef.RoleSet(idp=idp, role=role, friendly_account_name="", friendly_role_name="")) 50 | 51 | return result 52 | 53 | def _display_role(self, roles): 54 | """ gets a list of available roles and 55 | asks the user to select the role they want to assume 56 | """ 57 | # Gather the roles available to the user. 58 | role_strs = [] 59 | for i, role in enumerate(roles): 60 | if not role: 61 | continue 62 | role_strs.append('[{}] {}'.format(i, role.role)) 63 | 64 | return role_strs 65 | -------------------------------------------------------------------------------- /gimme_aws_creds/duo.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | # 13 | # Copyright 2018 Nathan V 14 | # https://github.com/nathan-v/aws_okta_keyman 15 | """All the Duo things.""" 16 | 17 | import time 18 | from http.server import HTTPServer, BaseHTTPRequestHandler 19 | from multiprocessing import Process 20 | 21 | import requests 22 | 23 | 24 | class PasscodeRequired(BaseException): 25 | """A 2FA Passcode Must Be Entered""" 26 | 27 | def __init__(self, factor, state_token): 28 | self.factor = factor 29 | self.state_token = state_token 30 | super(PasscodeRequired, self).__init__() 31 | 32 | 33 | class FactorRequired(BaseException): 34 | """A 2FA Factor Must Be Entered""" 35 | 36 | def __init__(self, factor, state_token): 37 | self.factor = factor 38 | self.state_token = state_token 39 | super(FactorRequired, self).__init__() 40 | 41 | 42 | class QuietHandler(BaseHTTPRequestHandler, object): 43 | """We have to do this HTTP sever silliness because the Duo widget has to be 44 | presented over HTTP or HTTPS or the callback won't work. 45 | """ 46 | 47 | def __init__(self, html, *args): 48 | self.html = html 49 | super(QuietHandler, self).__init__(*args) 50 | 51 | def log_message(self, _format, *args): 52 | """Mute the server log.""" 53 | 54 | def do_GET(self): 55 | """Handle the GET and displays the Duo iframe.""" 56 | self.send_response(200) 57 | self.send_header('Content-type', 'text/html') 58 | self.end_headers() 59 | self.wfile.write(self.html.encode('utf-8')) 60 | 61 | 62 | class Duo: 63 | """Does all the background work needed to serve the Duo iframe.""" 64 | 65 | def __init__(self, gac_ui, details, state_token, socket, factor=None,): 66 | self.ui = gac_ui 67 | self.socket = socket 68 | self.details = details 69 | self.token = state_token 70 | self.factor = factor 71 | self.html = None 72 | self.session = requests.Session() 73 | 74 | def trigger_web_duo(self): 75 | """Start the webserver with the data needed to display the Duo 76 | iframe for the user to see. 77 | """ 78 | host = self.details['host'] 79 | sig = self.details['signature'] 80 | script = self.details['_links']['script']['href'] 81 | callback = self.details['_links']['complete']['href'] 82 | 83 | self.html = '''

You may close this 84 | after the next page loads successfully

85 | 87 |
88 |
89 | '''.format(tkn=self.token, scr=script, 92 | hst=host, sig=sig, 93 | cb=callback) 94 | proc = Process(target=self.duo_webserver) 95 | proc.start() 96 | time.sleep(10) 97 | proc.terminate() 98 | 99 | def duo_webserver(self): 100 | """HTTP webserver.""" 101 | httpd = HTTPServer(self.socket, self.handler_with_html) 102 | httpd.serve_forever() 103 | 104 | def handler_with_html(self, *args): 105 | """Call the handler and include the HTML.""" 106 | return QuietHandler(self.html, *args) 107 | 108 | def trigger_duo(self, passcode=""): 109 | """Try to get a Duo Push without needing an iframe 110 | 111 | Args: 112 | passcode: String passcode to pass along to the OTP factor 113 | """ 114 | sid = self.do_auth(None, None) 115 | if self.factor == "call": 116 | transaction_id = self.get_txid(sid, "Phone+Call") 117 | elif self.factor == "passcode": 118 | if passcode: 119 | transaction_id = self.get_txid(sid, "Passcode", passcode) 120 | else: 121 | raise Exception("Cannot use passcode without one provided") 122 | elif self.factor == "push": 123 | transaction_id = self.get_txid(sid, "Duo+Push") 124 | else: 125 | raise Exception("Requested Duo factor not supported") 126 | auth = self.get_status(transaction_id, sid) 127 | return auth 128 | 129 | def do_auth(self, sid, certs_url): 130 | """Handle initial auth with Duo 131 | 132 | Args: 133 | sid: String Duo session ID if we have it 134 | certs_url: String certificates URL if we have it 135 | 136 | Returns: 137 | String Duo session ID 138 | """ 139 | txid = self.details['signature'].split(":")[0] 140 | fake_path = 'http://0.0.0.0:3000/duo&v=2.1' 141 | url = "https://{}/frame/web/v1/auth?tx={}&parent={}".format( 142 | self.details['host'], txid, fake_path) 143 | 144 | if sid and certs_url: 145 | self.session.params = {sid: sid, certs_url: certs_url} 146 | 147 | self.session.headers = { 148 | 'Origin': "https://{}".format(self.details['host']), 149 | 'Content-Type': "application/x-www-form-urlencoded" 150 | } 151 | 152 | ret = self.session.post(url, allow_redirects=False) 153 | 154 | if ret.status_code == 302: 155 | try: 156 | location = ret.headers['Location'] 157 | sid = location.split("=")[1] 158 | except KeyError: 159 | raise Exception("Location missing from auth response header.") 160 | elif ret.status_code == 200 and sid is None: 161 | sid = ret.json()['response']['sid'] 162 | certs_url = ret.json()['response']['certs_url'] 163 | sid = self.do_auth(sid, certs_url) 164 | else: 165 | raise Exception("Duo request failed.") 166 | 167 | return sid 168 | 169 | def get_txid(self, sid, factor, passcode=None): 170 | """Get Duo transaction ID 171 | 172 | Args: 173 | sid: String Duo session ID 174 | factor: String to tell Duo which factor to use 175 | passcode: OTP passcode string 176 | 177 | Returns: 178 | String Duo transaction ID 179 | """ 180 | url = "https://{}/frame/prompt".format(self.details['host']) 181 | self.session.headers = { 182 | 'Origin': "https://{}".format(self.details['host']), 183 | 'Content-Type': "application/x-www-form-urlencoded", 184 | 'X-Requested-With': 'XMLHttpRequest' 185 | } 186 | 187 | params = ( 188 | "sid={}&device=phone1&" 189 | "factor={}&out_of_date=False").format(sid, factor) 190 | 191 | if passcode: 192 | params = "{}&passcode={}".format(params, passcode) 193 | 194 | url = "{}?{}".format(url, params) 195 | 196 | ret = self.session.post(url) 197 | return ret.json()['response']['txid'] 198 | 199 | def get_status(self, transaction_id, sid): 200 | """Get Duo auth status 201 | 202 | Args: 203 | transaction_id: String Duo transaction ID 204 | sid: String Duo session ID 205 | 206 | Returns: 207 | String authorization from Duo to use in the Okta callback 208 | """ 209 | url = "https://{}/frame/status".format(self.details['host']) 210 | self.session.headers = { 211 | 'Origin': "https://{}".format(self.details['host']), 212 | 'Content-Type': "application/x-www-form-urlencoded", 213 | 'X-Requested-With': 'XMLHttpRequest' 214 | } 215 | 216 | params = "sid={}&txid={}".format(sid, transaction_id) 217 | 218 | url = "{}?{}".format(url, params) 219 | 220 | tries = 0 221 | auth = None 222 | while auth is None and tries < 30: 223 | tries += 1 224 | ret = self.session.post(url) 225 | 226 | if ret.status_code != 200: 227 | raise Exception("Push request failed with status {}".format( 228 | ret.status_code)) 229 | 230 | result = ret.json() 231 | self.ui.info("status: {}".format(result['response']['status'])) 232 | if result['response'].get('result') == 'FAILURE': 233 | raise Exception('DUO MFA failed: {}'.format(format(result['response']['status']))) 234 | if result['stat'] == "OK": 235 | if 'cookie' in result['response']: 236 | auth = result['response']['cookie'] 237 | elif 'result_url' in result['response']: 238 | auth = self.do_redirect( 239 | result['response']['result_url'], sid) 240 | else: 241 | time.sleep(1) 242 | 243 | if auth is None: 244 | raise Exception('Did not get callback information from Duo') 245 | return auth 246 | 247 | def do_redirect(self, url, sid): 248 | """Deal with redirected response from Duo 249 | 250 | Args: 251 | url: String URL we need to follow to try and get the auth 252 | sid: String duo session ID 253 | 254 | Returns: 255 | String Duo authorization to use in the Okta callback 256 | """ 257 | url = "https://{}{}?sid={}".format(self.details['host'], url, sid) 258 | self.session.headers = { 259 | 'Origin': "https://{}".format(self.details['host']), 260 | 'Content-Type': "application/x-www-form-urlencoded", 261 | 'X-Requested-With': 'XMLHttpRequest' 262 | } 263 | 264 | ret = self.session.post(url) 265 | 266 | if ret.status_code != 200: 267 | raise Exception("Bad status from Duo after redirect {}".format( 268 | ret.status_code)) 269 | 270 | result = ret.json() 271 | 272 | if 'cookie' in result['response']: 273 | return result['response']['cookie'] 274 | -------------------------------------------------------------------------------- /gimme_aws_creds/errors.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2018-present Krzysztof Nazarewski. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | You may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and* limitations under the License.* 11 | """ 12 | import sys 13 | 14 | from . import ui 15 | 16 | 17 | class GimmeAWSCredsExitBase(Exception): 18 | def __init__(self, message, return_code, result=None): 19 | """ 20 | :type message: str 21 | :type return_code: int 22 | :type result: str 23 | """ 24 | super().__init__(message, return_code) 25 | self.message = message 26 | self.return_code = return_code 27 | self.result = result 28 | 29 | def handle(self): 30 | self.handle_message() 31 | self.handle_result() 32 | self.exit() 33 | 34 | def handle_message(self): 35 | if self.message: 36 | ui.default.info(self.message) 37 | 38 | def handle_result(self): 39 | if self.result is not None: 40 | ui.default.result(self.result) 41 | 42 | def exit(self): 43 | sys.exit(self.return_code) 44 | 45 | 46 | class GimmeAWSCredsExitSuccess(GimmeAWSCredsExitBase): 47 | def __init__(self, message='', return_code=0, result=''): 48 | super().__init__(message, return_code, result) 49 | 50 | 51 | class GimmeAWSCredsExitError(GimmeAWSCredsExitBase): 52 | def __init__(self, message='ERROR', return_code=1, output=''): 53 | super().__init__(message, return_code, output) 54 | 55 | 56 | class GimmeAWSCredsExceptionBase(Exception): 57 | pass 58 | 59 | 60 | class GimmeAWSCredsError(GimmeAWSCredsExceptionBase, GimmeAWSCredsExitError): 61 | pass 62 | 63 | 64 | class GimmeAWSCredsMFAEnrollStatus(GimmeAWSCredsError): 65 | def __init__(self): 66 | super().__init__("You must enroll in MFA before using this tool.", 2) 67 | 68 | 69 | class NoFIDODeviceFoundError(Exception): 70 | pass 71 | 72 | 73 | class FIDODeviceTimeoutError(Exception): 74 | pass 75 | 76 | 77 | class FIDODeviceError(Exception): 78 | pass 79 | -------------------------------------------------------------------------------- /gimme_aws_creds/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Copyright 2016-present Nike, Inc. 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 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and* limitations under the License.* 12 | """ 13 | # For enumerating saml roles 14 | # standard imports 15 | import configparser 16 | import json 17 | import os 18 | import re 19 | import sys 20 | 21 | # extras 22 | import boto3 23 | from botocore.exceptions import ClientError 24 | from okta.framework.ApiClient import ApiClient 25 | from okta.framework.OktaError import OktaError 26 | 27 | # local imports 28 | from . import errors, ui 29 | from .aws import AwsResolver 30 | from .config import Config 31 | from .default import DefaultResolver 32 | from .okta import OktaClient 33 | from .registered_authenticators import RegisteredAuthenticators 34 | 35 | 36 | class GimmeAWSCreds(object): 37 | """ 38 | This is a CLI tool that gets temporary AWS credentials 39 | from Okta based the available AWS Okta Apps and roles 40 | assigned to the user. The user is able to select the app 41 | and role from the CLI or specify them in a config file by 42 | passing --action-configure to the CLI too. 43 | gimme_aws_creds will either write the credentials to stdout 44 | or ~/.aws/credentials depending on what was specified when 45 | --action-configure was ran. 46 | 47 | Usage: 48 | -h, --help show this help message and exit 49 | --username USERNAME, -u USERNAME 50 | The username to use when logging into Okta. The 51 | username can also be set via the OKTA_USERNAME env 52 | variable. If not provided you will be prompted to 53 | enter a username. 54 | --action-configure, -c If set, will prompt user for configuration parameters 55 | and then exit. 56 | --profile PROFILE, -p PROFILE 57 | If set, the specified configuration profile will be 58 | used instead of the default. 59 | --resolve, -r If set, performs alias resolution. 60 | --insecure, -k Allow connections to SSL sites without cert 61 | verification. 62 | --mfa-code MFA_CODE The MFA verification code to be used with SMS or TOTP 63 | authentication methods. If not provided you will be 64 | prompted to enter an MFA verification code. 65 | --remember-device, -m 66 | The MFA device will be remembered by Okta service for 67 | a limited time, otherwise, you will be prompted for it 68 | every time. 69 | --version gimme-aws-creds version 70 | 71 | Config Options: 72 | okta_org_url = Okta URL 73 | gimme_creds_server = URL of the gimme-creds-server 74 | client_id = OAuth Client id for the gimme-creds-server 75 | okta_auth_server = Server ID for the OAuth authorization server used by gimme-creds-server 76 | write_aws_creds = Option to write creds to ~/.aws/credentials 77 | cred_profile = Use DEFAULT or Role-based name as the profile in ~/.aws/credentials 78 | aws_appname = (optional) Okta AWS App Name 79 | aws_rolename = (optional) AWS Role ARN. 'ALL' will retrieve all roles, can be a CSV for multiple roles. 80 | okta_username = (optional) Okta User Name 81 | """ 82 | resolver = DefaultResolver() 83 | envvar_list = [ 84 | 'AWS_DEFAULT_DURATION', 85 | 'CLIENT_ID', 86 | 'CRED_PROFILE', 87 | 'GIMME_AWS_CREDS_CLIENT_ID', 88 | 'GIMME_AWS_CREDS_CRED_PROFILE', 89 | 'GIMME_AWS_CREDS_OUTPUT_FORMAT', 90 | 'OKTA_AUTH_SERVER', 91 | 'OKTA_DEVICE_TOKEN', 92 | 'OKTA_MFA_CODE', 93 | 'OKTA_PASSWORD', 94 | 'OKTA_USERNAME', 95 | ] 96 | 97 | envvar_conf_map = { 98 | 'GIMME_AWS_CREDS_CLIENT_ID': 'client_id', 99 | 'GIMME_AWS_CREDS_CRED_PROFILE': 'cred_profile', 100 | 'GIMME_AWS_CREDS_OUTPUT_FORMAT': 'output_format', 101 | 'OKTA_DEVICE_TOKEN': 'device_token', 102 | } 103 | 104 | def __init__(self, ui=ui.cli): 105 | """ 106 | :type ui: ui.UserInterface 107 | """ 108 | self.ui = ui 109 | self.FILE_ROOT = self.ui.HOME 110 | self.AWS_CONFIG = self.ui.environ.get( 111 | 'AWS_SHARED_CREDENTIALS_FILE', 112 | os.path.join(self.FILE_ROOT, '.aws', 'credentials') 113 | ) 114 | self._cache = {} 115 | 116 | # this is modified code from https://github.com/nimbusscale/okta_aws_login 117 | def _write_aws_creds(self, profile, access_key, secret_key, token, aws_config=None): 118 | """ Writes the AWS STS token into the AWS credential file""" 119 | # Check to see if the aws creds path exists, if not create it 120 | aws_config = aws_config or self.AWS_CONFIG 121 | creds_dir = os.path.dirname(aws_config) 122 | 123 | if os.path.exists(creds_dir) is False: 124 | os.makedirs(creds_dir) 125 | 126 | config = configparser.RawConfigParser() 127 | 128 | # Read in the existing config file if it exists 129 | if os.path.isfile(aws_config): 130 | config.read(aws_config) 131 | 132 | # Put the credentials into a saml specific section instead of clobbering 133 | # the default credentials 134 | if not config.has_section(profile): 135 | config.add_section(profile) 136 | 137 | config.set(profile, 'aws_access_key_id', access_key) 138 | config.set(profile, 'aws_secret_access_key', secret_key) 139 | config.set(profile, 'aws_session_token', token) 140 | config.set(profile, 'aws_security_token', token) 141 | 142 | # Write the updated config file 143 | with open(aws_config, 'w+') as configfile: 144 | config.write(configfile) 145 | # Update file permissions to secure sensitive credentials file 146 | os.chmod(aws_config, 0o600) 147 | self.ui.result('Written profile {} to {}'.format(profile, aws_config)) 148 | 149 | def write_aws_creds_from_data(self, data, aws_config=None): 150 | if not isinstance(data, dict): 151 | self.ui.warning('json line is not a dict! ' + repr(data)) 152 | return 153 | 154 | aws_config = aws_config or data.get('shared_credentials_file') 155 | credentials = data.get('credentials', {}) 156 | profile = data.get('profile', {}) 157 | 158 | errs = [] 159 | if not isinstance(profile, dict): 160 | errs.append('profile is not a dict!' + repr(profile)) 161 | else: 162 | for key in ('name',): 163 | value = profile.get(key, None) 164 | if not value: 165 | errs.append('{} is not set {} in profile! {}'.format(key, repr(value), str(profile.keys()))) 166 | 167 | if not isinstance(credentials, dict): 168 | errs.append('credentials are not a dict!' + repr(credentials)) 169 | else: 170 | for key in ('aws_access_key_id', 171 | 'aws_secret_access_key', 172 | 'aws_session_token'): 173 | value = credentials.get(key, None) 174 | if not value: 175 | errs.append( 176 | '{} is not set {} in credentials! {}'.format(key, repr(value), str(credentials.keys()))) 177 | 178 | if errs: 179 | for error in errs: 180 | self.ui.warning(error) 181 | return 182 | 183 | arn = data.get('role', {}).get('arn', '') 184 | self.ui.result('Saving {} as {}'.format(arn, profile['name'])) 185 | self._write_aws_creds( 186 | profile['name'], 187 | credentials['aws_access_key_id'], 188 | credentials['aws_secret_access_key'], 189 | credentials['aws_session_token'], 190 | aws_config=aws_config, 191 | ) 192 | 193 | @staticmethod 194 | def _get_partition_from_saml_acs(saml_acs_url): 195 | """ Determine the AWS partition by looking at the ACS endpoint URL""" 196 | if saml_acs_url == 'https://signin.aws.amazon.com/saml': 197 | return 'aws' 198 | elif saml_acs_url == 'https://signin.amazonaws.cn/saml': 199 | return 'aws-cn' 200 | elif saml_acs_url == 'https://signin.amazonaws-us-gov.com/saml': 201 | return 'aws-us-gov' 202 | else: 203 | raise errors.GimmeAWSCredsError("{} is an unknown ACS URL".format(saml_acs_url)) 204 | 205 | @staticmethod 206 | def _get_sts_creds(partition, assertion, idp, role, duration=3600): 207 | """ using the assertion and arns return aws sts creds """ 208 | 209 | # Use the first available region for partitions other than the public AWS 210 | session = boto3.session.Session(profile_name=None) 211 | if partition != 'aws': 212 | regions = session.get_available_regions('sts', partition) 213 | client = session.client('sts', regions[0]) 214 | else: 215 | client = session.client('sts') 216 | 217 | response = client.assume_role_with_saml( 218 | RoleArn=role, 219 | PrincipalArn=idp, 220 | SAMLAssertion=assertion, 221 | DurationSeconds=duration 222 | ) 223 | 224 | return response['Credentials'] 225 | 226 | @staticmethod 227 | def _call_gimme_creds_server(okta_connection, gimme_creds_server_url): 228 | """ Retrieve the user's AWS accounts from the gimme_creds_server""" 229 | response = okta_connection.get(gimme_creds_server_url) 230 | 231 | # Throw an error if we didn't get any accounts back 232 | if not response.json(): 233 | raise errors.GimmeAWSCredsError("No AWS accounts found.") 234 | 235 | return response.json() 236 | 237 | @staticmethod 238 | def _get_aws_account_info(okta_org_url, okta_api_key, username): 239 | """ Call the Okta User API and process the results to return 240 | just the information we need for gimme_aws_creds""" 241 | # We need access to the entire JSON response from the Okta APIs, so we need to 242 | # use the low-level ApiClient instead of UsersClient and AppInstanceClient 243 | users_client = ApiClient(okta_org_url, okta_api_key, pathname='/api/v1/users') 244 | 245 | # Get User information 246 | try: 247 | result = users_client.get_path('/{0}'.format(username)) 248 | user = result.json() 249 | except OktaError as e: 250 | if e.error_code == 'E0000007': 251 | raise errors.GimmeAWSCredsError("Error: " + username + " was not found!") 252 | else: 253 | raise errors.GimmeAWSCredsError("Error: " + e.error_summary) 254 | 255 | try: 256 | # Get first page of results 257 | result = users_client.get_path('/{0}/appLinks'.format(user['id'])) 258 | final_result = result.json() 259 | 260 | # Loop through other pages 261 | while 'next' in result.links: 262 | result = users_client.get(result.links['next']['url']) 263 | final_result = final_result + result.json() 264 | ui.default.info("done\n") 265 | except OktaError as e: 266 | if e.error_code == 'E0000007': 267 | raise errors.GimmeAWSCredsError("Error: No applications found for " + username) 268 | else: 269 | raise errors.GimmeAWSCredsError("Error: " + e.error_summary) 270 | 271 | # Loop through the list of apps and filter it down to just the info we need 272 | app_list = [] 273 | for app in final_result: 274 | # All AWS connections have the same app name 275 | if app['appName'] == 'amazon_aws': 276 | new_app_entry = { 277 | 'id': app['id'], 278 | 'name': app['label'], 279 | 'links': { 280 | 'appLink': app['linkUrl'], 281 | 'appLogo': app['logoUrl'] 282 | } 283 | } 284 | app_list.append(new_app_entry) 285 | 286 | # Throw an error if we didn't get any accounts back 287 | if not app_list: 288 | raise errors.GimmeAWSCredsError("No AWS accounts found.") 289 | 290 | return app_list 291 | 292 | @staticmethod 293 | def _parse_role_arn(arn): 294 | """ Extracts account number, path and role name from role arn string """ 295 | matches = re.match(r"arn:(aws|aws-cn|aws-us-gov):iam:.*:(?P\d{12}):role(?P(/[\w/]+)?/)(?P\S+)", arn) 296 | return { 297 | 'account': matches.group('accountid'), 298 | 'role': matches.group('role'), 299 | 'path': matches.group('path') 300 | } 301 | 302 | @staticmethod 303 | def _get_alias_from_friendly_name(friendly_name): 304 | """ Extracts alias from friendly name string """ 305 | res = None 306 | matches = re.match(r"Account:\s(?P.+)\s\(\d{12}\)", friendly_name) 307 | if matches: 308 | res = matches.group('alias') 309 | return res 310 | 311 | def _choose_app(self, aws_info): 312 | """ gets a list of available apps and 313 | ask the user to select the app they want 314 | to assume a roles for and returns the selection 315 | """ 316 | if not aws_info: 317 | return None 318 | 319 | if len(aws_info) == 1: 320 | return aws_info[0] # auto select when only 1 choice 321 | 322 | app_strs = [] 323 | for i, app in enumerate(aws_info): 324 | app_strs.append('[{}] {}'.format(i, app["name"])) 325 | 326 | if app_strs: 327 | self.ui.message("Pick an app:") 328 | # print out the apps and let the user select 329 | for app in app_strs: 330 | self.ui.message(app) 331 | else: 332 | return None 333 | 334 | selection = self._get_user_int_selection(0, len(aws_info) - 1) 335 | 336 | if selection is None: 337 | raise errors.GimmeAWSCredsError("You made an invalid selection") 338 | 339 | return aws_info[int(selection)] 340 | 341 | def _get_selected_app(self, aws_appname, aws_info): 342 | """ select the application from the config file if it exists in the 343 | results from Okta. If not, present the user with a menu.""" 344 | 345 | if aws_appname: 346 | for _, app in enumerate(aws_info): 347 | if app["name"] == aws_appname: 348 | return app 349 | elif app["name"] == "fakelabel": 350 | # auto select this app 351 | return app 352 | self.ui.error("ERROR: AWS account [{}] not found!".format(aws_appname)) 353 | 354 | # Present the user with a list of apps to choose from 355 | return self._choose_app(aws_info) 356 | 357 | def _get_user_int_selection(self, min_int, max_int, max_retries=5): 358 | selection = None 359 | for _ in range(0, max_retries): 360 | try: 361 | selection = int(self.ui.input("Selection: ")) 362 | break 363 | except ValueError: 364 | self.ui.warning('Invalid selection, must be an integer value.') 365 | 366 | if selection is None: 367 | return None 368 | 369 | # make sure the choice is valid 370 | if selection < min_int or selection > max_int: 371 | return None 372 | 373 | return selection 374 | 375 | def _get_selected_roles(self, requested_roles, aws_roles): 376 | """ select the role from the config file if it exists in the 377 | results from Okta. If not, present the user with a menu. """ 378 | # 'all' is a special case - skip processing 379 | if requested_roles == 'all': 380 | return set(role.role for role in aws_roles) 381 | # check to see if a role is in the config and look for it in the results from Okta 382 | if requested_roles: 383 | ret = set() 384 | if isinstance(requested_roles, str): 385 | requested_roles = requested_roles.split(',') 386 | 387 | for role_name in requested_roles: 388 | role_name = role_name.strip() 389 | if not role_name: 390 | continue 391 | 392 | is_regexp = len(role_name) > 2 and role_name[0] == role_name[-1] == '/' 393 | pattern = re.compile(role_name[1:-1]) 394 | for aws_role in aws_roles: 395 | if aws_role.role == role_name or (is_regexp and pattern.search(aws_role.role)): 396 | ret.add(aws_role.role) 397 | 398 | if ret: 399 | return ret 400 | self.ui.error("ERROR: AWS roles [{}] not found!".format(', '.join(requested_roles))) 401 | 402 | # Present the user with a list of roles to choose from 403 | return self._choose_roles(aws_roles) 404 | 405 | def _choose_roles(self, roles): 406 | """ gets a list of available roles and 407 | asks the user to select the role they want to assume 408 | """ 409 | if not roles: 410 | return set() 411 | 412 | # Check if only one role exists and return that role 413 | if len(roles) == 1: 414 | single_role = roles[0].role 415 | self.ui.info("Detected single role: {}".format(single_role)) 416 | return {single_role} 417 | 418 | # Gather the roles available to the user. 419 | role_strs = self.resolver._display_role(roles) 420 | 421 | if role_strs: 422 | self.ui.message("Pick a role:") 423 | for role in role_strs: 424 | self.ui.message(role) 425 | else: 426 | return set() 427 | 428 | selections = self._get_user_int_selections_many(0, len(roles) - 1) 429 | 430 | if not selections: 431 | raise errors.GimmeAWSCredsError("You made an invalid selection") 432 | 433 | return {roles[int(selection)].role for selection in selections} 434 | 435 | def _get_user_int_selections_many(self, min_int, max_int, max_retries=5): 436 | for _ in range(max_retries): 437 | selections = set() 438 | error = False 439 | 440 | for value in self.ui.input('Selections (comma separated): ').split(','): 441 | value = value.strip() 442 | 443 | if not value: 444 | continue 445 | 446 | try: 447 | selection = int(value) 448 | except ValueError: 449 | self.ui.warning('Invalid selection {}, must be an integer value.'.format(repr(value))) 450 | error = True 451 | continue 452 | 453 | if min_int <= selection <= max_int: 454 | selections.add(value) 455 | else: 456 | self.ui.warning( 457 | 'Selection {} out of range <{}, {}>'.format(repr(selection), min_int, max_int)) 458 | 459 | if error: 460 | continue 461 | 462 | if selections: 463 | return selections 464 | 465 | return set() 466 | 467 | def run(self): 468 | try: 469 | self._run() 470 | except errors.GimmeAWSCredsExitBase as exc: 471 | exc.handle() 472 | 473 | def generate_config(self): 474 | """ generates a new configuration and populates 475 | various config caches 476 | """ 477 | self._cache['config'] = config = Config(gac_ui=self.ui) 478 | config.get_args() 479 | self._cache['conf_dict'] = config.get_config_dict() 480 | 481 | for value in self.envvar_list: 482 | if self.ui.environ.get(value): 483 | key = self.envvar_conf_map.get(value, value).lower() 484 | self.conf_dict[key] = self.ui.environ.get(value) 485 | 486 | # AWS Default session duration .... 487 | if self.conf_dict.get('aws_default_duration'): 488 | self.config.aws_default_duration = int(self.conf_dict['aws_default_duration']) 489 | else: 490 | self.config.aws_default_duration = 3600 491 | 492 | self.resolver = self.get_resolver() 493 | return config 494 | 495 | @property 496 | def config(self): 497 | if 'config' in self._cache: 498 | return self._cache['config'] 499 | config = self.generate_config() 500 | return config 501 | 502 | @property 503 | def conf_dict(self): 504 | """ 505 | :rtype: dict 506 | """ 507 | # noinspection PyUnusedLocal 508 | config = self.config 509 | return self._cache['conf_dict'] 510 | 511 | @property 512 | def output_format(self): 513 | return self.conf_dict.setdefault('output_format', self.config.output_format) 514 | 515 | @property 516 | def okta_org_url(self): 517 | ret = self.conf_dict.get('okta_org_url') 518 | if not ret: 519 | raise errors.GimmeAWSCredsError('No Okta organization URL in configuration. Try running --config again.') 520 | return ret 521 | 522 | @property 523 | def gimme_creds_server(self): 524 | ret = self.conf_dict.get('gimme_creds_server') 525 | if not ret: 526 | raise errors.GimmeAWSCredsError('No Gimme-Creds server URL in configuration. Try running --config again.') 527 | return ret 528 | 529 | @property 530 | def okta(self): 531 | if 'okta' in self._cache: 532 | return self._cache['okta'] 533 | 534 | okta = self._cache['okta'] = OktaClient( 535 | self.ui, 536 | self.okta_org_url, 537 | self.config.verify_ssl_certs, 538 | self.device_token, 539 | ) 540 | 541 | if self.config.username is not None: 542 | okta.set_username(self.config.username) 543 | elif self.conf_dict.get('okta_username'): 544 | okta.set_username(self.conf_dict['okta_username']) 545 | 546 | if self.conf_dict.get('okta_password'): 547 | okta.set_password(self.conf_dict['okta_password']) 548 | 549 | if self.conf_dict.get('preferred_mfa_type'): 550 | okta.set_preferred_mfa_type(self.conf_dict['preferred_mfa_type']) 551 | 552 | if self.config.mfa_code is not None: 553 | okta.set_mfa_code(self.config.mfa_code) 554 | elif self.conf_dict.get('okta_mfa_code'): 555 | okta.set_mfa_code(self.conf_dict.get('okta_mfa_code')) 556 | 557 | okta.set_remember_device(self.config.remember_device 558 | or self.conf_dict.get('remember_device', False)) 559 | return okta 560 | 561 | def get_resolver(self): 562 | if self.config.resolve: 563 | return AwsResolver(self.config.verify_ssl_certs) 564 | elif str(self.conf_dict.get('resolve_aws_alias')) == 'True': 565 | return AwsResolver(self.config.verify_ssl_certs) 566 | return self.resolver 567 | 568 | @property 569 | def device_token(self): 570 | if self.config.action_register_device is True: 571 | self.conf_dict['device_token'] = None 572 | 573 | return self.conf_dict.get('device_token') 574 | 575 | def set_auth_session(self, auth_session): 576 | self._cache['auth_session'] = auth_session 577 | 578 | @property 579 | def auth_session(self): 580 | if 'auth_session' in self._cache: 581 | return self._cache['auth_session'] 582 | auth_result = self.okta.auth_session(redirect_uri=self.conf_dict.get('app_url')) 583 | self.set_auth_session(auth_result) 584 | return auth_result 585 | 586 | @property 587 | def aws_results(self): 588 | if 'aws_results' in self._cache: 589 | return self._cache['aws_results'] 590 | # Call the Okta APIs and process data locally 591 | if self.gimme_creds_server == 'internal': 592 | # Okta API key is required when calling Okta APIs internally 593 | if self.config.api_key is None: 594 | raise errors.GimmeAWSCredsError('OKTA_API_KEY environment variable not found!') 595 | auth_result = self.auth_session 596 | aws_results = self._get_aws_account_info(self.okta_org_url, self.config.api_key, 597 | auth_result['username']) 598 | 599 | elif self.gimme_creds_server == 'appurl': 600 | self.auth_session 601 | # bypass lambda & API call 602 | # Apps url is required when calling with appurl 603 | if self.conf_dict.get('app_url'): 604 | self.config.app_url = self.conf_dict['app_url'] 605 | if self.config.app_url is None: 606 | raise errors.GimmeAWSCredsError('app_url is not defined in your config!') 607 | 608 | # build app list 609 | aws_results = [] 610 | new_app_entry = { 611 | 'id': 'fakeid', # not used anyway 612 | 'name': 'fakelabel', # not used anyway 613 | 'links': {'appLink': self.config.app_url} 614 | } 615 | aws_results.append(new_app_entry) 616 | 617 | # Use the gimme_creds_lambda service 618 | else: 619 | if not self.conf_dict.get('client_id'): 620 | raise errors.GimmeAWSCredsError('No OAuth Client ID in configuration. Try running --config again.') 621 | if not self.conf_dict.get('okta_auth_server'): 622 | raise errors.GimmeAWSCredsError( 623 | 'No OAuth Authorization server in configuration. Try running --config again.') 624 | 625 | # Authenticate with Okta and get an OAuth access token 626 | self.okta.auth_oauth( 627 | self.conf_dict['client_id'], 628 | authorization_server=self.conf_dict['okta_auth_server'], 629 | access_token=True, 630 | id_token=False, 631 | scopes=['openid'] 632 | ) 633 | 634 | # Add Access Tokens to Okta-protected requests 635 | self.okta.use_oauth_access_token(True) 636 | 637 | self.ui.info("Authentication Success! Calling Gimme-Creds Server...") 638 | aws_results = self._call_gimme_creds_server(self.okta, self.gimme_creds_server) 639 | 640 | self._cache['aws_results'] = aws_results 641 | return aws_results 642 | 643 | @property 644 | def aws_app(self): 645 | if 'aws_app' in self._cache: 646 | return self._cache['aws_app'] 647 | self._cache['aws_app'] = aws_app = self._get_selected_app(self.conf_dict.get('aws_appname'), self.aws_results) 648 | return aws_app 649 | 650 | @property 651 | def saml_data(self): 652 | if 'saml_data' in self._cache: 653 | return self._cache['saml_data'] 654 | self._cache['saml_data'] = saml_data = self.okta.get_saml_response(self.aws_app['links']['appLink']) 655 | return saml_data 656 | 657 | @property 658 | def aws_roles(self): 659 | if 'aws_roles' in self._cache: 660 | return self._cache['aws_roles'] 661 | 662 | self._cache['aws_roles'] = roles = self.resolver._enumerate_saml_roles( 663 | self.saml_data['SAMLResponse'], 664 | self.saml_data['TargetUrl'], 665 | ) 666 | return roles 667 | 668 | @property 669 | def aws_selected_roles(self): 670 | if 'aws_selected_roles' in self._cache: 671 | return self._cache['aws_selected_roles'] 672 | selected_roles = self._get_selected_roles(self.requested_roles, self.aws_roles) 673 | self._cache['aws_selected_roles'] = ret = [ 674 | role 675 | for role in self.aws_roles 676 | if role.role in selected_roles 677 | ] 678 | return ret 679 | 680 | @property 681 | def requested_roles(self): 682 | if 'requested_roles' in self._cache: 683 | return self._cache['requested_roles'] 684 | self._cache['requested_roles'] = requested_roles = self.config.roles or self.conf_dict.get('aws_rolename', '') 685 | return requested_roles 686 | 687 | @property 688 | def aws_partition(self): 689 | if 'aws_partition' in self._cache: 690 | return self._cache['aws_partition'] 691 | self._cache['aws_partition'] = aws_partition = self._get_partition_from_saml_acs(self.saml_data['TargetUrl']) 692 | return aws_partition 693 | 694 | def prepare_data(self, role, generate_credentials=False): 695 | aws_creds = {} 696 | if generate_credentials: 697 | try: 698 | aws_creds = self._get_sts_creds( 699 | self.aws_partition, 700 | self.saml_data['SAMLResponse'], 701 | role.idp, 702 | role.role, 703 | self.config.aws_default_duration, 704 | ) 705 | except ClientError as ex: 706 | if 'requested DurationSeconds exceeds the MaxSessionDuration' in ex.response['Error']['Message']: 707 | self.ui.warning( 708 | "The requested session duration was too long for this role. Falling back to 1 hour.") 709 | aws_creds = self._get_sts_creds( 710 | self.aws_partition, 711 | self.saml_data['SAMLResponse'], 712 | role.idp, 713 | role.role, 714 | 3600, 715 | ) 716 | else: 717 | self.ui.error('Failed to generate credentials for {} due to {}'.format(role.role, ex)) 718 | 719 | naming_data = self._parse_role_arn(role.role) 720 | # set the profile name 721 | # Note if there are multiple roles 722 | # it will be overwritten multiple times and last role wins. 723 | cred_profile = self.conf_dict['cred_profile'] 724 | resolve_alias = self.conf_dict['resolve_aws_alias'] 725 | include_path = self.conf_dict.get('include_path') 726 | profile_name = self.get_profile_name(cred_profile, include_path, naming_data, resolve_alias, role) 727 | 728 | return { 729 | 'shared_credentials_file': self.AWS_CONFIG, 730 | 'profile': { 731 | 'name': profile_name, 732 | 'derived_name': naming_data['role'], 733 | 'config_name': self.conf_dict.get('cred_profile', ''), 734 | }, 735 | 'role': { 736 | 'arn': role.role, 737 | 'name': role.role, 738 | 'friendly_name': role.friendly_role_name, 739 | 'friendly_account_name': role.friendly_account_name, 740 | }, 741 | 'credentials': { 742 | 'aws_access_key_id': aws_creds.get('AccessKeyId', ''), 743 | 'aws_secret_access_key': aws_creds.get('SecretAccessKey', ''), 744 | 'aws_session_token': aws_creds.get('SessionToken', ''), 745 | 'aws_security_token': aws_creds.get('SessionToken', ''), 746 | 'expiration': aws_creds.get('Expiration').isoformat(), 747 | } if bool(aws_creds) else {} 748 | } 749 | 750 | def get_profile_name(self, cred_profile, include_path, naming_data, resolve_alias, role): 751 | if cred_profile.lower() == 'default': 752 | profile_name = 'default' 753 | elif cred_profile.lower() == 'role': 754 | profile_name = naming_data['role'] 755 | elif cred_profile.lower() == 'acc-role': 756 | account = naming_data['account'] 757 | role_name = naming_data['role'] 758 | path = naming_data['path'] 759 | if resolve_alias == 'True': 760 | account_alias = self._get_alias_from_friendly_name(role.friendly_account_name) 761 | if account_alias: 762 | account = account_alias 763 | if include_path == 'True': 764 | role_name = ''.join([path, role_name]) 765 | profile_name = '-'.join([account, 766 | role_name]) 767 | else: 768 | profile_name = cred_profile 769 | return profile_name 770 | 771 | def iter_selected_aws_credentials(self): 772 | results = [] 773 | for role in self.aws_selected_roles: 774 | data = self.prepare_data(role, generate_credentials=True) 775 | if not data: 776 | continue 777 | results.append(data) 778 | yield data 779 | 780 | self._cache['selected_aws_credentials'] = results 781 | 782 | @property 783 | def selected_aws_credentials(self): 784 | if 'selected_aws_credentials' in self._cache: 785 | return self._cache['selected_aws_credentials'] 786 | self._cache['selected_aws_credentials'] = ret = list(self.iter_selected_aws_credentials()) 787 | return ret 788 | 789 | def _run(self): 790 | """ Pulling it all together to make the CLI """ 791 | self.handle_action_configure() 792 | self.handle_action_register_device() 793 | self.handle_action_list_profiles() 794 | self.handle_action_store_json_creds() 795 | self.handle_action_list_roles() 796 | self.handle_setup_fido_authenticator() 797 | 798 | # for each data item, if we have an override on output, prioritize that 799 | # if we do not, prioritize writing credentials to file if that is in our 800 | # configuration. If we are not writing to a credentials file, use whatever 801 | # is in the output format field (default to exports) 802 | for data in self.iter_selected_aws_credentials(): 803 | if self.config.action_output_format: 804 | self.write_result_action(self.config.action_output_format, data) 805 | continue 806 | 807 | write_aws_creds = str(self.conf_dict['write_aws_creds']) == 'True' 808 | # check if write_aws_creds is true if so 809 | # get the profile name and write out the file 810 | if write_aws_creds: 811 | self.write_aws_creds_from_data(data) 812 | continue 813 | 814 | self.write_result_action(self.conf_dict["output_format"], data) 815 | 816 | self.config.clean_up() 817 | 818 | def write_result_action(self, action, data): 819 | if action == "json": 820 | self.ui.result(json.dumps(data)) 821 | return 822 | else: 823 | # Defaults to `export` format 824 | self.ui.result("export AWS_ROLE_ARN=" + data['role']['arn']) 825 | self.ui.result("export AWS_ACCESS_KEY_ID=" + 826 | data['credentials']['aws_access_key_id']) 827 | self.ui.result("export AWS_SECRET_ACCESS_KEY=" + 828 | data['credentials']['aws_secret_access_key']) 829 | self.ui.result("export AWS_SESSION_TOKEN=" + 830 | data['credentials']['aws_session_token']) 831 | self.ui.result("export AWS_SECURITY_TOKEN=" + 832 | data['credentials']['aws_security_token']) 833 | 834 | 835 | def handle_action_configure(self): 836 | # Create/Update config when configure arg set 837 | if not self.config.action_configure: 838 | return 839 | self.config.update_config_file() 840 | raise errors.GimmeAWSCredsExitSuccess() 841 | 842 | def handle_action_list_profiles(self): 843 | if not self.config.action_list_profiles: 844 | return 845 | if os.path.isfile(self.config.OKTA_CONFIG): 846 | with open(self.config.OKTA_CONFIG, 'r') as okta_config: 847 | raise errors.GimmeAWSCredsExitSuccess(result=okta_config.read()) 848 | raise errors.GimmeAWSCredsExitError('{} is not a file'.format(self.config.OKTA_CONFIG)) 849 | 850 | def handle_action_store_json_creds(self, stream=None): 851 | if not self.config.action_store_json_creds: 852 | return 853 | 854 | stream = stream or sys.stdin 855 | for line in stream: 856 | try: 857 | data = json.loads(line) 858 | except json.JSONDecodeError: 859 | self.ui.warning('error parsing json line {}'.format(repr(line))) 860 | continue 861 | self.write_aws_creds_from_data(data) 862 | raise errors.GimmeAWSCredsExitSuccess() 863 | 864 | def handle_action_register_device(self): 865 | # Capture the Device Token and write it to the config file 866 | if self.device_token is None or self.config.action_register_device is True: 867 | if not self.config.action_register_device: 868 | self.ui.notify('\n*** No device token found in configuration file, it will be created.') 869 | self.ui.notify('*** You may be prompted for MFA more than once for this run.\n') 870 | 871 | auth_result = self.auth_session 872 | base_config = self.config.get_config_dict(include_inherits = False) 873 | base_config['device_token'] = auth_result['device_token'] 874 | self.config.write_config_file(base_config) 875 | self.okta.device_token = base_config['device_token'] 876 | 877 | self.ui.notify('\nDevice token saved!\n') 878 | 879 | if self.config.action_register_device is True: 880 | raise errors.GimmeAWSCredsExitSuccess() 881 | 882 | def handle_action_list_roles(self): 883 | if self.config.action_list_roles: 884 | raise errors.GimmeAWSCredsExitSuccess(result='\n'.join(map(str, self.aws_roles))) 885 | 886 | def handle_setup_fido_authenticator(self): 887 | if self.config.action_setup_fido_authenticator: 888 | # Registers a new fido authenticator to Okta, to be used later as an MFA device 889 | self.ui.notify('\n*** Registering a new fido authenticator in Okta.') 890 | self.ui.notify('\n*** Note that webauthn authenticators must be allowed for this operation to succeed.') 891 | self.ui.notify('*** You may be prompted for MFA more than once for this run.\n') 892 | 893 | # noinspection PyStatementEffect 894 | self.auth_session 895 | 896 | self.okta.set_preferred_mfa_type(None) 897 | credential_id, user = self.okta.setup_fido_authenticator() 898 | 899 | registered_authenticators = RegisteredAuthenticators(self.ui) 900 | registered_authenticators.add_authenticator(credential_id, user) 901 | raise errors.GimmeAWSCredsExitSuccess() 902 | -------------------------------------------------------------------------------- /gimme_aws_creds/registered_authenticators.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import os 4 | 5 | 6 | class RegisteredAuthenticators(object): 7 | """ 8 | The RegisteredAuthenticators class manages a json file of gimme-aws-creds registered 9 | FIDO authenticators. 10 | 11 | There's a list of RegisteredAuthenticator entries with two fields: 12 | - cred_id_hash - sha512 of the registered credential id 13 | - user - a user identifier (email, name, uid, ...) 14 | """ 15 | 16 | JSON_PATH_ENV_VAR = 'OKTA_REGISTERED_AUTHENTICATORS_FILE' 17 | 18 | def __init__(self, gac_ui): 19 | """ 20 | :type gac_ui: ui.UserInterface 21 | """ 22 | self.ui = gac_ui 23 | self._json_path = self.ui.environ.get(self.JSON_PATH_ENV_VAR, 24 | os.path.join(self.ui.HOME, '.okta_aws_registered_authenticators')) 25 | self._create_file_if_necessary(self._json_path) 26 | 27 | @staticmethod 28 | def _create_file_if_necessary(path): 29 | if os.path.exists(path): 30 | return None 31 | 32 | with open(path, 'w') as f: 33 | json.dump([], f) 34 | 35 | def add_authenticator(self, credential_id, user): 36 | """ 37 | :param credential_id: the id of added authenticator credential 38 | :type credential_id: bytes 39 | :param user: a user identifier (email, name, uid, ...) 40 | :type user: str 41 | """ 42 | authenticators = self._get_authenticators() 43 | authenticators.append(RegisteredAuthenticator(credential_id=credential_id, user=user)) 44 | 45 | with open(self._json_path, 'w') as f: 46 | json.dump(authenticators, f) 47 | 48 | def get_authenticator_user(self, credential_id): 49 | """ 50 | :param credential_id: the id of the authenticator's credential 51 | :type credential_id: bytes 52 | :return: user identifier, if credential id was registered by gimme-aws-creds, or None 53 | :rtype: str 54 | """ 55 | authenticators = self._get_authenticators() 56 | for authenticator in authenticators: 57 | if authenticator.matches(credential_id): 58 | return authenticator.user 59 | 60 | return None 61 | 62 | def _get_authenticators(self): 63 | with open(self._json_path) as f: 64 | entries = json.load(f) 65 | return [RegisteredAuthenticator(**entry) for entry in entries] 66 | 67 | 68 | class RegisteredAuthenticator(dict): 69 | """ 70 | An entry in the registered authenticators json file, which holds a hashed credential id, and its user id. 71 | """ 72 | 73 | def __init__(self, credential_id=None, credential_id_hash=None, user=None): 74 | """ 75 | :type credential_id: bytes 76 | :type user: str 77 | """ 78 | credential_id_hash = credential_id_hash or self._hash_credential_id(credential_id) 79 | super().__init__(credential_id_hash=credential_id_hash, user=user) 80 | 81 | self.credential_id_hash = credential_id_hash 82 | self.user = user 83 | 84 | def matches(self, credential_id): 85 | return self.credential_id_hash == self._hash_credential_id(credential_id) 86 | 87 | @staticmethod 88 | def _hash_credential_id(credential_id): 89 | return hashlib.sha512(credential_id).hexdigest() 90 | -------------------------------------------------------------------------------- /gimme_aws_creds/u2f.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2018-present SYNETIS. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | You may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and* limitations under the License.* 11 | """ 12 | 13 | from __future__ import print_function, absolute_import, unicode_literals 14 | 15 | import json 16 | import time 17 | from threading import Event, Thread 18 | 19 | from fido2.ctap1 import APDU 20 | from fido2.ctap1 import ApduError 21 | from fido2.ctap1 import Ctap1 22 | from fido2.hid import CtapHidDevice 23 | from fido2.utils import sha256, websafe_decode 24 | 25 | from gimme_aws_creds.errors import NoFIDODeviceFoundError, FIDODeviceTimeoutError, FIDODeviceError 26 | 27 | 28 | class FactorU2F(object): 29 | 30 | def __init__(self, ui, appId, nonce, credentialId): 31 | """ 32 | :param appId: Base URL string for Okta IDP e.g. https://xxxx.okta.com' 33 | :param nonce: nonce 34 | :param credentialid: credentialid 35 | """ 36 | self.ui = ui 37 | self._clients = None 38 | self._has_prompted = False 39 | self._cancel = Event() 40 | self._credentialId = websafe_decode(credentialId) 41 | self._appId = sha256(appId.encode()) 42 | self._version = 'U2F_V2' 43 | self._signature = None 44 | self._clientData = json.dumps({ 45 | "challenge": nonce, 46 | "origin": appId, 47 | "typ": "navigator.id.getAssertion" 48 | }).encode() 49 | self._nonce = sha256(self._clientData) 50 | 51 | def locate_device(self): 52 | # Locate a device 53 | devs = list(CtapHidDevice.list_devices()) 54 | if not devs: 55 | self.ui.info("No FIDO device found") 56 | raise NoFIDODeviceFoundError 57 | 58 | self._clients = [Ctap1(d) for d in devs] 59 | 60 | def work(self, client): 61 | for _ in range(30): 62 | try: 63 | self._signature = client.authenticate( 64 | self._nonce, self._appId, self._credentialId ) 65 | except ApduError as e: 66 | if e.code == APDU.USE_NOT_SATISFIED: 67 | if not self._has_prompted: 68 | self.ui.info('\nTouch your authenticator device now...\n') 69 | self._has_prompted = True 70 | time.sleep(0.5) 71 | continue 72 | else: 73 | raise FIDODeviceError 74 | break 75 | 76 | if self._signature is None: 77 | raise FIDODeviceError 78 | 79 | self._cancel.set() 80 | 81 | def verify(self): 82 | # If authenticator is not found, prompt 83 | try: 84 | self.locate_device() 85 | except NoFIDODeviceFoundError: 86 | self.ui.input('Please insert your security key and press enter...') 87 | self.locate_device() 88 | 89 | threads = [] 90 | for client in self._clients: 91 | t = Thread(target=self.work, args=(client,)) 92 | threads.append(t) 93 | t.start() 94 | 95 | for t in threads: 96 | t.join() 97 | 98 | if not self._cancel.is_set(): 99 | self.ui.info('Operation timed out or no valid Security Key found !') 100 | raise FIDODeviceTimeoutError 101 | 102 | return self._clientData, self._signature 103 | -------------------------------------------------------------------------------- /gimme_aws_creds/ui.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2018-present Krzysztof Nazarewski. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | You may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and* limitations under the License.* 11 | """ 12 | import builtins 13 | import getpass 14 | import os 15 | import sys 16 | 17 | 18 | class UserInterface: 19 | def __init__(self, environ=os.environ, argv=None): 20 | if argv is None: 21 | argv = sys.argv 22 | 23 | self.environ = environ.copy() 24 | self.environ_bkp = None 25 | self.argv = argv[:] 26 | self.argv_bkp = None 27 | self.args = self.argv[1:] 28 | with self: 29 | self.HOME = os.path.expanduser('~') 30 | 31 | def result(self, result): 32 | """handles output lines 33 | :type result: str 34 | """ 35 | raise NotImplementedError() 36 | 37 | def prompt(self, message): 38 | """handles input's prompt message, but does not ask for input 39 | :type message: str 40 | """ 41 | raise NotImplementedError() 42 | 43 | def message(self, message): 44 | """handles messages meant for user interactions 45 | :type message: str 46 | """ 47 | raise NotImplementedError() 48 | 49 | def read_input(self, hidden=False): 50 | """returns user input 51 | :rtype: str 52 | """ 53 | raise NotImplementedError() 54 | 55 | def notify(self, message): 56 | """handles messages meant for user notifications 57 | :type message: str 58 | """ 59 | raise NotImplementedError() 60 | 61 | def input(self, message=None, hidden=False): 62 | """handles asking for user input, calls prompt() then read_input() 63 | :type message: str 64 | :rtype: str 65 | """ 66 | self.prompt(message) 67 | return self.read_input(hidden) 68 | 69 | def info(self, message): 70 | """handles messages meant for info 71 | :type message: str 72 | """ 73 | self.notify(message) 74 | 75 | def warning(self, message): 76 | """handles messages meant for warnings 77 | :type message: str 78 | """ 79 | self.notify(message) 80 | 81 | def error(self, message): 82 | """handles messages meant for errors 83 | :type message: str 84 | """ 85 | self.notify(message) 86 | 87 | def __enter__(self): 88 | self.environ_bkp = os.environ 89 | self.argv_bkp = sys.argv 90 | 91 | os.environ = self.environ 92 | sys.argv = sys.argv[:1] + self.args 93 | return self 94 | 95 | def __exit__(self, exc_type, exc_val, exc_tb): 96 | os.environ = self.environ_bkp 97 | sys.argv = self.argv_bkp 98 | self.environ_bkp = None 99 | self.argv_bkp = None 100 | 101 | 102 | class CLIUserInterface(UserInterface): 103 | def result(self, result): 104 | builtins.print(result, file=sys.stdout) 105 | 106 | def prompt(self, message=None): 107 | if message is not None: 108 | builtins.print(message, file=sys.stderr, end='') 109 | sys.stderr.flush() 110 | 111 | def message(self, message): 112 | builtins.print(message, file=sys.stderr) 113 | 114 | def read_input(self, hidden=False): 115 | return getpass.getpass('') if hidden else builtins.input() 116 | 117 | def notify(self, message): 118 | builtins.print(message, file=sys.stderr) 119 | 120 | 121 | cli = CLIUserInterface() 122 | default = cli 123 | -------------------------------------------------------------------------------- /gimme_aws_creds/webauthn.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2018-present SYNETIS. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | You may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and* limitations under the License.* 11 | """ 12 | 13 | from __future__ import print_function, absolute_import, unicode_literals 14 | 15 | from getpass import getpass 16 | from threading import Event, Thread 17 | 18 | from ctap_keyring_device.ctap_keyring_device import CtapKeyringDevice 19 | from ctap_keyring_device.ctap_strucs import CtapOptions 20 | from fido2 import cose 21 | from fido2.client import Fido2Client, ClientError 22 | from fido2.hid import CtapHidDevice, STATUS 23 | from fido2.utils import websafe_decode 24 | from fido2.webauthn import PublicKeyCredentialCreationOptions, \ 25 | PublicKeyCredentialType, PublicKeyCredentialParameters, PublicKeyCredentialDescriptor, UserVerificationRequirement 26 | from fido2.webauthn import PublicKeyCredentialRequestOptions 27 | 28 | from gimme_aws_creds.errors import NoFIDODeviceFoundError, FIDODeviceTimeoutError 29 | 30 | 31 | class FakeAssertion(object): 32 | def __init__(self): 33 | self.signature = b'fake' 34 | self.auth_data = b'fake' 35 | 36 | 37 | class WebAuthnClient(object): 38 | def __init__(self, ui, okta_org_url, challenge, credential_id=None, timeout_ms=30_000): 39 | """ 40 | :param okta_org_url: Base URL string for Okta IDP. 41 | :param challenge: Challenge 42 | :param credential_id: FIDO credential ID 43 | """ 44 | self.ui = ui 45 | self._okta_org_url = okta_org_url 46 | self._clients = None 47 | self._has_prompted = False 48 | self._challenge = websafe_decode(challenge) 49 | self._timeout_ms = timeout_ms 50 | self._event = Event() 51 | self._assertions = None 52 | self._client_data = None 53 | self._rp = {'id': okta_org_url[8:], 'name': okta_org_url[8:]} 54 | 55 | if credential_id: 56 | self._allow_list = [ 57 | PublicKeyCredentialDescriptor(PublicKeyCredentialType.PUBLIC_KEY, websafe_decode(credential_id)) 58 | ] 59 | 60 | def locate_device(self): 61 | # Locate a device 62 | devs = list(CtapHidDevice.list_devices()) 63 | if not devs: 64 | devs = CtapKeyringDevice.list_devices() 65 | 66 | self._clients = [Fido2Client(d, self._okta_org_url) for d in devs] 67 | 68 | def on_keepalive(self, status): 69 | if status == STATUS.UPNEEDED and not self._has_prompted: 70 | self.ui.info('\nTouch your authenticator device now...\n') 71 | self._has_prompted = True 72 | 73 | def verify(self): 74 | self._run_in_thread(self._verify) 75 | return self._client_data, self._assertions[0] 76 | 77 | def _verify(self, client): 78 | try: 79 | user_verification = self._get_user_verification_requirement_from_client(client) 80 | options = PublicKeyCredentialRequestOptions(challenge=self._challenge, rp_id=self._rp['id'], 81 | allow_credentials=self._allow_list, timeout=self._timeout_ms, 82 | user_verification=user_verification) 83 | 84 | pin = self._get_pin_from_client(client) 85 | assertion_selection = client.get_assertion(options, event=self._event, 86 | on_keepalive=self.on_keepalive, 87 | pin=pin) 88 | self._assertions = assertion_selection.get_assertions() 89 | assert len(self._assertions) >= 0 90 | 91 | assertion_res = assertion_selection.get_response(0) 92 | self._client_data = assertion_res.client_data 93 | self._event.set() 94 | except ClientError as e: 95 | if e.code == ClientError.ERR.DEVICE_INELIGIBLE: 96 | self.ui.info('Security key is ineligible') # TODO extract key info 97 | return 98 | 99 | elif e.code != ClientError.ERR.TIMEOUT: 100 | raise 101 | 102 | else: 103 | return 104 | 105 | def make_credential(self, user): 106 | self._run_in_thread(self._make_credential, user) 107 | return self._client_data, self._attestation.with_string_keys() 108 | 109 | def _make_credential(self, client, user): 110 | pub_key_cred_params = [PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, cose.ES256.ALGORITHM)] 111 | options = PublicKeyCredentialCreationOptions(self._rp, user, self._challenge, pub_key_cred_params, 112 | timeout=self._timeout_ms) 113 | 114 | pin = self._get_pin_from_client(client) 115 | attestation_res = client.make_credential(options, event=self._event, 116 | on_keepalive=self.on_keepalive, 117 | pin=pin) 118 | 119 | self._attestation, self._client_data = attestation_res.attestation_object, attestation_res.client_data 120 | self._event.set() 121 | 122 | def _run_in_thread(self, method, *args, **kwargs): 123 | # If authenticator is not found, prompt 124 | try: 125 | self.locate_device() 126 | except NoFIDODeviceFoundError: 127 | self.ui.input('Please insert your security key and press enter...') 128 | self.locate_device() 129 | 130 | threads = [] 131 | for client in self._clients: 132 | t = Thread(target=method, args=(client,) + args, kwargs=kwargs) 133 | threads.append(t) 134 | t.start() 135 | 136 | for t in threads: 137 | t.join() 138 | 139 | if not self._event.is_set(): 140 | self.ui.info('Operation timed out or no valid Security Key found !') 141 | raise FIDODeviceTimeoutError 142 | 143 | @staticmethod 144 | def _get_pin_from_client(client): 145 | if not client.info.options.get(CtapOptions.CLIENT_PIN): 146 | return None 147 | 148 | # Prompt for PIN if needed 149 | pin = getpass("Please enter PIN: ") 150 | return pin 151 | 152 | @staticmethod 153 | def _get_user_verification_requirement_from_client(client): 154 | if not client.info.options.get(CtapOptions.USER_VERIFICATION): 155 | return None 156 | 157 | return UserVerificationRequirement.PREFERRED 158 | -------------------------------------------------------------------------------- /lambda/README.md: -------------------------------------------------------------------------------- 1 | # Lambda Service for gimme-aws-creds 2 | 3 | ### Summary 4 | This service interacts with the Okta User and App APIs on behalf of the gimme-aws-creds CLI client. It removes the need for an Okta API key within the gimme-aws-creds client and filters the API results to just the data necessary for requesting AWS credentials. 5 | 6 | ### Dependencies 7 | - Python 2.7 (https://www.python.org) 8 | - okta-sdk-python v0.4+ (https://github.com/okta/okta-sdk-python) 9 | 10 | ### Environmental Variables 11 | To run the lambda, you'll need to pass in two environment variables: 12 | - `OKTA_API_KEY` A read-only Okta API key that will be used for the User and Apps APIs 13 | - `OKTA_ORG_URL` The Okta domain URL to use for API calls (e.g. https://example.okta.com) 14 | 15 | ### OAuth Token Authorizer 16 | Developing the OAuth Token authorizer and deployment process for the Lambda is left up to you. For an example of how to write an Authorizer and deploy an API using the [Serverless](https://serverless.com/) framework, take a look at this [Github repo](https://github.com/pmcdowell-okta/oauth-jwt-serverless-aws-apigateway). 17 | -------------------------------------------------------------------------------- /lambda/lambda_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | 5 | from okta.framework.ApiClient import ApiClient 6 | from okta.framework.OktaError import OktaError 7 | 8 | 9 | def aws_account_info(event, context): 10 | # We need access to the entire JSON response from the Okta APIs, so we need to 11 | # use the low-level ApiClient instead of UsersClient and AppInstanceClient 12 | usersClient = ApiClient(os.environ['OKTA_ORG_URL'], 13 | os.environ['OKTA_API_KEY'], 14 | pathname='/api/v1/users') 15 | appClient = ApiClient(os.environ['OKTA_ORG_URL'], 16 | os.environ['OKTA_API_KEY'], 17 | pathname='/api/v1/apps') 18 | 19 | # Get User information 20 | username = event['requestContext']['authorizer']['principalId'] 21 | try: 22 | result = usersClient.get_path('/{0}'.format(username)) 23 | user = result.json() 24 | except OktaError as e: 25 | if e.error_code == 'E0000007': 26 | statusCode = 404 27 | else: 28 | statusCode = 500 29 | return { 30 | 'headers': { 31 | 'Content-Type': 'application/json', 32 | 'Access-Control-Allow-Origin' : '*', 33 | 'Access-Control-Allow-Credentials' : True 34 | }, 35 | "statusCode": statusCode, 36 | "body": e.error_summary 37 | } 38 | 39 | # Get a list of apps for this user and include extended info about the user 40 | params = { 41 | 'limit': 200, 42 | 'filter': 'user.id+eq+%22' + user['id'] + '%22&expand=user%2F' + user['id'] 43 | } 44 | 45 | try: 46 | # Get first page of results 47 | result = usersClient.get_path('/{0}/appLinks'.format(user['id'])) 48 | final_result = result.json() 49 | 50 | # Loop through other pages 51 | while 'next' in result.links: 52 | result = appClient.get(result.links['next']['url']) 53 | final_result = final_result + result.json() 54 | except OktaError as e: 55 | if e.error_code == 'E0000007': 56 | statusCode = 404 57 | else: 58 | statusCode = 500 59 | return { 60 | 'headers': { 61 | 'Content-Type': 'application/json', 62 | 'Access-Control-Allow-Origin' : '*', 63 | 'Access-Control-Allow-Credentials' : True 64 | }, 65 | "statusCode": statusCode, 66 | "body": e.error_summary 67 | } 68 | 69 | # Loop through the list of apps and filter it down to just the info we need 70 | appList = [] 71 | for app in final_result: 72 | # All AWS connections have the same app name 73 | if (app['appName'] == 'amazon_aws'): 74 | newAppEntry = {} 75 | newAppEntry['id'] = app['id'] 76 | newAppEntry['name'] = app['label'] 77 | newAppEntry['links'] = {} 78 | newAppEntry['links']['appLink'] = app['linkUrl'] 79 | newAppEntry['links']['appLogo'] = app['logoUrl'] 80 | appList.append(newAppEntry) 81 | 82 | response = { 83 | 'headers': { 84 | 'Content-Type': 'application/json', 85 | 'Access-Control-Allow-Origin' : '*', 86 | 'Access-Control-Allow-Credentials' : True 87 | }, 88 | "statusCode": 200, 89 | "body": json.dumps(appList) 90 | } 91 | 92 | return response 93 | 94 | def main(): 95 | event = { 96 | 'requestContext': { 97 | 'authorizer': { 98 | 'principalId' : sys.argv[1] 99 | } 100 | } 101 | } 102 | 103 | print(aws_account_info(event, {})) 104 | 105 | if __name__ == "__main__": main() 106 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3>=1.7.70,<2.0.0 2 | python-dateutil<=2.8.1 3 | beautifulsoup4>=4.6.0,<5.0.0 4 | configparser>=3.5.0,<4.0.0 5 | keyring>=21.4.0 6 | requests>=2.13.0,<3.0.0 7 | fido2>=0.9.1,<0.10.0 8 | okta>=0.0.4,<1.0.0 9 | ctap-keyring-device>=1.0.6 10 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | responses>=0.5.1,<1.0.0 3 | nose>=1.3.7 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | import gimme_aws_creds 4 | 5 | with open('requirements.txt') as f: 6 | requirements = f.read().splitlines() 7 | 8 | setup( 9 | name='gimme aws creds', 10 | version=gimme_aws_creds.version, 11 | install_requires=requirements, 12 | author='Ann Wallace', 13 | author_email='ann.wallace@nike.com', 14 | description="A CLI to get temporary AWS credentials from Okta", 15 | url='https://github.com/Nike-Inc/gimme-aws-creds', 16 | license='Apache License, v2.0', 17 | packages=find_packages(exclude=('tests', 'docs')), 18 | test_suite="tests", 19 | scripts=['bin/gimme-aws-creds', 'bin/gimme-aws-creds.cmd'], 20 | classifiers=[ 21 | 'Natural Language :: English', 22 | 'Programming Language :: Python :: 3 :: Only', 23 | 'License :: OSI Approved :: Apache Software License' 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HBOCodeLabs/gimme-aws-creds/b93d5aee184c4f2d6d3ee63474abd0e91b21507e/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """Unit tests for gimme_aws_creds.config.Config""" 2 | import argparse 3 | import unittest 4 | from unittest.mock import patch 5 | 6 | from gimme_aws_creds import ui, errors 7 | from gimme_aws_creds.config import Config 8 | from tests.user_interface_mock import MockUserInterface 9 | 10 | 11 | class TestConfig(unittest.TestCase): 12 | """Class to test Config Class. 13 | Mock is used to mock external calls""" 14 | 15 | def setUp(self): 16 | """Set up for the unit tests""" 17 | self.config = Config(gac_ui=ui.cli, create_config=False) 18 | 19 | def tearDown(self): 20 | """Run Clean Up""" 21 | self.config.clean_up() 22 | 23 | @patch( 24 | "argparse.ArgumentParser.parse_args", 25 | return_value=argparse.Namespace( 26 | username="ann", 27 | profile=None, 28 | insecure=False, 29 | resolve=None, 30 | mfa_code=None, 31 | remember_device=False, 32 | output_format=None, 33 | roles=None, 34 | action_register_device=False, 35 | action_configure=False, 36 | action_list_profiles=False, 37 | action_list_roles=False, 38 | action_store_json_creds=False, 39 | action_setup_fido_authenticator=False, 40 | ), 41 | ) 42 | def test_get_args_username(self, mock_arg): 43 | """Test to make sure username gets returned""" 44 | self.config.get_args() 45 | self.assertEqual(self.config.username, "ann") 46 | 47 | def test_read_config(self): 48 | """Test to make sure getting config works""" 49 | test_ui = MockUserInterface(argv=[ 50 | "--profile", 51 | "myprofile", 52 | ]) 53 | with open(test_ui.HOME + "/.okta_aws_login_config", "w") as config_file: 54 | config_file.write(""" 55 | [myprofile] 56 | client_id = foo 57 | """) 58 | config = Config(gac_ui=test_ui, create_config=False) 59 | config.conf_profile = "myprofile" 60 | profile_config = config.get_config_dict() 61 | self.assertEqual(profile_config, {"client_id": "foo"}) 62 | 63 | def test_read_config_inherited(self): 64 | """Test to make sure getting config works when inherited""" 65 | test_ui = MockUserInterface(argv=[ 66 | "--profile", 67 | "myprofile", 68 | ]) 69 | with open(test_ui.HOME + "/.okta_aws_login_config", "w") as config_file: 70 | config_file.write( 71 | """ 72 | [mybase] 73 | client_id = bar 74 | aws_appname = baz 75 | [myprofile] 76 | inherits = mybase 77 | client_id = foo 78 | aws_rolename = myrole 79 | """ 80 | ) 81 | 82 | config = Config(gac_ui=test_ui, create_config=False) 83 | config.conf_profile = "myprofile" 84 | profile_config = config.get_config_dict() 85 | self.assertEqual(profile_config, { 86 | "client_id": "foo", 87 | "aws_appname": "baz", 88 | "aws_rolename": "myrole", 89 | }) 90 | 91 | def test_read_nested_config_inherited(self): 92 | """Test to make sure getting config works when inherited""" 93 | test_ui = MockUserInterface(argv = [ 94 | "--profile", 95 | "myprofile", 96 | ]) 97 | with open(test_ui.HOME + "/.okta_aws_login_config", "w") as config_file: 98 | config_file.write(""" 99 | [mybase-level1] 100 | client_id = bar 101 | [mybase-level2] 102 | inherits = mybase-level1 103 | aws_appname = baz 104 | [myprofile] 105 | inherits = mybase-level2 106 | client_id = foo 107 | aws_rolename = myrole 108 | """) 109 | config = Config(gac_ui=test_ui, create_config=False) 110 | config.conf_profile = "myprofile" 111 | profile_config = config.get_config_dict() 112 | self.assertEqual(profile_config, { 113 | "client_id": "foo", 114 | "aws_appname": "baz", 115 | "aws_rolename": "myrole", 116 | }) 117 | 118 | def test_fail_if_profile_not_found(self): 119 | """Test to make sure missing Default fails properly""" 120 | test_ui = MockUserInterface(argv=[]) 121 | with open(test_ui.HOME + "/.okta_aws_login_config", "w") as config_file: 122 | config_file.write(""" 123 | [myprofile] 124 | client_id = foo 125 | """) 126 | config = Config(gac_ui=test_ui, create_config=False) 127 | config.conf_profile = "DEFAULT" 128 | with self.assertRaises(errors.GimmeAWSCredsError) as context: 129 | config.get_config_dict() 130 | self.assertTrue('DEFAULT profile is missing! This is profile is required when not using --profile' == context.exception.message) 131 | 132 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | from gimme_aws_creds import errors 5 | from gimme_aws_creds.common import RoleSet 6 | from gimme_aws_creds.main import GimmeAWSCreds 7 | 8 | 9 | class TestMain(unittest.TestCase): 10 | APP_INFO = [ 11 | RoleSet(idp='idp', role='test1', friendly_account_name='', friendly_role_name=''), 12 | RoleSet(idp='idp', role='test2', friendly_account_name='', friendly_role_name='') 13 | ] 14 | 15 | AWS_INFO = [ 16 | {'name': 'test1'}, 17 | {'name': 'test2'} 18 | ] 19 | 20 | @patch('builtins.input', return_value='-1') 21 | def test_choose_roles_app_neg1(self, mock): 22 | creds = GimmeAWSCreds() 23 | self.assertRaises(errors.GimmeAWSCredsExitBase, creds._choose_roles, self.APP_INFO) 24 | self.assertRaises(errors.GimmeAWSCredsExitBase, creds._choose_app, self.AWS_INFO) 25 | 26 | @patch('builtins.input', return_value='0') 27 | def test_choose_roles_app_0(self, mock): 28 | creds = GimmeAWSCreds() 29 | selections = creds._choose_roles(self.APP_INFO) 30 | self.assertEqual(selections, {self.APP_INFO[0].role}) 31 | 32 | selections = creds._choose_roles(self.APP_INFO) 33 | self.assertEqual(selections, {self.APP_INFO[0].role}) 34 | 35 | @patch('builtins.input', return_value='1') 36 | def test_choose_roles_app_1(self, mock): 37 | creds = GimmeAWSCreds() 38 | selections = creds._choose_roles(self.APP_INFO) 39 | self.assertEqual(selections, {self.APP_INFO[1].role}) 40 | 41 | selections = creds._choose_roles(self.APP_INFO) 42 | self.assertEqual(selections, {self.APP_INFO[1].role}) 43 | 44 | @patch('builtins.input', return_value='2') 45 | def test_choose_roles_app_2(self, mock): 46 | creds = GimmeAWSCreds() 47 | self.assertRaises(errors.GimmeAWSCredsExitBase, creds._choose_roles, self.APP_INFO) 48 | self.assertRaises(errors.GimmeAWSCredsExitBase, creds._choose_app, self.AWS_INFO) 49 | 50 | @patch('builtins.input', return_value='a') 51 | def test_choose_roles_app_a(self, mock): 52 | creds = GimmeAWSCreds() 53 | self.assertRaises(errors.GimmeAWSCredsExitBase, creds._choose_roles, self.APP_INFO) 54 | self.assertRaises(errors.GimmeAWSCredsExitBase, creds._choose_app, self.AWS_INFO) 55 | 56 | def test_get_selected_app_from_config_0(self): 57 | creds = GimmeAWSCreds() 58 | 59 | selection = creds._get_selected_app('test1', self.AWS_INFO) 60 | self.assertEqual(selection, self.AWS_INFO[0]) 61 | 62 | def test_get_selected_app_from_config_1(self): 63 | creds = GimmeAWSCreds() 64 | 65 | selection = creds._get_selected_app('test2', self.AWS_INFO) 66 | self.assertEqual(selection, self.AWS_INFO[1]) 67 | 68 | @patch('builtins.input', return_value='0') 69 | def test_missing_app_from_config(self, mock): 70 | creds = GimmeAWSCreds() 71 | 72 | selection = creds._get_selected_app('test3', self.AWS_INFO) 73 | self.assertEqual(selection, self.AWS_INFO[0]) 74 | 75 | def test_get_selected_roles_from_config_0(self): 76 | creds = GimmeAWSCreds() 77 | 78 | selections = creds._get_selected_roles('test1', self.APP_INFO) 79 | self.assertEqual(selections, {'test1'}) 80 | 81 | def test_get_selected_roles_from_config_1(self): 82 | creds = GimmeAWSCreds() 83 | 84 | selections = creds._get_selected_roles('test2', self.APP_INFO) 85 | self.assertEqual(selections, {'test2'}) 86 | 87 | def test_get_selected_roles_multiple(self): 88 | creds = GimmeAWSCreds() 89 | 90 | selections = creds._get_selected_roles('test1, test2', self.APP_INFO) 91 | self.assertEqual(selections, {'test1', 'test2'}) 92 | 93 | def test_get_selected_roles_multiple_list(self): 94 | creds = GimmeAWSCreds() 95 | 96 | selections = creds._get_selected_roles(['test1', 'test2'], self.APP_INFO) 97 | self.assertEqual(selections, {'test1', 'test2'}) 98 | 99 | def test_get_selected_roles_all(self): 100 | creds = GimmeAWSCreds() 101 | 102 | selections = creds._get_selected_roles('all', self.APP_INFO) 103 | self.assertEqual(selections, {'test1', 'test2'}) 104 | 105 | @patch('builtins.input', return_value='0') 106 | def test_missing_role_from_config(self, mock): 107 | creds = GimmeAWSCreds() 108 | 109 | selections = creds._get_selected_roles('test3', self.APP_INFO) 110 | self.assertEqual(selections, {'test1'}) 111 | 112 | def test_get_partition_aws(self): 113 | creds = GimmeAWSCreds() 114 | 115 | partition = creds._get_partition_from_saml_acs('https://signin.aws.amazon.com/saml') 116 | self.assertEqual(partition, 'aws') 117 | 118 | def test_get_partition_china(self): 119 | creds = GimmeAWSCreds() 120 | 121 | partition = creds._get_partition_from_saml_acs('https://signin.amazonaws.cn/saml') 122 | self.assertEqual(partition, 'aws-cn') 123 | 124 | def test_get_partition_govcloud(self): 125 | creds = GimmeAWSCreds() 126 | 127 | partition = creds._get_partition_from_saml_acs('https://signin.amazonaws-us-gov.com/saml') 128 | self.assertEqual(partition, 'aws-us-gov') 129 | 130 | def test_get_partition_unkown(self): 131 | creds = GimmeAWSCreds() 132 | 133 | self.assertRaises(errors.GimmeAWSCredsExitBase, creds._get_partition_from_saml_acs, 134 | 'https://signin.amazonaws-foo.com/saml') 135 | 136 | def test_parse_role_arn_base_path(self): 137 | creds = GimmeAWSCreds() 138 | arn = "arn:aws:iam::123456789012:role/okta-1234-role" 139 | self.assertEqual(creds._parse_role_arn(arn), 140 | { 141 | 'account': '123456789012', 142 | 'path': '/', 143 | 'role': 'okta-1234-role' 144 | }) 145 | 146 | def test_parse_role_arn_extended_path(self): 147 | creds = GimmeAWSCreds() 148 | arn = "arn:aws:iam::123456789012:role/a/really/extended/path/okta-1234-role" 149 | self.assertEqual(creds._parse_role_arn(arn), 150 | { 151 | 'account': '123456789012', 152 | 'path': '/a/really/extended/path/', 153 | 'role': 'okta-1234-role' 154 | }) 155 | 156 | def test_get_alias_from_friendly_name_no_alias(self): 157 | creds = GimmeAWSCreds() 158 | friendly_name = "Account: 123456789012" 159 | self.assertEqual(creds._get_alias_from_friendly_name(friendly_name), None) 160 | 161 | def test_get_alias_from_friendly_name_with_alias(self): 162 | creds = GimmeAWSCreds() 163 | friendly_name = "Account: my-account-org (123456789012)" 164 | self.assertEqual(creds._get_alias_from_friendly_name(friendly_name), "my-account-org") 165 | 166 | 167 | def test_get_profile_name_accrole_resolve_alias_do_not_include_paths(self): 168 | "Testing the acc-role, with alias resolution, and not including full role path" 169 | creds = GimmeAWSCreds() 170 | naming_data = {'account': '123456789012', 'role': 'administrator', 'path': '/administrator/'} 171 | role = RoleSet(idp='arn:aws:iam::123456789012:saml-provider/my-okta-provider', 172 | role='arn:aws:iam::123456789012:role/administrator/administrator', 173 | friendly_account_name='Account: my-org-master (123456789012)', 174 | friendly_role_name='administrator/administrator') 175 | cred_profile = 'acc-role' 176 | resolve_alias = 'True' 177 | include_path = 'False' 178 | self.assertEqual(creds.get_profile_name(cred_profile, include_path, naming_data, resolve_alias, role), "my-org-master-administrator") 179 | 180 | def test_get_profile_accrole_name_do_not_resolve_alias_do_not_include_paths(self): 181 | "Testing the acc-role, without alias resolution, and not including full role path" 182 | creds = GimmeAWSCreds() 183 | naming_data = {'account': '123456789012', 'role': 'administrator', 'path': '/administrator/'} 184 | role = RoleSet(idp='arn:aws:iam::123456789012:saml-provider/my-okta-provider', 185 | role='arn:aws:iam::123456789012:role/administrator/administrator', 186 | friendly_account_name='Account: my-org-master (123456789012)', 187 | friendly_role_name='administrator/administrator') 188 | cred_profile = 'acc-role' 189 | resolve_alias = 'False' 190 | include_path = 'False' 191 | self.assertEqual(creds.get_profile_name(cred_profile, include_path, naming_data, resolve_alias, role), 192 | "123456789012-administrator") 193 | 194 | def test_get_profile_accrole_name_do_not_resolve_alias_include_paths(self): 195 | "Testing the acc-role, without alias resolution, and including full role path" 196 | creds = GimmeAWSCreds() 197 | naming_data = {'account': '123456789012', 'role': 'administrator', 'path': '/some/long/extended/path/'} 198 | role = RoleSet(idp='arn:aws:iam::123456789012:saml-provider/my-okta-provider', 199 | role='arn:aws:iam::123456789012:role/some/long/extended/path/administrator', 200 | friendly_account_name='Account: my-org-master (123456789012)', 201 | friendly_role_name='administrator/administrator') 202 | cred_profile = 'acc-role' 203 | resolve_alias = 'False' 204 | include_path = 'True' 205 | self.assertEqual(creds.get_profile_name(cred_profile, include_path, naming_data, resolve_alias, role), 206 | "123456789012-/some/long/extended/path/administrator") 207 | def test_get_profile_name_role(self): 208 | "Testing the role" 209 | creds = GimmeAWSCreds() 210 | naming_data = {'account': '123456789012', 'role': 'administrator', 'path': '/some/long/extended/path/'} 211 | role = RoleSet(idp='arn:aws:iam::123456789012:saml-provider/my-okta-provider', 212 | role='arn:aws:iam::123456789012:role/some/long/extended/path/administrator', 213 | friendly_account_name='Account: my-org-master (123456789012)', 214 | friendly_role_name='administrator/administrator') 215 | cred_profile = 'role' 216 | resolve_alias = 'False' 217 | include_path = 'True' 218 | self.assertEqual(creds.get_profile_name(cred_profile, include_path, naming_data, resolve_alias, role), 219 | 'administrator') 220 | 221 | def test_get_profile_name_default(self): 222 | "Testing the default" 223 | creds = GimmeAWSCreds() 224 | naming_data = {'account': '123456789012', 'role': 'administrator', 'path': '/some/long/extended/path/'} 225 | role = RoleSet(idp='arn:aws:iam::123456789012:saml-provider/my-okta-provider', 226 | role='arn:aws:iam::123456789012:role/some/long/extended/path/administrator', 227 | friendly_account_name='Account: my-org-master (123456789012)', 228 | friendly_role_name='administrator/administrator') 229 | cred_profile = 'default' 230 | resolve_alias = 'False' 231 | include_path = 'True' 232 | self.assertEqual(creds.get_profile_name(cred_profile, include_path, naming_data, resolve_alias, role), 233 | 'default') 234 | 235 | def test_get_profile_name_else(self): 236 | "testing else statement in get_profile_name" 237 | creds = GimmeAWSCreds() 238 | naming_data = {'account': '123456789012', 'role': 'administrator', 'path': '/some/long/extended/path/'} 239 | role = RoleSet(idp='arn:aws:iam::123456789012:saml-provider/my-okta-provider', 240 | role='arn:aws:iam::123456789012:role/some/long/extended/path/administrator', 241 | friendly_account_name='Account: my-org-master (123456789012)', 242 | friendly_role_name='administrator/administrator') 243 | cred_profile = 'foo' 244 | resolve_alias = 'False' 245 | include_path = 'True' 246 | self.assertEqual(creds.get_profile_name(cred_profile, include_path, naming_data, resolve_alias, role), 247 | 'foo') 248 | -------------------------------------------------------------------------------- /tests/test_registered_authenticators.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import unittest 4 | 5 | from gimme_aws_creds.registered_authenticators import RegisteredAuthenticators, RegisteredAuthenticator 6 | from tests.user_interface_mock import MockUserInterface 7 | 8 | 9 | class TestConfig(unittest.TestCase): 10 | """Class to test RegisteredAuthenticators Class.""" 11 | 12 | def setUp(self): 13 | """Set up for the unit tests""" 14 | ui_obj = MockUserInterface() 15 | self.registered_authenticators = RegisteredAuthenticators(ui_obj) 16 | self.file_path = self.registered_authenticators._json_path 17 | 18 | def test_file_creation_post_init(self): 19 | assert os.path.exists(self.file_path) 20 | 21 | def test_add_authenticator_sanity(self): 22 | cred_id, user = b'my-credential-id', 'my-user' 23 | self.registered_authenticators.add_authenticator(cred_id, user) 24 | 25 | with open(self.file_path) as f: 26 | data = json.load(f) 27 | 28 | assert len(data) == 1 29 | assert type(data) == list 30 | assert type(data[0]) == dict 31 | 32 | authenticator = RegisteredAuthenticator(**data[0]) 33 | assert authenticator.user == user 34 | 35 | def test_get_authenticator_user_sanity(self): 36 | cred_id, user = b'my-credential-id', 'my-user' 37 | self.registered_authenticators.add_authenticator(cred_id, user) 38 | 39 | authenticator_user = self.registered_authenticators.get_authenticator_user(cred_id) 40 | assert authenticator_user == user 41 | -------------------------------------------------------------------------------- /tests/user_interface_mock.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | 3 | from gimme_aws_creds import ui 4 | 5 | 6 | class MockUserInterface(ui.UserInterface): 7 | def result(self, result): 8 | pass 9 | 10 | def prompt(self, message): 11 | pass 12 | 13 | def message(self, message): 14 | pass 15 | 16 | def read_input(self, hidden=False): 17 | pass 18 | 19 | def notify(self, message): 20 | pass 21 | 22 | def __init__(self, environ=None, argv=None): 23 | super().__init__(environ=environ or {}, argv=argv or []) 24 | self.HOME = tempfile.mkdtemp() 25 | --------------------------------------------------------------------------------