.*?)(\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 |
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 |
--------------------------------------------------------------------------------