├── tests ├── __init__.py └── unit │ ├── __init__.py │ └── kmsauth │ ├── __init__.py │ ├── lru_test.py │ └── kmsauth_test.py ├── kmsauth ├── utils │ ├── __init__.py │ └── lru.py ├── services.py └── __init__.py ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── Makefile ├── CHANGELOG.md ├── setup.cfg ├── .github └── workflows │ ├── push.yml │ └── pull_request.yml ├── requirements.txt ├── setup.py ├── .gitignore ├── README.md └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /kmsauth/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/kmsauth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: '^docs/.*$' 2 | repos: 3 | - repo: https://github.com/pycqa/flake8 4 | rev: 3.7.9 5 | hooks: 6 | - id: flake8 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | This project is governed by [Lyft's code of conduct](https://github.com/lyft/code-of-conduct). All contributors and participants agree to abide by its terms. 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # bash needed for pipefail 2 | SHELL := /bin/bash 3 | 4 | test: test_unit 5 | 6 | test_unit: 7 | mkdir -p build 8 | coverage run -m pytest tests/unit 9 | coverage xml 10 | coverage report 11 | -------------------------------------------------------------------------------- /tests/unit/kmsauth/lru_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from kmsauth.utils import lru 4 | 5 | 6 | class LruTest(unittest.TestCase): 7 | def test_lru(self): 8 | cache = lru.LRUCache(1) 9 | cache['test'] = 'data we set' 10 | self.assertEquals(cache['test'], 'data we set') 11 | cache['test2'] = 'data we set' 12 | self.assertEquals(cache['test2'], 'data we set') 13 | self.assertTrue('test' not in cache) 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.6.0 2 | 3 | * kmsauth will use lru-dict library for its token cache, rather than a slower pure-python implementation, if lru-dict is available. 4 | 5 | ## 0.5.0 6 | 7 | * KMSTokenValidator now accepts a ``stats`` argument, which allows you to pass in an instance of a statsd client, so that the validator can track stats. 8 | 9 | ## 0.4.0 10 | 11 | * KMSTokenValidator now accepts a ``token_cache_size`` argument, to set the size of the in-memory LRU token cache. 12 | 13 | ## 0.3.0 14 | 15 | * python3 compat 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Project specific configuration used by the following tools: 2 | # - pytest 3 | # - flake8 4 | 5 | 6 | [coverage:report] 7 | fail_under = 70 8 | 9 | [coverage:xml] 10 | output = build/coverage.xml 11 | 12 | [flake8] 13 | # The jenkins violations plugin can read the pylint format. 14 | format = pylint 15 | max-line-length = 80 16 | 17 | # .svn,CVS,.bzr,.hg,.git,__pycache__: 18 | # default excludes 19 | # venv/: 20 | # third party libraries are all stored in venv - so we don't want to 21 | # check them for style issues. 22 | exclude = .git,__pycache__,venv,tests/,.ropeproject 23 | 24 | [pep8] 25 | max-line-length = 80 26 | -------------------------------------------------------------------------------- /kmsauth/utils/lru.py: -------------------------------------------------------------------------------- 1 | "LRU cache" 2 | import collections 3 | 4 | 5 | class LRUCache(object): 6 | """ 7 | Cheap LRU cache implementation. 8 | """ 9 | 10 | def __init__(self, capacity): 11 | self.capacity = capacity 12 | self.cache = collections.OrderedDict() 13 | 14 | def __contains__(self, key): 15 | return key in self.cache 16 | 17 | def __getitem__(self, key): 18 | value = self.cache.pop(key) 19 | self.cache[key] = value 20 | return value 21 | 22 | def __setitem__(self, key, value): 23 | try: 24 | self.cache.pop(key) 25 | except KeyError: 26 | if len(self.cache) >= self.capacity: 27 | self.cache.popitem(last=False) 28 | self.cache[key] = value 29 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - master 6 | tags: 7 | - '*' 8 | jobs: 9 | build-and-publish-python-module: 10 | name: Build and publish python module to pypi 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v1 15 | - name: Setup python 3.8 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.8 19 | - name: Add wheel dependency 20 | run: pip install wheel 21 | - name: Generate dist 22 | run: python setup.py sdist bdist_wheel 23 | - name: Publish to PyPI 24 | if: startsWith(github.event.ref, 'refs/tags') 25 | uses: pypa/gh-action-pypi-publish@master 26 | with: 27 | user: __token__ 28 | password: ${{ secrets.pypi_password }} 29 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Boto3 is the Amazon Web Services (AWS) Software Development Kit (SDK) 2 | # for Python. 3 | # License: Apache2 4 | # Use: For KMS 5 | boto3==1.11.9 6 | 7 | # The modular source code checker: pep8, pyflakes and co 8 | # License: MIT 9 | # Upstream url: http://bitbucket.org/tarek/flake8 10 | flake8==2.3.0 11 | 12 | # Measures code coverage and emits coverage reports 13 | # Licence: BSD 14 | # Upstream url: https://pypi.python.org/pypi/coverage 15 | coverage==4.5.4 16 | 17 | # tool to check your Python code against some of the style conventions 18 | # License: Expat License 19 | # Upstream url: https://github.com/jcrocholl/pep8.git 20 | pep8==1.5.7 21 | 22 | # nose makes testing easier 23 | # License: GNU Library or Lesser General Public License (LGPL) 24 | # Upstream url: http://readthedocs.org/docs/nose 25 | # nose==1.3.7 26 | 27 | # Testing library 28 | # License: MIT 29 | # Upstream url: https://github.com/pytest-dev/pytest 30 | pytest==6.2.5 31 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | on: pull_request 2 | jobs: 3 | pre-commit: 4 | runs-on: ubuntu-22.04 5 | steps: 6 | - name: Checkout 7 | uses: actions/checkout@v1 8 | - name: Setup python 3.8 9 | uses: actions/setup-python@v1 10 | with: 11 | python-version: 3.8 12 | - name: Install pre-commit 13 | run: pip install pre-commit 14 | - name: Run pre-commit 15 | run: pre-commit run --all-files 16 | test: 17 | runs-on: ubuntu-22.04 18 | strategy: 19 | matrix: 20 | python-version: ['3.7.x', '3.8.x', '3.9.x', '3.10.x', '3.11.x'] 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v1 24 | - name: Setup python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v1 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: pip install -r requirements.txt 30 | - name: Run tests 31 | run: make test 32 | -------------------------------------------------------------------------------- /setup.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 10 | # implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from setuptools import setup, find_packages 15 | 16 | VERSION = "0.6.3" 17 | 18 | requirements = [ 19 | # Boto3 is the Amazon Web Services (AWS) Software Development Kit (SDK) 20 | # for Python. 21 | # License: Apache2 22 | # Upstream url: https://github.com/boto/boto3 23 | # Use: For KMS 24 | 'boto3>=1.2.0,<2.0.0' 25 | ] 26 | 27 | setup( 28 | name="kmsauth", 29 | version=VERSION, 30 | install_requires=requirements, 31 | packages=find_packages(exclude=["test*"]), 32 | author="Ryan Lane", 33 | author_email="rlane@lyft.com", 34 | description=("A python library for reusing KMS for your own authentication" 35 | " and authorization."), 36 | license="apache2", 37 | url="https://github.com/lyft/python-kmsauth" 38 | ) 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | .mypy_cache 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | 92 | # vim swap files 93 | *.swp 94 | -------------------------------------------------------------------------------- /kmsauth/services.py: -------------------------------------------------------------------------------- 1 | """Module for accessing boto3 clients, resources and sessions.""" 2 | 3 | import boto3 4 | import botocore 5 | import logging 6 | 7 | CLIENT_CACHE = {} 8 | RESOURCE_CACHE = {} 9 | 10 | 11 | def get_boto_client( 12 | client, 13 | region=None, 14 | aws_access_key_id=None, 15 | aws_secret_access_key=None, 16 | aws_session_token=None, 17 | endpoint_url=None, 18 | max_pool_connections=None, 19 | connect_timeout=None, 20 | read_timeout=None, 21 | ): 22 | """Get a boto3 client connection.""" 23 | cache_key = '{0}:{1}:{2}:{3}'.format( 24 | client, 25 | region, 26 | aws_access_key_id, 27 | endpoint_url or '' 28 | ) 29 | if not aws_session_token: 30 | if cache_key in CLIENT_CACHE: 31 | return CLIENT_CACHE[cache_key] 32 | session = get_boto_session( 33 | region, 34 | aws_access_key_id, 35 | aws_secret_access_key, 36 | aws_session_token 37 | ) 38 | if not session: 39 | logging.error("Failed to get {0} client.".format(client)) 40 | return None 41 | 42 | # do not explicitly set any params as None 43 | config_params = dict( 44 | max_pool_connections=max_pool_connections, 45 | connect_timeout=connect_timeout, 46 | read_timeout=read_timeout, 47 | ) 48 | config_params = {k: v for (k, v) in config_params.items() if v is not None} 49 | CLIENT_CACHE[cache_key] = session.client( 50 | client, 51 | endpoint_url=endpoint_url, 52 | config=botocore.config.Config(**config_params) 53 | ) 54 | return CLIENT_CACHE[cache_key] 55 | 56 | 57 | def get_boto_resource( 58 | resource, 59 | region=None, 60 | aws_access_key_id=None, 61 | aws_secret_access_key=None, 62 | aws_session_token=None, 63 | endpoint_url=None 64 | ): 65 | """Get a boto resource connection.""" 66 | cache_key = '{0}:{1}:{2}:{3}'.format( 67 | resource, 68 | region, 69 | aws_access_key_id, 70 | endpoint_url or '' 71 | ) 72 | if not aws_session_token: 73 | if cache_key in RESOURCE_CACHE: 74 | return RESOURCE_CACHE[cache_key] 75 | session = get_boto_session( 76 | region, 77 | aws_access_key_id, 78 | aws_secret_access_key, 79 | aws_session_token 80 | ) 81 | if not session: 82 | logging.error("Failed to get {0} resource.".format(resource)) 83 | return None 84 | 85 | RESOURCE_CACHE[cache_key] = session.resource( 86 | resource, 87 | endpoint_url=endpoint_url 88 | ) 89 | return RESOURCE_CACHE[cache_key] 90 | 91 | 92 | def get_boto_session( 93 | region, 94 | aws_access_key_id=None, 95 | aws_secret_access_key=None, 96 | aws_session_token=None 97 | ): 98 | """Get a boto3 session.""" 99 | return boto3.session.Session( 100 | region_name=region, 101 | aws_secret_access_key=aws_secret_access_key, 102 | aws_access_key_id=aws_access_key_id, 103 | aws_session_token=aws_session_token 104 | ) 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-kmsauth 2 | 3 | A python library for KMS authentication and authorization 4 | 5 | ## Usage 6 | 7 | kmsauth can generate authentication tokens and validate authentication tokens. 8 | kmsauth current supports tokens in v1 or v2 format. By default, when generating 9 | tokens, it will generate tokens in v2 format. The difference between the 10 | formats is the encryption context and the username format. 11 | 12 | Decrypting tokens requires the username and the token, so when passing this to 13 | a service, you should pass both along. 14 | 15 | ### Token formats 16 | 17 | v1: 18 | * username: 'my-service-name' 19 | * encryption context: {"to":"their-service-name","from":"my-service-name"} 20 | 21 | v2: 22 | * username: '2/service/my-service-name' 23 | * encryption context: {"to":"their-service-name","from":"my-service-name","user\_type":"service"} 24 | 25 | ### Generating tokens 26 | 27 | ```python 28 | import kmsauth 29 | # user to service authentication 30 | generator = kmsauth.KMSTokenGenerator( 31 | # KMS key to use for authentication 32 | 'alias/authnz-production', 33 | # Encryption context to use 34 | { 35 | # We're authenticating to this service 36 | 'to':'confidant-production', 37 | # It's from this user 38 | 'from':'rlane', 39 | # This token is for a user 40 | 'user_type': 'user' 41 | }, 42 | # Find the KMS key in this region 43 | 'us-east-1' 44 | ) 45 | username = generator.get_username() 46 | token = generator.get_token() 47 | 48 | # service to service authentication 49 | generator = kmsauth.KMSTokenGenerator( 50 | # KMS key to use for authentication 51 | 'alias/authnz-production', 52 | # Encryption context to use 53 | { 54 | # We're authenticating to this service 55 | 'to':'confidant-production', 56 | # It's from this service 57 | 'from':'example-production', 58 | # This token is for a service 59 | 'user_type': 'service' 60 | }, 61 | # Find the KMS key in this region 62 | 'us-east-1' 63 | ) 64 | username = generator.get_username() 65 | token = generator.get_token() 66 | ``` 67 | 68 | ### Validating tokens 69 | 70 | ```python 71 | import kmsauth 72 | validator = kmsauth.KMSTokenValidator( 73 | # KMS keys to use for service authentication 74 | ['alias/authnz-production'], 75 | # KMS keys to use for user authentication 76 | ['alias/authnz-users-production', '6655d2a8-0606-4727-a1f6-f5b6a6754377'], 77 | # The context of this validation (the "to" context to validate against) 78 | 'confidant-production', 79 | # Find the KMS keys in this region 80 | 'us-east-1' 81 | ) 82 | validator.decrypt_token(username, token) 83 | ``` 84 | 85 | If you're extending the common KMS auth token context, you can pass extra 86 | context into the validator: 87 | 88 | ```python 89 | import kmsauth 90 | validator = kmsauth.KMSTokenValidator( 91 | # KMS keys to use for service authentication 92 | ['alias/authnz-production'], 93 | # KMS keys to use for user authentication 94 | ['alias/authnz-users-production', '6655d2a8-0606-4727-a1f6-f5b6a6754377'], 95 | # The context of this validation (the "to" context to validate against) 96 | 'confidant-production', 97 | # Find the KMS keys in this region 98 | 'us-east-1', 99 | extra_context={'action': 'create_resource'} 100 | ) 101 | validator.decrypt_token(username, token) 102 | ``` 103 | 104 | Note: 'to', 'from', and 'user_type' keys are not allowed to be set in 105 | extra_context. 106 | 107 | ## Performance Tuning 108 | 109 | With the [boto defaults](https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html), the AWS KMS client used in `KMSTokenValidator` may not be performant under higher loads, due to latency when communicating with AWS KMS. Try tuning these parameters below with the given starting points. 110 | 111 | ```python 112 | ... 113 | max_pool_connections=100, 114 | connect_timeout=1, 115 | read_timeout=1, 116 | ... 117 | ``` 118 | 119 | ## Reporting security vulnerabilities 120 | 121 | If you've found a vulnerability or a potential vulnerability in kmsauth 122 | please let us know at security@lyft.com. We'll send a confirmation email to 123 | acknowledge your report, and we'll send an additional email when we've 124 | identified the issue positively or negatively. 125 | 126 | ## Getting support or asking questions 127 | 128 | kmsauth is a component of Confidant, so discussion for it is through the same 129 | channels as Confidant. We have a mailing list for discussion, and a low volume 130 | list for announcements: 131 | 132 | * https://groups.google.com/forum/#!forum/confidant-users 133 | * https://groups.google.com/forum/#!forum/confidant-announce 134 | 135 | We also have an IRC channel on freenode and a Gitter channel: 136 | 137 | * [#confidant](http://webchat.freenode.net/?channels=confidant) 138 | * [lyft/confidant on Gitter](https://gitter.im/lyft/confidant) 139 | 140 | Feel free to drop into either Gitter or the IRC channel for any reason, even 141 | if just to chat. It doesn't matter which one you join, the messages are sync'd 142 | between the two. 143 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/unit/kmsauth/kmsauth_test.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import datetime 3 | import json 4 | 5 | import unittest 6 | from unittest.mock import patch 7 | from unittest.mock import MagicMock 8 | 9 | import kmsauth 10 | from kmsauth.utils import lru 11 | 12 | 13 | class KMSTokenValidatorTest(unittest.TestCase): 14 | @patch( 15 | 'kmsauth.services.get_boto_client', 16 | MagicMock() 17 | ) 18 | def test_validate_config(self): 19 | with self.assertRaises(kmsauth.ConfigurationError): 20 | kmsauth.KMSTokenValidator( 21 | ['alias/authnz-unittest'], 22 | None, 23 | 'kmsauth-unittest', 24 | 'us-east-1', 25 | # 0 is an invalid token version 26 | minimum_token_version=0 27 | ) 28 | with self.assertRaises(kmsauth.ConfigurationError): 29 | kmsauth.KMSTokenValidator( 30 | ['alias/authnz-unittest'], 31 | None, 32 | 'kmsauth-unittest', 33 | 'us-east-1', 34 | # 3 is an invalid token version 35 | minimum_token_version=3 36 | ) 37 | with self.assertRaises(kmsauth.ConfigurationError): 38 | kmsauth.KMSTokenValidator( 39 | ['alias/authnz-unittest'], 40 | None, 41 | 'kmsauth-unittest', 42 | 'us-east-1', 43 | # 0 is an invalid token version 44 | maximum_token_version=0 45 | ) 46 | with self.assertRaises(kmsauth.ConfigurationError): 47 | kmsauth.KMSTokenValidator( 48 | ['alias/authnz-unittest'], 49 | None, 50 | 'kmsauth-unittest', 51 | 'us-east-1', 52 | # 3 is an invalid token version 53 | maximum_token_version=3 54 | ) 55 | with self.assertRaises(kmsauth.ConfigurationError): 56 | kmsauth.KMSTokenValidator( 57 | ['alias/authnz-unittest'], 58 | None, 59 | 'kmsauth-unittest', 60 | 'us-east-1', 61 | # minimum can't be greater than maximum 62 | minimum_token_version=2, 63 | maximum_token_version=1 64 | ) 65 | with self.assertRaises(kmsauth.ConfigurationError): 66 | kmsauth.KMSTokenValidator( 67 | # kms key must be string, list, or None 68 | 1234, 69 | None, 70 | 'kmsauth-unittest', 71 | 'us-east-1', 72 | ) 73 | assert(kmsauth.KMSTokenValidator( 74 | 'alias/authnz-unittest', 75 | None, 76 | 'kmsauth-unittest', 77 | 'us-east-1' 78 | )) 79 | assert(kmsauth.KMSTokenValidator( 80 | ['alias/authnz-unittest'], 81 | None, 82 | 'kmsauth-unittest', 83 | 'us-east-1' 84 | )) 85 | 86 | def test__get_key_arn(self): 87 | validator = kmsauth.KMSTokenValidator( 88 | 'alias/authnz-unittest', 89 | None, 90 | 'kmsauth-unittest', 91 | 'us-east-1' 92 | ) 93 | validator.kms_client = MagicMock() 94 | validator.KEY_METADATA = {} 95 | validator.kms_client.describe_key.return_value = { 96 | 'KeyMetadata': {'Arn': 'mocked:arn'} 97 | } 98 | self.assertEqual( 99 | validator._get_key_arn('mockalias'), 100 | 'mocked:arn' 101 | ) 102 | 103 | def test__get_key_arn_cached(self): 104 | validator = kmsauth.KMSTokenValidator( 105 | 'alias/authnz-unittest', 106 | None, 107 | 'kmsauth-unittest', 108 | 'us-east-1' 109 | ) 110 | validator.kms_client = MagicMock() 111 | validator.KEY_METADATA = { 112 | 'mockalias': {'KeyMetadata': {'Arn': 'mocked:arn'}} 113 | } 114 | self.assertEqual( 115 | validator._get_key_arn('mockalias'), 116 | 'mocked:arn' 117 | ) 118 | 119 | def test__valid_service_auth_key(self): 120 | validator = kmsauth.KMSTokenValidator( 121 | 'alias/authnz-unittest', 122 | None, 123 | 'kmsauth-unittest', 124 | 'us-east-1' 125 | ) 126 | validator._get_key_arn = MagicMock() 127 | # Test AUTH_KEY arn checking 128 | validator._get_key_arn.side_effect = ['test::arn'] 129 | self.assertTrue(validator._valid_service_auth_key('test::arn')) 130 | 131 | validator = kmsauth.KMSTokenValidator( 132 | 'alias/authnz-unittest', 133 | None, 134 | 'kmsauth-unittest', 135 | 'us-east-1', 136 | scoped_auth_keys={'test-key': 'test-account'} 137 | ) 138 | validator._get_key_arn = MagicMock() 139 | # Test SCOPED_AUTH_KEYS arn checking. There's two items in the side 140 | # effect because get_key_arn will be called twice: once for AUTH_KEY 141 | # check (which will fail) and another for the SCOPED_AUTH_KEYS check. 142 | validator._get_key_arn.side_effect = [ 143 | 'auth::key', 144 | 'test::arn' 145 | ] 146 | self.assertTrue(validator._valid_service_auth_key('test::arn')) 147 | # Test failure mode, where both AUTH_KEY and SCOPED_AUTH_KEYS checks 148 | # fail. We have two items in side effects because get_key_arn will be 149 | # called twice. 150 | validator._get_key_arn.side_effect = ['auth::key', 'test::arn'] 151 | self.assertFalse(validator._valid_service_auth_key('bad::arn')) 152 | 153 | def test__valid_user_auth_key(self): 154 | validator = kmsauth.KMSTokenValidator( 155 | None, 156 | 'alias/authnz-user-unittest', 157 | 'kmsauth-unittest', 158 | 'us-east-1' 159 | ) 160 | validator._get_key_arn = MagicMock() 161 | # Test USER_AUTH_KEY arn checking 162 | validator._get_key_arn.return_value = 'test::arn' 163 | self.assertTrue(validator._valid_user_auth_key('test::arn')) 164 | self.assertFalse(validator._valid_service_auth_key('bad::arn')) 165 | 166 | def test__parse_username(self): 167 | validator = kmsauth.KMSTokenValidator( 168 | None, 169 | 'alias/authnz-user-unittest', 170 | 'kmsauth-unittest', 171 | 'us-east-1' 172 | ) 173 | self.assertEqual( 174 | validator._parse_username('kmsauth-unittest'), 175 | (1, 'service', 'kmsauth-unittest') 176 | ) 177 | self.assertEqual( 178 | validator._parse_username('2/service/kmsauth-unittest'), 179 | (2, 'service', 'kmsauth-unittest') 180 | ) 181 | with self.assertRaisesRegexp( 182 | kmsauth.TokenValidationError, 183 | 'Unsupported username format.'): 184 | validator._parse_username('3/service/kmsauth-unittest/extratoken') 185 | 186 | def test_decrypt_token(self): 187 | validator = kmsauth.KMSTokenValidator( 188 | 'alias/authnz-unittest', 189 | 'alias/authnz-user-unittest', 190 | 'kmsauth-unittest', 191 | 'us-east-1' 192 | ) 193 | validator._get_key_arn = MagicMock(return_value='mocked') 194 | validator._get_key_alias_from_cache = MagicMock( 195 | return_value='authnz-testing' 196 | ) 197 | time_format = "%Y%m%dT%H%M%SZ" 198 | now = datetime.datetime.utcnow() 199 | not_before = now.strftime(time_format) 200 | _not_after = now + datetime.timedelta(minutes=60) 201 | not_after = _not_after.strftime(time_format) 202 | payload = json.dumps({ 203 | 'not_before': not_before, 204 | 'not_after': not_after 205 | }) 206 | validator.kms_client.decrypt = MagicMock() 207 | validator.kms_client.decrypt.return_value = { 208 | 'Plaintext': payload, 209 | 'KeyId': 'mocked' 210 | } 211 | # Ensure decrypt succeeds and payload and key alias are the mocked 212 | # values using v1 token. 213 | self.assertEqual( 214 | validator.decrypt_token( 215 | 'kmsauth-unittest', 216 | 'ZW5jcnlwdGVk' 217 | ), 218 | { 219 | 'payload': json.loads(payload), 220 | 'key_alias': 'authnz-testing' 221 | } 222 | ) 223 | # Ensure decrypt succeeds and payload and key alias are the mocked 224 | # values using v2 token. 225 | validator.TOKENS = lru.LRUCache(4096) 226 | self.assertEqual( 227 | validator.decrypt_token( 228 | '2/user/testuser', 229 | 'ZW5jcnlwdGVk' 230 | ), 231 | { 232 | 'payload': json.loads(payload), 233 | 'key_alias': 'authnz-testing' 234 | } 235 | ) 236 | # Ensure we check token version 237 | validator.TOKENS = lru.LRUCache(4096) 238 | with self.assertRaisesRegexp( 239 | kmsauth.TokenValidationError, 240 | 'Unacceptable token version.'): 241 | validator.decrypt_token( 242 | '3/user/testuser', 243 | 'ZW5jcnlwdGVk' 244 | ) 245 | # Ensure we check user types 246 | with self.assertRaisesRegexp( 247 | kmsauth.TokenValidationError, 248 | 'Authentication error. Unsupported user_type.'): 249 | validator.decrypt_token( 250 | '2/unsupported/testuser', 251 | 'ZW5jcnlwdGVk' 252 | ) 253 | # Missing KeyId, will cause an exception to be thrown 254 | validator.kms_client.decrypt.return_value = { 255 | 'Plaintext': payload 256 | } 257 | with self.assertRaisesRegexp( 258 | kmsauth.TokenValidationError, 259 | 'Authentication error. General error.'): 260 | validator.decrypt_token( 261 | '2/service/kmsauth-unittest', 262 | 'ZW5jcnlwdGVk' 263 | ) 264 | # Payload missing not_before/not_after 265 | empty_payload = json.dumps({}) 266 | validator.kms_client.decrypt.return_value = { 267 | 'Plaintext': empty_payload, 268 | 'KeyId': 'mocked' 269 | } 270 | with self.assertRaisesRegexp( 271 | kmsauth.TokenValidationError, 272 | 'Authentication error. Missing validity.'): 273 | validator.decrypt_token( 274 | '2/service/kmsauth-unittest', 275 | 'ZW5jcnlwdGVk' 276 | ) 277 | # lifetime of 0 will make every token invalid. testing for proper delta 278 | # checking. 279 | validator = kmsauth.KMSTokenValidator( 280 | 'alias/authnz-unittest', 281 | 'alias/authnz-user-unittest', 282 | 'kmsauth-unittest', 283 | 'us-east-1', 284 | auth_token_max_lifetime=0 285 | ) 286 | validator._get_key_arn = MagicMock(return_value='mocked') 287 | validator._get_key_alias_from_cache = MagicMock( 288 | return_value='authnz-testing' 289 | ) 290 | validator.kms_client.decrypt = MagicMock() 291 | validator.kms_client.decrypt.return_value = { 292 | 'Plaintext': payload, 293 | 'KeyId': 'mocked' 294 | } 295 | with self.assertRaisesRegexp( 296 | kmsauth.TokenValidationError, 297 | 'Authentication error. Token lifetime exceeded.'): 298 | validator.decrypt_token( 299 | '2/service/kmsauth-unittest', 300 | 'ZW5jcnlwdGVk' 301 | ) 302 | # Token too old 303 | validator = kmsauth.KMSTokenValidator( 304 | 'alias/authnz-unittest', 305 | 'alias/authnz-user-unittest', 306 | 'kmsauth-unittest', 307 | 'us-east-1' 308 | ) 309 | validator._get_key_arn = MagicMock(return_value='mocked') 310 | validator._get_key_alias_from_cache = MagicMock( 311 | return_value='authnz-testing' 312 | ) 313 | now = datetime.datetime.utcnow() 314 | _not_before = now - datetime.timedelta(minutes=60) 315 | not_before = _not_before.strftime(time_format) 316 | _not_after = now - datetime.timedelta(minutes=1) 317 | not_after = _not_after.strftime(time_format) 318 | payload = json.dumps({ 319 | 'not_before': not_before, 320 | 'not_after': not_after 321 | }) 322 | validator.kms_client.decrypt = MagicMock() 323 | validator.kms_client.decrypt.return_value = { 324 | 'Plaintext': payload, 325 | 'KeyId': 'mocked' 326 | } 327 | with self.assertRaisesRegexp( 328 | kmsauth.TokenValidationError, 329 | 'Authentication error. Invalid time validity for token.'): 330 | validator.decrypt_token( 331 | '2/service/kmsauth-unittest', 332 | 'ZW5jcnlwdGVk' 333 | ) 334 | # Token too young 335 | now = datetime.datetime.utcnow() 336 | _not_before = now + datetime.timedelta(minutes=60) 337 | not_before = _not_before.strftime(time_format) 338 | _not_after = now + datetime.timedelta(minutes=120) 339 | not_after = _not_after.strftime(time_format) 340 | payload = json.dumps({ 341 | 'not_before': not_before, 342 | 'not_after': not_after 343 | }) 344 | validator.kms_client.decrypt.return_value = { 345 | 'Plaintext': payload, 346 | 'KeyId': 'mocked' 347 | } 348 | with self.assertRaisesRegexp( 349 | kmsauth.TokenValidationError, 350 | 'Authentication error. Invalid time validity for token'): 351 | validator.decrypt_token( 352 | '2/service/kmsauth-unittest', 353 | 'ZW5jcnlwdGVk' 354 | ) 355 | 356 | 357 | class KMSTokenGeneratorTest(unittest.TestCase): 358 | 359 | @patch( 360 | 'kmsauth.services.get_boto_client', 361 | MagicMock() 362 | ) 363 | def test_validate_config(self): 364 | with self.assertRaises(kmsauth.ConfigurationError): 365 | kmsauth.KMSTokenGenerator( 366 | 'alias/authnz-unittest', 367 | # Missing auth context. This causes a validation error 368 | {}, 369 | 'us-east-1' 370 | ) 371 | with self.assertRaises(kmsauth.ConfigurationError): 372 | kmsauth.KMSTokenGenerator( 373 | 'alias/authnz-unittest', 374 | # Missing user_type context. This causes a validation error 375 | {'from': 'test', 'to': 'test'}, 376 | 'us-east-1' 377 | ) 378 | with self.assertRaises(kmsauth.ConfigurationError): 379 | kmsauth.KMSTokenGenerator( 380 | 'alias/authnz-unittest', 381 | {'from': 'test', 'to': 'test', 'user_type': 'user'}, 382 | 'us-east-1', 383 | # invalid token version 384 | token_version=3 385 | ) 386 | assert(kmsauth.KMSTokenGenerator( 387 | 'alias/authnz-unittest', 388 | {'from': 'test', 'to': 'test'}, 389 | 'us-east-1', 390 | token_version=1 391 | )) 392 | assert(kmsauth.KMSTokenGenerator( 393 | 'alias/authnz-unittest', 394 | {'from': 'test', 'to': 'test', 'user_type': 'user'}, 395 | 'us-east-1', 396 | token_version=2 397 | )) 398 | 399 | @patch( 400 | 'kmsauth.services.get_boto_client', 401 | MagicMock() 402 | ) 403 | def test_get_username(self): 404 | client = kmsauth.KMSTokenGenerator( 405 | 'alias/authnz-testing', 406 | {'from': 'kmsauth-unittest', 'to': 'test'}, 407 | 'us-east-1', 408 | token_version=1 409 | ) 410 | self.assertEqual( 411 | client.get_username(), 412 | 'kmsauth-unittest' 413 | ) 414 | client = kmsauth.KMSTokenGenerator( 415 | 'alias/authnz-testing', 416 | {'from': 'kmsauth-unittest', 417 | 'to': 'test', 418 | 'user_type': 'service'}, 419 | 'us-east-1', 420 | token_version=2 421 | ) 422 | self.assertEqual( 423 | client.get_username(), 424 | '2/service/kmsauth-unittest' 425 | ) 426 | 427 | @patch( 428 | 'kmsauth.services.get_boto_client' 429 | ) 430 | def test_get_token(self, boto_mock): 431 | kms_mock = MagicMock() 432 | kms_mock.encrypt = MagicMock( 433 | return_value={'CiphertextBlob': b'encrypted'} 434 | ) 435 | boto_mock.return_value = kms_mock 436 | client = kmsauth.KMSTokenGenerator( 437 | 'alias/authnz-testing', 438 | {'from': 'kmsauth-unittest', 439 | 'to': 'test', 440 | 'user_type': 'service'}, 441 | 'us-east-1' 442 | ) 443 | token = client.get_token() 444 | self.assertEqual(token, base64.b64encode(b'encrypted')) 445 | -------------------------------------------------------------------------------- /kmsauth/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import hashlib 3 | import json 4 | import datetime 5 | import base64 6 | import os 7 | import copy 8 | 9 | from botocore.vendored import six 10 | from botocore.exceptions import (ConnectionError, 11 | EndpointConnectionError) 12 | 13 | import kmsauth.services 14 | # Try to import the more efficient lru-dict, and fallback to slower pure-python 15 | # lru dict implementation if it's not available. 16 | try: 17 | from lru import LRU 18 | except ImportError: 19 | from kmsauth.utils.lru import LRUCache as LRU 20 | 21 | TOKEN_SKEW = 3 22 | TIME_FORMAT = "%Y%m%dT%H%M%SZ" 23 | 24 | 25 | def ensure_text(str_or_bytes, encoding='utf-8'): 26 | """Ensures an input is a string, decoding if it is bytes. 27 | """ 28 | if not isinstance(str_or_bytes, six.text_type): 29 | return str_or_bytes.decode(encoding) 30 | return str_or_bytes 31 | 32 | 33 | def ensure_bytes(str_or_bytes, encoding='utf-8', errors='strict'): 34 | """Ensures an input is bytes, encoding if it is a string. 35 | """ 36 | if isinstance(str_or_bytes, six.text_type): 37 | return str_or_bytes.encode(encoding, errors) 38 | return str_or_bytes 39 | 40 | 41 | class KMSTokenValidator(object): 42 | 43 | """A class that represents a token validator for KMS auth.""" 44 | 45 | def __init__( 46 | self, 47 | auth_key, 48 | user_auth_key, 49 | to_auth_context, 50 | region, 51 | scoped_auth_keys=None, 52 | minimum_token_version=1, 53 | maximum_token_version=2, 54 | auth_token_max_lifetime=60, 55 | aws_creds=None, 56 | extra_context=None, 57 | endpoint_url=None, 58 | token_cache_size=4096, 59 | stats=None, 60 | max_pool_connections=None, 61 | connect_timeout=None, 62 | read_timeout=None, 63 | ): 64 | """Create a KMSTokenValidator object. 65 | 66 | Args: 67 | auth_key: A list of KMS key ARNs or aliases to use for service 68 | authentication. Required. 69 | user_auth_key: A list of KMS key ARNs or aliases to use for user 70 | authentication. Required. 71 | to_auth_context: The KMS encryption context to use for the to 72 | context for authentication. Required. 73 | region: AWS region to connect to. Required. 74 | scoped_auth_keys: A dict of KMS key to account mappings. These keys 75 | are for the 'service' role to support multiple AWS accounts. If 76 | services are scoped to accounts, kmsauth will ensure the service 77 | authentication KMS auth used the mapped key. 78 | Example: {"sandbox-auth-key":"sandbox","primary-auth-key":"primary"} 79 | minimum_token_version: The minimum version of the authentication 80 | token accepted. 81 | maximum_token_version: The maximum version of the authentication 82 | token accepted. 83 | auth_token_max_lifetime: The maximum lifetime of an authentication 84 | token in minutes. 85 | token_cache_size: Size of the in-memory LRU cache for auth tokens. 86 | aws_creds: A dict of AccessKeyId, SecretAccessKey, SessionToken. 87 | Useful if you wish to pass in assumed role credentials or MFA 88 | credentials. Default: None 89 | endpoint_url: A URL to override the default endpoint used to access 90 | the KMS service. Default: None 91 | stats: A statsd client instance, to be used to track stats. 92 | Default: None 93 | """ 94 | self.auth_key = auth_key 95 | self.user_auth_key = user_auth_key 96 | self.to_auth_context = to_auth_context 97 | self.region = region 98 | if scoped_auth_keys is None: 99 | self.scoped_auth_keys = {} 100 | else: 101 | self.scoped_auth_keys = scoped_auth_keys 102 | self.minimum_token_version = minimum_token_version 103 | self.maximum_token_version = maximum_token_version 104 | self.auth_token_max_lifetime = auth_token_max_lifetime 105 | self.aws_creds = aws_creds 106 | if aws_creds: 107 | self.kms_client = kmsauth.services.get_boto_client( 108 | 'kms', 109 | region=self.region, 110 | aws_access_key_id=self.aws_creds['AccessKeyId'], 111 | aws_secret_access_key=self.aws_creds['SecretAccessKey'], 112 | aws_session_token=self.aws_creds['SessionToken'], 113 | endpoint_url=endpoint_url, 114 | max_pool_connections=max_pool_connections, 115 | connect_timeout=connect_timeout, 116 | read_timeout=read_timeout, 117 | ) 118 | else: 119 | self.kms_client = kmsauth.services.get_boto_client( 120 | 'kms', 121 | region=self.region, 122 | endpoint_url=endpoint_url, 123 | max_pool_connections=max_pool_connections, 124 | connect_timeout=connect_timeout, 125 | read_timeout=read_timeout, 126 | ) 127 | if extra_context is None: 128 | self.extra_context = {} 129 | else: 130 | self.extra_context = extra_context 131 | self.TOKENS = LRU(token_cache_size) 132 | self.KEY_METADATA = {} 133 | self.stats = stats 134 | self._validate() 135 | 136 | def _validate(self): 137 | for key in ['from', 'to', 'user_type']: 138 | if key in self.extra_context: 139 | logging.warning( 140 | '{0} in extra_context will be ignored.'.format(key) 141 | ) 142 | if self.minimum_token_version < 1 or self.minimum_token_version > 2: 143 | raise ConfigurationError( 144 | 'Invalid minimum_token_version provided.' 145 | ) 146 | if self.maximum_token_version < 1 or self.maximum_token_version > 2: 147 | raise ConfigurationError( 148 | 'Invalid maximum_token_version provided.' 149 | ) 150 | if self.minimum_token_version > self.maximum_token_version: 151 | raise ConfigurationError( 152 | 'minimum_token_version can not be greater than' 153 | ' self.minimum_token_version' 154 | ) 155 | self.auth_key = self._format_auth_key(self.auth_key) 156 | self.user_auth_key = self._format_auth_key(self.user_auth_key) 157 | 158 | def _format_auth_key(self, keys): 159 | if isinstance(keys, six.string_types): 160 | logging.debug( 161 | 'Passing auth key as string is deprecated, and will be removed' 162 | ' in 1.0.0' 163 | ) 164 | return [keys] 165 | elif (keys is None or isinstance(keys, list)): 166 | return keys 167 | raise ConfigurationError( 168 | 'auth_key and user_auth_key must be a string, list, or None' 169 | ) 170 | 171 | def _get_key_arn(self, key): 172 | if key.startswith('arn:aws:kms:'): 173 | self.KEY_METADATA[key] = { 174 | 'KeyMetadata': {'Arn': key} 175 | } 176 | if key not in self.KEY_METADATA: 177 | self.KEY_METADATA[key] = self.kms_client.describe_key( 178 | KeyId='{0}'.format(key) 179 | ) 180 | return self.KEY_METADATA[key]['KeyMetadata']['Arn'] 181 | 182 | def _get_key_alias_from_cache(self, key_arn): 183 | ''' 184 | Find a key's alias by looking up its key_arn in the KEY_METADATA 185 | cache. This function will only work after a key has been lookedup by 186 | its alias and is meant as a convenience function for turning an ARN 187 | that's already been looked up back into its alias. 188 | ''' 189 | for alias in self.KEY_METADATA: 190 | if self.KEY_METADATA[alias]['KeyMetadata']['Arn'] == key_arn: 191 | return alias 192 | return None 193 | 194 | def _valid_service_auth_key(self, key_arn): 195 | if self.auth_key is None: 196 | return False 197 | for key in self.auth_key: 198 | if key_arn == self._get_key_arn(key): 199 | return True 200 | for key in self.scoped_auth_keys: 201 | if key_arn == self._get_key_arn(key): 202 | return True 203 | return False 204 | 205 | def _valid_user_auth_key(self, key_arn): 206 | if self.user_auth_key is None: 207 | return False 208 | for key in self.user_auth_key: 209 | if key_arn == self._get_key_arn(key): 210 | return True 211 | return False 212 | 213 | def _parse_username(self, username): 214 | username_arr = username.split('/') 215 | if len(username_arr) == 3: 216 | # V2 token format: version/service/myservice or version/user/myuser 217 | version = int(username_arr[0]) 218 | user_type = username_arr[1] 219 | _from = username_arr[2] 220 | elif len(username_arr) == 1: 221 | # Old format, specific to services: myservice 222 | version = 1 223 | _from = username_arr[0] 224 | user_type = 'service' 225 | else: 226 | raise TokenValidationError('Unsupported username format.') 227 | return version, user_type, _from 228 | 229 | def extract_username_field(self, username, field): 230 | version, user_type, _from = self._parse_username(username) 231 | if field == 'from': 232 | return _from 233 | elif field == 'user_type': 234 | return user_type 235 | elif field == 'version': 236 | return version 237 | return None 238 | 239 | def decrypt_token(self, username, token): 240 | ''' 241 | Decrypt a token. 242 | ''' 243 | version, user_type, _from = self._parse_username(username) 244 | if (version > self.maximum_token_version or 245 | version < self.minimum_token_version): 246 | raise TokenValidationError('Unacceptable token version.') 247 | if self.stats: 248 | self.stats.incr('token_version_{0}'.format(version)) 249 | try: 250 | token_key = '{0}{1}{2}{3}'.format( 251 | hashlib.sha256(ensure_bytes(token)).hexdigest(), 252 | _from, 253 | self.to_auth_context, 254 | user_type 255 | ) 256 | except Exception: 257 | raise TokenValidationError('Authentication error.') 258 | if token_key not in self.TOKENS: 259 | try: 260 | token = base64.b64decode(token) 261 | # Ensure normal context fields override whatever is in 262 | # extra_context. 263 | context = copy.deepcopy(self.extra_context) 264 | context['to'] = self.to_auth_context 265 | context['from'] = _from 266 | if version > 1: 267 | context['user_type'] = user_type 268 | if self.stats: 269 | with self.stats.timer('kms_decrypt_token'): 270 | data = self.kms_client.decrypt( 271 | CiphertextBlob=token, 272 | EncryptionContext=context 273 | ) 274 | else: 275 | data = self.kms_client.decrypt( 276 | CiphertextBlob=token, 277 | EncryptionContext=context 278 | ) 279 | # Decrypt doesn't take KeyId as an argument. We need to verify 280 | # the correct key was used to do the decryption. 281 | # Annoyingly, the KeyId from the data is actually an arn. 282 | key_arn = data['KeyId'] 283 | if user_type == 'service': 284 | if not self._valid_service_auth_key(key_arn): 285 | raise TokenValidationError( 286 | 'Authentication error (wrong KMS key).' 287 | ) 288 | elif user_type == 'user': 289 | if not self._valid_user_auth_key(key_arn): 290 | raise TokenValidationError( 291 | 'Authentication error (wrong KMS key).' 292 | ) 293 | else: 294 | raise TokenValidationError( 295 | 'Authentication error. Unsupported user_type.' 296 | ) 297 | plaintext = data['Plaintext'] 298 | payload = json.loads(plaintext) 299 | key_alias = self._get_key_alias_from_cache(key_arn) 300 | ret = {'payload': payload, 'key_alias': key_alias} 301 | except TokenValidationError: 302 | raise 303 | except (ConnectionError, EndpointConnectionError): 304 | logging.exception('Failure connecting to AWS endpoint.') 305 | raise TokenValidationError( 306 | 'Authentication error. Failure connecting to AWS endpoint.' 307 | ) 308 | # We don't care what exception is thrown. For paranoia's sake, fail 309 | # here. 310 | except Exception: 311 | logging.exception('Failed to validate token.') 312 | raise TokenValidationError( 313 | 'Authentication error. General error.' 314 | ) 315 | else: 316 | ret = self.TOKENS[token_key] 317 | now = datetime.datetime.utcnow() 318 | try: 319 | not_before = datetime.datetime.strptime( 320 | ret['payload']['not_before'], 321 | TIME_FORMAT 322 | ) 323 | not_after = datetime.datetime.strptime( 324 | ret['payload']['not_after'], 325 | TIME_FORMAT 326 | ) 327 | except Exception: 328 | logging.exception( 329 | 'Failed to get not_before and not_after from token payload.' 330 | ) 331 | raise TokenValidationError( 332 | 'Authentication error. Missing validity.' 333 | ) 334 | delta = (not_after - not_before).seconds / 60 335 | if delta > self.auth_token_max_lifetime: 336 | logging.warning('Token used which exceeds max token lifetime.') 337 | raise TokenValidationError( 338 | 'Authentication error. Token lifetime exceeded.' 339 | ) 340 | if (now < not_before) or (now > not_after): 341 | logging.warning('Invalid time validity for token.') 342 | raise TokenValidationError( 343 | 'Authentication error. Invalid time validity for token.' 344 | ) 345 | self.TOKENS[token_key] = ret 346 | return self.TOKENS[token_key] 347 | 348 | 349 | class KMSTokenGenerator(object): 350 | 351 | """A class that represents a token generator for KMS auth.""" 352 | 353 | def __init__( 354 | self, 355 | auth_key, 356 | auth_context, 357 | region, 358 | token_version=2, 359 | token_cache_file=None, 360 | token_lifetime=10, 361 | aws_creds=None, 362 | endpoint_url=None 363 | ): 364 | """Create a KMSTokenGenerator object. 365 | 366 | Args: 367 | auth_key: The KMS key ARN or alias to use for authentication. 368 | Required. 369 | auth_context: The KMS encryption context to use for authentication. 370 | Required. 371 | region: AWS region to connect to. Required. 372 | token_version: The version of the authentication token. Default: 2 373 | token_cache_file: he location to use for caching the auth token. 374 | If set to empty string, no cache will be used. Default: None 375 | token_lifetime: Lifetime of the authentication token generated. 376 | Default: 10 377 | aws_creds: A dict of AccessKeyId, SecretAccessKey, SessionToken. 378 | Useful if you wish to pass in assumed role credentials or MFA 379 | credentials. Default: None 380 | endpoint_url: A URL to override the default endpoint used to access 381 | the KMS service. Default: None 382 | """ 383 | self.auth_key = auth_key 384 | if auth_context is None: 385 | self.auth_context = {} 386 | else: 387 | self.auth_context = auth_context 388 | self.token_cache_file = token_cache_file 389 | self.token_lifetime = token_lifetime 390 | self.region = region 391 | self.token_version = token_version 392 | self.aws_creds = aws_creds 393 | if aws_creds: 394 | self.kms_client = kmsauth.services.get_boto_client( 395 | 'kms', 396 | region=self.region, 397 | aws_access_key_id=self.aws_creds['AccessKeyId'], 398 | aws_secret_access_key=self.aws_creds['SecretAccessKey'], 399 | aws_session_token=self.aws_creds['SessionToken'], 400 | endpoint_url=endpoint_url 401 | ) 402 | else: 403 | self.kms_client = kmsauth.services.get_boto_client( 404 | 'kms', 405 | region=self.region, 406 | endpoint_url=endpoint_url 407 | ) 408 | self._validate() 409 | 410 | def _validate(self): 411 | for key in ['from', 'to']: 412 | if key not in self.auth_context: 413 | raise ConfigurationError( 414 | '{0} missing from auth_context.'.format(key) 415 | ) 416 | if self.token_version > 1: 417 | if 'user_type' not in self.auth_context: 418 | raise ConfigurationError( 419 | 'user_type missing from auth_context.' 420 | ) 421 | if self.token_version > 2: 422 | raise ConfigurationError( 423 | 'Invalid token_version provided.' 424 | ) 425 | 426 | def _get_cached_token(self): 427 | token = None 428 | if not self.token_cache_file: 429 | return token 430 | try: 431 | with open(self.token_cache_file, 'r') as f: 432 | token_data = json.load(f) 433 | _not_after = token_data['not_after'] 434 | _auth_context = token_data['auth_context'] 435 | _token = token_data['token'] 436 | _not_after_cache = datetime.datetime.strptime( 437 | _not_after, 438 | TIME_FORMAT 439 | ) 440 | except IOError as e: 441 | logging.debug( 442 | 'Failed to read confidant auth token cache: {0}'.format(e) 443 | ) 444 | return token 445 | except Exception: 446 | logging.exception('Failed to read confidant auth token cache.') 447 | return token 448 | skew_delta = datetime.timedelta(minutes=TOKEN_SKEW) 449 | _not_after_cache = _not_after_cache - skew_delta 450 | now = datetime.datetime.utcnow() 451 | if (now <= _not_after_cache and 452 | _auth_context == self.auth_context): 453 | logging.debug('Using confidant auth token cache.') 454 | token = _token 455 | return token 456 | 457 | def _cache_token(self, token, not_after): 458 | if not self.token_cache_file: 459 | return 460 | try: 461 | cachedir = os.path.dirname(self.token_cache_file) 462 | if not os.path.exists(cachedir): 463 | os.makedirs(cachedir) 464 | with open(self.token_cache_file, 'w') as f: 465 | json.dump({ 466 | 'token': ensure_text(token), 467 | 'not_after': not_after, 468 | 'auth_context': self.auth_context 469 | }, f) 470 | except Exception: 471 | logging.exception('Failed to write confidant auth token cache.') 472 | 473 | def get_username(self): 474 | """Get a username formatted for a specific token version.""" 475 | _from = self.auth_context['from'] 476 | if self.token_version == 1: 477 | return '{0}'.format(_from) 478 | elif self.token_version == 2: 479 | _user_type = self.auth_context['user_type'] 480 | return '{0}/{1}/{2}'.format( 481 | self.token_version, 482 | _user_type, 483 | _from 484 | ) 485 | 486 | def get_token(self): 487 | """Get an authentication token.""" 488 | # Generate string formatted timestamps for not_before and not_after, 489 | # for the lifetime specified in minutes. 490 | now = datetime.datetime.utcnow() 491 | # Start the not_before time x minutes in the past, to avoid clock skew 492 | # issues. 493 | _not_before = now - datetime.timedelta(minutes=TOKEN_SKEW) 494 | not_before = _not_before.strftime(TIME_FORMAT) 495 | # Set the not_after time in the future, by the lifetime, but ensure the 496 | # skew we applied to not_before is taken into account. 497 | _not_after = now + datetime.timedelta( 498 | minutes=self.token_lifetime - TOKEN_SKEW 499 | ) 500 | not_after = _not_after.strftime(TIME_FORMAT) 501 | # Generate a json string for the encryption payload contents. 502 | payload = json.dumps({ 503 | 'not_before': not_before, 504 | 'not_after': not_after 505 | }) 506 | token = self._get_cached_token() 507 | if token: 508 | return token 509 | # Generate a base64 encoded KMS encrypted token to use for 510 | # authentication. We encrypt the token lifetime information as the 511 | # payload for verification in Confidant. 512 | try: 513 | token = self.kms_client.encrypt( 514 | KeyId=self.auth_key, 515 | Plaintext=payload, 516 | EncryptionContext=self.auth_context 517 | )['CiphertextBlob'] 518 | token = base64.b64encode(ensure_bytes(token)) 519 | except (ConnectionError, EndpointConnectionError) as e: 520 | logging.exception('Failure connecting to AWS: {}'.format(str(e))) 521 | raise ServiceConnectionError() 522 | except Exception: 523 | logging.exception('Failed to create auth token.') 524 | raise TokenGenerationError() 525 | self._cache_token(token, not_after) 526 | return token 527 | 528 | 529 | class ServiceConnectionError(Exception): 530 | """An exception raised when there was an AWS connection error.""" 531 | pass 532 | 533 | 534 | class ConfigurationError(Exception): 535 | 536 | """An exception raised when a token was unsuccessfully created.""" 537 | 538 | pass 539 | 540 | 541 | class TokenValidationError(Exception): 542 | """An exception raised when a token was unsuccessfully validated.""" 543 | pass 544 | 545 | 546 | class TokenGenerationError(Exception): 547 | 548 | """An exception raised when a token was unsuccessfully generated.""" 549 | 550 | pass 551 | --------------------------------------------------------------------------------