├── tests ├── __init__.py └── test_session.py ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── pull_request_template.md └── workflows │ ├── pull_request.yml │ └── push.yml ├── .flake8 ├── doc ├── brs.png ├── downloads.png ├── installation.rst ├── modules │ ├── generated │ │ ├── boto3_refresh_session.session.RefreshableSession.rst │ │ ├── boto3_refresh_session.methods.sts.STSRefreshableSession.rst │ │ ├── boto3_refresh_session.methods.custom.CustomRefreshableSession.rst │ │ └── boto3_refresh_session.methods.iot.IOTX509RefreshableSession.rst │ ├── exceptions.rst │ ├── sts.rst │ ├── session.rst │ ├── custom.rst │ ├── iot.rst │ └── index.rst ├── Makefile ├── authorization.rst ├── _static │ └── custom.css ├── index.rst ├── make.bat ├── raison.rst ├── contributing.rst ├── qanda.rst ├── conf.py └── usage.rst ├── boto3_refresh_session ├── methods │ ├── iot │ │ ├── __init__.py │ │ ├── core.py │ │ └── x509.py │ ├── __init__.py │ ├── sts.py │ └── custom.py ├── utils │ ├── __init__.py │ ├── typing.py │ └── internal.py ├── __init__.py ├── exceptions.py └── session.py ├── .pre-commit-config.yaml ├── NOTICE ├── LICENSE ├── pyproject.toml ├── .gitignore ├── CODE_OF_CONDUCT.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | @michaelthomasletts 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 79 3 | per-file-ignores = 4 | */__init__.py:F401,F403 -------------------------------------------------------------------------------- /doc/brs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelthomasletts/boto3-refresh-session/HEAD/doc/brs.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [michaelthomasletts] 4 | -------------------------------------------------------------------------------- /doc/downloads.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelthomasletts/boto3-refresh-session/HEAD/doc/downloads.png -------------------------------------------------------------------------------- /boto3_refresh_session/methods/iot/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [] 2 | 3 | from . import core 4 | from .core import IoTRefreshableSession 5 | from .x509 import IOTX509RefreshableSession 6 | 7 | __all__.extend(core.__all__) 8 | -------------------------------------------------------------------------------- /boto3_refresh_session/utils/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [] 2 | 3 | from . import internal, typing 4 | from .internal import * 5 | from .typing import * 6 | 7 | __all__.extend(internal.__all__) 8 | __all__.extend(typing.__all__) 9 | -------------------------------------------------------------------------------- /doc/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ************ 5 | 6 | To install `boto3-refresh-session `_ using ``pip``: 7 | 8 | .. code-block:: bash 9 | 10 | pip install boto3-refresh-session -------------------------------------------------------------------------------- /boto3_refresh_session/methods/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [] 2 | 3 | from . import custom, iot, sts 4 | from .custom import * 5 | from .iot import * 6 | from .sts import * 7 | 8 | __all__.extend(custom.__all__) 9 | __all__.extend(iot.__all__) 10 | __all__.extend(sts.__all__) 11 | -------------------------------------------------------------------------------- /doc/modules/generated/boto3_refresh_session.session.RefreshableSession.rst: -------------------------------------------------------------------------------- 1 | boto3\_refresh\_session.session.RefreshableSession 2 | ================================================== 3 | 4 | .. currentmodule:: boto3_refresh_session.session 5 | 6 | .. autoclass:: RefreshableSession 7 | :exclude-members: __init__, __new__ 8 | :inherited-members: -------------------------------------------------------------------------------- /doc/modules/generated/boto3_refresh_session.methods.sts.STSRefreshableSession.rst: -------------------------------------------------------------------------------- 1 | boto3\_refresh\_session.methods.sts.STSRefreshableSession 2 | ========================================================= 3 | 4 | .. currentmodule:: boto3_refresh_session.methods.sts 5 | 6 | .. autoclass:: STSRefreshableSession 7 | :exclude-members: __init__, __new__ 8 | :inherited-members: -------------------------------------------------------------------------------- /doc/modules/exceptions.rst: -------------------------------------------------------------------------------- 1 | .. _exceptions: 2 | 3 | boto3_refresh_session.exceptions 4 | ================================ 5 | 6 | .. currentmodule:: boto3_refresh_session.exceptions 7 | 8 | .. autoclass:: BRSError 9 | :exclude-members: __init__ 10 | :inherited-members: 11 | 12 | .. autoclass:: BRSWarning 13 | :exclude-members: __init__ 14 | :inherited-members: -------------------------------------------------------------------------------- /doc/modules/generated/boto3_refresh_session.methods.custom.CustomRefreshableSession.rst: -------------------------------------------------------------------------------- 1 | boto3\_refresh\_session.methods.custom.CustomRefreshableSession 2 | =============================================================== 3 | 4 | .. currentmodule:: boto3_refresh_session.methods.custom 5 | 6 | .. autoclass:: CustomRefreshableSession 7 | :exclude-members: __init__, __new__ 8 | :inherited-members: -------------------------------------------------------------------------------- /doc/modules/generated/boto3_refresh_session.methods.iot.IOTX509RefreshableSession.rst: -------------------------------------------------------------------------------- 1 | boto3\_refresh\_session.methods.iot.IOTX509RefreshableSessionRefreshableSession 2 | =============================================================================== 3 | 4 | .. currentmodule:: boto3_refresh_session.methods.iot 5 | 6 | .. autoclass:: IOTX509RefreshableSession 7 | :exclude-members: __init__, __new__ 8 | :inherited-members: -------------------------------------------------------------------------------- /boto3_refresh_session/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [] 2 | 3 | from . import exceptions, session 4 | from .exceptions import * 5 | from .methods.custom import * 6 | from .methods.iot import * 7 | from .methods.sts import * 8 | from .session import * 9 | 10 | __all__.extend(session.__all__) 11 | __all__.extend(exceptions.__all__) 12 | __version__ = "5.1.8" 13 | __title__ = "boto3-refresh-session" 14 | __author__ = "Mike Letts" 15 | __maintainer__ = "Mike Letts" 16 | __license__ = "MIT" 17 | __email__ = "lettsmt@gmail.com" 18 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | 4 | repos: 5 | - repo: https://github.com/psf/black 6 | rev: 24.10.0 7 | hooks: 8 | - id: black 9 | alias: black 10 | args: ['--config=pyproject.toml'] 11 | - repo: https://github.com/PyCQA/flake8 12 | rev: 7.1.1 13 | hooks: 14 | - id: flake8 15 | alias: flake8 16 | args: ['--config=.flake8'] 17 | - repo: https://github.com/PyCQA/isort 18 | rev: 5.13.2 19 | hooks: 20 | - id: isort 21 | alias: isort 22 | - repo: local 23 | hooks: 24 | - id: pytest 25 | name: pytest 26 | alias: pytest 27 | types: [python] 28 | entry: python -m pytest -v tests/ -s 29 | language: system 30 | always_run: true 31 | pass_filenames: false -------------------------------------------------------------------------------- /doc/authorization.rst: -------------------------------------------------------------------------------- 1 | .. _authorization: 2 | 3 | Authorization 4 | ************* 5 | 6 | In order to use this package, it is **recommended** that you follow one of the 7 | below methods for authorizing access to your AWS instance: 8 | 9 | - Create local environment variables containing your credentials, 10 | e.g. ``ACCESS_KEY``, ``SECRET_KEY``, and ``SESSION_TOKEN``. 11 | - Create a shared credentials file, i.e. ``~/.aws/credentials``. 12 | - Create an AWS config file, i.e. ``~/.aws/config``. 13 | 14 | For additional details concerning how to authorize access, check the 15 | `boto3 documentation `_. 16 | 17 | For additional details concerning how to configure an AWS credentials file 18 | on your machine, check the `AWS CLI documentation `_. -------------------------------------------------------------------------------- /doc/_static/custom.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;1,400&display=swap'); 2 | 3 | body { 4 | font-family: 'Lato', sans-serif; 5 | font-size: 18px; 6 | } 7 | 8 | html[data-theme="dark"] img { 9 | filter: none; 10 | } 11 | html[data-theme="dark"] .bd-content img:not(.only-dark):not(.dark-light) { 12 | background: unset; 13 | } 14 | 15 | section { 16 | margin-bottom: 2rem; 17 | } 18 | 19 | dl.field-list, div.seealso { 20 | margin-top: 1.5rem; 21 | margin-bottom: 1.5rem; 22 | } 23 | 24 | code { 25 | background-color: rgba(200, 200, 200, 0.07); 26 | padding: 0.1em 0.2em; 27 | } 28 | 29 | h1 { 30 | font-size: 2.2rem; 31 | } 32 | 33 | h2 { 34 | font-size: 1.6rem; 35 | } 36 | 37 | div.highlight pre { 38 | padding: 1em; 39 | } 40 | 41 | .bd-content { 42 | max-width: 90ch; 43 | } 44 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. image:: brs.png 2 | :align: center 3 | 4 | | 5 | 6 | .. toctree:: 7 | :maxdepth: 1 8 | :hidden: 9 | 10 | Raison d'Être 11 | Q&A 12 | Installation 13 | Usage 14 | Modules 15 | Authorization 16 | Contributing 17 | 18 | boto3-refresh-session 19 | --------------------- 20 | 21 | **Version:** |release| 22 | 23 | **Useful Links:** 24 | :ref:`Raison d'Être ` | 25 | :ref:`Q&A ` | 26 | :ref:`Installation ` | 27 | :ref:`Usage ` | 28 | :ref:`Modules ` | 29 | :ref:`Authorization ` 30 | 31 | **Authors:** `Mike Letts `_ 32 | 33 | boto3-refresh-session is a simple Python package for refreshing the temporary security credentials in a :class:`boto3.session.Session` object automatically. -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | boto3-refresh-session 2 | Copyright 2024 Michael Letts 3 | 4 | boto3-refresh-session (BRS) includes software designed and developed by Michael Letts (the author). 5 | 6 | Although the author was formerly employed by Amazon, this project was conceived, designed, developed, and released independently after that period of employment. It is not affiliated with or endorsed by Amazon Web Services (AWS), Amazon, or any contributors to boto3 or botocore, regardless of their employment status. 7 | 8 | Developers are welcome and encouraged to modify and adapt this software to suit their needs — this is in fact already common practice among some of the largest users of BRS. 9 | 10 | If you find boto3-refresh-session (BRS) helpful in your work, a note of thanks or a mention in your project’s documentation or acknowledgments is appreciated — though not required under the terms of the MIT License. 11 | 12 | Licensed under the MIT License. See the LICENSE file for details. 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Mike Letts 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /doc/modules/sts.rst: -------------------------------------------------------------------------------- 1 | .. _sts: 2 | 3 | .. currentmodule:: boto3_refresh_session.methods.sts 4 | 5 | boto3_refresh_session.methods.sts 6 | ================================= 7 | 8 | Implements the STS-based credential refresh strategy for use with 9 | :class:`boto3_refresh_session.session.RefreshableSession`. 10 | 11 | This module defines the :class:`STSRefreshableSession` class, which uses 12 | IAM role assumption via STS to automatically refresh temporary credentials 13 | in the background. 14 | 15 | .. versionadded:: 1.1.0 16 | 17 | Examples 18 | -------- 19 | >>> from boto3_refresh_session import RefreshableSession 20 | >>> session = RefreshableSession( 21 | ... method="sts", 22 | ... assume_role_kwargs={ 23 | ... "RoleArn": "arn:aws:iam::123456789012:role/MyRole", 24 | ... "RoleSessionName": "my-session" 25 | ... }, 26 | ... region_name="us-east-1" 27 | ... ) 28 | >>> s3 = session.client("s3") 29 | >>> s3.list_buckets() 30 | 31 | .. seealso:: 32 | :class:`boto3_refresh_session.session.RefreshableSession` 33 | 34 | STS 35 | --- 36 | 37 | .. autosummary:: 38 | :toctree: generated/ 39 | :nosignatures: 40 | 41 | STSRefreshableSession -------------------------------------------------------------------------------- /doc/modules/session.rst: -------------------------------------------------------------------------------- 1 | .. _session: 2 | 3 | .. currentmodule:: boto3_refresh_session.session 4 | 5 | boto3_refresh_session.session 6 | ============================= 7 | 8 | This module provides the main interface for constructing refreshable boto3 sessions. 9 | 10 | The ``RefreshableSession`` class serves as a factory that dynamically selects the appropriate 11 | credential refresh strategy based on the ``method`` parameter, e.g., ``sts``. 12 | 13 | Users can interact with AWS services just like they would with a normal :class:`boto3.session.Session`, 14 | with the added benefit of automatic credential refreshing. 15 | 16 | Examples 17 | -------- 18 | >>> from boto3_refresh_session import RefreshableSession 19 | >>> session = RefreshableSession( 20 | ... assume_role_kwargs={"RoleArn": "...", "RoleSessionName": "..."}, 21 | ... region_name="us-east-1" 22 | ... ) 23 | >>> s3 = session.client("s3") 24 | >>> s3.list_buckets() 25 | 26 | .. seealso:: 27 | :class:`boto3_refresh_session.methods.custom.CustomRefreshableSession` 28 | :class:`boto3_refresh_session.methods.iot.IOTX509RefreshableSession` 29 | :class:`boto3_refresh_session.methods.sts.STSRefreshableSession` 30 | 31 | Factory interface 32 | ----------------- 33 | .. autosummary:: 34 | :toctree: generated/ 35 | :nosignatures: 36 | 37 | RefreshableSession -------------------------------------------------------------------------------- /boto3_refresh_session/methods/iot/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __all__ = ["IoTRefreshableSession"] 4 | 5 | from typing import get_args 6 | 7 | from ...exceptions import BRSError 8 | from ...utils import ( 9 | BaseIoTRefreshableSession, 10 | BaseRefreshableSession, 11 | IoTAuthenticationMethod, 12 | ) 13 | 14 | 15 | class IoTRefreshableSession(BaseRefreshableSession, registry_key="iot"): 16 | def __new__( 17 | cls, 18 | authentication_method: IoTAuthenticationMethod = "x509", 19 | **kwargs, 20 | ) -> BaseIoTRefreshableSession: 21 | if authentication_method not in ( 22 | methods := cls.get_available_authentication_methods() 23 | ): 24 | raise BRSError( 25 | f"{authentication_method!r} is an invalid authentication " 26 | "method parameter. Available authentication methods are " 27 | f"{', '.join(repr(meth) for meth in methods)}." 28 | ) 29 | 30 | return BaseIoTRefreshableSession.registry[authentication_method]( 31 | **kwargs 32 | ) 33 | 34 | @classmethod 35 | def get_available_authentication_methods(cls) -> list[str]: 36 | args = list(get_args(IoTAuthenticationMethod)) 37 | args.remove("__iot_sentinel__") 38 | return args 39 | -------------------------------------------------------------------------------- /boto3_refresh_session/exceptions.py: -------------------------------------------------------------------------------- 1 | __all__ = ["BRSError", "BRSWarning"] 2 | 3 | import warnings 4 | 5 | 6 | class BRSError(Exception): 7 | """The base exception for boto3-refresh-session. 8 | 9 | Parameters 10 | ---------- 11 | message : str, optional 12 | The message to raise. 13 | """ 14 | 15 | def __init__(self, message: str | None = None): 16 | self.message = "" if message is None else message 17 | super().__init__(self.message) 18 | 19 | def __str__(self) -> str: 20 | return self.message 21 | 22 | def __repr__(self) -> str: 23 | return f"{self.__class__.__name__}({self.message!r})" 24 | 25 | 26 | class BRSWarning(UserWarning): 27 | """The base warning for boto3-refresh-session. 28 | 29 | Parameters 30 | ---------- 31 | message : str, optional 32 | The message to raise. 33 | """ 34 | 35 | def __init__(self, message: str | None = None): 36 | self.message = "" if message is None else message 37 | super().__init__(self.message) 38 | 39 | def __str__(self) -> str: 40 | return self.message 41 | 42 | def __repr__(self) -> str: 43 | return f"{self.__class__.__name__}({self.message!r})" 44 | 45 | @classmethod 46 | def warn(cls, message: str, *, stacklevel: int = 2): 47 | """Emits a BRSWarning with a consistent stacklevel.""" 48 | 49 | warnings.warn(cls(message), stacklevel=stacklevel) 50 | -------------------------------------------------------------------------------- /doc/raison.rst: -------------------------------------------------------------------------------- 1 | .. _raison: 2 | 3 | Raison d'Être 4 | ------------- 5 | 6 | Long-running data pipelines, security tooling, ETL jobs, and cloud automation scripts frequently interact with the AWS API via ``boto3`` — and often run into the same problem: 7 | 8 | **Temporary credentials expire.** 9 | 10 | When that happens, engineers typically fall back on one of two strategies: 11 | 12 | - Wrapping AWS calls in ``try/except`` blocks that catch ``ClientError`` exceptions 13 | - Writing ad hoc logic to refresh credentials using ``botocore.credentials`` internals 14 | 15 | Both approaches are fragile, tedious to maintain, and error-prone at scale. 16 | 17 | Over the years, I noticed that every company I worked for — whether a scrappy startup or FAANG — ended up with some variation of the same pattern: 18 | a small in-house module to manage credential refresh, written in haste, duplicated across services, and riddled with edge cases. Things only 19 | got more strange and difficult when I needed to run things in parallel. 20 | 21 | Eventually, I decided to build ``boto3-refresh-session`` as a proper open-source Python package: 22 | 23 | - Fully tested 24 | - Extensible 25 | - Integrated with ``boto3`` idioms 26 | - Equipped with automatic documentation and CI tooling 27 | 28 | **The goal:** to solve a real, recurring problem once — cleanly, consistently, and for everyone -- with multiple refresh strategies. 29 | 30 | If you've ever written the same AWS credential-refresh boilerplate more than once, this library is for you. 31 | -------------------------------------------------------------------------------- /doc/modules/custom.rst: -------------------------------------------------------------------------------- 1 | .. _custom: 2 | 3 | .. currentmodule:: boto3_refresh_session.methods.custom 4 | 5 | boto3_refresh_session.methods.custom 6 | ==================================== 7 | 8 | Implements a custom credential refresh strategy for use with 9 | :class:`boto3_refresh_session.session.RefreshableSession`. 10 | 11 | This module defines the :class:`CustomRefreshableSession` class, which retrieves 12 | temporary credentials using a user provided custom credential object and automatically 13 | refreshes those credentials in the background. 14 | 15 | This module is useful for users with highly sophisticated, novel, or idiosyncratic 16 | authentication flows not included in this library. This module is AWS service agnostic. 17 | Meaning: this module is extremely flexible. 18 | 19 | .. versionadded:: 1.3.0 20 | 21 | Examples 22 | -------- 23 | Write (or import) the callable object for obtaining temporary AWS security credentials. 24 | 25 | >>> def your_custom_credential_getter(your_param, another_param): 26 | >>> ... 27 | >>> return { 28 | >>> 'access_key': ..., 29 | >>> 'secret_key': ..., 30 | >>> 'token': ..., 31 | >>> 'expiry_time': ..., 32 | >>> } 33 | 34 | Pass that callable object to ``RefreshableSession``. 35 | 36 | >>> sess = RefreshableSession( 37 | >>> method='custom', 38 | >>> custom_credentials_method=your_custom_credential_getter, 39 | >>> custom_credentials_method_args=..., 40 | >>> ) 41 | 42 | .. seealso:: 43 | :class:`boto3_refresh_session.session.RefreshableSession` 44 | 45 | Custom 46 | ------ 47 | 48 | .. autosummary:: 49 | :toctree: generated/ 50 | :nosignatures: 51 | 52 | CustomRefreshableSession -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "boto3-refresh-session" 3 | version = "5.1.8" 4 | description = "A simple Python package for refreshing the temporary security credentials in a boto3.session.Session object automatically." 5 | authors = [ 6 | {name = "Mike Letts",email = "lettsmt@gmail.com"} 7 | ] 8 | license = {text = "MIT"} 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | dependencies = ["boto3", "botocore", "requests", "typing-extensions", "awscrt", "awsiotsdk"] 12 | keywords = ["boto3", "botocore", "aws", "sts", "credentials", "token", "refresh", "iot", "x509", "mqtt"] 13 | maintainers = [ 14 | {name="Michael Letts", email="lettsmt@gmail.com"}, 15 | ] 16 | 17 | [tool.poetry] 18 | include = ["NOTICE"] 19 | 20 | [project.urls] 21 | repository = "https://github.com/michaelthomasletts/boto3-refresh-session" 22 | documentation = "https://michaelthomasletts.github.io/boto3-refresh-session/index.html" 23 | 24 | [build-system] 25 | requires = ["poetry-core>=2.0.0,<3.0.0"] 26 | build-backend = "poetry.core.masonry.api" 27 | 28 | [tool.poetry.group.dev.dependencies] 29 | pytest = "^8.3.4" 30 | black = "^24.10.0" 31 | isort = "^5.13.2" 32 | flake8 = "^7.1.1" 33 | pre-commit = "^4.0.1" 34 | sphinx = "^8.1.3" 35 | pydata-sphinx-theme = "^0.16.1" 36 | numpydoc = "^1.8.0" 37 | tomlkit = "^0.13.2" 38 | flask = "^3.1.1" 39 | 40 | [tool.black] 41 | line-length = 79 42 | target-version = ["py310"] 43 | quiet = true 44 | 45 | [tool.isort] 46 | line_length = 79 47 | ensure_newline_before_comments = true 48 | use_parentheses = true 49 | include_trailing_comma = true 50 | multi_line_output = 3 51 | 52 | [tool.pytest.ini_options] 53 | log_cli = true 54 | log_cli_level = "INFO" 55 | log_cli_date_format = "%Y-%m-%d %H:%M:%S" 56 | log_cli_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## All submissions 2 | 3 | * [ ] Did you include the version part parameter, i.e. [major | minor | patch], to the beginning of the pull request title so that the version is bumped correctly? 4 | * Example pull request title: '[minor] Added a new parameter to the `RefreshableSession` object.' 5 | * Note: the version part parameter is only required for major and minor updates. Patches may exclude the part parameter from the pull request title, as the default is 'patch'. 6 | * [ ] Did you verify that your changes pass pre-commit checks before opening this pull request? 7 | * The pre-commit checks are identical to required status checks for pull requests in this repository. Know that suppressing pre-commit checks via the `--no-verify` | `-nv` arguments will not help you avoid the status checks! 8 | * To ensure that pre-commit checks work on your branch before running `git commit`, run `pre-commit install` and `pre-commit install-hooks` beforehand. 9 | * [ ] Have you checked that your changes don't relate to other open pull requests? 10 | 11 | 12 | 13 | ## New feature submissions 14 | 15 | * [ ] Does your new feature include documentation? If not, why not? 16 | * [ ] Does that documentation match the numpydoc guidelines? 17 | * [ ] Did you locally test your documentation changes using `sphinx-build doc doc/_build` from the root directory? 18 | * [ ] Did you write unit tests for the new feature? If not, why not? 19 | * [ ] Did the unit tests pass? 20 | * [ ] Did you know that locally running unit tests requires an AWS account? 21 | * You must create a ROLE_ARN environment variable on your machine using `export ROLE_ARN=`. 22 | 23 | ## Submission details 24 | 25 | Describe your changes here. Be detailed! -------------------------------------------------------------------------------- /doc/modules/iot.rst: -------------------------------------------------------------------------------- 1 | .. _iot: 2 | 3 | .. currentmodule:: boto3_refresh_session.methods.iot 4 | 5 | boto3_refresh_session.methods.iot 6 | ================================= 7 | 8 | This module currently only supports X.509 certificate based authentication for retrieving 9 | temporary security credentials from the AWS IoT credentials provider (backed by STS). 10 | In the future, this module may support additional authentication methods like Cognito. 11 | MQTT actions are available! 12 | 13 | .. versionadded:: 5.0.0 14 | 15 | Examples 16 | -------- 17 | 18 | ``private_key`` is optional if ``pkcs11`` is provided instead. 19 | 20 | Additionally, if you prefer to explicitly pass the certificate, private key, and-or CA 21 | then you may pass those as bytes. If a string is provided then it will be assumed to be 22 | the location of those files on disk. 23 | 24 | >>> session = RefreshableSession( 25 | >>> method="iot", 26 | >>> role_alias=, 27 | >>> endpoint="https://.credentials.iot..amazonaws.com", 28 | >>> certificate="/path/certificate.pem.crt", 29 | >>> private_key="/path/private.pem.key", 30 | >>> ca=".pem", 31 | >>> ... 32 | >>> ) 33 | 34 | You may also interact with MQTT actions using this session! 35 | 36 | >>> from awscrt.mqtt.QoS import AT_LEAST_ONCE 37 | >>> conn = session.mqtt( 38 | >>> endpoint="-ats.iot..amazonaws.com", 39 | >>> client_id="", 40 | >>> ) 41 | >>> conn.connect() 42 | >>> conn.connect().result() 43 | >>> conn.publish(topic="foo/bar", payload=b"hi", qos=AT_LEAST_ONCE) 44 | >>> conn.disconnect().result() 45 | 46 | .. seealso:: 47 | :class:`boto3_refresh_session.session.RefreshableSession` 48 | 49 | IoT 50 | --- 51 | 52 | .. autosummary:: 53 | :toctree: generated/ 54 | :nosignatures: 55 | 56 | IOTX509RefreshableSession -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Lint, format, and test 2 | 3 | on: 4 | pull_request: 5 | types: [labeled, opened, synchronize, reopened] 6 | branches: 7 | - '*' 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | name: Run tests, linting, and formatting 16 | runs-on: ubuntu-latest 17 | env: 18 | ROLE_ARN: ${{ secrets.ROLE_ARN }} 19 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 20 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 21 | steps: 22 | - name: Check out repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Python 26 | id: setup-python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: '3.10' 30 | 31 | - name: Install Poetry 32 | uses: snok/install-poetry@v1 33 | with: 34 | virtualenvs-create: true 35 | virtualenvs-in-project: true 36 | virtualenvs-path: .venv 37 | installer-parallel: true 38 | 39 | - name: Cache Poetry dependencies 40 | id: cached-poetry-dependencies 41 | uses: actions/cache@v4 42 | with: 43 | path: .venv 44 | key: poetry-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock', '**/pyproject.toml') }} 45 | 46 | - name: Install dependencies 47 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 48 | run: poetry install --no-interaction --no-root --all-groups 49 | 50 | - name: Install project 51 | run: poetry install --no-interaction --all-groups 52 | 53 | - name: Check formatting with Black 54 | uses: psf/black@stable 55 | with: 56 | options: ". --check" 57 | 58 | - name: Lint with Flake8 59 | uses: reviewdog/action-flake8@v3 60 | with: 61 | github_token: ${{ secrets.GITHUB_TOKEN }} 62 | 63 | - name: Run unit tests 64 | run: | 65 | source .venv/bin/activate 66 | pytest tests/ -v 67 | -------------------------------------------------------------------------------- /doc/modules/index.rst: -------------------------------------------------------------------------------- 1 | .. _modules: 2 | 3 | .. currentmodule:: boto3_refresh_session 4 | 5 | Modules 6 | ======= 7 | 8 | boto3-refresh-session includes multiple modules, grouped into two categories: 9 | 10 | - The core interface (session) 11 | - Individual modules for each supported refresh strategy (e.g., STS) 12 | 13 | .. toctree:: 14 | :maxdepth: 1 15 | :hidden: 16 | 17 | exceptions 18 | session 19 | custom 20 | iot 21 | sts 22 | 23 | Core interface 24 | -------------- 25 | 26 | Basic usage of boto3-refresh-session requires familiarity only with the `session` module. 27 | The :class:`boto3_refresh_session.session.RefreshableSession` class provides a unified interface for all supported credential refresh strategies. 28 | 29 | .. tip:: 30 | 31 | For most users, STS is sufficient — there’s no need to manually specify the ``method`` parameter unless using advanced strategies like ``custom``. 32 | All users should, however, familiarize themselves with the documentation in the Refresh strategies in order to understand required and optional parameters and available methods. 33 | 34 | - :ref:`session` — Factory interface for creating refreshable boto3 sessions 35 | 36 | Refresh strategies 37 | ------------------ 38 | 39 | boto3-refresh-session supports multiple AWS services. 40 | There is also a highly flexible module named "custom" for users with highly sophisticated, novel, or idiosyncratic authentication flows. 41 | 42 | .. tip:: 43 | 44 | It is recommended to use :class:`boto3_refresh_session.session.RefreshableSession` instead of initializing the below classes. 45 | Refer to the below documentation to understand what sort of parameters each refresh strategy requires and what sort of methods 46 | are available. 47 | 48 | Each strategy supported by boto3-refresh-session is encapsulated in its own module below. 49 | 50 | - :ref:`custom` - Refresh strategy using a custom credential refresh strategy 51 | - :ref:`iot` - Refresh strategies for IoT Core 52 | - :ref:`sts` — Refresh strategy using :class:`STS.Client` 53 | 54 | Exceptions and Warnings 55 | ----------------------- 56 | 57 | Mistakes and problems happen. You can find all of the custom exceptions and warnings for boto3-refresh-session below. 58 | 59 | - :ref:`exceptions` - Exceptions and warnings for boto3-refresh-session. -------------------------------------------------------------------------------- /boto3_refresh_session/session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __all__ = ["RefreshableSession"] 4 | 5 | from typing import get_args 6 | 7 | from .exceptions import BRSError 8 | from .utils import BaseRefreshableSession, Method 9 | 10 | 11 | class RefreshableSession: 12 | """Factory class for constructing refreshable boto3 sessions using various 13 | authentication methods, e.g. STS. 14 | 15 | This class provides a unified interface for creating boto3 sessions whose 16 | credentials are automatically refreshed in the background. 17 | 18 | Use ``RefreshableSession(method="...")`` to construct an instance using 19 | the desired method. 20 | 21 | For additional information on required parameters, refer to the See Also 22 | section below. 23 | 24 | Parameters 25 | ---------- 26 | method : Method 27 | The authentication and refresh method to use for the session. Must 28 | match a registered method name. Default is "sts". 29 | 30 | Other Parameters 31 | ---------------- 32 | **kwargs : dict 33 | Additional keyword arguments forwarded to the constructor of the 34 | selected session class. 35 | 36 | See Also 37 | -------- 38 | boto3_refresh_session.methods.custom.CustomRefreshableSession 39 | boto3_refresh_session.methods.iot.IOTX509RefreshableSession 40 | boto3_refresh_session.methods.sts.STSRefreshableSession 41 | """ 42 | 43 | def __new__( 44 | cls, method: Method = "sts", **kwargs 45 | ) -> BaseRefreshableSession: 46 | if method not in (methods := cls.get_available_methods()): 47 | raise BRSError( 48 | f"{method!r} is an invalid method parameter. " 49 | "Available methods are " 50 | f"{', '.join(repr(meth) for meth in methods)}." 51 | ) 52 | 53 | return BaseRefreshableSession.registry[method](**kwargs) 54 | 55 | @classmethod 56 | def get_available_methods(cls) -> list[str]: 57 | """Lists all currently available credential refresh methods. 58 | 59 | Returns 60 | ------- 61 | list[str] 62 | A list of all currently available credential refresh methods, 63 | e.g. 'sts', 'custom'. 64 | """ 65 | 66 | args = list(get_args(Method)) 67 | args.remove("__sentinel__") 68 | return args 69 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from os import getenv 3 | 4 | import boto3 5 | 6 | from boto3_refresh_session import RefreshableSession 7 | 8 | # configuring logging 9 | logging.basicConfig( 10 | level=logging.INFO, 11 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 12 | ) 13 | 14 | # creating logger 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def custom_credentials_method() -> dict[str, str]: 19 | assume_role_kwargs = { 20 | "RoleArn": getenv("ROLE_ARN"), 21 | "RoleSessionName": "unit-testing-custom", 22 | "DurationSeconds": 900, 23 | } 24 | temporary_credentials = boto3.client( 25 | "sts", region_name="us-east-1" 26 | ).assume_role(**assume_role_kwargs)["Credentials"] 27 | return { 28 | "access_key": temporary_credentials.get("AccessKeyId"), 29 | "secret_key": temporary_credentials.get("SecretAccessKey"), 30 | "token": temporary_credentials.get("SessionToken"), 31 | "expiry_time": temporary_credentials.get("Expiration").isoformat(), 32 | } 33 | 34 | 35 | def test_custom(): 36 | region_name = "us-east-1" 37 | session = RefreshableSession( 38 | method="custom", 39 | custom_credentials_method=custom_credentials_method, 40 | region_name=region_name, 41 | ) 42 | s3 = session.client(service_name="s3") 43 | s3.list_buckets() 44 | 45 | 46 | def test_defer_refresh(): 47 | # initializing parameters 48 | region_name = "us-east-1" 49 | assume_role_kwargs = { 50 | "RoleArn": getenv("ROLE_ARN"), 51 | "RoleSessionName": "unit-testing", 52 | "DurationSeconds": 900, 53 | } 54 | sts_client_kwargs = {"region_name": region_name} 55 | 56 | # testing defer_refresh = True 57 | logger.info("Testing RefreshableSession with defer_refresh = True") 58 | session = RefreshableSession( 59 | assume_role_kwargs=assume_role_kwargs, 60 | sts_client_kwargs=sts_client_kwargs, 61 | region_name=region_name, 62 | ) 63 | s3 = session.client(service_name="s3") 64 | s3.list_buckets() 65 | 66 | # testing defer_refresh = False 67 | logger.info("Testing RefreshableSession with defer_refresh = False") 68 | session = RefreshableSession( 69 | defer_refresh=False, 70 | assume_role_kwargs=assume_role_kwargs, 71 | sts_client_kwargs=sts_client_kwargs, 72 | region_name=region_name, 73 | ) 74 | s3 = session.client(service_name="s3") 75 | s3.list_buckets() 76 | -------------------------------------------------------------------------------- /doc/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Thank you for choosing to contribute to this project! 5 | 6 | Please follow the below documentation closely. 7 | 8 | Requirements 9 | ------------ 10 | 11 | - Python 3.10+ 12 | - An AWS account 13 | - An IAM role with ``s3:ListBuckets`` permissions 14 | - A local environment variable named ``ROLE_ARN`` containing your IAM role ARN 15 | 16 | Steps 17 | ----- 18 | 19 | 1. Fork this project. 20 | 2. Clone the newly created (i.e. forked) repository in your account to your local machine. 21 | 3. From your terminal, navigate to where the forked repository was cloned to your local machine, i.e. ``cd boto3-refresh-session``. 22 | 4. Run ``poetry install --all-groups`` 23 | 24 | * This command installs all developer and package dependencies. 25 | 26 | 5. Run ``pre-commit install && pre-commit install-hooks`` 27 | 28 | * This command installs all pre-commit hooks. 29 | 30 | 6. Create a local environment variable containing your role ARN from your terminal by running ``export ROLE_ARN=`` 31 | 7. Make your changes. 32 | 33 | * You will be met by a pull request checklist when you attempt to create a pull request with your changes. Follow that checklist to ensure your changes satisfy the requirements in order to expedite the review process. 34 | 35 | 8. If your changes include an additional dependency, then you will need to run ``poetry add ``. This command will update ``pyproject.toml`` with your dependency. 36 | 9. Commit and push your changes to a branch on the forked repository. 37 | 38 | * ``pre-commit`` will run a few checks when ``git commit`` is run. Those checks **must** succeed for you to proceed to ``git push``! 39 | 40 | 10. Open a pull request that compares your forked repository branch with the ``main`` branch of the production repository. 41 | 11. Upon creation (or update), your pull request will: 42 | 43 | * Trigger status checks 44 | 45 | * .. warning:: 46 | **Forked pull requests cannot use repository secrets!** Therefore, unit tests cannot be performed via Github Actions! Please bear with the codeowners as they evaluate your work, due to that limitation. Include screenshots of successful local ``pre-commit`` runs in order to expedite the review process! Apologies -- this limitation of Github is steadfast, and the codeowners are looking for additional strategies for circumventing this limitation in the meantime. We understand it is frustrating that status checks will fail, no matter what, until a solution is found. 47 | 48 | * Require code owner approval in order to be merged 49 | 50 | 12. Make and submit additional changes, if requested; else, merge your approved pull request. -------------------------------------------------------------------------------- /doc/qanda.rst: -------------------------------------------------------------------------------- 1 | .. _qanda: 2 | 3 | Q&A 4 | --- 5 | 6 | Answers to common questions (and criticisms) about boto3-refresh-session. 7 | 8 | Doesn't boto3 already refresh temporary credentials? 9 | ==================================================== 10 | 11 | **No.** 12 | 13 | Botocore provides methods for *manually* refreshing temporary credentials. 14 | These methods are used internally by boto3-refresh-session, but must otherwise be applied *explicitly* by developers. 15 | 16 | There is **no built-in mechanism** in boto3 for *automatically* refreshing credentials. 17 | This omission can be problematic in production systems. 18 | 19 | The boto3 team has historically declined to support this feature — 20 | despite its availability in other SDKs like 21 | `aws-sdk-go-v2 `_. 22 | 23 | boto3-refresh-session was created specifically to address this gap. 24 | 25 | Is this package really necessary? 26 | ================================= 27 | 28 | If you’re willing to manage temporary credentials yourself, maybe not. 29 | 30 | But if you’d rather avoid boilerplate and use an actively maintained solution, boto3-refresh-session provides a drop-in interface that does the right thing — automatically. 31 | 32 | How are people using boto3-refresh-session? 33 | =========================================== 34 | 35 | Here’s a testimonial from a cybersecurity engineer at a FAANG company: 36 | 37 | *"Most of my work is on tooling related to AWS security, so I'm pretty choosy about boto3 credentials-adjacent code. 38 | I often opt to just write this sort of thing myself so I at least know that I can reason about it. 39 | But I found boto3-refresh-session to be very clean and intuitive. 40 | We're using the `RefreshableSession` class as part of a client cache construct. 41 | We're using AWS Lambda to perform lots of operations across several regions in hundreds of accounts, all day every day. 42 | And it turns out there's a surprising amount of overhead to creating boto3 clients (mostly deserializing service definition JSON), 43 | so we run MUCH more efficiently if we cache clients — all equipped with automatically refreshing sessions."* 44 | 45 | Why aren’t most constructor parameters exposed as attributes? 46 | ============================================================= 47 | 48 | Good question. 49 | 50 | boto3-refresh-session aims to be simple and intuitive. 51 | Parameters like ``defer_refresh`` and ``assume_role_kwargs`` are not part of `boto3`’s interface, and are only useful at initialization. 52 | 53 | Rather than surface them as persistent attributes (which adds noise), the decision was made to treat them as ephemeral setup-time inputs. 54 | 55 | Can I submit a feature request? 56 | =============================== 57 | 58 | It depends. 59 | 60 | If your request adds a general-purpose feature (e.g. CLI tooling), it’s likely to be considered. 61 | 62 | But if your proposal is *highly specific* or *non-generalizable*, you’re encouraged to fork the project and tailor it to your needs. 63 | boto3-refresh-session is MIT-licensed, and local modifications are fully permitted. 64 | 65 | Remember: BRS has thousands of users. 66 | Changes that break compatibility or narrow scope have wide consequences. 67 | 68 | Before submitting a request, ask yourself: 69 | 70 | *“Does this benefit everyone — or just me?”* 71 | -------------------------------------------------------------------------------- /boto3_refresh_session/methods/sts.py: -------------------------------------------------------------------------------- 1 | __all__ = ["STSRefreshableSession"] 2 | 3 | from ..exceptions import BRSWarning 4 | from ..utils import ( 5 | AssumeRoleParams, 6 | BaseRefreshableSession, 7 | Identity, 8 | STSClientParams, 9 | TemporaryCredentials, 10 | refreshable_session, 11 | ) 12 | 13 | 14 | @refreshable_session 15 | class STSRefreshableSession(BaseRefreshableSession, registry_key="sts"): 16 | """A :class:`boto3.session.Session` object that automatically refreshes 17 | temporary AWS credentials using an IAM role that is assumed via STS. 18 | 19 | Parameters 20 | ---------- 21 | assume_role_kwargs : AssumeRoleParams 22 | Required keyword arguments for :meth:`STS.Client.assume_role` (i.e. 23 | boto3 STS client). 24 | defer_refresh : bool, optional 25 | If ``True`` then temporary credentials are not automatically refreshed 26 | until they are explicitly needed. If ``False`` then temporary 27 | credentials refresh immediately upon expiration. It is highly 28 | recommended that you use ``True``. Default is ``True``. 29 | sts_client_kwargs : STSClientParams, optional 30 | Optional keyword arguments for the :class:`STS.Client` object. Do not 31 | provide values for ``service_name`` as they are unnecessary. Default 32 | is None. 33 | 34 | Other Parameters 35 | ---------------- 36 | kwargs : dict 37 | Optional keyword arguments for the :class:`boto3.session.Session` 38 | object. 39 | """ 40 | 41 | def __init__( 42 | self, 43 | assume_role_kwargs: AssumeRoleParams, 44 | sts_client_kwargs: STSClientParams | None = None, 45 | **kwargs, 46 | ): 47 | if "refresh_method" in kwargs: 48 | BRSWarning.warn( 49 | "'refresh_method' cannot be set manually. " 50 | "Reverting to 'sts-assume-role'." 51 | ) 52 | del kwargs["refresh_method"] 53 | 54 | # initializing BRSSession 55 | super().__init__(refresh_method="sts-assume-role", **kwargs) 56 | 57 | # initializing various other attributes 58 | self.assume_role_kwargs = assume_role_kwargs 59 | 60 | if sts_client_kwargs is not None: 61 | # overwriting 'service_name' if if appears in sts_client_kwargs 62 | if "service_name" in sts_client_kwargs: 63 | BRSWarning.warn( 64 | "'sts_client_kwargs' cannot contain values for " 65 | "'service_name'. Reverting to service_name = 'sts'." 66 | ) 67 | del sts_client_kwargs["service_name"] 68 | self._sts_client = self.client( 69 | service_name="sts", **sts_client_kwargs 70 | ) 71 | else: 72 | self._sts_client = self.client(service_name="sts") 73 | 74 | def _get_credentials(self) -> TemporaryCredentials: 75 | temporary_credentials = self._sts_client.assume_role( 76 | **self.assume_role_kwargs 77 | )["Credentials"] 78 | return { 79 | "access_key": temporary_credentials.get("AccessKeyId"), 80 | "secret_key": temporary_credentials.get("SecretAccessKey"), 81 | "token": temporary_credentials.get("SessionToken"), 82 | "expiry_time": temporary_credentials.get("Expiration").isoformat(), 83 | } 84 | 85 | def get_identity(self) -> Identity: 86 | """Returns metadata about the identity assumed. 87 | 88 | Returns 89 | ------- 90 | Identity 91 | Dict containing caller identity according to AWS STS. 92 | """ 93 | 94 | return self._sts_client.get_caller_identity() 95 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from datetime import date 4 | from pathlib import Path 5 | 6 | import tomlkit 7 | 8 | # fetching pyproject.toml 9 | path = Path("../pyproject.toml") 10 | 11 | with path.open("r", encoding="utf-8") as f: 12 | pyproject = tomlkit.parse(f.read()) 13 | 14 | # sphinx config 15 | sys.path.insert(0, os.path.abspath(".")) 16 | sys.path.insert(0, os.path.abspath("..")) 17 | extensions = [ 18 | "sphinx.ext.autodoc", 19 | "numpydoc", 20 | "sphinx.ext.intersphinx", 21 | "sphinx.ext.napoleon", 22 | "sphinx.ext.autosummary", 23 | "sphinx.ext.linkcode", 24 | "sphinx.ext.extlinks", 25 | ] 26 | language = "en" 27 | project = str(pyproject["project"]["name"]) 28 | author = "Michael Letts" 29 | copyright = f"{date.today().year}, {author}" 30 | release = str(pyproject["project"]["version"]) 31 | source_encoding = "utf-8" 32 | source_suffix = ".rst" 33 | pygments_style = "sphinx" 34 | add_function_parentheses = False 35 | templates_path = ["_templates"] 36 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "tests/"] 37 | html_logo = "brs.png" 38 | html_favicon = html_logo 39 | html_title = project 40 | html_theme = "pydata_sphinx_theme" 41 | html_static_path = ["_static"] 42 | html_file_suffix = ".html" 43 | html_sidebars = { 44 | "index": [], 45 | "usage": [], 46 | "authorization": [], 47 | "contributing": [], 48 | "raison": [], 49 | "qanda": [], 50 | "installation": [], 51 | "modules/**": ["sidebar-nav-bs.html", "search-field.html"], 52 | } 53 | html_context = { 54 | "default_mode": "dark", 55 | } 56 | htmlhelp_basename = project 57 | html_css_files = ["custom.css"] 58 | html_theme_options = { 59 | "collapse_navigation": True, 60 | "navbar_end": [ 61 | "search-button", 62 | "navbar-icon-links.html", 63 | ], 64 | "icon_links": [ 65 | { 66 | "name": "GitHub", 67 | "url": f"https://github.com/michaelthomasletts/{project}", 68 | "icon": "fab fa-github-square", 69 | "type": "fontawesome", 70 | }, 71 | { 72 | "name": "PyPI", 73 | "url": f"https://pypi.org/project/{project}/", 74 | "icon": "fab fa-python", 75 | "type": "fontawesome", 76 | }, 77 | ], 78 | } 79 | 80 | # autodoc config 81 | autodoc_default_options = { 82 | "members": True, 83 | "member-order": "bysource", 84 | "exclude-members": "__init__,__new__", 85 | "inherited-members": True, 86 | } 87 | autodoc_typehints = "signature" 88 | autodoc_inherit_docstrings = True 89 | 90 | # numpydoc config 91 | numpydoc_show_class_members = False 92 | numpydoc_show_inherited_class_members = False 93 | numpydoc_attributes_as_param_list = False 94 | numpydoc_class_members_toctree = False 95 | 96 | # napoleon config 97 | napoleon_numpy_docstring = True 98 | napoleon_include_init_with_doc = False 99 | 100 | # autosummary 101 | autosummary_generate = False 102 | 103 | # intersphinx 104 | intersphinx_mapping = { 105 | "boto3": ( 106 | "https://boto3.amazonaws.com/v1/documentation/api/latest/", 107 | None, 108 | ), 109 | } 110 | extlinks = { 111 | "botocore": ( 112 | "https://botocore.amazonaws.com/v1/documentation/api/latest/%s", 113 | "", 114 | ), 115 | } 116 | 117 | 118 | def linkcode_resolve(domain, info): 119 | """Resolves 'source' link in documentation.""" 120 | 121 | if domain != "py": 122 | return None 123 | if not info["module"]: 124 | return None 125 | 126 | filename = info["module"].replace(".", "/") 127 | result = ( 128 | f"https://github.com/michaelthomasletts/{project}/blob/main/" 129 | f"{filename}.py" 130 | ) 131 | return result 132 | -------------------------------------------------------------------------------- /boto3_refresh_session/utils/typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __all__ = [ 4 | "AssumeRoleParams", 5 | "CustomCredentialsMethod", 6 | "CustomCredentialsMethodArgs", 7 | "Identity", 8 | "IoTAuthenticationMethod", 9 | "Method", 10 | "PKCS11", 11 | "RefreshMethod", 12 | "RegistryKey", 13 | "STSClientParams", 14 | "TemporaryCredentials", 15 | "RefreshableTemporaryCredentials", 16 | "Transport", 17 | ] 18 | 19 | from datetime import datetime 20 | from typing import ( 21 | Any, 22 | List, 23 | Literal, 24 | Mapping, 25 | Protocol, 26 | TypeAlias, 27 | TypedDict, 28 | TypeVar, 29 | ) 30 | 31 | try: 32 | from typing import NotRequired # type: ignore[import] 33 | except ImportError: 34 | from typing_extensions import NotRequired 35 | 36 | #: Type alias for all currently available IoT authentication methods. 37 | IoTAuthenticationMethod = Literal["x509", "__iot_sentinel__"] 38 | 39 | #: Type alias for all currently available credential refresh methods. 40 | Method = Literal[ 41 | "custom", 42 | "iot", 43 | "sts", 44 | "__sentinel__", 45 | "__iot_sentinel__", 46 | ] 47 | 48 | #: Type alias for all refresh method names. 49 | RefreshMethod = Literal[ 50 | "custom", 51 | "iot-x509", 52 | "sts-assume-role", 53 | ] 54 | 55 | #: Type alias for all currently registered credential refresh methods. 56 | RegistryKey = TypeVar("RegistryKey", bound=str) 57 | 58 | #: Type alias for values returned by get_identity 59 | Identity: TypeAlias = dict[str, Any] 60 | 61 | #: Type alias for acceptable transports 62 | Transport: TypeAlias = Literal["x509", "ws"] 63 | 64 | 65 | class TemporaryCredentials(TypedDict): 66 | """Temporary IAM credentials.""" 67 | 68 | access_key: str 69 | secret_key: str 70 | token: str 71 | expiry_time: datetime | str 72 | 73 | 74 | class _CustomCredentialsMethod(Protocol): 75 | def __call__(self, **kwargs: Any) -> TemporaryCredentials: ... 76 | 77 | 78 | #: Type alias for custom credential retrieval methods. 79 | CustomCredentialsMethod: TypeAlias = _CustomCredentialsMethod 80 | 81 | #: Type alias for custom credential method arguments. 82 | CustomCredentialsMethodArgs: TypeAlias = Mapping[str, Any] 83 | 84 | 85 | class RefreshableTemporaryCredentials(TypedDict): 86 | """Refreshable IAM credentials. 87 | 88 | Parameters 89 | ---------- 90 | AWS_ACCESS_KEY_ID : str 91 | AWS access key identifier. 92 | AWS_SECRET_ACCESS_KEY : str 93 | AWS secret access key. 94 | AWS_SESSION_TOKEN : str 95 | AWS session token. 96 | """ 97 | 98 | AWS_ACCESS_KEY_ID: str 99 | AWS_SECRET_ACCESS_KEY: str 100 | AWS_SESSION_TOKEN: str 101 | 102 | 103 | class Tag(TypedDict): 104 | Key: str 105 | Value: str 106 | 107 | 108 | class PolicyDescriptorType(TypedDict): 109 | arn: str 110 | 111 | 112 | class ProvidedContext(TypedDict): 113 | ProviderArn: str 114 | ContextAssertion: str 115 | 116 | 117 | class AssumeRoleParams(TypedDict): 118 | RoleArn: str 119 | RoleSessionName: str 120 | PolicyArns: NotRequired[List[PolicyDescriptorType]] 121 | Policy: NotRequired[str] 122 | DurationSeconds: NotRequired[int] 123 | ExternalId: NotRequired[str] 124 | SerialNumber: NotRequired[str] 125 | TokenCode: NotRequired[str] 126 | Tags: NotRequired[List[Tag]] 127 | TransitiveTagKeys: NotRequired[List[str]] 128 | SourceIdentity: NotRequired[str] 129 | ProvidedContexts: NotRequired[List[ProvidedContext]] 130 | 131 | 132 | class STSClientParams(TypedDict): 133 | region_name: NotRequired[str] 134 | api_version: NotRequired[str] 135 | use_ssl: NotRequired[bool] 136 | verify: NotRequired[bool | str] 137 | endpoint_url: NotRequired[str] 138 | aws_access_key_id: NotRequired[str] 139 | aws_secret_access_key: NotRequired[str] 140 | aws_session_token: NotRequired[str] 141 | config: NotRequired[Any] 142 | aws_account_id: NotRequired[str] 143 | 144 | 145 | class PKCS11(TypedDict): 146 | pkcs11_lib: str 147 | user_pin: NotRequired[str] 148 | slot_id: NotRequired[int] 149 | token_label: NotRequired[str | None] 150 | private_key_label: NotRequired[str | None] 151 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | *.DS_Store 29 | .DS_Store 30 | .tox/* 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # UV 101 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | #uv.lock 105 | 106 | # poetry 107 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 108 | # This is especially recommended for binary packages to ensure reproducibility, and is more 109 | # commonly ignored for libraries. 110 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 111 | poetry.lock 112 | .poetry 113 | 114 | # pdm 115 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 116 | #pdm.lock 117 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 118 | # in version control. 119 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 120 | .pdm.toml 121 | .pdm-python 122 | .pdm-build/ 123 | 124 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 125 | __pypackages__/ 126 | 127 | # Celery stuff 128 | celerybeat-schedule 129 | celerybeat.pid 130 | 131 | # SageMath parsed files 132 | *.sage.py 133 | 134 | # Environments 135 | .env 136 | .venv 137 | env/ 138 | venv/ 139 | ENV/ 140 | env.bak/ 141 | venv.bak/ 142 | 143 | # Spyder project settings 144 | .spyderproject 145 | .spyproject 146 | 147 | # Rope project settings 148 | .ropeproject 149 | 150 | # mkdocs documentation 151 | /site 152 | 153 | # mypy 154 | .mypy_cache/ 155 | .dmypy.json 156 | dmypy.json 157 | 158 | # Pyre type checker 159 | .pyre/ 160 | 161 | # pytype static type analyzer 162 | .pytype/ 163 | 164 | # Cython debug symbols 165 | cython_debug/ 166 | 167 | # PyCharm 168 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 169 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 170 | # and can be added to the global gitignore or merged into this file. For a more nuclear 171 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 172 | #.idea/ 173 | 174 | # PyPI configuration file 175 | .pypirc 176 | 177 | # docs 178 | doc/_build 179 | _build 180 | 181 | # VS Code 182 | .vscode/ -------------------------------------------------------------------------------- /boto3_refresh_session/methods/custom.py: -------------------------------------------------------------------------------- 1 | __all__ = ["CustomRefreshableSession"] 2 | 3 | from ..exceptions import BRSError, BRSWarning 4 | from ..utils import ( 5 | BaseRefreshableSession, 6 | CustomCredentialsMethod, 7 | CustomCredentialsMethodArgs, 8 | Identity, 9 | TemporaryCredentials, 10 | refreshable_session, 11 | ) 12 | 13 | 14 | @refreshable_session 15 | class CustomRefreshableSession(BaseRefreshableSession, registry_key="custom"): 16 | """A :class:`boto3.session.Session` object that automatically refreshes 17 | temporary credentials returned by a custom credential getter provided 18 | by the user. Useful for users with highly sophisticated or idiosyncratic 19 | authentication flows. 20 | 21 | Parameters 22 | ---------- 23 | custom_credentials_method: CustomCredentialsMethod 24 | Required. Accepts a callable object that returns temporary AWS 25 | security credentials. That object must return a dictionary containing 26 | 'access_key', 'secret_key', 'token', and 'expiry_time' when called. 27 | custom_credentials_method_args : CustomCredentialsMethodArgs, optional 28 | Optional keyword arguments for the function passed to the 29 | ``custom_credentials_method`` parameter. 30 | defer_refresh : bool, optional 31 | If ``True`` then temporary credentials are not automatically refreshed 32 | until they are explicitly needed. If ``False`` then temporary 33 | credentials refresh immediately upon expiration. It is highly 34 | recommended that you use ``True``. Default is ``True``. 35 | 36 | Other Parameters 37 | ---------------- 38 | kwargs : dict 39 | Optional keyword arguments for the :class:`boto3.session.Session` 40 | object. 41 | 42 | Examples 43 | -------- 44 | Write (or import) the callable object for obtaining temporary AWS security 45 | credentials. 46 | 47 | >>> def your_custom_credential_getter(your_param, another_param): 48 | >>> ... 49 | >>> return { 50 | >>> 'access_key': ..., 51 | >>> 'secret_key': ..., 52 | >>> 'token': ..., 53 | >>> 'expiry_time': ..., 54 | >>> } 55 | 56 | Pass that callable object to ``RefreshableSession``. 57 | 58 | >>> sess = RefreshableSession( 59 | >>> method='custom', 60 | >>> custom_credentials_method=your_custom_credential_getter, 61 | >>> custom_credentials_method_args=..., 62 | >>> ) 63 | """ 64 | 65 | def __init__( 66 | self, 67 | custom_credentials_method: CustomCredentialsMethod, 68 | custom_credentials_method_args: ( 69 | CustomCredentialsMethodArgs | None 70 | ) = None, 71 | **kwargs, 72 | ): 73 | if "refresh_method" in kwargs: 74 | BRSWarning.warn( 75 | "'refresh_method' cannot be set manually. " 76 | "Reverting to 'custom'." 77 | ) 78 | del kwargs["refresh_method"] 79 | 80 | # initializing BRSSession 81 | super().__init__(refresh_method="custom", **kwargs) 82 | 83 | # initializing various other attributes 84 | self._custom_get_credentials: CustomCredentialsMethod = ( 85 | custom_credentials_method 86 | ) 87 | self._custom_get_credentials_args: CustomCredentialsMethodArgs = ( 88 | custom_credentials_method_args 89 | if custom_credentials_method_args is not None 90 | else {} 91 | ) 92 | 93 | def _get_credentials(self) -> TemporaryCredentials: 94 | credentials: TemporaryCredentials = self._custom_get_credentials( 95 | **self._custom_get_credentials_args 96 | ) 97 | required_keys = {"access_key", "secret_key", "token", "expiry_time"} 98 | 99 | if missing := required_keys - credentials.keys(): 100 | raise BRSError( 101 | f"The dict returned by custom_credentials_method is missing " 102 | "these key-value pairs: " 103 | f"{', '.join(repr(param) for param in missing)}. " 104 | ) 105 | 106 | return credentials 107 | 108 | def get_identity(self) -> Identity: 109 | """Returns metadata about the custom credential getter. 110 | 111 | Returns 112 | ------- 113 | Identity 114 | Dict containing information about the custom credential getter. 115 | """ 116 | 117 | source = getattr( 118 | self._custom_get_credentials, 119 | "__name__", 120 | repr(self._custom_get_credentials), 121 | ) 122 | return {"method": "custom", "source": repr(source)} 123 | -------------------------------------------------------------------------------- /doc/usage.rst: -------------------------------------------------------------------------------- 1 | .. _usage: 2 | 3 | Usage 4 | ***** 5 | 6 | To use `boto3-refresh-session`, you must have AWS credentials configured locally. 7 | Refer to the :ref:`authorization documentation ` for details on supported authentication methods. 8 | 9 | Basic Initialization 10 | -------------------- 11 | 12 | Everything in `boto3` is ultimately built on the :class:`boto3.session.Session` object — 13 | including ``Client`` and ``Resource`` objects. 14 | `boto3-refresh-session` extends this interface while adding automatic credential refresh. 15 | 16 | Creating a session is straightforward: 17 | 18 | .. code-block:: python 19 | 20 | from boto3_refresh_session import RefreshableSession 21 | 22 | assume_role_kwargs = { 23 | "RoleArn": "", 24 | "RoleSessionName": "", 25 | } 26 | 27 | session = RefreshableSession( 28 | assume_role_kwargs=assume_role_kwargs 29 | ) 30 | 31 | s3 = session.client('s3') 32 | 33 | You can also create a ``Resource`` the same way: 34 | 35 | .. code-block:: python 36 | 37 | s3 = session.resource('s3') 38 | 39 | Optional: set this session globally as the default for `boto3`: 40 | 41 | .. code-block:: python 42 | 43 | import boto3 44 | boto3.DEFAULT_SESSION = session 45 | s3 = boto3.client('s3') # will use the custom session automatically 46 | 47 | Parameters 48 | ---------- 49 | 50 | At a minimum, you must provide parameters for the STS ``assume_role`` call via ``assume_role_kwargs``: 51 | 52 | .. code-block:: python 53 | 54 | assume_role_kwargs = { 55 | "RoleArn": "", 56 | "RoleSessionName": "", 57 | "DurationSeconds": 3600, # optional 58 | } 59 | 60 | Optional keyword arguments for the underlying ``boto3.client("sts")`` can be passed via ``sts_client_kwargs``: 61 | 62 | .. code-block:: python 63 | 64 | sts_client_kwargs = { 65 | "config": Config(retries={"max_attempts": 5}) 66 | } 67 | 68 | And any arguments accepted by :class:`boto3.session.Session` (e.g., ``region_name``, etc.) can be passed directly: 69 | 70 | .. code-block:: python 71 | 72 | session = RefreshableSession( 73 | assume_role_kwargs=assume_role_kwargs, 74 | sts_client_kwargs=sts_client_kwargs, 75 | region_name="us-east-1" 76 | ) 77 | 78 | Refresh Behavior 79 | ---------------- 80 | 81 | There are two ways to trigger automatic credential refresh: 82 | 83 | 1. **Deferred (default)** — Refresh occurs only when credentials are required 84 | 2. **Eager** — Credentials are refreshed as soon as they expire 85 | 86 | Set ``defer_refresh`` to False to enable eager refresh: 87 | 88 | .. code-block:: python 89 | 90 | session = RefreshableSession( 91 | defer_refresh=False, 92 | assume_role_kwargs=assume_role_kwargs 93 | ) 94 | 95 | .. warning:: 96 | It is **highly recommended** to use the default: ``defer_refresh=True``. 97 | Eager refresh adds overhead and is only suitable for low-latency systems that cannot tolerate refresh delays. 98 | 99 | Parallel Usage and Performance 100 | ------------------------------ 101 | 102 | If you're working with large datasets and sensitive value detection or redaction, 103 | you may wish to use ``boto3-refresh-session`` in parallel. 104 | 105 | The core session class is thread-safe and compatible with Python’s ``concurrent.futures`` or ``multiprocessing``. 106 | 107 | To maximize throughput: 108 | 109 | - Reuse a single ``RefreshableSession`` object across threads or subprocesses 110 | - Use ``defer_refresh=True`` to avoid concurrent refreshes at process boundaries 111 | - Mount the session into a poolable or global shared object 112 | 113 | **Example (using concurrent.futures):** 114 | 115 | .. code-block:: python 116 | 117 | from concurrent.futures import ThreadPoolExecutor 118 | from boto3_refresh_session import RefreshableSession 119 | 120 | session = RefreshableSession(assume_role_kwargs={...}) 121 | 122 | def upload_one(bucket, key, body): 123 | s3 = session.client("s3") 124 | s3.put_object(Bucket=bucket, Key=key, Body=body) 125 | 126 | with ThreadPoolExecutor() as executor: 127 | futures = [ 128 | executor.submit(upload_one, "my-bucket", f"file-{i}", b"data") 129 | for i in range(10) 130 | ] 131 | 132 | for future in futures: 133 | future.result() 134 | 135 | .. note:: 136 | 137 | For process-based concurrency (e.g., ``ProcessPoolExecutor``), initialize the session 138 | **before** spawning or forking the pool. This ensures memory is shared efficiently via copy-on-write, 139 | and avoids unnecessary duplication of temporary credentials. 140 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Bump version, publish to PyPI, tag, and deploy docs 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - "boto3_refresh_session/**" 8 | - "README.md" 9 | - "doc/brs.png" 10 | 11 | permissions: 12 | contents: write # needed for pushing commits and tags 13 | 14 | jobs: 15 | bump_version: 16 | if: | 17 | !contains(github.event.head_commit.message, '[skip release]') && 18 | github.event_name == 'push' && 19 | github.ref == 'refs/heads/main' 20 | 21 | name: Bump Version, Publish to PyPI, tag, and deploy docs 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Check out repository 25 | uses: actions/checkout@v4 26 | with: 27 | token: ${{ secrets.GH_PAT }} 28 | 29 | - name: Set up Python 30 | uses: actions/setup-python@v5 31 | id: setup-python 32 | with: 33 | python-version: '3.10' 34 | 35 | - name: Install Poetry 36 | uses: snok/install-poetry@v1 37 | with: 38 | virtualenvs-create: true 39 | virtualenvs-in-project: true 40 | virtualenvs-path: .venv 41 | installer-parallel: true 42 | 43 | - name: Cache Poetry dependencies 44 | id: cached-poetry-dependencies 45 | uses: actions/cache@v4 46 | with: 47 | path: .venv 48 | key: poetry-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock', '**/pyproject.toml') }} 49 | 50 | - name: Install dependencies (no-root) 51 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 52 | run: poetry install --no-interaction --no-root --all-groups 53 | 54 | - name: Determine version part to update 55 | run: | 56 | VERSION_TYPE="patch" 57 | MESSAGE=$(git log -1 --pretty=%B) 58 | 59 | if echo "$MESSAGE" | grep -qi '\[major\]'; then 60 | VERSION_TYPE="major" 61 | elif echo "$MESSAGE" | grep -qi '\[minor\]'; then 62 | VERSION_TYPE="minor" 63 | fi 64 | 65 | echo "Determined VERSION_TYPE=$VERSION_TYPE" 66 | echo "VERSION_TYPE=$VERSION_TYPE" >> $GITHUB_ENV 67 | 68 | - name: Bump version with Poetry 69 | run: | 70 | poetry version $VERSION_TYPE 71 | echo "NEW_VERSION=$(poetry version -s)" >> $GITHUB_ENV 72 | 73 | - name: Update __version__ in package __init__.py 74 | run: | 75 | python - << 'PY' 76 | import re, subprocess, sys 77 | from pathlib import Path 78 | 79 | # Get the version Poetry just set in pyproject.toml 80 | ver = subprocess.check_output(["poetry", "version", "-s"], text=True).strip() 81 | 82 | pkg_init = Path("boto3_refresh_session/__init__.py") 83 | if not pkg_init.exists(): 84 | print("No __init__.py found; skipping.") 85 | sys.exit(0) 86 | 87 | text = pkg_init.read_text(encoding="utf-8") 88 | 89 | # Replace existing __version__ line if present 90 | pattern = re.compile(r'(?m)^(?P__version__\s*=\s*)([\'"])(?P.*?)(\2)') 91 | if pattern.search(text): 92 | new_text = pattern.sub(lambda m: f"{m.group('prefix')}{m.group(2)}{ver}{m.group(2)}", text, count=1) 93 | else: 94 | # Otherwise, try to insert after the last import; fall back to appending 95 | import_pat = re.compile(r'(?m)^(?:from\s+\S+\s+import\s+.+|import\s+\S+.*)$') 96 | last_import = None 97 | for m in import_pat.finditer(text): 98 | last_import = m 99 | if last_import: 100 | insert_at = last_import.end() 101 | new_text = text[:insert_at] + f"\n__version__ = '{ver}'\n" + text[insert_at:] 102 | else: 103 | new_text = text.rstrip() + f"\n\n__version__ = '{ver}'\n" 104 | 105 | if new_text != text: 106 | pkg_init.write_text(new_text, encoding="utf-8") 107 | print(f"Updated __version__ to {ver}") 108 | else: 109 | print("No change required.") 110 | PY 111 | 112 | - name: Commit version bump 113 | run: | 114 | git config --global user.name "github-actions[bot]" 115 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 116 | git add pyproject.toml boto3_refresh_session/__init__.py || true 117 | if ! git diff --cached --quiet; then 118 | git commit -m "bump $VERSION_TYPE version [skip ci]" 119 | git push origin main 120 | else 121 | echo "No changes to commit." 122 | fi 123 | 124 | - name: Sync local repo to latest 125 | run: | 126 | git fetch origin main --tags 127 | git reset --hard origin/main 128 | 129 | - name: Create tag 130 | run: | 131 | VERSION="${NEW_VERSION:-$(poetry version -s)}" 132 | if git rev-parse "$VERSION" >/dev/null 2>&1; then 133 | echo "Tag $VERSION already exists; skipping." 134 | else 135 | git tag "$VERSION" 136 | git push origin "$VERSION" 137 | fi 138 | 139 | - name: Build wheel and publish to PyPI 140 | env: 141 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} 142 | run: | 143 | poetry install --no-interaction --all-groups 144 | poetry build 145 | poetry publish --no-interaction 146 | 147 | - name: Build Documentation 148 | run: | 149 | source .venv/bin/activate 150 | cd doc/ && make clean && cd .. 151 | sphinx-build doc _build 152 | 153 | - name: Deploy to GitHub Pages 154 | uses: peaceiris/actions-gh-pages@v3 155 | with: 156 | publish_branch: gh-pages 157 | github_token: ${{ secrets.GITHUB_TOKEN }} 158 | publish_dir: _build/ 159 | force_orphan: true -------------------------------------------------------------------------------- /boto3_refresh_session/utils/internal.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "AWSCRTResponse", 3 | "BaseIoTRefreshableSession", 4 | "BaseRefreshableSession", 5 | "BRSSession", 6 | "CredentialProvider", 7 | "Registry", 8 | "refreshable_session", 9 | ] 10 | 11 | from abc import ABC, abstractmethod 12 | from functools import wraps 13 | from typing import Any, Callable, ClassVar, Generic, TypeVar, cast 14 | 15 | from awscrt.http import HttpHeaders 16 | from boto3.session import Session 17 | from botocore.credentials import ( 18 | DeferredRefreshableCredentials, 19 | RefreshableCredentials, 20 | ) 21 | 22 | from ..exceptions import BRSWarning 23 | from .typing import ( 24 | Identity, 25 | IoTAuthenticationMethod, 26 | Method, 27 | RefreshableTemporaryCredentials, 28 | RefreshMethod, 29 | RegistryKey, 30 | TemporaryCredentials, 31 | ) 32 | 33 | 34 | class CredentialProvider(ABC): 35 | """Defines the abstract surface every refreshable session must expose.""" 36 | 37 | @abstractmethod 38 | def _get_credentials(self) -> TemporaryCredentials: ... 39 | 40 | @abstractmethod 41 | def get_identity(self) -> Identity: ... 42 | 43 | 44 | class Registry(Generic[RegistryKey]): 45 | """Gives any hierarchy a class-level registry.""" 46 | 47 | registry: ClassVar[dict[str, type]] = {} 48 | 49 | def __init_subclass__(cls, *, registry_key: RegistryKey, **kwargs: Any): 50 | super().__init_subclass__(**kwargs) 51 | 52 | if registry_key in cls.registry: 53 | BRSWarning.warn( 54 | f"{registry_key!r} already registered. Overwriting." 55 | ) 56 | 57 | if "sentinel" not in registry_key: 58 | cls.registry[registry_key] = cls 59 | 60 | @classmethod 61 | def items(cls) -> dict[str, type]: 62 | """Typed accessor for introspection / debugging.""" 63 | 64 | return dict(cls.registry) 65 | 66 | 67 | # defining this here instead of utils to avoid circular imports lol 68 | T_BRSSession = TypeVar("T_BRSSession", bound="BRSSession") 69 | 70 | #: Type alias for a generic refreshable session type. 71 | BRSSessionType = type[T_BRSSession] 72 | 73 | 74 | def refreshable_session( 75 | cls: BRSSessionType, 76 | ) -> BRSSessionType: 77 | """Wraps cls.__init__ so self.__post_init__ runs after init (if present). 78 | 79 | In plain English: this is essentially a post-initialization hook. 80 | 81 | Returns 82 | ------- 83 | BRSSessionType 84 | The decorated class. 85 | """ 86 | 87 | init = getattr(cls, "__init__", None) 88 | 89 | # synthesize __init__ if undefined in the class 90 | if init in (None, object.__init__): 91 | 92 | def __init__(self, *args, **kwargs): 93 | super(cls, self).__init__(*args, **kwargs) 94 | post = getattr(self, "__post_init__", None) 95 | if callable(post) and not getattr(self, "_post_inited", False): 96 | post() 97 | setattr(self, "_post_inited", True) 98 | 99 | cls.__init__ = __init__ # type: ignore[assignment] 100 | return cls 101 | 102 | # avoids double wrapping 103 | if getattr(init, "__post_init_wrapped__", False): 104 | return cls 105 | 106 | @wraps(init) 107 | def wrapper(self, *args, **kwargs): 108 | init(self, *args, **kwargs) 109 | post = getattr(self, "__post_init__", None) 110 | if callable(post) and not getattr(self, "_post_inited", False): 111 | post() 112 | setattr(self, "_post_inited", True) 113 | 114 | wrapper.__post_init_wrapped__ = True # type: ignore[attr-defined] 115 | cls.__init__ = cast(Callable[..., None], wrapper) 116 | return cls 117 | 118 | 119 | class BRSSession(Session): 120 | """Wrapper for boto3.session.Session. 121 | 122 | Parameters 123 | ---------- 124 | refresh_method : RefreshMethod 125 | The method to use for refreshing temporary credentials. 126 | defer_refresh : bool, default=True 127 | If True, the initial credential refresh is deferred until the 128 | credentials are first accessed. If False, the initial refresh 129 | 130 | Other Parameters 131 | ---------------- 132 | kwargs : Any 133 | Optional keyword arguments for initializing boto3.session.Session. 134 | """ 135 | 136 | def __init__( 137 | self, 138 | refresh_method: RefreshMethod, 139 | defer_refresh: bool | None = None, 140 | **kwargs, 141 | ): 142 | self.refresh_method: RefreshMethod = refresh_method 143 | self.defer_refresh: bool = defer_refresh is not False 144 | super().__init__(**kwargs) 145 | 146 | def __post_init__(self): 147 | if not self.defer_refresh: 148 | self._credentials = RefreshableCredentials.create_from_metadata( 149 | metadata=self._get_credentials(), 150 | refresh_using=self._get_credentials, 151 | method=self.refresh_method, 152 | ) 153 | else: 154 | self._credentials = DeferredRefreshableCredentials( 155 | refresh_using=self._get_credentials, method=self.refresh_method 156 | ) 157 | 158 | def refreshable_credentials(self) -> RefreshableTemporaryCredentials: 159 | """The current temporary AWS security credentials. 160 | 161 | Returns 162 | ------- 163 | RefreshableTemporaryCredentials 164 | Temporary AWS security credentials containing: 165 | AWS_ACCESS_KEY_ID : str 166 | AWS access key identifier. 167 | AWS_SECRET_ACCESS_KEY : str 168 | AWS secret access key. 169 | AWS_SESSION_TOKEN : str 170 | AWS session token. 171 | """ 172 | 173 | creds = self.get_credentials().get_frozen_credentials() 174 | return { 175 | "AWS_ACCESS_KEY_ID": creds.access_key, 176 | "AWS_SECRET_ACCESS_KEY": creds.secret_key, 177 | "AWS_SESSION_TOKEN": creds.token, 178 | } 179 | 180 | @property 181 | def credentials(self) -> RefreshableTemporaryCredentials: 182 | """The current temporary AWS security credentials.""" 183 | 184 | return self.refreshable_credentials() 185 | 186 | 187 | class BaseRefreshableSession( 188 | Registry[Method], 189 | CredentialProvider, 190 | BRSSession, 191 | registry_key="__sentinel__", 192 | ): 193 | """Abstract base class for implementing refreshable AWS sessions. 194 | 195 | Provides a common interface and factory registration mechanism 196 | for subclasses that generate temporary credentials using various 197 | AWS authentication methods (e.g., STS). 198 | 199 | Subclasses must implement ``_get_credentials()`` and ``get_identity()``. 200 | They should also register themselves using the ``method=...`` argument 201 | to ``__init_subclass__``. 202 | 203 | Parameters 204 | ---------- 205 | registry : dict[str, type[BaseRefreshableSession]] 206 | Class-level registry mapping method names to registered session types. 207 | """ 208 | 209 | def __init__(self, **kwargs): 210 | super().__init__(**kwargs) 211 | 212 | 213 | class BaseIoTRefreshableSession( 214 | Registry[IoTAuthenticationMethod], 215 | CredentialProvider, 216 | BRSSession, 217 | registry_key="__iot_sentinel__", 218 | ): 219 | def __init__(self, **kwargs): 220 | super().__init__(**kwargs) 221 | 222 | 223 | class AWSCRTResponse: 224 | """Lightweight response collector for awscrt HTTP.""" 225 | 226 | def __init__(self): 227 | """Initialize to default for when callbacks are called.""" 228 | 229 | self.status_code = None 230 | self.headers = None 231 | self.body = bytearray() 232 | 233 | def on_response(self, http_stream, status_code, headers, **kwargs): 234 | """Process awscrt.io response.""" 235 | 236 | self.status_code = status_code 237 | self.headers = HttpHeaders(headers) 238 | 239 | def on_body(self, http_stream, chunk, **kwargs): 240 | """Process awscrt.io body.""" 241 | 242 | self.body.extend(chunk) 243 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 7 |
8 | A simple Python package for refreshing the temporary security credentials in a boto3.session.Session object automatically. 9 |
10 | 11 |
12 | 13 |
14 | 15 | 16 | pypi_version 20 | 21 | 22 | 23 | py_version 27 | 28 | 29 | 30 | workflow 34 | 35 | 36 | 37 | last_commit 41 | 42 | 43 | 44 | stars 48 | 49 | 50 | 51 | downloads 55 | 56 | 57 | 58 | 59 | documentation 63 | 64 | 65 | 66 | github 70 | 71 | 72 | 73 | qanda 77 | 78 | 79 | 80 | blog 84 | 85 | 86 | 87 | sponsorship 91 | 92 | 93 |
94 | 95 | ## 😛 Features 96 | 97 | - Drop-in replacement for `boto3.session.Session` 98 | - Supports automatic credential refresh for: 99 | - **STS** 100 | - **IoT Core** 101 | - X.509 certificates w/ role aliases over mTLS (PEM files and PKCS#11) 102 | - MQTT actions are available! 103 | - Custom authentication methods 104 | - Natively supports all parameters supported by `boto3.session.Session` 105 | - [Tested](https://github.com/michaelthomasletts/boto3-refresh-session/tree/main/tests), [documented](https://michaelthomasletts.github.io/boto3-refresh-session/index.html), and [published to PyPI](https://pypi.org/project/boto3-refresh-session/) 106 | 107 | ## 😌 Recognition and Testimonials 108 | 109 | [Featured in TL;DR Sec.](https://tldrsec.com/p/tldr-sec-282) 110 | 111 | [Featured in CloudSecList.](https://cloudseclist.com/issues/issue-290) 112 | 113 | Recognized during AWS Community Day Midwest on June 5th, 2025. 114 | 115 | A testimonial from a Cyber Security Engineer at a FAANG company: 116 | 117 | > _Most of my work is on tooling related to AWS security, so I'm pretty choosy about boto3 credentials-adjacent code. I often opt to just write this sort of thing myself so I at least know that I can reason about it. But I found boto3-refresh-session to be very clean and intuitive [...] We're using the RefreshableSession class as part of a client cache construct [...] We're using AWS Lambda to perform lots of operations across several regions in hundreds of accounts, over and over again, all day every day. And it turns out that there's a surprising amount of overhead to creating boto3 clients (mostly deserializing service definition json), so we can run MUCH more efficiently if we keep a cache of clients, all equipped with automatically refreshing sessions._ 118 | 119 | ## 💻 Installation 120 | 121 | ```bash 122 | pip install boto3-refresh-session 123 | ``` 124 | 125 | ## 📝 Usage 126 | 127 |
128 | Core Concepts (click to expand) 129 | 130 | ### Core Concepts 131 | 132 | 1. `RefreshableSession` is the intended interface for using `boto3-refresh-session`. Whether you're using this package to refresh temporary credentials returned by STS, the IoT credential provider (which is really just STS, but I digress), or some custom authentication or credential provider, `RefreshableSession` is where you *ought to* be working when using `boto3-refresh-session`. 133 | 134 | 2. *You can use all of the same keyword parameters normally associated with `boto3.session.Session`!* For instance, suppose you want to pass `region_name` to `RefreshableSession` as a parameter, whereby it's passed to `boto3.session.Session`. That's perfectly fine! Just pass it like you normally would when initializing `boto3.session.Session`. These keyword parameters are *completely optional*, though. If you're confused, the main idea to remember is this: if initializing `boto3.session.Session` *requires* a particular keyword parameter then pass it to `RefreshableSession`; if not, don't worry about it. 135 | 136 | 3. To tell `RefreshableSession` which AWS service you're working with for authentication and credential retrieval purposes (STS vs. IoT vs. some custom credential provider), you'll need to pass a `method` parameter to `RefreshableSession`. Since the `service_name` namespace is already occupied by `boto3.sesssion.Session`, [`boto3-refresh-session` uses `method` instead of "service" so as to avoid confusion](https://github.com/michaelthomasletts/boto3-refresh-session/blob/04acb2adb34e505c4dc95711f6b2f97748a2a489/boto3_refresh_session/utils/typing.py#L40). If you're using `RefreshableSession` for STS, however, then `method` is set to `"sts"` by default. You don't need to pass the `method` keyword argument in that case. 137 | 138 | 4. Using `RefreshableSession` for STS, IoT, or custom flows requires different keyword parameters that are unique to those particular methods. For instance, `STSRefreshableSession`, which is the engine for STS in `boto3-refresh-session`, requires `assume_role_kwargs` and optionally allows `sts_client_kwargs` whereas `CustomRefreshableSession` and `IoTX509RefreshableSession` do not. To familiarize yourself with the keyword parameters for each method, check the documentation for each of those engines [in the Refresh Strategies section here](https://michaelthomasletts.com/boto3-refresh-session/modules/index.html). 139 | 140 | 5. Irrespective of whatever `method` you pass as a keyword parameter, `RefreshableSession` accepts a keyword parameter named `defer_refresh`. Basically, this boolean tells `boto3-refresh-session` either to refresh credentials *the moment they expire* or to *wait until credentials are explicitly needed*. If you are working in a low-latency environment then `defer_refresh = False` might be helpful. For most users, however, `defer_refresh = True` is most desirable. For that reason, `defer_refresh = True` is the default value. Most users, therefore, should not concern themselves too much with this feature. 141 | 142 | 6. Some developers struggle to imagine where `boto3-refresh-session` might be helpful. To figure out if `boto3-refresh-session` is for your use case, or whether `credential_process` satisfies your needs, check out [this blog post](https://michaelthomasletts.com/blog/brs-rationale/). `boto3-refresh-session` is not for every developer or use-case; it is a niche tool. 143 | 144 |
145 | 146 |
147 | Clients and Resources (click to expand) 148 | 149 | ### Clients and Resources 150 | 151 | Most developers who use `boto3` interact primarily with `boto3.client` or `boto3.resource` instead of `boto3.session.Session`. But many developers may not realize that `boto3.session.Session` belies `boto3.client` and `boto3.resource`! In fact, that's precisely what makes `boto3-refresh-session` possible! 152 | 153 | To use the `boto3.client` or `boto3.resource` interface, but with the benefits of `boto3-refresh-session`, you have a few options! 154 | 155 | In the following examples, let's assume you want to use STS for retrieving temporary credentials for the sake of simplicity. Let's also focus specifically on `client`. Switching to `resource` follows the same exact idioms as below, except that `client` must be switched to `resource` in the pseudo-code, obviously. If you are not sure how to use `RefreshableSession` for STS (or custom auth flows) then check the usage instructions in the following sections! 156 | 157 | ##### `RefreshableSession.client` (Recommended) 158 | 159 | So long as you reuse the same `session` object when creating `client` and `resource` objects, this approach can be used everywhere in your code. It is very simple and straight-forward! 160 | 161 | ```python 162 | from boto3_refresh_session import RefreshableSession 163 | 164 | assume_role_kwargs = { 165 | "RoleArn": "", 166 | "RoleSessionName": "", 167 | "DurationSeconds": "", 168 | ... 169 | } 170 | session = RefreshableSession(assume_role_kwargs=assume_role_kwargs) 171 | s3 = session.client("s3") 172 | ``` 173 | 174 | ##### `DEFAULT_SESSION` 175 | 176 | This technique can be helpful if you want to use the same instance of `RefreshableSession` everywhere in your code without reference to `boto3_refresh_session`! 177 | 178 | ```python 179 | from boto3 import DEFAULT_SESSION, client 180 | from boto3_refresh_session import RefreshableSession 181 | 182 | assume_role_kwargs = { 183 | "RoleArn": "", 184 | "RoleSessionName": "", 185 | "DurationSeconds": "", 186 | ... 187 | } 188 | DEFAULT_SESSION = RefreshableSession(assume_role_kwargs=assume_role_kwargs) 189 | s3 = client("s3") 190 | ``` 191 | 192 | ##### `botocore_session` 193 | 194 | ```python 195 | from boto3 import client 196 | from boto3_refresh_session import RefreshableSession 197 | 198 | assume_role_kwargs = { 199 | "RoleArn": "", 200 | "RoleSessionName": "", 201 | "DurationSeconds": "", 202 | ... 203 | } 204 | s3 = client( 205 | service_name="s3", 206 | botocore_session=RefreshableSession(assume_role_kwargs=assume_role_kwargs) 207 | ) 208 | ``` 209 | 210 |
211 | 212 |
213 | STS (click to expand) 214 | 215 | ### STS 216 | 217 | Most developers use AWS STS to assume an IAM role and return a set of temporary security credentials. boto3-refresh-session can be used to ensure those temporary credentials refresh automatically. For additional information on the exact parameters that `RefreshableSession` takes for STS, [check this documentation](https://michaelthomasletts.com/boto3-refresh-session/modules/generated/boto3_refresh_session.methods.sts.STSRefreshableSession.html). 218 | 219 | ```python 220 | import boto3_refresh_session as brs 221 | 222 | # OPTIONAL - you can pass all of the params normally associated with boto3.session.Session 223 | profile_name = "" 224 | region_name = "us-east-1" 225 | ... 226 | 227 | # REQUIRED - as well as all of the params associated with STS.Client.assume_role 228 | assume_role_kwargs = { 229 | "RoleArn": "", 230 | "RoleSessionName": "", 231 | "DurationSeconds": "", 232 | ... 233 | } 234 | 235 | # OPTIONAL - as well as all of the params associated with STS.Client, except for 'service_name' 236 | sts_client_kwargs = { 237 | "region_name": region_name, 238 | ... 239 | } 240 | 241 | # basic initialization of boto3.session.Session 242 | session = brs.RefreshableSession( 243 | assume_role_kwargs=assume_role_kwargs, # required 244 | sts_client_kwargs=sts_client_kwargs, # optional 245 | region_name=region_name, # optional 246 | profile_name=profile_name, # optional 247 | ... # misc. params for boto3.session.Session 248 | ) 249 | ``` 250 | 251 |
252 | 253 |
254 | Custom Authentication Flows (click to expand) 255 | 256 | ### Custom 257 | 258 | If you have a highly sophisticated, novel, or idiosyncratic authentication flow not included in boto3-refresh-session then you will need to provide your own custom temporary credentials callable object. `RefreshableSession` accepts custom credentials callable objects, as shown below. For additional information on the exact parameters that `RefreshableSession` takes for custom authentication flows, [check this documentation](https://michaelthomasletts.com/boto3-refresh-session/modules/generated/boto3_refresh_session.methods.custom.CustomRefreshableSession.html#boto3_refresh_session.methods.custom.CustomRefreshableSession). 259 | 260 | ```python 261 | # create (or import) your custom credential method 262 | def your_custom_credential_getter(...): 263 | ... 264 | return { 265 | "access_key": ..., 266 | "secret_key": ..., 267 | "token": ..., 268 | "expiry_time": ..., 269 | } 270 | 271 | # and pass it to RefreshableSession 272 | session = RefreshableSession( 273 | method="custom", # required 274 | custom_credentials_method=your_custom_credential_getter, # required 275 | custom_credentials_method_args=..., # optional 276 | region_name=region_name, # optional 277 | profile_name=profile_name, # optional 278 | ... # misc. params for boto3.session.Session 279 | ) 280 | ``` 281 | 282 |
283 | 284 |
285 | IoT Core X.509 (click to expand) 286 | 287 | ### IoT Core X.509 288 | 289 | AWS IoT Core can vend temporary AWS credentials through the **credentials provider** when you connect with an X.509 certificate and a **role alias**. `boto3-refresh-session` makes this flow seamless by automatically refreshing credentials over **mTLS**. 290 | 291 | For additional information on the exact parameters that `IOTX509RefreshableSession` takes, [check this documentation](https://michaelthomasletts.com/boto3-refresh-session/modules/generated/boto3_refresh_session.methods.iot.IOTX509RefreshableSession.html). 292 | 293 | ### PEM file 294 | 295 | ```python 296 | import boto3_refresh_session as brs 297 | 298 | # PEM certificate + private key example 299 | session = brs.RefreshableSession( 300 | method="iot", 301 | endpoint=".credentials.iot..amazonaws.com", 302 | role_alias="", 303 | certificate="/path/to/certificate.pem", 304 | private_key="/path/to/private-key.pem", 305 | thing_name="", # optional, if used in policies 306 | duration_seconds=3600, # optional, capped by role alias 307 | region_name="us-east-1", 308 | ) 309 | 310 | # Now you can use the session like any boto3 session 311 | s3 = session.client("s3") 312 | print(s3.list_buckets()) 313 | ``` 314 | 315 | ### PKCS#11 316 | 317 | ```python 318 | session = brs.RefreshableSession( 319 | method="iot", 320 | endpoint=".credentials.iot..amazonaws.com", 321 | role_alias="", 322 | certificate="/path/to/certificate.pem", 323 | pkcs11={ 324 | "pkcs11_lib": "/usr/local/lib/softhsm/libsofthsm2.so", 325 | "user_pin": "1234", 326 | "slot_id": 0, 327 | "token_label": "MyToken", 328 | "private_key_label": "MyKey", 329 | }, 330 | thing_name="", 331 | region_name="us-east-1", 332 | ) 333 | ``` 334 | 335 | ### MQTT 336 | 337 | After initializing a session object, you can can begin making actions with MQTT using the [mqtt method](https://github.com/michaelthomasletts/boto3-refresh-session/blob/deb68222925bf648f26e878ed4bc24b45317c7db/boto3_refresh_session/methods/iot/x509.py#L367)! You can reuse the same certificate, private key, et al as that used to initialize `RefreshableSession`. Or, alternatively, you can provide separate PKCS#11 or certificate information, whether those be file paths or bytes values. Either way, at a minimum, you will need to provide the endpoint and client identifier (i.e. thing name). 338 | 339 | ```python 340 | from awscrt.mqtt.QoS import AT_LEAST_ONCE 341 | conn = session.mqtt( 342 | endpoint="-ats.iot..amazonaws.com", 343 | client_id="", 344 | ) 345 | conn.connect() 346 | conn.connect().result() 347 | conn.publish(topic="foo/bar", payload=b"hi", qos=AT_LEAST_ONCE) 348 | conn.disconnect().result() 349 | ``` 350 | 351 |
352 | 353 | ## ⚠️ Changes 354 | 355 | Browse through the various changes to `boto3-refresh-session` over time. 356 | 357 | #### 😥 v3.0.0 358 | 359 | **The changes introduced by v3.0.0 will not impact ~99% of users** who generally interact with `boto3-refresh-session` by only `RefreshableSession`, *which is the intended usage for this package after all.* 360 | 361 | Advanced users, however, particularly those using low-level objects such as `BaseRefreshableSession | refreshable_session | BRSSession | utils.py`, may experience breaking changes. 362 | 363 | Please review [this PR](https://github.com/michaelthomasletts/boto3-refresh-session/pull/75) for additional details. 364 | 365 | #### ✂️ v4.0.0 366 | 367 | The `ecs` module has been dropped. For additional details and rationale, please review [this PR](https://github.com/michaelthomasletts/boto3-refresh-session/pull/78). 368 | 369 | #### 😛 v5.0.0 370 | 371 | Support for IoT Core via X.509 certificate-based authentication (over HTTPS) is now available! 372 | 373 | #### ➕ v5.1.0 374 | 375 | MQTT support added for IoT Core via X.509 certificate-based authentication. -------------------------------------------------------------------------------- /boto3_refresh_session/methods/iot/x509.py: -------------------------------------------------------------------------------- 1 | __all__ = ["IOTX509RefreshableSession"] 2 | 3 | import json 4 | import re 5 | from atexit import register 6 | from pathlib import Path 7 | from tempfile import NamedTemporaryFile 8 | from typing import cast, get_args 9 | from urllib.parse import ParseResult, urlparse 10 | 11 | from awscrt import auth, io 12 | from awscrt.exceptions import AwsCrtError 13 | from awscrt.http import HttpClientConnection, HttpRequest 14 | from awscrt.io import ( 15 | ClientBootstrap, 16 | ClientTlsContext, 17 | DefaultHostResolver, 18 | EventLoopGroup, 19 | LogLevel, 20 | Pkcs11Lib, 21 | TlsConnectionOptions, 22 | TlsContextOptions, 23 | init_logging, 24 | ) 25 | from awscrt.mqtt import Connection 26 | from awsiot import mqtt_connection_builder 27 | 28 | from ...exceptions import BRSError, BRSWarning 29 | from ...utils import ( 30 | PKCS11, 31 | AWSCRTResponse, 32 | Identity, 33 | TemporaryCredentials, 34 | Transport, 35 | refreshable_session, 36 | ) 37 | from .core import BaseIoTRefreshableSession 38 | 39 | _TEMP_PATHS: list[str] = [] 40 | 41 | 42 | @refreshable_session 43 | class IOTX509RefreshableSession( 44 | BaseIoTRefreshableSession, registry_key="x509" 45 | ): 46 | """A :class:`boto3.session.Session` object that automatically refreshes 47 | temporary credentials returned by the IoT Core credential provider. 48 | 49 | Parameters 50 | ---------- 51 | endpoint : str 52 | The endpoint URL for the IoT Core credential provider. Must contain 53 | '.credentials.iot.'. 54 | role_alias : str 55 | The IAM role alias to use when requesting temporary credentials. 56 | certificate : str | bytes 57 | The X.509 certificate to use when requesting temporary credentials. 58 | ``str`` represents the file path to the certificate, while ``bytes`` 59 | represents the actual certificate data. 60 | thing_name : str, optional 61 | The name of the IoT thing to use when requesting temporary 62 | credentials. Default is None. 63 | private_key : str | bytes | None, optional 64 | The private key to use when requesting temporary credentials. ``str`` 65 | represents the file path to the private key, while ``bytes`` 66 | represents the actual private key data. Optional only if ``pkcs11`` 67 | is provided. Default is None. 68 | pkcs11 : PKCS11, optional 69 | The PKCS#11 library to use when requesting temporary credentials. If 70 | provided, ``private_key`` must be None. 71 | ca : str | bytes | None, optional 72 | The CA certificate to use when verifying the IoT Core endpoint. ``str`` 73 | represents the file path to the CA certificate, while ``bytes`` 74 | represents the actual CA certificate data. Default is None. 75 | verify_peer : bool, optional 76 | Whether to verify the CA certificate when establishing the TLS 77 | connection. Default is True. 78 | timeout : float | int | None, optional 79 | The timeout for the TLS connection in seconds. Default is 10.0. 80 | duration_seconds : int | None, optional 81 | The duration for which the temporary credentials are valid, in 82 | seconds. Cannot exceed the value declared in the IAM policy. 83 | Default is None. 84 | awscrt_log_level : awscrt.LogLevel | None, optional 85 | The logging level for the AWS CRT library, e.g. 86 | ``awscrt.LogLevel.INFO``. Default is None. 87 | 88 | Other Parameters 89 | ---------------- 90 | kwargs : dict, optional 91 | Optional keyword arguments for the :class:`boto3.session.Session` 92 | object. 93 | 94 | Notes 95 | ----- 96 | Gavin Adams at AWS was a major influence on this implementation. 97 | Thank you, Gavin! 98 | """ 99 | 100 | def __init__( 101 | self, 102 | endpoint: str, 103 | role_alias: str, 104 | certificate: str | bytes, 105 | thing_name: str | None = None, 106 | private_key: str | bytes | None = None, 107 | pkcs11: PKCS11 | None = None, 108 | ca: str | bytes | None = None, 109 | verify_peer: bool = True, 110 | timeout: float | int | None = None, 111 | duration_seconds: int | None = None, 112 | awscrt_log_level: LogLevel | None = None, 113 | **kwargs, 114 | ): 115 | # initializing BRSSession 116 | super().__init__(refresh_method="iot-x509", **kwargs) 117 | 118 | # logging 119 | if awscrt_log_level: 120 | init_logging(log_level=awscrt_log_level, file_name="stdout") 121 | 122 | # initializing public attributes 123 | self.endpoint = self._normalize_iot_credential_endpoint( 124 | endpoint=endpoint 125 | ) 126 | self.role_alias = role_alias 127 | self.certificate = self._read_maybe_path_to_bytes( 128 | certificate, fallback=None, name="certificate" 129 | ) 130 | self.thing_name = thing_name 131 | self.private_key = self._read_maybe_path_to_bytes( 132 | private_key, fallback=None, name="private_key" 133 | ) 134 | self.pkcs11 = self._validate_pkcs11(pkcs11) if pkcs11 else None 135 | self.ca = self._read_maybe_path_to_bytes(ca, fallback=None, name="ca") 136 | self.verify_peer = verify_peer 137 | self.timeout = 10.0 if timeout is None else timeout 138 | self.duration_seconds = duration_seconds 139 | 140 | # either private_key or pkcs11 must be provided 141 | if self.private_key is None and self.pkcs11 is None: 142 | raise BRSError( 143 | "Either 'private_key' or 'pkcs11' must be provided." 144 | ) 145 | 146 | # . . . but both cannot be provided! 147 | if self.private_key is not None and self.pkcs11 is not None: 148 | raise BRSError( 149 | "Only one of 'private_key' or 'pkcs11' can be provided." 150 | ) 151 | 152 | def _get_credentials(self) -> TemporaryCredentials: 153 | url = urlparse( 154 | f"https://{self.endpoint}/role-aliases/{self.role_alias}" 155 | "/credentials" 156 | ) 157 | request = HttpRequest("GET", url.path) 158 | request.headers.add("host", str(url.hostname)) 159 | if self.thing_name: 160 | request.headers.add("x-amzn-iot-thingname", self.thing_name) 161 | if self.duration_seconds: 162 | request.headers.add( 163 | "x-amzn-iot-credential-duration-seconds", 164 | str(self.duration_seconds), 165 | ) 166 | response = AWSCRTResponse() 167 | port = 443 if not url.port else url.port 168 | connection = ( 169 | self._mtls_client_connection(url=url, port=port) 170 | if not self.pkcs11 171 | else self._mtls_pkcs11_client_connection(url=url, port=port) 172 | ) 173 | 174 | try: 175 | stream = connection.request( 176 | request, response.on_response, response.on_body 177 | ) 178 | stream.activate() 179 | stream.completion_future.result(float(self.timeout)) 180 | finally: 181 | try: 182 | connection.close() 183 | except Exception: 184 | ... 185 | 186 | if response.status_code == 200: 187 | credentials = json.loads(response.body.decode("utf-8"))[ 188 | "credentials" 189 | ] 190 | return { 191 | "access_key": credentials["accessKeyId"], 192 | "secret_key": credentials["secretAccessKey"], 193 | "token": credentials["sessionToken"], 194 | "expiry_time": credentials["expiration"], 195 | } 196 | else: 197 | raise BRSError( 198 | "Error getting credentials: " 199 | f"{json.loads(response.body.decode())}" 200 | ) 201 | 202 | def _mtls_client_connection( 203 | self, url: ParseResult, port: int 204 | ) -> HttpClientConnection: 205 | event_loop_group: EventLoopGroup = EventLoopGroup() 206 | host_resolver: DefaultHostResolver = DefaultHostResolver( 207 | event_loop_group 208 | ) 209 | bootstrap: ClientBootstrap = ClientBootstrap( 210 | event_loop_group, host_resolver 211 | ) 212 | tls_ctx_opt = TlsContextOptions.create_client_with_mtls( 213 | cert_buffer=self.certificate, key_buffer=self.private_key 214 | ) 215 | 216 | if self.ca: 217 | tls_ctx_opt.override_default_trust_store(self.ca) 218 | 219 | tls_ctx_opt.verify_peer = self.verify_peer 220 | tls_ctx = ClientTlsContext(tls_ctx_opt) 221 | tls_conn_opt: TlsConnectionOptions = cast( 222 | TlsConnectionOptions, tls_ctx.new_connection_options() 223 | ) 224 | tls_conn_opt.set_server_name(str(url.hostname)) 225 | 226 | try: 227 | connection_future = HttpClientConnection.new( 228 | host_name=str(url.hostname), 229 | port=port, 230 | bootstrap=bootstrap, 231 | tls_connection_options=tls_conn_opt, 232 | ) 233 | return connection_future.result(self.timeout) 234 | except AwsCrtError as err: 235 | raise BRSError( 236 | "Error completing mTLS connection to endpoint " 237 | f"'{url.hostname}'" 238 | ) from err 239 | 240 | def _mtls_pkcs11_client_connection( 241 | self, url: ParseResult, port: int 242 | ) -> HttpClientConnection: 243 | event_loop_group: EventLoopGroup = EventLoopGroup() 244 | host_resolver: DefaultHostResolver = DefaultHostResolver( 245 | event_loop_group 246 | ) 247 | bootstrap: ClientBootstrap = ClientBootstrap( 248 | event_loop_group, host_resolver 249 | ) 250 | 251 | if not self.pkcs11: 252 | raise BRSError( 253 | "Attempting to establish mTLS connection using PKCS#11" 254 | "but 'pkcs11' parameter is 'None'!" 255 | ) 256 | 257 | tls_ctx_opt = TlsContextOptions.create_client_with_mtls_pkcs11( 258 | pkcs11_lib=Pkcs11Lib(file=self.pkcs11["pkcs11_lib"]), 259 | user_pin=self.pkcs11["user_pin"], 260 | slot_id=self.pkcs11["slot_id"], 261 | token_label=self.pkcs11["token_label"], 262 | private_key_label=self.pkcs11["private_key_label"], 263 | cert_file_contents=self.certificate, 264 | ) 265 | 266 | if self.ca: 267 | tls_ctx_opt.override_default_trust_store(self.ca) 268 | 269 | tls_ctx_opt.verify_peer = self.verify_peer 270 | tls_ctx = ClientTlsContext(tls_ctx_opt) 271 | tls_conn_opt: TlsConnectionOptions = cast( 272 | TlsConnectionOptions, tls_ctx.new_connection_options() 273 | ) 274 | tls_conn_opt.set_server_name(str(url.hostname)) 275 | 276 | try: 277 | connection_future = HttpClientConnection.new( 278 | host_name=str(url.hostname), 279 | port=port, 280 | bootstrap=bootstrap, 281 | tls_connection_options=tls_conn_opt, 282 | ) 283 | return connection_future.result(self.timeout) 284 | except AwsCrtError as err: 285 | raise BRSError("Error completing mTLS connection.") from err 286 | 287 | def get_identity(self) -> Identity: 288 | """Returns metadata about the current caller identity. 289 | 290 | Returns 291 | ------- 292 | Identity 293 | Dict containing information about the current calleridentity. 294 | """ 295 | 296 | return self.client("sts").get_caller_identity() 297 | 298 | @staticmethod 299 | def _normalize_iot_credential_endpoint(endpoint: str) -> str: 300 | if ".credentials.iot." in endpoint: 301 | return endpoint 302 | 303 | if ".iot." in endpoint and "-ats." in endpoint: 304 | logged_data_endpoint = re.sub(r"^[^. -]+", "***", endpoint) 305 | logged_credential_endpoint = re.sub( 306 | r"^[^. -]+", 307 | "***", 308 | (endpoint := endpoint.replace("-ats.iot", ".credentials.iot")), 309 | ) 310 | BRSWarning.warn( 311 | "The 'endpoint' parameter you provided represents the data " 312 | "endpoint for IoT not the credentials endpoint! The endpoint " 313 | "you provided was therefore modified from " 314 | f"'{logged_data_endpoint}' -> '{logged_credential_endpoint}'" 315 | ) 316 | return endpoint 317 | 318 | raise BRSError( 319 | "Invalid IoT endpoint provided for credentials provider. " 320 | "Expected '.credentials.iot..amazonaws.com'" 321 | ) 322 | 323 | @staticmethod 324 | def _validate_pkcs11(pkcs11: PKCS11) -> PKCS11: 325 | if "pkcs11_lib" not in pkcs11: 326 | raise BRSError( 327 | "PKCS#11 library path must be provided as 'pkcs11_lib'" 328 | " in 'pkcs11'." 329 | ) 330 | elif not Path(pkcs11["pkcs11_lib"]).expanduser().resolve().is_file(): 331 | raise BRSError( 332 | f"'{pkcs11['pkcs11_lib']}' is not a valid file path for " 333 | "'pkcs11_lib' in 'pkcs11'." 334 | ) 335 | pkcs11.setdefault("user_pin", None) 336 | pkcs11.setdefault("slot_id", None) 337 | pkcs11.setdefault("token_label", None) 338 | pkcs11.setdefault("private_key_label", None) 339 | return pkcs11 340 | 341 | @staticmethod 342 | def _read_maybe_path_to_bytes( 343 | v: str | bytes | None, fallback: bytes | None, name: str 344 | ) -> bytes | None: 345 | match v: 346 | case None: 347 | return fallback 348 | case bytes(): 349 | return v 350 | case str() as p if Path(p).expanduser().resolve().is_file(): 351 | return Path(p).expanduser().resolve().read_bytes() 352 | case _: 353 | raise BRSError(f"Invalid {name} provided.") 354 | 355 | @staticmethod 356 | def _bytes_to_tempfile(b: bytes, suffix: str = ".pem") -> str: 357 | f = NamedTemporaryFile("wb", suffix=suffix, delete=False) 358 | f.write(b) 359 | f.flush() 360 | f.close() 361 | _TEMP_PATHS.append(f.name) 362 | return f.name 363 | 364 | @staticmethod 365 | @register 366 | def _cleanup_tempfiles(): 367 | for p in _TEMP_PATHS: 368 | try: 369 | Path(p).unlink(missing_ok=True) 370 | except Exception: 371 | ... 372 | 373 | def mqtt( 374 | self, 375 | *, 376 | endpoint: str, 377 | client_id: str, 378 | transport: Transport = "x509", 379 | certificate: str | bytes | None = None, 380 | private_key: str | bytes | None = None, 381 | ca: str | bytes | None = None, 382 | pkcs11: PKCS11 | None = None, 383 | region: str | None = None, 384 | keep_alive_secs: int = 60, 385 | clean_start: bool = True, 386 | port: int | None = None, 387 | use_alpn: bool = False, 388 | ) -> Connection: 389 | """Establishes an MQTT connection using the specified parameters. 390 | 391 | .. versionadded:: 5.1.0 392 | 393 | Parameters 394 | ---------- 395 | endpoint: str 396 | The MQTT endpoint to connect to. 397 | client_id: str 398 | The client ID to use for the MQTT connection. 399 | transport: Transport 400 | The transport protocol to use (e.g., "x509" or "ws"). 401 | certificate: str | bytes | None, optional 402 | The client certificate to use for the connection. Defaults to the 403 | session certificate. 404 | private_key: str | bytes | None, optional 405 | The private key to use for the connection. Defaults to the 406 | session private key. 407 | ca: str | bytes | None, optional 408 | The CA certificate to use for the connection. Defaults to the 409 | session CA certificate. 410 | pkcs11: PKCS11 | None, optional 411 | PKCS#11 configuration for hardware-backed keys. Defaults to the 412 | session PKCS#11 configuration. 413 | region: str | None, optional 414 | The AWS region to use for the connection. Defaults to the 415 | session region. 416 | keep_alive_secs: int, optional 417 | The keep-alive interval for the MQTT connection. Default is 60 418 | seconds. 419 | clean_start: bool, optional 420 | Whether to start a clean session. Default is True. 421 | port: int | None, optional 422 | The port to use for the MQTT connection. Default is 8883 if not 423 | using ALPN, otherwise 443. 424 | use_alpn: bool, optional 425 | Whether to use ALPN for the connection. Default is False. 426 | 427 | Returns 428 | ------- 429 | awscrt.mqtt.Connection 430 | The established MQTT connection. 431 | """ 432 | 433 | # Validate transport 434 | if transport not in list(get_args(Transport)): 435 | raise BRSError("Transport must be 'x509' or 'ws'") 436 | 437 | # Region default (WS only) 438 | if region is None: 439 | region = self.region_name 440 | 441 | # Normalize inputs to bytes using session defaults 442 | cert_bytes = self._read_maybe_path_to_bytes( 443 | certificate, getattr(self, "certificate", None), "certificate" 444 | ) 445 | key_bytes = self._read_maybe_path_to_bytes( 446 | private_key, getattr(self, "private_key", None), "private_key" 447 | ) 448 | ca_bytes = self._read_maybe_path_to_bytes( 449 | ca, getattr(self, "ca", None), "ca" 450 | ) 451 | 452 | # Validate PKCS#11 453 | match pkcs11: 454 | case None: 455 | pkcs11 = getattr(self, "pkcs11", None) 456 | case dict(): 457 | pkcs11 = self._validate_pkcs11(pkcs11) 458 | case _: 459 | raise BRSError("Invalid PKCS#11 configuration provided.") 460 | 461 | # X.509 invariants 462 | if transport == "x509": 463 | has_key = key_bytes is not None 464 | has_hsm = pkcs11 is not None 465 | if not has_key and not has_hsm: 466 | raise BRSError( 467 | "For transport='x509', provide either 'private_key' " 468 | "(bytes/path) or 'pkcs11'." 469 | ) 470 | if has_key and has_hsm: 471 | raise BRSError( 472 | "Provide only one of 'private_key' or 'pkcs11' for " 473 | "transport='x509'." 474 | ) 475 | if cert_bytes is None: 476 | raise BRSError("Certificate is required for transport='x509'") 477 | 478 | # CRT bootstrap 479 | event_loop = io.EventLoopGroup(1) 480 | host_resolver = io.DefaultHostResolver(event_loop) 481 | bootstrap = io.ClientBootstrap(event_loop, host_resolver) 482 | 483 | # Build connection 484 | if transport == "x509": 485 | if pkcs11 is not None: 486 | # Cert must be a filepath for PKCS#11 builder → write temp 487 | cert_path = self._bytes_to_tempfile( 488 | cast(bytes, cert_bytes), ".crt" 489 | ) 490 | ca_path = ( 491 | self._bytes_to_tempfile(ca_bytes, ".pem") 492 | if ca_bytes 493 | else None 494 | ) 495 | 496 | return mqtt_connection_builder.mtls_with_pkcs11( 497 | endpoint=endpoint, 498 | client_bootstrap=bootstrap, 499 | pkcs11_lib=Pkcs11Lib(file=pkcs11["pkcs11_lib"]), 500 | user_pin=pkcs11.get("user_pin"), 501 | slot_id=pkcs11.get("slot_id"), 502 | token_label=pkcs11.get("token_label"), 503 | private_key_object=pkcs11.get("private_key_label"), 504 | cert_filepath=cert_path, 505 | ca_filepath=ca_path, 506 | client_id=client_id, 507 | clean_session=clean_start, 508 | keep_alive_secs=keep_alive_secs, 509 | port=port or (443 if use_alpn else 8883), 510 | alpn_list=["x-amzn-mqtt-ca"] if use_alpn else None, 511 | ) 512 | else: 513 | # pure mTLS with in-memory cert/key/CA 514 | return mqtt_connection_builder.mtls_from_bytes( 515 | endpoint=endpoint, 516 | cert_bytes=cert_bytes, 517 | pri_key_bytes=key_bytes, 518 | ca_bytes=ca_bytes, 519 | client_bootstrap=bootstrap, 520 | client_id=client_id, 521 | clean_session=clean_start, 522 | keep_alive_secs=keep_alive_secs, 523 | port=port or (443 if use_alpn else 8883), 524 | alpn_list=["x-amzn-mqtt-ca"] if use_alpn else None, 525 | ) 526 | 527 | else: # transport == "ws" 528 | # WebSockets + SigV4 529 | creds_provider = auth.AwsCredentialsProvider.new_delegate( 530 | self._credentials 531 | ) 532 | ca_path = ( 533 | self._bytes_to_tempfile(ca_bytes, ".pem") if ca_bytes else None 534 | ) 535 | 536 | return mqtt_connection_builder.websockets_with_default_aws_signing( 537 | endpoint=endpoint, 538 | client_bootstrap=bootstrap, 539 | region=region, 540 | credentials_provider=creds_provider, 541 | client_id=client_id, 542 | clean_session=clean_start, 543 | keep_alive_secs=keep_alive_secs, 544 | ca_filepath=ca_path, 545 | port=port or 443, 546 | ) 547 | --------------------------------------------------------------------------------