capo-ebs 0.1.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.
Files changed (96) hide show
  1. capo_ebs/__init__.py +71 -0
  2. capo_ebs/_async.py +25 -0
  3. capo_ebs/_auth/_identity.py +16 -0
  4. capo_ebs/_auth/_providers.py +882 -0
  5. capo_ebs/_auth/_signers.py +111 -0
  6. capo_ebs/_auth/_sigv4.py +439 -0
  7. capo_ebs/_auth/_zapros_handler.py +80 -0
  8. capo_ebs/_body.py +221 -0
  9. capo_ebs/_iter.py +113 -0
  10. capo_ebs/_operations/ebs/complete_snapshot.py +190 -0
  11. capo_ebs/_operations/ebs/get_snapshot_block.py +187 -0
  12. capo_ebs/_operations/ebs/list_changed_blocks.py +182 -0
  13. capo_ebs/_operations/ebs/list_snapshot_blocks.py +178 -0
  14. capo_ebs/_operations/ebs/put_snapshot_block.py +270 -0
  15. capo_ebs/_operations/ebs/start_snapshot.py +183 -0
  16. capo_ebs/_pagination.py +21 -0
  17. capo_ebs/_protocol/__init__.py +1 -0
  18. capo_ebs/_protocol/errors.py +93 -0
  19. capo_ebs/_protocol/eventstream.py +239 -0
  20. capo_ebs/_protocol/serialize.py +47 -0
  21. capo_ebs/_protocol/xml.py +33 -0
  22. capo_ebs/_rule_engine/__init__.py +0 -0
  23. capo_ebs/_rule_engine/_aws_partition.py +160 -0
  24. capo_ebs/_rule_engine/_endpoint_rule_set.py +136 -0
  25. capo_ebs/_rule_engine/_endpoint_runtime.py +389 -0
  26. capo_ebs/_services/_aws_config.py +160 -0
  27. capo_ebs/_services/_pipeline.py +247 -0
  28. capo_ebs/_services/async_ebs.py +617 -0
  29. capo_ebs/_services/ebs.py +613 -0
  30. capo_ebs/errors/__init__.py +41 -0
  31. capo_ebs/errors/_base.py +94 -0
  32. capo_ebs/errors/access_denied_exception.py +67 -0
  33. capo_ebs/errors/concurrent_limit_exceeded_exception.py +53 -0
  34. capo_ebs/errors/conflict_exception.py +49 -0
  35. capo_ebs/errors/internal_server_exception.py +51 -0
  36. capo_ebs/errors/request_throttled_exception.py +72 -0
  37. capo_ebs/errors/resource_not_found_exception.py +72 -0
  38. capo_ebs/errors/service_quota_exceeded_exception.py +74 -0
  39. capo_ebs/errors/validation_exception.py +66 -0
  40. capo_ebs/py.typed +0 -0
  41. capo_ebs/types/_prelude/blob.py +12 -0
  42. capo_ebs/types/_prelude/timestamp.py +17 -0
  43. capo_ebs/types/access_denied_exception_reason.py +17 -0
  44. capo_ebs/types/block.py +35 -0
  45. capo_ebs/types/block_data.py +7 -0
  46. capo_ebs/types/block_index.py +5 -0
  47. capo_ebs/types/block_size.py +5 -0
  48. capo_ebs/types/block_token.py +5 -0
  49. capo_ebs/types/blocks.py +29 -0
  50. capo_ebs/types/boolean.py +5 -0
  51. capo_ebs/types/changed_block.py +41 -0
  52. capo_ebs/types/changed_blocks.py +29 -0
  53. capo_ebs/types/changed_blocks_count.py +5 -0
  54. capo_ebs/types/checksum.py +5 -0
  55. capo_ebs/types/checksum_aggregation_method.py +14 -0
  56. capo_ebs/types/checksum_algorithm.py +14 -0
  57. capo_ebs/types/complete_snapshot_request.py +40 -0
  58. capo_ebs/types/complete_snapshot_response.py +32 -0
  59. capo_ebs/types/data_length.py +5 -0
  60. capo_ebs/types/description.py +5 -0
  61. capo_ebs/types/error_message.py +5 -0
  62. capo_ebs/types/get_snapshot_block_request.py +30 -0
  63. capo_ebs/types/get_snapshot_block_response.py +24 -0
  64. capo_ebs/types/idempotency_token.py +5 -0
  65. capo_ebs/types/kms_key_arn.py +5 -0
  66. capo_ebs/types/list_changed_blocks_request.py +35 -0
  67. capo_ebs/types/list_changed_blocks_response.py +72 -0
  68. capo_ebs/types/list_snapshot_blocks_request.py +33 -0
  69. capo_ebs/types/list_snapshot_blocks_response.py +68 -0
  70. capo_ebs/types/max_results.py +5 -0
  71. capo_ebs/types/owner_id.py +5 -0
  72. capo_ebs/types/page_token.py +5 -0
  73. capo_ebs/types/progress.py +5 -0
  74. capo_ebs/types/put_snapshot_block_request.py +31 -0
  75. capo_ebs/types/put_snapshot_block_response.py +29 -0
  76. capo_ebs/types/request_throttled_exception_reason.py +18 -0
  77. capo_ebs/types/resource_not_found_exception_reason.py +19 -0
  78. capo_ebs/types/service_quota_exceeded_exception_reason.py +16 -0
  79. capo_ebs/types/snapshot_id.py +5 -0
  80. capo_ebs/types/sse_type.py +18 -0
  81. capo_ebs/types/start_snapshot_request.py +84 -0
  82. capo_ebs/types/start_snapshot_response.py +115 -0
  83. capo_ebs/types/status.py +18 -0
  84. capo_ebs/types/tag.py +35 -0
  85. capo_ebs/types/tag_key.py +5 -0
  86. capo_ebs/types/tag_value.py +5 -0
  87. capo_ebs/types/tags.py +29 -0
  88. capo_ebs/types/time_stamp.py +20 -0
  89. capo_ebs/types/timeout.py +5 -0
  90. capo_ebs/types/validation_exception_reason.py +30 -0
  91. capo_ebs/types/volume_size.py +5 -0
  92. capo_ebs-0.1.0.dist-info/METADATA +148 -0
  93. capo_ebs-0.1.0.dist-info/RECORD +96 -0
  94. capo_ebs-0.1.0.dist-info/WHEEL +5 -0
  95. capo_ebs-0.1.0.dist-info/licenses/LICENSE +21 -0
  96. capo_ebs-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,882 @@
1
+ from __future__ import annotations
2
+
3
+ import configparser
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import time
8
+ from abc import abstractmethod
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timedelta, timezone
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Generic, TypeVar, cast
13
+
14
+ from zapros import AsyncClient, Client, ZaprosError
15
+
16
+ from capo_ebs._auth._identity import (
17
+ Credentials,
18
+ Identity,
19
+ )
20
+ from capo_ebs._services._aws_config import _load_profile, active_profile, config_file
21
+
22
+ # The SSO, assume-role and web-identity providers call other AWS services, so
23
+ # they need the `sso` extra: `pip install capo-ebs[sso]`. Without it the imports
24
+ # below stay `None` and only those providers fail — see `require_dependency`.
25
+ if TYPE_CHECKING:
26
+ import capo_sso
27
+ import capo_sso.errors
28
+ import capo_sso.types.get_role_credentials_response
29
+ import capo_sso_oidc
30
+ import capo_sso_oidc.errors
31
+ import capo_sso_oidc.types.create_token_response
32
+ import capo_sts
33
+ import capo_sts.types.credentials
34
+ else:
35
+ try:
36
+ import capo_sso
37
+ import capo_sso.errors
38
+ except ImportError:
39
+ capo_sso = None
40
+ try:
41
+ import capo_sso_oidc
42
+ import capo_sso_oidc.errors
43
+ except ImportError:
44
+ capo_sso_oidc = None
45
+ try:
46
+ import capo_sts
47
+ except ImportError:
48
+ capo_sts = None
49
+
50
+ # refresh an SSO token this long before it actually expires
51
+ SSO_TOKEN_REFRESH_WINDOW = timedelta(minutes=5)
52
+
53
+
54
+ class IdentityNotFound(Exception):
55
+ """Raised when a provider cannot resolve an identity. Chain continues."""
56
+
57
+
58
+ class MissingDependencyError(Exception):
59
+ """Raised when a provider needs the `sso` extra but it is not installed."""
60
+
61
+
62
+ class SSOError(Exception):
63
+ """Raised when SSO is configured but unusable. Chain stops."""
64
+
65
+
66
+ class AssumeRoleError(Exception):
67
+ """Raised when a `role_arn` profile is configured but unusable. Chain stops."""
68
+
69
+
70
+ def require_dependency(module: object | None, package: str) -> None:
71
+ if module is None:
72
+ raise MissingDependencyError(
73
+ f"{package} is required for this credentials provider but is not "
74
+ "installed; reinstall with the sso feature enabled: capo-ebs[sso]"
75
+ )
76
+
77
+
78
+ IdentityT = TypeVar("IdentityT", bound="Identity")
79
+
80
+
81
+ class IdentityProvider(Generic[IdentityT]):
82
+ @abstractmethod
83
+ def resolve_identity(self) -> IdentityT:
84
+ raise NotImplementedError
85
+
86
+ async def aresolve_identity(self) -> IdentityT:
87
+ # default: no network I/O, reuse the sync resolution
88
+ return self.resolve_identity()
89
+
90
+
91
+ class ChainedProvider(IdentityProvider[IdentityT]):
92
+ """Try each provider in order; first non-`IdentityNotFound` wins."""
93
+
94
+ def __init__(self, *providers: IdentityProvider[IdentityT]) -> None:
95
+ if not providers:
96
+ raise ValueError("ChainedProvider requires at least one provider")
97
+ self._providers = providers
98
+
99
+ def resolve_identity(self) -> IdentityT:
100
+ errors: list[str] = []
101
+ for p in self._providers:
102
+ try:
103
+ return p.resolve_identity()
104
+ except IdentityNotFound as e:
105
+ errors.append(f"{type(p).__name__}: {e}")
106
+ raise IdentityNotFound("no provider succeeded: " + "; ".join(errors))
107
+
108
+ async def aresolve_identity(self) -> IdentityT:
109
+ errors: list[str] = []
110
+ for p in self._providers:
111
+ try:
112
+ return await p.aresolve_identity()
113
+ except IdentityNotFound as e:
114
+ errors.append(f"{type(p).__name__}: {e}")
115
+ raise IdentityNotFound("no provider succeeded: " + "; ".join(errors))
116
+
117
+
118
+ class CachedProvider(IdentityProvider[IdentityT]):
119
+ """Cache an identity until its `expiration` (minus skew) elapses."""
120
+
121
+ _SKEW_SECONDS = 60
122
+
123
+ def __init__(self, inner: IdentityProvider[IdentityT]) -> None:
124
+ self._inner = inner
125
+ self._cached: IdentityT | None = None
126
+
127
+ def resolve_identity(self) -> IdentityT:
128
+ if self._cached is not None and not self._expired(self._cached):
129
+ return self._cached
130
+ self._cached = self._inner.resolve_identity()
131
+ return self._cached
132
+
133
+ async def aresolve_identity(self) -> IdentityT:
134
+ if self._cached is not None and not self._expired(self._cached):
135
+ return self._cached
136
+ self._cached = await self._inner.aresolve_identity()
137
+ return self._cached
138
+
139
+ @classmethod
140
+ def _expired(cls, ident: Identity) -> bool:
141
+ exp = ident.get("expiration")
142
+ if exp is None:
143
+ return False
144
+ return (exp - datetime.now(timezone.utc)).total_seconds() <= cls._SKEW_SECONDS
145
+
146
+
147
+ class CredentialsProvider(IdentityProvider[Credentials]):
148
+ """Base class for providers that resolve AWS `Credentials`."""
149
+
150
+ @abstractmethod
151
+ def resolve_identity(self) -> Credentials:
152
+ raise NotImplementedError
153
+
154
+
155
+ class StaticAwsCredentialsProvider(CredentialsProvider):
156
+ def __init__(self, credentials: Credentials) -> None:
157
+ self._credentials = credentials
158
+
159
+ def resolve_identity(self) -> Credentials:
160
+ return self._credentials
161
+
162
+
163
+ class EnvCredentialsProvider(CredentialsProvider):
164
+ """Read AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN."""
165
+
166
+ def resolve_identity(self) -> Credentials:
167
+ ak = os.environ.get("AWS_ACCESS_KEY_ID")
168
+ sk = os.environ.get("AWS_SECRET_ACCESS_KEY")
169
+ if not ak or not sk:
170
+ raise IdentityNotFound("AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY unset")
171
+ out: Credentials = {"access_key": ak, "secret_key": sk}
172
+ token = os.environ.get("AWS_SESSION_TOKEN")
173
+ if token:
174
+ out["session_token"] = token
175
+ return out
176
+
177
+
178
+ class ProfileCredentialsProvider(CredentialsProvider):
179
+ """Read ~/.aws/credentials and ~/.aws/config for the active profile."""
180
+
181
+ def __init__(
182
+ self, credentials_file: Path | None = None, profile: str | None = None
183
+ ) -> None:
184
+ self._profile = profile or active_profile()
185
+ self._cred_file = credentials_file or Path(
186
+ os.environ.get("AWS_SHARED_CREDENTIALS_FILE")
187
+ or Path.home() / ".aws" / "credentials"
188
+ )
189
+
190
+ def resolve_identity(self) -> Credentials:
191
+ section = self._load_section()
192
+ ak = section.get("aws_access_key_id")
193
+ sk = section.get("aws_secret_access_key")
194
+ if not ak or not sk:
195
+ raise IdentityNotFound(
196
+ f"profile {self._profile!r}: missing aws_access_key_id/aws_secret_access_key"
197
+ )
198
+ out: Credentials = {"access_key": ak, "secret_key": sk}
199
+ token = section.get("aws_session_token")
200
+ if token:
201
+ out["session_token"] = token
202
+ return out
203
+
204
+ def _load_section(self) -> dict[str, str]:
205
+ # config-file profile reuses the loader from _services/_aws_config
206
+ merged, _ = _load_profile(self._profile)
207
+ if self._cred_file.is_file():
208
+ cfg = configparser.ConfigParser(interpolation=None)
209
+ cfg.read(self._cred_file)
210
+ if cfg.has_section(self._profile):
211
+ merged.update(dict(cfg.items(self._profile)))
212
+ if not merged:
213
+ raise IdentityNotFound(
214
+ f"profile {self._profile!r} not found in credentials/config files"
215
+ )
216
+ return merged
217
+
218
+
219
+ class EcsContainerCredentialsProvider(CredentialsProvider):
220
+ """Resolve credentials from the ECS/EKS container credentials endpoint."""
221
+
222
+ def __init__(self, client: Client | AsyncClient) -> None:
223
+ self._client = client
224
+
225
+ def resolve_identity(self) -> Credentials:
226
+ if isinstance(self._client, AsyncClient):
227
+ raise TypeError(
228
+ "EcsContainerCredentialsProvider configured with AsyncClient; use aresolve_identity"
229
+ )
230
+ url, headers = self._request_args()
231
+ resp = self._client.get(url, headers=headers)
232
+ if resp.status < 200 or resp.status >= 300:
233
+ raise IdentityNotFound(
234
+ f"ECS credentials endpoint returned status {resp.status}"
235
+ )
236
+ return _credentials_from_json(resp.json)
237
+
238
+ async def aresolve_identity(self) -> Credentials:
239
+ if not isinstance(self._client, AsyncClient):
240
+ raise TypeError(
241
+ "EcsContainerCredentialsProvider configured with sync Client; use resolve_identity"
242
+ )
243
+ url, headers = self._request_args()
244
+ resp = await self._client.get(url, headers=headers)
245
+ if resp.status < 200 or resp.status >= 300:
246
+ raise IdentityNotFound(
247
+ f"ECS credentials endpoint returned status {resp.status}"
248
+ )
249
+ return _credentials_from_json(resp.json)
250
+
251
+ def _request_args(self) -> tuple[str, dict[str, str]]:
252
+ relative = os.environ.get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")
253
+ full = os.environ.get("AWS_CONTAINER_CREDENTIALS_FULL_URI")
254
+ if relative:
255
+ url = "http://169.254.170.2" + relative
256
+ elif full:
257
+ url = full
258
+ else:
259
+ raise IdentityNotFound("no ECS container credentials env var set")
260
+ headers: dict[str, str] = {}
261
+ token = os.environ.get("AWS_CONTAINER_AUTHORIZATION_TOKEN")
262
+ token_file = os.environ.get("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE")
263
+ if token_file:
264
+ token = Path(token_file).read_text().strip()
265
+ if token:
266
+ headers["Authorization"] = token
267
+ return url, headers
268
+
269
+
270
+ class Ec2InstanceMetadataProvider(CredentialsProvider):
271
+ """Resolve credentials from the EC2 Instance Metadata Service (IMDSv2)."""
272
+
273
+ _BASE = "http://169.254.169.254"
274
+ _TOKEN_PATH = "/latest/api/token"
275
+ _CREDS_PATH = "/latest/meta-data/iam/security-credentials/"
276
+
277
+ def __init__(self, client: Client | AsyncClient) -> None:
278
+ self._client = client
279
+
280
+ def resolve_identity(self) -> Credentials:
281
+ if isinstance(self._client, AsyncClient):
282
+ raise TypeError(
283
+ "Ec2InstanceMetadataProvider configured with AsyncClient; use aresolve_identity"
284
+ )
285
+ if os.environ.get("AWS_EC2_METADATA_DISABLED", "").strip().lower() in (
286
+ "true",
287
+ "1",
288
+ ):
289
+ raise IdentityNotFound("IMDS disabled via AWS_EC2_METADATA_DISABLED")
290
+ try:
291
+ token_resp = self._client.put(
292
+ self._BASE + self._TOKEN_PATH,
293
+ headers={"X-aws-ec2-metadata-token-ttl-seconds": "21600"},
294
+ body=b"",
295
+ )
296
+ auth = {"X-aws-ec2-metadata-token": token_resp.text}
297
+ role_resp = self._client.get(self._BASE + self._CREDS_PATH, headers=auth)
298
+ role = role_resp.text.strip()
299
+ creds_resp = self._client.get(
300
+ self._BASE + self._CREDS_PATH + role, headers=auth
301
+ )
302
+ except ZaprosError as e:
303
+ raise IdentityNotFound(f"IMDS request failed: {e}")
304
+ if creds_resp.status < 200 or creds_resp.status >= 300:
305
+ raise IdentityNotFound(f"IMDS returned status {creds_resp.status}")
306
+ return _credentials_from_json(creds_resp.json)
307
+
308
+ async def aresolve_identity(self) -> Credentials:
309
+ if not isinstance(self._client, AsyncClient):
310
+ raise TypeError(
311
+ "Ec2InstanceMetadataProvider configured with sync Client; use resolve_identity"
312
+ )
313
+ if os.environ.get("AWS_EC2_METADATA_DISABLED", "").strip().lower() in (
314
+ "true",
315
+ "1",
316
+ ):
317
+ raise IdentityNotFound("IMDS disabled via AWS_EC2_METADATA_DISABLED")
318
+ try:
319
+ token_resp = await self._client.put(
320
+ self._BASE + self._TOKEN_PATH,
321
+ headers={"X-aws-ec2-metadata-token-ttl-seconds": "21600"},
322
+ body=b"",
323
+ )
324
+ auth = {"X-aws-ec2-metadata-token": token_resp.text}
325
+ role_resp = await self._client.get(
326
+ self._BASE + self._CREDS_PATH, headers=auth
327
+ )
328
+ role = role_resp.text.strip()
329
+ creds_resp = await self._client.get(
330
+ self._BASE + self._CREDS_PATH + role, headers=auth
331
+ )
332
+ except ZaprosError as e:
333
+ raise IdentityNotFound(f"IMDS request failed: {e}")
334
+ if creds_resp.status < 200 or creds_resp.status >= 300:
335
+ raise IdentityNotFound(f"IMDS returned status {creds_resp.status}")
336
+ return _credentials_from_json(creds_resp.json)
337
+
338
+
339
+ @dataclass(frozen=True)
340
+ class SsoConfig:
341
+ start_url: str
342
+ region: str
343
+ account_id: str
344
+ role_name: str
345
+ session_name: str | None
346
+
347
+
348
+ class SsoCredentialsProvider(CredentialsProvider):
349
+ """Resolve credentials from AWS IAM Identity Center (SSO).
350
+
351
+ Reads the profile's ``sso_*`` settings, loads the access token that
352
+ ``aws sso login`` cached under ``~/.aws/sso/cache`` (refreshing it through
353
+ SSO-OIDC when it is about to expire) and exchanges it for short-term
354
+ credentials through the SSO ``GetRoleCredentials`` API.
355
+ """
356
+
357
+ def __init__(
358
+ self, client: Client | AsyncClient, profile: str | None = None
359
+ ) -> None:
360
+ self._client = client
361
+ self._profile = profile
362
+
363
+ def resolve_identity(self) -> Credentials:
364
+ if isinstance(self._client, AsyncClient):
365
+ raise TypeError(
366
+ "SsoCredentialsProvider configured with AsyncClient; use aresolve_identity"
367
+ )
368
+ # resolve the config first so an unconfigured profile just skips SSO
369
+ config = load_sso_config(self._profile)
370
+ require_dependency(capo_sso, "capo-sso")
371
+ token = load_sso_token(config, self._client)
372
+ sso = capo_sso.SSOClient(http_handler=self._client.handler)
373
+ try:
374
+ response = sso.get_role_credentials(
375
+ config.role_name,
376
+ config.account_id,
377
+ token,
378
+ config_overrides={
379
+ "region": config.region,
380
+ "credentials_provider": None,
381
+ },
382
+ )
383
+ except capo_sso.errors.UnauthorizedException as e:
384
+ raise SSOError(f"SSO token rejected; run `aws sso login`: {e}")
385
+ return credentials_from_role_credentials(response)
386
+
387
+ async def aresolve_identity(self) -> Credentials:
388
+ if not isinstance(self._client, AsyncClient):
389
+ raise TypeError(
390
+ "SsoCredentialsProvider configured with sync Client; use resolve_identity"
391
+ )
392
+ # resolve the config first so an unconfigured profile just skips SSO
393
+ config = load_sso_config(self._profile)
394
+ require_dependency(capo_sso, "capo-sso")
395
+ token = await aload_sso_token(config, self._client)
396
+ sso = capo_sso.AsyncSSOClient(http_handler=self._client.handler)
397
+ try:
398
+ response = await sso.get_role_credentials(
399
+ config.role_name,
400
+ config.account_id,
401
+ token,
402
+ config_overrides={
403
+ "region": config.region,
404
+ "credentials_provider": None,
405
+ },
406
+ )
407
+ except capo_sso.errors.UnauthorizedException as e:
408
+ raise SSOError(f"SSO token rejected; run `aws sso login`: {e}")
409
+ return credentials_from_role_credentials(response)
410
+
411
+
412
+ def load_config_section(name: str) -> dict[str, str]:
413
+ cfg_file = config_file()
414
+ if not cfg_file.is_file():
415
+ return {}
416
+ cfg = configparser.ConfigParser(interpolation=None)
417
+ cfg.read(cfg_file)
418
+ return dict(cfg.items(name)) if cfg.has_section(name) else {}
419
+
420
+
421
+ def load_sso_config(profile_name: str | None = None) -> SsoConfig:
422
+ profile, _ = _load_profile(profile_name)
423
+ session_name = profile.get("sso_session")
424
+ if session_name is None and not any(k.startswith("sso_") for k in profile):
425
+ raise IdentityNotFound("profile has no sso_* settings")
426
+ if session_name is not None:
427
+ # `sso_session` moves the portal settings into an [sso-session NAME] section
428
+ session = load_config_section(f"sso-session {session_name}")
429
+ start_url = session.get("sso_start_url")
430
+ region = session.get("sso_region")
431
+ else:
432
+ start_url = profile.get("sso_start_url")
433
+ region = profile.get("sso_region")
434
+ account_id = profile.get("sso_account_id")
435
+ role_name = profile.get("sso_role_name")
436
+ missing = [
437
+ name
438
+ for name, value in (
439
+ ("sso_start_url", start_url),
440
+ ("sso_region", region),
441
+ ("sso_account_id", account_id),
442
+ ("sso_role_name", role_name),
443
+ )
444
+ if not value
445
+ ]
446
+ if missing:
447
+ raise SSOError("incomplete sso configuration: missing " + ", ".join(missing))
448
+ assert start_url and region and account_id and role_name # narrowed by `missing`
449
+ return SsoConfig(
450
+ start_url=start_url,
451
+ region=region,
452
+ account_id=account_id,
453
+ role_name=role_name,
454
+ session_name=session_name,
455
+ )
456
+
457
+
458
+ def read_sso_token_cache(config: SsoConfig) -> tuple[Path, dict[str, object]]:
459
+ # cache file is sha1 of the session name (new format) or the start url (legacy)
460
+ key = config.session_name or config.start_url
461
+ digest = hashlib.sha1(key.encode("utf-8")).hexdigest()
462
+ path = Path.home() / ".aws" / "sso" / "cache" / f"{digest}.json"
463
+ if not path.is_file():
464
+ raise SSOError(f"no cached SSO token at {path}; run `aws sso login`")
465
+ data = json.loads(path.read_text())
466
+ if not isinstance(data.get("accessToken"), str) or not isinstance(
467
+ data.get("expiresAt"), str
468
+ ):
469
+ raise SSOError(f"SSO token cache {path} has no accessToken/expiresAt")
470
+ return path, data
471
+
472
+
473
+ def unexpired_sso_token(data: dict[str, object], *, skew: timedelta) -> str | None:
474
+ """The cached token if it is still good for at least ``skew``, else None."""
475
+ expires_at = parse_sso_expiry(str(data["expiresAt"]))
476
+ if expires_at - datetime.now(timezone.utc) <= skew:
477
+ return None
478
+ return str(data["accessToken"])
479
+
480
+
481
+ def sso_refresh_args(data: dict[str, object]) -> tuple[str, str, str] | None:
482
+ """Client registration + refresh token, if this entry can be refreshed.
483
+
484
+ Only tokens cached for an ``sso_session`` carry a client registration; the
485
+ legacy ``sso_start_url`` format has nothing to refresh with.
486
+ """
487
+ client_id = data.get("clientId")
488
+ client_secret = data.get("clientSecret")
489
+ refresh_token = data.get("refreshToken")
490
+ if (
491
+ not isinstance(client_id, str)
492
+ or not isinstance(client_secret, str)
493
+ or not isinstance(refresh_token, str)
494
+ ):
495
+ return None
496
+ registration_expires_at = data.get("registrationExpiresAt")
497
+ if isinstance(registration_expires_at, str):
498
+ if parse_sso_expiry(registration_expires_at) <= datetime.now(timezone.utc):
499
+ return None
500
+ return client_id, client_secret, refresh_token
501
+
502
+
503
+ def load_sso_token(config: SsoConfig, client: Client) -> str:
504
+ path, data = read_sso_token_cache(config)
505
+ fresh = unexpired_sso_token(data, skew=SSO_TOKEN_REFRESH_WINDOW)
506
+ if fresh is not None:
507
+ return fresh
508
+ refresh_args = sso_refresh_args(data)
509
+ if refresh_args is not None:
510
+ require_dependency(capo_sso_oidc, "capo-sso-oidc")
511
+ oidc = capo_sso_oidc.SSOOIDCClient(http_handler=client.handler)
512
+ try:
513
+ response = oidc.create_token(
514
+ refresh_args[0],
515
+ refresh_args[1],
516
+ "refresh_token",
517
+ refresh_token=refresh_args[2],
518
+ config_overrides={
519
+ "region": config.region,
520
+ "credentials_provider": None,
521
+ },
522
+ )
523
+ except (capo_sso_oidc.errors.SSOOIDCError, ZaprosError):
524
+ return sso_token_or_error(data, path)
525
+ return store_refreshed_sso_token(path, data, response)
526
+ return sso_token_or_error(data, path)
527
+
528
+
529
+ async def aload_sso_token(config: SsoConfig, client: AsyncClient) -> str:
530
+ path, data = read_sso_token_cache(config)
531
+ fresh = unexpired_sso_token(data, skew=SSO_TOKEN_REFRESH_WINDOW)
532
+ if fresh is not None:
533
+ return fresh
534
+ refresh_args = sso_refresh_args(data)
535
+ if refresh_args is not None:
536
+ require_dependency(capo_sso_oidc, "capo-sso-oidc")
537
+ oidc = capo_sso_oidc.AsyncSSOOIDCClient(http_handler=client.handler)
538
+ try:
539
+ response = await oidc.create_token(
540
+ refresh_args[0],
541
+ refresh_args[1],
542
+ "refresh_token",
543
+ refresh_token=refresh_args[2],
544
+ config_overrides={
545
+ "region": config.region,
546
+ "credentials_provider": None,
547
+ },
548
+ )
549
+ except (capo_sso_oidc.errors.SSOOIDCError, ZaprosError):
550
+ return sso_token_or_error(data, path)
551
+ return store_refreshed_sso_token(path, data, response)
552
+ return sso_token_or_error(data, path)
553
+
554
+
555
+ def sso_token_or_error(data: dict[str, object], path: Path) -> str:
556
+ """Fall back to the cached token when refreshing was impossible or failed."""
557
+ token = unexpired_sso_token(data, skew=timedelta(0))
558
+ if token is None:
559
+ raise SSOError(
560
+ f"cached SSO token in {path} expired at {data['expiresAt']}; "
561
+ "run `aws sso login`"
562
+ )
563
+ return token
564
+
565
+
566
+ def store_refreshed_sso_token(
567
+ path: Path,
568
+ data: dict[str, object],
569
+ response: capo_sso_oidc.types.create_token_response.CreateTokenResponse,
570
+ ) -> str:
571
+ access_token = response.get("access_token")
572
+ if not access_token:
573
+ raise SSOError("CreateToken response has no accessToken")
574
+ expires_at = datetime.now(timezone.utc) + timedelta(
575
+ seconds=response.get("expires_in", 0)
576
+ )
577
+ updated = dict(data)
578
+ updated["accessToken"] = access_token
579
+ updated["expiresAt"] = expires_at.strftime("%Y-%m-%dT%H:%M:%SZ")
580
+ refresh_token = response.get("refresh_token")
581
+ if refresh_token:
582
+ updated["refreshToken"] = refresh_token
583
+ path.write_text(json.dumps(updated))
584
+ path.chmod(0o600)
585
+ return access_token
586
+
587
+
588
+ def parse_sso_expiry(value: str) -> datetime:
589
+ # the CLI writes either an ISO offset, a trailing 'Z', or a trailing 'UTC'
590
+ parsed = datetime.fromisoformat(value.removesuffix("UTC").removesuffix("Z"))
591
+ if parsed.tzinfo is None:
592
+ parsed = parsed.replace(tzinfo=timezone.utc)
593
+ return parsed
594
+
595
+
596
+ def credentials_from_role_credentials(
597
+ response: capo_sso.types.get_role_credentials_response.GetRoleCredentialsResponse,
598
+ ) -> Credentials:
599
+ role = response.get("role_credentials") or {}
600
+ ak = role.get("access_key_id")
601
+ sk = role.get("secret_access_key")
602
+ if not ak or not sk:
603
+ raise SSOError("GetRoleCredentials response missing access key / secret key")
604
+ out: Credentials = {"access_key": ak, "secret_key": sk}
605
+ token = role.get("session_token")
606
+ if token:
607
+ out["session_token"] = token
608
+ expiration = role.get("expiration")
609
+ if expiration:
610
+ # SSO reports the expiry in epoch milliseconds
611
+ out["expiration"] = datetime.fromtimestamp(expiration / 1000, timezone.utc)
612
+ return out
613
+
614
+
615
+ @dataclass(frozen=True)
616
+ class AssumeRoleConfig:
617
+ role_arn: str
618
+ session_name: str
619
+ region: str
620
+ external_id: str | None
621
+ duration_seconds: int | None
622
+
623
+
624
+ class AssumeRoleCredentialsProvider(CredentialsProvider):
625
+ """Assume the profile's ``role_arn`` through STS ``AssumeRole``.
626
+
627
+ The credentials used to make the call come from the profile's
628
+ ``source_profile`` (which may itself be an SSO profile, or another
629
+ ``role_arn`` profile for role chaining) or from ``credential_source``.
630
+ """
631
+
632
+ def __init__(
633
+ self,
634
+ client: Client | AsyncClient,
635
+ profile: str | None = None,
636
+ _visited: frozenset[str] = frozenset(),
637
+ ) -> None:
638
+ self._client = client
639
+ self._profile = profile
640
+ self._visited = _visited
641
+
642
+ def resolve_identity(self) -> Credentials:
643
+ if isinstance(self._client, AsyncClient):
644
+ raise TypeError(
645
+ "AssumeRoleCredentialsProvider configured with AsyncClient; use aresolve_identity"
646
+ )
647
+ # resolve the config first so a profile without role_arn just skips
648
+ section, _ = _load_profile(self._profile)
649
+ config = assume_role_config(section)
650
+ require_dependency(capo_sts, "capo-sts")
651
+ source = self._source_provider(section)
652
+ sts = capo_sts.STSClient(
653
+ http_handler=self._client.handler,
654
+ region=config.region,
655
+ credentials_provider=cast("capo_sts.CredentialsProvider", source),
656
+ )
657
+ response = sts.assume_role(
658
+ config.role_arn,
659
+ config.session_name,
660
+ external_id=config.external_id,
661
+ duration_seconds=config.duration_seconds,
662
+ )
663
+ return credentials_from_sts(response.get("credentials"))
664
+
665
+ async def aresolve_identity(self) -> Credentials:
666
+ if not isinstance(self._client, AsyncClient):
667
+ raise TypeError(
668
+ "AssumeRoleCredentialsProvider configured with sync Client; use resolve_identity"
669
+ )
670
+ # resolve the config first so a profile without role_arn just skips
671
+ section, _ = _load_profile(self._profile)
672
+ config = assume_role_config(section)
673
+ require_dependency(capo_sts, "capo-sts")
674
+ source = self._source_provider(section)
675
+ sts = capo_sts.AsyncSTSClient(
676
+ http_handler=self._client.handler,
677
+ region=config.region,
678
+ credentials_provider=cast("capo_sts.CredentialsProvider", source),
679
+ )
680
+ response = await sts.assume_role(
681
+ config.role_arn,
682
+ config.session_name,
683
+ external_id=config.external_id,
684
+ duration_seconds=config.duration_seconds,
685
+ )
686
+ return credentials_from_sts(response.get("credentials"))
687
+
688
+ def _source_provider(
689
+ self, section: dict[str, str]
690
+ ) -> IdentityProvider[Credentials]:
691
+ source_profile = section.get("source_profile")
692
+ credential_source = section.get("credential_source")
693
+ if source_profile and credential_source:
694
+ raise AssumeRoleError(
695
+ "source_profile and credential_source are mutually exclusive"
696
+ )
697
+ if credential_source:
698
+ match credential_source:
699
+ case "Environment":
700
+ return EnvCredentialsProvider()
701
+ case "Ec2InstanceMetadata":
702
+ return Ec2InstanceMetadataProvider(self._client)
703
+ case "EcsContainer":
704
+ return EcsContainerCredentialsProvider(self._client)
705
+ case _:
706
+ raise AssumeRoleError(
707
+ f"unsupported credential_source {credential_source!r}"
708
+ )
709
+ if source_profile in self._visited:
710
+ raise AssumeRoleError(
711
+ f"circular source_profile reference through {source_profile!r}"
712
+ )
713
+ assert source_profile is not None # assume_role_config rejects the empty case
714
+ return ChainedProvider(
715
+ AssumeRoleCredentialsProvider(
716
+ self._client, source_profile, self._visited | {source_profile}
717
+ ),
718
+ SsoCredentialsProvider(self._client, source_profile),
719
+ ProfileCredentialsProvider(profile=source_profile),
720
+ )
721
+
722
+
723
+ class WebIdentityCredentialsProvider(CredentialsProvider):
724
+ """Exchange an OIDC token for credentials via ``AssumeRoleWithWebIdentity``.
725
+
726
+ Reads ``AWS_WEB_IDENTITY_TOKEN_FILE``/``AWS_ROLE_ARN`` or the profile's
727
+ ``web_identity_token_file``/``role_arn`` — this is how EKS service accounts
728
+ (IRSA) obtain credentials.
729
+ """
730
+
731
+ def __init__(
732
+ self, client: Client | AsyncClient, profile: str | None = None
733
+ ) -> None:
734
+ self._client = client
735
+ self._profile = profile
736
+
737
+ def resolve_identity(self) -> Credentials:
738
+ if isinstance(self._client, AsyncClient):
739
+ raise TypeError(
740
+ "WebIdentityCredentialsProvider configured with AsyncClient; use aresolve_identity"
741
+ )
742
+ config, token = self._config()
743
+ require_dependency(capo_sts, "capo-sts")
744
+ sts = capo_sts.STSClient(
745
+ http_handler=self._client.handler, region=config.region
746
+ )
747
+ response = sts.assume_role_with_web_identity(
748
+ config.role_arn,
749
+ config.session_name,
750
+ token,
751
+ duration_seconds=config.duration_seconds,
752
+ config_overrides={"credentials_provider": None},
753
+ )
754
+ return credentials_from_sts(response.get("credentials"))
755
+
756
+ async def aresolve_identity(self) -> Credentials:
757
+ if not isinstance(self._client, AsyncClient):
758
+ raise TypeError(
759
+ "WebIdentityCredentialsProvider configured with sync Client; use resolve_identity"
760
+ )
761
+ config, token = self._config()
762
+ require_dependency(capo_sts, "capo-sts")
763
+ sts = capo_sts.AsyncSTSClient(
764
+ http_handler=self._client.handler, region=config.region
765
+ )
766
+ response = await sts.assume_role_with_web_identity(
767
+ config.role_arn,
768
+ config.session_name,
769
+ token,
770
+ duration_seconds=config.duration_seconds,
771
+ config_overrides={"credentials_provider": None},
772
+ )
773
+ return credentials_from_sts(response.get("credentials"))
774
+
775
+ def _config(self) -> tuple[AssumeRoleConfig, str]:
776
+ section, _ = _load_profile(self._profile)
777
+ token_file = os.environ.get("AWS_WEB_IDENTITY_TOKEN_FILE")
778
+ role_arn = os.environ.get("AWS_ROLE_ARN")
779
+ session_name = os.environ.get("AWS_ROLE_SESSION_NAME")
780
+ if not token_file or not role_arn:
781
+ # the env pair is all-or-nothing; otherwise fall back to the profile
782
+ token_file = section.get("web_identity_token_file")
783
+ role_arn = section.get("role_arn")
784
+ session_name = section.get("role_session_name")
785
+ if not token_file or not role_arn:
786
+ raise IdentityNotFound("no web identity token file / role arn configured")
787
+ path = Path(token_file).expanduser()
788
+ if not path.is_file():
789
+ raise AssumeRoleError(f"web identity token file {path} does not exist")
790
+ config = AssumeRoleConfig(
791
+ role_arn=role_arn,
792
+ session_name=session_name or default_session_name(),
793
+ region=sts_region(section),
794
+ external_id=None,
795
+ duration_seconds=int_or_none(section.get("duration_seconds")),
796
+ )
797
+ return config, path.read_text().strip()
798
+
799
+
800
+ def assume_role_config(section: dict[str, str]) -> AssumeRoleConfig:
801
+ role_arn = section.get("role_arn")
802
+ if not role_arn:
803
+ raise IdentityNotFound("profile has no role_arn")
804
+ if section.get("web_identity_token_file"):
805
+ # handled by WebIdentityCredentialsProvider, not AssumeRole
806
+ raise IdentityNotFound("profile uses web_identity_token_file")
807
+ if not section.get("source_profile") and not section.get("credential_source"):
808
+ raise IdentityNotFound(
809
+ "role_arn profile has no source_profile/credential_source"
810
+ )
811
+ if section.get("mfa_serial"):
812
+ raise AssumeRoleError("mfa_serial profiles are not supported")
813
+ return AssumeRoleConfig(
814
+ role_arn=role_arn,
815
+ session_name=section.get("role_session_name") or default_session_name(),
816
+ region=sts_region(section),
817
+ external_id=section.get("external_id"),
818
+ duration_seconds=int_or_none(section.get("duration_seconds")),
819
+ )
820
+
821
+
822
+ def default_session_name() -> str:
823
+ return f"capo-session-{int(time.time())}"
824
+
825
+
826
+ def sts_region(section: dict[str, str]) -> str:
827
+ return os.environ.get("AWS_REGION") or section.get("region") or "us-east-1"
828
+
829
+
830
+ def int_or_none(value: str | None) -> int | None:
831
+ return int(value) if value else None
832
+
833
+
834
+ def credentials_from_sts(
835
+ credentials: capo_sts.types.credentials.Credentials | None,
836
+ ) -> Credentials:
837
+ if credentials is None:
838
+ raise AssumeRoleError("STS response contained no credentials")
839
+ return {
840
+ "access_key": credentials["access_key_id"],
841
+ "secret_key": credentials["secret_access_key"],
842
+ "session_token": credentials["session_token"],
843
+ "expiration": credentials["expiration"],
844
+ }
845
+
846
+
847
+ def _parse_iso8601(value: str) -> datetime:
848
+ # tolerate trailing 'Z'
849
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
850
+
851
+
852
+ def _credentials_from_json(data: dict[str, object]) -> Credentials:
853
+ ak = data.get("AccessKeyId")
854
+ sk = data.get("SecretAccessKey")
855
+ if not isinstance(ak, str) or not isinstance(sk, str):
856
+ raise IdentityNotFound(
857
+ "credentials response missing AccessKeyId/SecretAccessKey"
858
+ )
859
+ out: Credentials = {"access_key": ak, "secret_key": sk}
860
+ token = data.get("Token")
861
+ if isinstance(token, str):
862
+ out["session_token"] = token
863
+ exp = data.get("Expiration")
864
+ if isinstance(exp, str):
865
+ out["expiration"] = _parse_iso8601(exp)
866
+ return out
867
+
868
+
869
+ def default_aws_credentials_chain(
870
+ client: Client | AsyncClient,
871
+ ) -> IdentityProvider[Credentials]:
872
+ return CachedProvider(
873
+ ChainedProvider(
874
+ EnvCredentialsProvider(),
875
+ AssumeRoleCredentialsProvider(client),
876
+ WebIdentityCredentialsProvider(client),
877
+ SsoCredentialsProvider(client),
878
+ ProfileCredentialsProvider(),
879
+ EcsContainerCredentialsProvider(client),
880
+ Ec2InstanceMetadataProvider(client),
881
+ )
882
+ )