boto3-refresh-session 3.0.3__py3-none-any.whl → 5.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,13 +3,13 @@ __all__ = []
3
3
  from . import exceptions, session
4
4
  from .exceptions import *
5
5
  from .methods.custom import *
6
- from .methods.ecs import *
6
+ from .methods.iot import *
7
7
  from .methods.sts import *
8
8
  from .session import *
9
9
 
10
10
  __all__.extend(session.__all__)
11
11
  __all__.extend(exceptions.__all__)
12
- __version__ = "3.0.3"
12
+ __version__ = "5.0.0"
13
13
  __title__ = "boto3-refresh-session"
14
14
  __author__ = "Mike Letts"
15
15
  __maintainer__ = "Mike Letts"
@@ -1,5 +1,7 @@
1
1
  __all__ = ["BRSError", "BRSWarning"]
2
2
 
3
+ import warnings
4
+
3
5
 
4
6
  class BRSError(Exception):
5
7
  """The base exception for boto3-refresh-session.
@@ -39,3 +41,9 @@ class BRSWarning(UserWarning):
39
41
 
40
42
  def __repr__(self) -> str:
41
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)
@@ -1,12 +1,10 @@
1
1
  __all__ = []
2
2
 
3
- # TODO: import iot submodules when finished
4
- from . import custom, ecs, sts
5
- from .custom import CustomRefreshableSession
6
- from .ecs import ECSRefreshableSession
7
- from .sts import STSRefreshableSession
3
+ from . import custom, iot, sts
4
+ from .custom import *
5
+ from .iot import *
6
+ from .sts import *
8
7
 
9
- # TODO: add iot submodules to __all__ when finished
10
8
  __all__.extend(custom.__all__)
11
- __all__.extend(ecs.__all__)
9
+ __all__.extend(iot.__all__)
12
10
  __all__.extend(sts.__all__)
@@ -71,7 +71,7 @@ class CustomRefreshableSession(BaseRefreshableSession, registry_key="custom"):
71
71
  **kwargs,
72
72
  ):
73
73
  if "refresh_method" in kwargs:
74
- BRSWarning(
74
+ BRSWarning.warn(
75
75
  "'refresh_method' cannot be set manually. "
76
76
  "Reverting to 'custom'."
77
77
  )
@@ -0,0 +1,7 @@
1
+ __all__ = []
2
+
3
+ from . import core
4
+ from .core import IoTRefreshableSession
5
+ from .x509 import IOTX509RefreshableSession
6
+
7
+ __all__.extend(core.__all__)
@@ -6,28 +6,16 @@ from typing import get_args
6
6
 
7
7
  from ...exceptions import BRSError
8
8
  from ...utils import (
9
- BaseRefreshableSession,
10
- BRSSession,
11
- CredentialProvider,
12
- IoTAuthenticationMethod,
13
- Registry,
9
+ BaseIoTRefreshableSession,
10
+ BaseRefreshableSession,
11
+ IoTAuthenticationMethod,
14
12
  )
15
13
 
16
14
 
17
- class BaseIoTRefreshableSession(
18
- Registry[IoTAuthenticationMethod],
19
- CredentialProvider,
20
- BRSSession,
21
- registry_key="__iot_sentinel__",
22
- ):
23
- def __init__(self, **kwargs):
24
- super().__init__(**kwargs)
25
-
26
-
27
15
  class IoTRefreshableSession(BaseRefreshableSession, registry_key="iot"):
28
16
  def __new__(
29
17
  cls,
30
- authentication_method: IoTAuthenticationMethod = "certificate",
18
+ authentication_method: IoTAuthenticationMethod = "x509",
31
19
  **kwargs,
32
20
  ) -> BaseIoTRefreshableSession:
33
21
  if authentication_method not in (
@@ -0,0 +1,336 @@
1
+ __all__ = ["IOTX509RefreshableSession"]
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import cast
7
+ from urllib.parse import ParseResult, urlparse
8
+
9
+ from awscrt.exceptions import AwsCrtError
10
+ from awscrt.http import HttpClientConnection, HttpRequest
11
+ from awscrt.io import (
12
+ ClientBootstrap,
13
+ ClientTlsContext,
14
+ DefaultHostResolver,
15
+ EventLoopGroup,
16
+ Pkcs11Lib,
17
+ TlsConnectionOptions,
18
+ TlsContextOptions,
19
+ )
20
+
21
+ from ...exceptions import BRSError, BRSWarning
22
+ from ...utils import (
23
+ PKCS11,
24
+ AWSCRTResponse,
25
+ Identity,
26
+ TemporaryCredentials,
27
+ refreshable_session,
28
+ )
29
+ from .core import BaseIoTRefreshableSession
30
+
31
+
32
+ @refreshable_session
33
+ class IOTX509RefreshableSession(
34
+ BaseIoTRefreshableSession, registry_key="x509"
35
+ ):
36
+ """A :class:`boto3.session.Session` object that automatically refreshes
37
+ temporary credentials returned by the IoT Core credential provider.
38
+
39
+ Parameters
40
+ ----------
41
+ endpoint : str
42
+ The endpoint URL for the IoT Core credential provider. Must contain
43
+ '.credentials.iot.'.
44
+ role_alias : str
45
+ The IAM role alias to use when requesting temporary credentials.
46
+ certificate : str | bytes
47
+ The X.509 certificate to use when requesting temporary credentials.
48
+ ``str`` represents the file path to the certificate, while ``bytes``
49
+ represents the actual certificate data.
50
+ thing_name : str, optional
51
+ The name of the IoT thing to use when requesting temporary
52
+ credentials. Default is None.
53
+ private_key : str | bytes | None, optional
54
+ The private key to use when requesting temporary credentials. ``str``
55
+ represents the file path to the private key, while ``bytes``
56
+ represents the actual private key data. Optional only if ``pkcs11``
57
+ is provided. Default is None.
58
+ pkcs11 : PKCS11, optional
59
+ The PKCS#11 library to use when requesting temporary credentials. If
60
+ provided, ``private_key`` must be None.
61
+ ca : str | bytes | None, optional
62
+ The CA certificate to use when verifying the IoT Core endpoint. ``str``
63
+ represents the file path to the CA certificate, while ``bytes``
64
+ represents the actual CA certificate data. Default is None.
65
+ verify_peer : bool, optional
66
+ Whether to verify the CA certificate when establishing the TLS
67
+ connection. Default is True.
68
+ timeout : float | int | None, optional
69
+ The timeout for the TLS connection in seconds. Default is 10.0.
70
+ duration_seconds : int | None, optional
71
+ The duration for which the temporary credentials are valid, in
72
+ seconds. Cannot exceed the value declared in the IAM policy.
73
+ Default is None.
74
+
75
+ Notes
76
+ -----
77
+ Gavin Adams at AWS was a major influence on this implementation.
78
+ Thank you, Gavin!
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ endpoint: str,
84
+ role_alias: str,
85
+ certificate: str | bytes,
86
+ thing_name: str | None = None,
87
+ private_key: str | bytes | None = None,
88
+ pkcs11: PKCS11 | None = None,
89
+ ca: str | bytes | None = None,
90
+ verify_peer: bool = True,
91
+ timeout: float | int | None = None,
92
+ duration_seconds: int | None = None,
93
+ **kwargs,
94
+ ):
95
+ # initializing BRSSession
96
+ super().__init__(refresh_method="iot-x509", **kwargs)
97
+
98
+ # initializing public attributes
99
+ self.endpoint = self._normalize_iot_credential_endpoint(
100
+ endpoint=endpoint
101
+ )
102
+ self.role_alias = role_alias
103
+ self.certificate = certificate
104
+ self.thing_name = thing_name
105
+ self.private_key = private_key
106
+ self.pkcs11 = pkcs11
107
+ self.ca = ca
108
+ self.verify_peer = verify_peer
109
+ self.timeout = 10.0 if timeout is None else timeout
110
+ self.duration_seconds = duration_seconds
111
+
112
+ # loading X.509 certificate if presented as a string, which
113
+ # is presumed to be the file path.
114
+ # if presented as bytes then self.certificate is presumed to be
115
+ # the actual certificate itself
116
+ if self.certificate and isinstance(self.certificate, str):
117
+ self.certificate = (
118
+ Path(self.certificate).expanduser().resolve().read_bytes()
119
+ )
120
+
121
+ # either private_key or pkcs11 must be provided
122
+ if self.private_key is None and self.pkcs11 is None:
123
+ raise BRSError(
124
+ "Either 'private_key' or 'pkcs11' must be provided."
125
+ )
126
+
127
+ # . . . but both cannot be provided!
128
+ if self.private_key is not None and self.pkcs11 is not None:
129
+ raise BRSError(
130
+ "Only one of 'private_key' or 'pkcs11' can be provided."
131
+ )
132
+
133
+ # if the provided private_key is bytes then it's presumed to be
134
+ # the actual private key. but if it's string then it's presumed
135
+ # to be the file path
136
+ if self.private_key and isinstance(self.private_key, str):
137
+ self.private_key = (
138
+ Path(self.private_key).expanduser().resolve().read_bytes()
139
+ )
140
+
141
+ # verifying PKCS#11 dict
142
+ if self.pkcs11:
143
+ self.pkcs11 = self._validate_pkcs11(pkcs11=self.pkcs11)
144
+
145
+ # ca is like many other attributes in that str implies file location
146
+ if self.ca and isinstance(self.ca, str):
147
+ self.ca = Path(self.ca).expanduser().resolve().read_bytes()
148
+
149
+ def _get_credentials(self) -> TemporaryCredentials:
150
+ url = urlparse(
151
+ f"https://{self.endpoint}/role-aliases/{self.role_alias}"
152
+ "/credentials"
153
+ )
154
+ request = HttpRequest("GET", url.path)
155
+ request.headers.add("host", str(url.hostname))
156
+ if self.thing_name:
157
+ request.headers.add("x-amzn-iot-thingname", self.thing_name)
158
+ if self.duration_seconds:
159
+ request.headers.add(
160
+ "x-amzn-iot-credential-duration-seconds",
161
+ str(self.duration_seconds),
162
+ )
163
+ response = AWSCRTResponse()
164
+ port = 443 if not url.port else url.port
165
+ connection = (
166
+ self._mtls_client_connection(url=url, port=port)
167
+ if not self.pkcs11
168
+ else self._mtls_pkcs11_client_connection(url=url, port=port)
169
+ )
170
+
171
+ try:
172
+ stream = connection.request(
173
+ request, response.on_response, response.on_body
174
+ )
175
+ stream.activate()
176
+ stream.completion_future.result(float(self.timeout))
177
+ finally:
178
+ try:
179
+ connection.close()
180
+ except Exception:
181
+ ...
182
+
183
+ if response.status_code == 200:
184
+ credentials = json.loads(response.body.decode("utf-8"))[
185
+ "credentials"
186
+ ]
187
+ return {
188
+ "access_key": credentials["accessKeyId"],
189
+ "secret_key": credentials["secretAccessKey"],
190
+ "token": credentials["sessionToken"],
191
+ "expiry_time": credentials["expiration"],
192
+ }
193
+ else:
194
+ raise BRSError(
195
+ "Error getting credentials: "
196
+ f"{json.loads(response.body.decode())}"
197
+ )
198
+
199
+ def _mtls_client_connection(
200
+ self, url: ParseResult, port: int
201
+ ) -> HttpClientConnection:
202
+ event_loop_group: EventLoopGroup = EventLoopGroup()
203
+ host_resolver: DefaultHostResolver = DefaultHostResolver(
204
+ event_loop_group
205
+ )
206
+ bootstrap: ClientBootstrap = ClientBootstrap(
207
+ event_loop_group, host_resolver
208
+ )
209
+ tls_ctx_opt = TlsContextOptions.create_client_with_mtls(
210
+ cert_buffer=self.certificate, key_buffer=self.private_key
211
+ )
212
+
213
+ if self.ca:
214
+ tls_ctx_opt.override_default_trust_store(self.ca)
215
+
216
+ tls_ctx_opt.verify_peer = self.verify_peer
217
+ tls_ctx = ClientTlsContext(tls_ctx_opt)
218
+ tls_conn_opt: TlsConnectionOptions = cast(
219
+ TlsConnectionOptions, tls_ctx.new_connection_options()
220
+ )
221
+ tls_conn_opt.set_server_name(str(url.hostname))
222
+
223
+ try:
224
+ connection_future = HttpClientConnection.new(
225
+ host_name=str(url.hostname),
226
+ port=port,
227
+ bootstrap=bootstrap,
228
+ tls_connection_options=tls_conn_opt,
229
+ )
230
+ return connection_future.result(self.timeout)
231
+ except AwsCrtError as err:
232
+ raise BRSError(
233
+ "Error completing mTLS connection to endpoint "
234
+ f"'{url.hostname}'"
235
+ ) from err
236
+
237
+ def _mtls_pkcs11_client_connection(
238
+ self, url: ParseResult, port: int
239
+ ) -> HttpClientConnection:
240
+ event_loop_group: EventLoopGroup = EventLoopGroup()
241
+ host_resolver: DefaultHostResolver = DefaultHostResolver(
242
+ event_loop_group
243
+ )
244
+ bootstrap: ClientBootstrap = ClientBootstrap(
245
+ event_loop_group, host_resolver
246
+ )
247
+
248
+ if not self.pkcs11:
249
+ raise BRSError(
250
+ "Attempting to establish mTLS connection using PKCS#11"
251
+ "but 'pkcs11' parameter is 'None'!"
252
+ )
253
+
254
+ tls_ctx_opt = TlsContextOptions.create_client_with_mtls_pkcs11(
255
+ pkcs11_lib=Pkcs11Lib(file=self.pkcs11["pkcs11_lib"]),
256
+ user_pin=self.pkcs11["user_pin"],
257
+ slot_id=self.pkcs11["slot_id"],
258
+ token_label=self.pkcs11["token_label"],
259
+ private_key_label=self.pkcs11["private_key_label"],
260
+ cert_file_contents=self.certificate,
261
+ )
262
+
263
+ if self.ca:
264
+ tls_ctx_opt.override_default_trust_store(self.ca)
265
+
266
+ tls_ctx_opt.verify_peer = self.verify_peer
267
+ tls_ctx = ClientTlsContext(tls_ctx_opt)
268
+ tls_conn_opt: TlsConnectionOptions = cast(
269
+ TlsConnectionOptions, tls_ctx.new_connection_options()
270
+ )
271
+ tls_conn_opt.set_server_name(str(url.hostname))
272
+
273
+ try:
274
+ connection_future = HttpClientConnection.new(
275
+ host_name=str(url.hostname),
276
+ port=port,
277
+ bootstrap=bootstrap,
278
+ tls_connection_options=tls_conn_opt,
279
+ )
280
+ return connection_future.result(self.timeout)
281
+ except AwsCrtError as err:
282
+ raise BRSError("Error completing mTLS connection.") from err
283
+
284
+ def get_identity(self) -> Identity:
285
+ """Returns metadata about the current caller identity.
286
+
287
+ Returns
288
+ -------
289
+ Identity
290
+ Dict containing information about the current calleridentity.
291
+ """
292
+
293
+ return self.client("sts").get_caller_identity()
294
+
295
+ @staticmethod
296
+ def _normalize_iot_credential_endpoint(endpoint: str) -> str:
297
+ if ".credentials.iot." in endpoint:
298
+ return endpoint
299
+
300
+ if ".iot." in endpoint and "-ats." in endpoint:
301
+ logged_data_endpoint = re.sub(r"^[^. -]+", "***", endpoint)
302
+ logged_credential_endpoint = re.sub(
303
+ r"^[^. -]+",
304
+ "***",
305
+ (endpoint := endpoint.replace("-ats.iot", ".credentials.iot")),
306
+ )
307
+ BRSWarning.warn(
308
+ "The 'endpoint' parameter you provided represents the data "
309
+ "endpoint for IoT not the credentials endpoint! The endpoint "
310
+ "you provided was therefore modified from "
311
+ f"'{logged_data_endpoint}' -> '{logged_credential_endpoint}'"
312
+ )
313
+ return endpoint
314
+
315
+ raise BRSError(
316
+ "Invalid IoT endpoint provided for credentials provider. "
317
+ "Expected '<id>.credentials.iot.<region>.amazonaws.com'"
318
+ )
319
+
320
+ @staticmethod
321
+ def _validate_pkcs11(pkcs11: PKCS11) -> PKCS11:
322
+ if "pkcs11_lib" not in pkcs11:
323
+ raise BRSError(
324
+ "PKCS#11 library path must be provided as 'pkcs11_lib'"
325
+ " in 'pkcs11'."
326
+ )
327
+ elif not Path(pkcs11["pkcs11_lib"]).expanduser().resolve().is_file():
328
+ raise BRSError(
329
+ f"'{pkcs11['pkcs11_lib']}' is not a valid file path for "
330
+ "'pkcs11_lib' in 'pkcs11'."
331
+ )
332
+ pkcs11.setdefault("user_pin", None)
333
+ pkcs11.setdefault("slot_id", None)
334
+ pkcs11.setdefault("token_label", None)
335
+ pkcs11.setdefault("private_key_label", None)
336
+ return pkcs11
@@ -45,7 +45,7 @@ class STSRefreshableSession(BaseRefreshableSession, registry_key="sts"):
45
45
  **kwargs,
46
46
  ):
47
47
  if "refresh_method" in kwargs:
48
- BRSWarning(
48
+ BRSWarning.warn(
49
49
  "'refresh_method' cannot be set manually. "
50
50
  "Reverting to 'sts-assume-role'."
51
51
  )
@@ -60,7 +60,7 @@ class STSRefreshableSession(BaseRefreshableSession, registry_key="sts"):
60
60
  if sts_client_kwargs is not None:
61
61
  # overwriting 'service_name' if if appears in sts_client_kwargs
62
62
  if "service_name" in sts_client_kwargs:
63
- BRSWarning(
63
+ BRSWarning.warn(
64
64
  "'sts_client_kwargs' cannot contain values for "
65
65
  "'service_name'. Reverting to service_name = 'sts'."
66
66
  )
@@ -36,8 +36,8 @@ class RefreshableSession:
36
36
  See Also
37
37
  --------
38
38
  boto3_refresh_session.methods.custom.CustomRefreshableSession
39
+ boto3_refresh_session.methods.iot.IOTX509RefreshableSession
39
40
  boto3_refresh_session.methods.sts.STSRefreshableSession
40
- boto3_refresh_session.methods.ecs.ECSRefreshableSession
41
41
  """
42
42
 
43
43
  def __new__(
@@ -60,7 +60,7 @@ class RefreshableSession:
60
60
  -------
61
61
  list[str]
62
62
  A list of all currently available credential refresh methods,
63
- e.g. 'sts', 'ecs', 'custom'.
63
+ e.g. 'sts', 'custom'.
64
64
  """
65
65
 
66
66
  args = list(get_args(Method))
@@ -1,4 +1,6 @@
1
1
  __all__ = [
2
+ "AWSCRTResponse",
3
+ "BaseIoTRefreshableSession",
2
4
  "BaseRefreshableSession",
3
5
  "BRSSession",
4
6
  "CredentialProvider",
@@ -10,6 +12,7 @@ from abc import ABC, abstractmethod
10
12
  from functools import wraps
11
13
  from typing import Any, Callable, ClassVar, Generic, TypeVar, cast
12
14
 
15
+ from awscrt.http import HttpHeaders
13
16
  from boto3.session import Session
14
17
  from botocore.credentials import (
15
18
  DeferredRefreshableCredentials,
@@ -19,6 +22,7 @@ from botocore.credentials import (
19
22
  from ..exceptions import BRSWarning
20
23
  from .typing import (
21
24
  Identity,
25
+ IoTAuthenticationMethod,
22
26
  Method,
23
27
  RefreshableTemporaryCredentials,
24
28
  RefreshMethod,
@@ -46,7 +50,9 @@ class Registry(Generic[RegistryKey]):
46
50
  super().__init_subclass__(**kwargs)
47
51
 
48
52
  if registry_key in cls.registry:
49
- BRSWarning(f"{registry_key!r} already registered. Overwriting.")
53
+ BRSWarning.warn(
54
+ f"{registry_key!r} already registered. Overwriting."
55
+ )
50
56
 
51
57
  if "sentinel" not in registry_key:
52
58
  cls.registry[registry_key] = cls
@@ -202,3 +208,35 @@ class BaseRefreshableSession(
202
208
 
203
209
  def __init__(self, **kwargs):
204
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)
@@ -33,22 +33,23 @@ except ImportError:
33
33
  from typing_extensions import NotRequired
34
34
 
35
35
  #: Type alias for all currently available IoT authentication methods.
36
- IoTAuthenticationMethod = Literal["certificate", "cognito", "__iot_sentinel__"]
36
+ IoTAuthenticationMethod = Literal["x509", "__iot_sentinel__"]
37
37
 
38
38
  #: Type alias for all currently available credential refresh methods.
39
39
  Method = Literal[
40
- "sts",
41
- "ecs",
42
40
  "custom",
41
+ "iot",
42
+ "sts",
43
43
  "__sentinel__",
44
- ] # TODO: Add iot when implemented
44
+ "__iot_sentinel__",
45
+ ]
45
46
 
46
47
  #: Type alias for all refresh method names.
47
48
  RefreshMethod = Literal[
48
- "sts-assume-role",
49
- "ecs-container-metadata",
50
49
  "custom",
51
- ] # TODO: Add iot-certificate and iot-cognito when iot implemented
50
+ "iot-x509",
51
+ "sts-assume-role",
52
+ ]
52
53
 
53
54
  #: Type alias for all currently registered credential refresh methods.
54
55
  RegistryKey = TypeVar("RegistryKey", bound=str)
@@ -138,7 +139,7 @@ class STSClientParams(TypedDict):
138
139
 
139
140
 
140
141
  class PKCS11(TypedDict):
141
- pkcs11_loc: str
142
+ pkcs11_lib: str
142
143
  user_pin: NotRequired[str]
143
144
  slot_id: NotRequired[int]
144
145
  token_label: NotRequired[str | None]
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: boto3-refresh-session
3
- Version: 3.0.3
3
+ Version: 5.0.0
4
4
  Summary: A simple Python package for refreshing the temporary security credentials in a boto3.session.Session object automatically.
5
5
  License: MIT
6
- Keywords: boto3,botocore,aws,sts,ecs,credentials,token,refresh
6
+ Keywords: boto3,botocore,aws,sts,credentials,token,refresh,iot,x509
7
7
  Author: Mike Letts
8
8
  Author-email: lettsmt@gmail.com
9
9
  Maintainer: Michael Letts
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Dist: awscrt
18
19
  Requires-Dist: boto3
19
20
  Requires-Dist: botocore
20
21
  Requires-Dist: requests
@@ -120,13 +121,14 @@ Description-Content-Type: text/markdown
120
121
  ## 😛 Features
121
122
 
122
123
  - Drop-in replacement for `boto3.session.Session`
123
- - Supports automatic credential refresh methods for various AWS services:
124
- - STS
125
- - ECS
126
- - Supports custom authentication methods for complicated authentication flows
124
+ - Supports automatic credential refresh for:
125
+ - **STS**
126
+ - **IoT Core**
127
+ - X.509 certificates w/ role aliases over mTLS (PEM files and PKCS#11)
128
+ - Custom authentication methods
127
129
  - Natively supports all parameters supported by `boto3.session.Session`
128
130
  - [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/)
129
- - Future releases will include support for IoT (coming soon), EC2, and SSO
131
+ - Future releases will include support for IoT (coming soon)
130
132
 
131
133
  ## ⚠️ Important Updates
132
134
 
@@ -138,6 +140,14 @@ Advanced users, however, particularly those using low-level objects such as `Bas
138
140
 
139
141
  Please review [this PR](https://github.com/michaelthomasletts/boto3-refresh-session/pull/75) for additional details.
140
142
 
143
+ #### ✂️ v4.0.0
144
+
145
+ The `ecs` module has been dropped. For additional details and rationale, please review [this PR](https://github.com/michaelthomasletts/boto3-refresh-session/pull/78).
146
+
147
+ #### 😛 v5.0.0
148
+
149
+ Support for IoT Core via X.509 certificate-based authentication (over HTTPS) is now available!
150
+
141
151
  #### ☎️ Delayed Responses
142
152
 
143
153
  I am currently grappling with a very serious medical condition. Accordingly, expect delayed responses to issues and requests until my health stabilizes.
@@ -171,7 +181,7 @@ pip install boto3-refresh-session
171
181
 
172
182
  To use the `boto3.client` or `boto3.resource` interface, but with the benefits of `boto3-refresh-session`, you have a few options!
173
183
 
174
- 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 ECS or custom auth flows) then check the usage instructions in the following sections!
184
+ 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!
175
185
 
176
186
  ##### `RefreshableSession.client` (Recommended)
177
187
 
@@ -269,24 +279,6 @@ pip install boto3-refresh-session
269
279
 
270
280
  </details>
271
281
 
272
- <details>
273
- <summary><strong>ECS (click to expand)</strong></summary>
274
-
275
- ### ECS
276
-
277
- You can use boto3-refresh-session in an ECS container to automatically refresh temporary security credentials. For additional information on the exact parameters that `RefreshableSession` takes for ECS, [check this documentation](https://github.com/michaelthomasletts/boto3-refresh-session/blob/main/boto3_refresh_session/methods/ecs.py).
278
-
279
- ```python
280
- session = RefreshableSession(
281
- method="ecs",
282
- region_name=region_name,
283
- profile_name=profile_name,
284
- ...
285
- )
286
- ```
287
-
288
- </details>
289
-
290
282
  <details>
291
283
  <summary><strong>Custom Authentication Flows (click to expand)</strong></summary>
292
284
 
@@ -317,3 +309,56 @@ pip install boto3-refresh-session
317
309
  ```
318
310
 
319
311
  </details>
312
+
313
+ <details>
314
+ <summary><strong>IoT Core X.509 (click to expand)</strong></summary>
315
+
316
+ ### IoT Core X.509
317
+
318
+ 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**.
319
+
320
+ For additional information on the exact parameters that `IOTX509RefreshableSession` takes, [check this documentation](https://github.com/michaelthomasletts/boto3-refresh-session/blob/main/boto3_refresh_session/methods/iot/x509.py).
321
+
322
+ ### PEM file
323
+
324
+ ```python
325
+ import boto3_refresh_session as brs
326
+
327
+ # PEM certificate + private key example
328
+ session = brs.RefreshableSession(
329
+ method="iot",
330
+ endpoint="<your-credentials-endpoint>.credentials.iot.<region>.amazonaws.com",
331
+ role_alias="<your-role-alias>",
332
+ certificate="/path/to/certificate.pem",
333
+ private_key="/path/to/private-key.pem",
334
+ thing_name="<your-thing-name>", # optional, if used in policies
335
+ duration_seconds=3600, # optional, capped by role alias
336
+ region_name="us-east-1",
337
+ )
338
+
339
+ # Now you can use the session like any boto3 session
340
+ s3 = session.client("s3")
341
+ print(s3.list_buckets())
342
+ ```
343
+
344
+ ### PKCS#11
345
+
346
+ ```python
347
+ session = brs.RefreshableSession(
348
+ method="iot",
349
+ endpoint="<your-credentials-endpoint>.credentials.iot.<region>.amazonaws.com",
350
+ role_alias="<your-role-alias>",
351
+ certificate="/path/to/certificate.pem",
352
+ pkcs11={
353
+ "pkcs11_lib": "/usr/local/lib/softhsm/libsofthsm2.so",
354
+ "user_pin": "1234",
355
+ "slot_id": 0,
356
+ "token_label": "MyToken",
357
+ "private_key_label": "MyKey",
358
+ },
359
+ thing_name="<your-thing-name>",
360
+ region_name="us-east-1",
361
+ )
362
+ ```
363
+
364
+ </details>
@@ -0,0 +1,17 @@
1
+ boto3_refresh_session/__init__.py,sha256=XR8slNXWaySOCG-_BnO5yi6d8276TH34EzGUTS347vM,415
2
+ boto3_refresh_session/exceptions.py,sha256=QS5_xy3hNrfkdT_wKPZWH8WqSbFYCKPcK8DomGYIvcU,1218
3
+ boto3_refresh_session/methods/__init__.py,sha256=FpwWixSVpy_6pUe1u4fXmjO-_fDH--qTk_xrMnBCHxU,193
4
+ boto3_refresh_session/methods/custom.py,sha256=MLdUMU9s6NQoJWBKQ5Fsxeyxb_Xrm9V59pVX22M8fyI,4178
5
+ boto3_refresh_session/methods/iot/__init__.py,sha256=wIYp7HFZ_Q8XEHwWmpKjDNXxBm29C0RisP_9GSVwzZI,147
6
+ boto3_refresh_session/methods/iot/core.py,sha256=xtvbC23h6fw06lRZWN4r7TlnUEf3t9T7-zSPGCSlSLI,1151
7
+ boto3_refresh_session/methods/iot/x509.py,sha256=QvHXJKRkRuF5TOUOEH4rTN8lfm_rRnp-flLoolMaxbw,12368
8
+ boto3_refresh_session/methods/sts.py,sha256=NGqJFJNLjG9Mve7o19tb_i6lvgQW1HoALIqF6lJNV9A,3336
9
+ boto3_refresh_session/session.py,sha256=UM_dWHSo0Wn8gLN99zg36SRVb-Yy_to1wk8UgZEuQZA,2086
10
+ boto3_refresh_session/utils/__init__.py,sha256=6F2ErbgBT2ZmZwFF3OzvQEd1Vh4XM3kaL6YGMTrcrkQ,156
11
+ boto3_refresh_session/utils/internal.py,sha256=HbuIzT0pC8QS4pgNj3M7POGaW-OEz2l3ESfYI1Qouuo,7072
12
+ boto3_refresh_session/utils/typing.py,sha256=AqPey1N8nNUU2BwQYIIz-xGrfgjyNUzDt8MK0eB5DSQ,3429
13
+ boto3_refresh_session-5.0.0.dist-info/LICENSE,sha256=I3ZYTXAjbIly6bm6J-TvFTuuHwTKws4h89QaY5c5HiY,1067
14
+ boto3_refresh_session-5.0.0.dist-info/METADATA,sha256=xTwVXf2-RzpcMWZH9SvZTvrVM2stHCp6ZWRYrX5Q7p4,14151
15
+ boto3_refresh_session-5.0.0.dist-info/NOTICE,sha256=1s8r33qbl1z0YvPB942iWgvbkP94P_e8AnROr1qXXuw,939
16
+ boto3_refresh_session-5.0.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
+ boto3_refresh_session-5.0.0.dist-info/RECORD,,
@@ -1,115 +0,0 @@
1
- __all__ = ["ECSRefreshableSession"]
2
-
3
- import os
4
-
5
- import requests
6
-
7
- from ..exceptions import BRSError, BRSWarning
8
- from ..utils import (
9
- BaseRefreshableSession,
10
- Identity,
11
- TemporaryCredentials,
12
- refreshable_session,
13
- )
14
-
15
- _ECS_CREDENTIALS_RELATIVE_URI = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"
16
- _ECS_CREDENTIALS_FULL_URI = "AWS_CONTAINER_CREDENTIALS_FULL_URI"
17
- _ECS_AUTHORIZATION_TOKEN = "AWS_CONTAINER_AUTHORIZATION_TOKEN"
18
- _DEFAULT_ENDPOINT_BASE = "http://169.254.170.2"
19
-
20
-
21
- @refreshable_session
22
- class ECSRefreshableSession(BaseRefreshableSession, registry_key="ecs"):
23
- """A boto3 session that automatically refreshes temporary AWS credentials
24
- from the ECS container credentials metadata endpoint.
25
-
26
- Parameters
27
- ----------
28
- defer_refresh : bool, optional
29
- If ``True`` then temporary credentials are not automatically
30
- refreshed until they are explicitly needed. If ``False`` then
31
- temporary credentials refresh immediately upon expiration. It
32
- is highly recommended that you use ``True``. Default is ``True``.
33
-
34
- Other Parameters
35
- ----------------
36
- kwargs : dict
37
- Optional keyword arguments passed to :class:`boto3.session.Session`.
38
- """
39
-
40
- def __init__(self, **kwargs):
41
- if "refresh_method" in kwargs:
42
- BRSWarning(
43
- "'refresh_method' cannot be set manually. "
44
- "Reverting to 'ecs-container-metadata'."
45
- )
46
- del kwargs["refresh_method"]
47
-
48
- # initializing BRSSession
49
- super().__init__(refresh_method="ecs-container-metadata", **kwargs)
50
-
51
- # initializing various other attributes
52
- self._endpoint = self._resolve_endpoint()
53
- self._headers = self._build_headers()
54
- self._http = self._init_http_session()
55
-
56
- def _resolve_endpoint(self) -> str:
57
- uri = os.environ.get(_ECS_CREDENTIALS_FULL_URI) or os.environ.get(
58
- _ECS_CREDENTIALS_RELATIVE_URI
59
- )
60
- if not uri:
61
- raise BRSError(
62
- "Neither AWS_CONTAINER_CREDENTIALS_FULL_URI nor "
63
- "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is set. "
64
- "Are you running inside an ECS container?"
65
- )
66
- if uri.startswith("http://") or uri.startswith("https://"):
67
- return uri
68
- return f"{_DEFAULT_ENDPOINT_BASE}{uri}"
69
-
70
- def _build_headers(self) -> dict[str, str]:
71
- token = os.environ.get(_ECS_AUTHORIZATION_TOKEN)
72
- if token:
73
- return {"Authorization": f"Bearer {token}"}
74
- return {}
75
-
76
- def _init_http_session(self) -> requests.Session:
77
- session = requests.Session()
78
- session.headers.update(self._headers)
79
- return session
80
-
81
- def _get_credentials(self) -> TemporaryCredentials:
82
- try:
83
- response = self._http.get(self._endpoint, timeout=3)
84
- response.raise_for_status()
85
- except requests.RequestException as exc:
86
- raise BRSError(
87
- f"Failed to retrieve ECS credentials from {self._endpoint}"
88
- ) from exc
89
-
90
- credentials = response.json()
91
- required = {
92
- "AccessKeyId",
93
- "SecretAccessKey",
94
- "SessionToken",
95
- "Expiration",
96
- }
97
- if not required.issubset(credentials):
98
- raise BRSError(f"Incomplete credentials received: {credentials}")
99
- return {
100
- "access_key": credentials.get("AccessKeyId"),
101
- "secret_key": credentials.get("SecretAccessKey"),
102
- "token": credentials.get("SessionToken"),
103
- "expiry_time": credentials.get("Expiration"), # already ISO8601
104
- }
105
-
106
- def get_identity(self) -> Identity:
107
- """Returns metadata about ECS.
108
-
109
- Returns
110
- -------
111
- Identity
112
- Dict containing metadata about ECS.
113
- """
114
-
115
- return {"method": "ecs", "source": "ecs-container-metadata"}
@@ -1,4 +0,0 @@
1
- from .certificate import IoTCertificateRefreshableSession
2
- from .core import IoTRefreshableSession
3
-
4
- __all__ = ["IoTRefreshableSession"]
@@ -1,57 +0,0 @@
1
- __all__ = ["IoTCertificateRefreshableSession"]
2
-
3
- from pathlib import Path
4
- from typing import Any
5
-
6
- from ...exceptions import BRSError
7
- from ...utils import (
8
- Identity, PKCS11, TemporaryCredentials, refreshable_session
9
- )
10
- from .core import BaseIoTRefreshableSession
11
-
12
-
13
- @refreshable_session
14
- class IoTCertificateRefreshableSession(
15
- BaseIoTRefreshableSession, registry_key="certificate"
16
- ):
17
- def __init__(
18
- self,
19
- endpoint: str,
20
- role_alias: str,
21
- thing_name: str,
22
- certificate: str | bytes,
23
- private_key: str | bytes | None = None,
24
- pkcs11: PKCS11 | None = None,
25
- ca: bytes | None = None,
26
- verify_peer: bool = True,
27
- ):
28
- self.endpoint = endpoint
29
- self.role_alias = role_alias
30
- self.thing_name = thing_name
31
- self.certificate = certificate
32
- self.private_key = private_key
33
- self.pkcs11 = pkcs11
34
- self.ca = ca
35
- self.verify_peer = verify_peer
36
-
37
- if self.certificate and isinstance(self.certificate, str):
38
- with open(Path(self.certificate), "rb") as cert_pem_file:
39
- self.certificate = cert_pem_file.read()
40
-
41
- if self.private_key is None and self.pkcs11 is None:
42
- raise BRSError(
43
- "Either 'private_key' or 'pkcs11' must be provided."
44
- )
45
-
46
- if self.private_key is not None and self.pkcs11 is not None:
47
- raise BRSError(
48
- "Only one of 'private_key' or 'pkcs11' can be provided."
49
- )
50
-
51
- if self.private_key and isinstance(self.private_key, str):
52
- with open(Path(self.private_key), "rb") as private_key_pem_file:
53
- self.private_key = private_key_pem_file.read()
54
-
55
- def _get_credentials(self) -> TemporaryCredentials: ...
56
-
57
- def get_identity(self) -> Identity: ...
@@ -1,17 +0,0 @@
1
- __all__ = ["IoTCognitoRefreshableSession"]
2
-
3
- from typing import Any
4
-
5
- from ...utils import Identity, TemporaryCredentials, refreshable_session
6
- from .core import BaseIoTRefreshableSession
7
-
8
-
9
- @refreshable_session
10
- class IoTCognitoRefreshableSession(
11
- BaseIoTRefreshableSession, registry_key="cognito"
12
- ):
13
- def __init__(self): ...
14
-
15
- def _get_credentials(self) -> TemporaryCredentials: ...
16
-
17
- def get_identity(self) -> Identity: ...
@@ -1,19 +0,0 @@
1
- boto3_refresh_session/__init__.py,sha256=L-9FGJIUsvwRFNlfJeKb_61NvZpT07xGdBAyV38DMhI,415
2
- boto3_refresh_session/exceptions.py,sha256=DumBh6cDVU46eelSNt1CsG2uMSBekSbmhqWEaAWw130,1003
3
- boto3_refresh_session/methods/__init__.py,sha256=zpVBJIR4P-l4pjE9kMnLGffehPVawY1vLiX2CPcpV7w,352
4
- boto3_refresh_session/methods/custom.py,sha256=j90Iv1DKdGgP1JNwQfpEhaJDBrB2AtDe8kqI2Mktwlg,4173
5
- boto3_refresh_session/methods/ecs.py,sha256=dxDrNOu8xTFHciuwL7jLh5nB2QXWwQRRA1CoY7AuO5g,3893
6
- boto3_refresh_session/methods/iot/__init__.typed,sha256=Z33nIB6oCsz9TZwikHfNHgY1SKxkSCdB5rwdPSUl3C4,135
7
- boto3_refresh_session/methods/iot/certificate.typed,sha256=sFTa1rF7tebr48Bjw_YtVeOdVvazAHBJGGiM33tsFXI,1828
8
- boto3_refresh_session/methods/iot/cognito.typed,sha256=wyBMWUkuhLt27JsKZIwtfylDdCavNexcEy16ZaDFjUY,435
9
- boto3_refresh_session/methods/iot/core.typed,sha256=Q5WshxgIIOgAaqoU7n8wBKMe9eSzZ6H8db-q1gThHzk,1407
10
- boto3_refresh_session/methods/sts.py,sha256=dzf68BE0f1nFsITOKOnygh-mTvBqThKkrW2eEc-wFKA,3326
11
- boto3_refresh_session/session.py,sha256=8YAdanwnJUG622Cv9MNKg25uj9ZmMYzRL4xiqH1i0nk,2089
12
- boto3_refresh_session/utils/__init__.py,sha256=6F2ErbgBT2ZmZwFF3OzvQEd1Vh4XM3kaL6YGMTrcrkQ,156
13
- boto3_refresh_session/utils/internal.py,sha256=bpKTAF_xdBw1wJPHIG8aGRMiXkSkp7CI9et0U5o3qEI,6103
14
- boto3_refresh_session/utils/typing.py,sha256=I4VJS1vkRwIRdJF08dZF1YgUed_anviz3hq4hLvPnLw,3537
15
- boto3_refresh_session-3.0.3.dist-info/LICENSE,sha256=I3ZYTXAjbIly6bm6J-TvFTuuHwTKws4h89QaY5c5HiY,1067
16
- boto3_refresh_session-3.0.3.dist-info/METADATA,sha256=91keQVV9MNrd7zoC8s5c4e59qzrU7vMB9plvJ1d4Sr8,12561
17
- boto3_refresh_session-3.0.3.dist-info/NOTICE,sha256=1s8r33qbl1z0YvPB942iWgvbkP94P_e8AnROr1qXXuw,939
18
- boto3_refresh_session-3.0.3.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
19
- boto3_refresh_session-3.0.3.dist-info/RECORD,,