Skip to content

Commit bdbd5de

Browse files
authored
Merge pull request #222 from britive/v2.4.0-rc.1
v2.4.0-rc.1
2 parents 6c05cff + 9654742 commit bdbd5de

7 files changed

Lines changed: 175 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Changelog
22

3+
## v2.4.0-rc.1 [2026-06-08]
4+
5+
__What's New:__
6+
7+
* Added `awsstsjwt` federation provider support (OIDC-based AWS federation via STS `GetWebIdentityToken`)
8+
9+
__Enhancements:__
10+
11+
* Updated federation provider help text to list all valid providers including `awsstsjwt` parameter format
12+
13+
__Bug Fixes:__
14+
15+
* Added per-profile file locking to prevent concurrent duplicate checkouts
16+
17+
__Dependencies:__
18+
19+
* Bumped `britive` SDK requirement from `>=4.1.2` to `>=4.6.0`
20+
21+
__Other:__
22+
23+
* None
24+
325
## v2.3.2 [2026-04-07]
426

527
__What's New:__

docs/index.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,8 @@ At feature launch the following types of identity providers are supported for wo
266266
`pybritive` offers some native integrations with the following services.
267267

268268
* Github Actions
269-
* AWS
269+
* AWS (STS)
270+
* AWS (STS via OIDC JWT)
270271
* Bitbucket
271272
* Azure System Assigned Managed Identities
272273
* Azure User Assigned Managed Identities
@@ -318,6 +319,24 @@ pybritive checkout "profile" --federation-provider aws-profile_expirationseconds
318319
pybritive checkout "profile" --federation-provider aws_expirationseconds
319320
```
320321

322+
#### AWS STS via OIDC (JWT)
323+
324+
```sh
325+
# use awsstsjwt with an AWS CLI profile, audience, signing algorithm, and duration
326+
# format: awsstsjwt-<profile>|<audience>|<signing_algorithm>|<duration_seconds>
327+
pybritive checkout "profile" --federation-provider awsstsjwt-myprofile|sts.amazonaws.com|RS256|3600
328+
329+
# use awsstsjwt with only an AWS CLI profile (other params use defaults)
330+
pybritive checkout "profile" --federation-provider awsstsjwt-myprofile
331+
332+
# use awsstsjwt without an AWS CLI profile (source credentials via the standard credential discovery process)
333+
pybritive checkout "profile" --federation-provider awsstsjwt
334+
```
335+
336+
The `awsstsjwt` provider uses the AWS STS `AssumeRoleWithWebIdentity` API to federate using an OIDC JWT token.
337+
Parameters are pipe-delimited in the format `awsstsjwt-<profile>|<audience>|<signing_algorithm>|<duration_seconds>`.
338+
All parameters after the profile are optional.
339+
321340
#### Bitbucket
322341

323342
> _note: no additional options are available for bitbucket._

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ classifiers = [
2626
license = {file = "LICENSE"}
2727
requires-python = ">= 3.9"
2828
dependencies = [
29-
"britive>=4.1.2,<5.0",
29+
"britive>=4.6.0,<5.0",
3030
"click>=8.1.7",
3131
"colored>=2.2.5",
3232
"cryptography",

src/pybritive/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '2.3.2'
1+
__version__ = '2.4.0-rc.1'

src/pybritive/britive_cli.py

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from . import __version__
2525
from .helpers import cloud_credential_printer as printer
2626
from .helpers.cache import Cache
27+
from .helpers.checkout_lock import CheckoutLock
2728
from .helpers.config import ConfigManager
2829
from .helpers.credentials import EncryptedFileCredentialManager, FileCredentialManager
2930
from .helpers.split import profile_split
@@ -792,6 +793,17 @@ def _checkout(
792793
}
793794
raise e
794795

796+
def _check_cache(self, passphrase: Optional[str], profile_name: str, mode: str) -> Optional[dict]:
797+
credentials = Cache(passphrase=passphrase).get_credentials(profile_name=profile_name, mode=mode)
798+
if credentials:
799+
expiration_timestamp_str = jmespath.search(
800+
expression=self.cachable_modes[mode]['expiration_jmespath'], data=credentials
801+
).replace('Z', '')
802+
expires = datetime.fromisoformat(expiration_timestamp_str)
803+
if datetime.utcnow() < expires:
804+
return credentials
805+
return None
806+
795807
@staticmethod
796808
def _should_check_force_renew(app, force_renew, console):
797809
return app in ['AWS', 'AWS Standalone'] and force_renew and not console
@@ -903,25 +915,14 @@ def _access_checkout(
903915
self._validate_justification(justification)
904916

905917
if mode in self.cachable_modes:
906-
self.silent = True # CANNOT output anything other than the expected JSON
907-
# we need to check the cache for the credentials first and then check to see if they are expired
908-
# if not simply return those credentials, if they are expired, continue to do an actual checkout
918+
self.silent = True
909919
app_type = self.cachable_modes[mode]['app_type']
910-
credentials = Cache(passphrase=passphrase).get_credentials(profile_name=alias or profile, mode=mode)
920+
credentials = self._check_cache(passphrase, alias or profile, mode)
911921
if credentials:
912-
expiration_timestamp_str = jmespath.search(
913-
expression=self.cachable_modes[mode]['expiration_jmespath'], data=credentials
914-
).replace('Z', '')
915-
expires = datetime.fromisoformat(expiration_timestamp_str)
916-
now = datetime.utcnow()
917-
if now >= expires: # check to ensure the credentials are still valid, if not, set to None and get new
918-
credentials = None
919-
else:
920-
cached_credentials_found = True
922+
cached_credentials_found = True
921923

922924
parts = self._split_profile_into_parts(profile)
923925

924-
# create this params once so we can use it multiple places
925926
params = {
926927
'app_name': parts['app'],
927928
'blocktime': blocktime,
@@ -936,30 +937,41 @@ def _access_checkout(
936937
'ticket_type': ticket_type,
937938
}
938939

939-
if not cached_credentials_found: # nothing found in cache, cache is expired, or not a cachable mode
940-
response = self._checkout(**params)
941-
app_type = self._get_app_type(response['appContainerId'])
942-
credentials = response['credentials']
943-
console_fallback = response.get('console-fallback')
940+
if not cached_credentials_found:
941+
if mode in self.cachable_modes:
942+
with CheckoutLock(profile_key=alias or profile, mode=mode):
943+
credentials = self._check_cache(passphrase, alias or profile, mode)
944+
if credentials:
945+
cached_credentials_found = True
946+
else:
947+
response = self._checkout(**params)
948+
app_type = self._get_app_type(response['appContainerId'])
949+
credentials = response['credentials']
950+
console_fallback = response.get('console-fallback')
951+
Cache(passphrase=passphrase).save_credentials(
952+
profile_name=alias or profile, credentials=credentials, mode=mode
953+
)
954+
else:
955+
response = self._checkout(**params)
956+
app_type = self._get_app_type(response['appContainerId'])
957+
credentials = response['credentials']
958+
console_fallback = response.get('console-fallback')
944959

945-
# this handles the --force-renew flag
946-
# lets check to see if we should checkin this profile first and check it out again
947960
if self._should_check_force_renew(app_type, force_renew, console):
948961
expiration = datetime.fromisoformat(credentials['expirationTime'].replace('Z', ''))
949962
now = datetime.utcnow()
950963
diff = (expiration - now).total_seconds() / 60.0
951-
if diff < force_renew: # time to checkin the profile so we can refresh creds
964+
if diff < force_renew:
952965
self.print('checking in the profile to get renewed credentials....standby')
953966
self.checkin(profile=profile, console=console)
954967
response = self._checkout(**params)
955-
cached_credentials_found = False # need to write new creds to cache
956968
credentials = response['credentials']
957969
console_fallback = response.get('console-fallback')
970+
if mode in self.cachable_modes:
971+
Cache(passphrase=passphrase).save_credentials(
972+
profile_name=alias or profile, credentials=credentials, mode=mode
973+
)
958974

959-
if mode in self.cachable_modes and not cached_credentials_found:
960-
Cache(passphrase=passphrase).save_credentials(
961-
profile_name=alias or profile, credentials=credentials, mode=mode
962-
)
963975
return app_type, console_fallback, credentials, k8s_processor
964976

965977
def checkout(
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import hashlib
2+
import os
3+
import time
4+
from pathlib import Path
5+
from types import TracebackType
6+
from typing import Optional, Type
7+
8+
9+
class CheckoutLockTimeout(Exception):
10+
pass
11+
12+
13+
class _WouldBlock(Exception):
14+
pass
15+
16+
17+
try:
18+
import fcntl
19+
20+
def _lock_fd(fd: int) -> None:
21+
try:
22+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
23+
except (OSError, IOError):
24+
raise _WouldBlock()
25+
26+
def _unlock_fd(fd: int) -> None:
27+
fcntl.flock(fd, fcntl.LOCK_UN)
28+
29+
except ImportError:
30+
import msvcrt
31+
32+
def _lock_fd(fd: int) -> None:
33+
try:
34+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
35+
except (OSError, IOError):
36+
raise _WouldBlock()
37+
38+
def _unlock_fd(fd: int) -> None:
39+
try:
40+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
41+
except (OSError, IOError):
42+
pass
43+
44+
45+
class CheckoutLock:
46+
def __init__(self, profile_key: str, mode: str, timeout: float = 120.0, poll_interval: float = 0.1) -> None:
47+
self.timeout: float = timeout
48+
self.poll_interval: float = poll_interval
49+
self._fd: Optional[int] = None
50+
51+
home = os.getenv('PYBRITIVE_HOME_DIR', str(Path.home()))
52+
lock_dir = Path(home) / '.britive' / 'locks'
53+
lock_dir.mkdir(parents=True, exist_ok=True)
54+
55+
lock_name = hashlib.sha256(f'{mode}:{profile_key}'.lower().encode('utf-8')).hexdigest()[:16]
56+
self.lock_path: str = str(lock_dir / f'{lock_name}.lock')
57+
58+
def acquire(self) -> None:
59+
self._fd = os.open(self.lock_path, os.O_CREAT | os.O_RDWR)
60+
deadline = time.monotonic() + self.timeout
61+
while True:
62+
try:
63+
_lock_fd(self._fd)
64+
return
65+
except _WouldBlock:
66+
if time.monotonic() >= deadline:
67+
os.close(self._fd)
68+
self._fd = None
69+
raise CheckoutLockTimeout(
70+
f'Timed out after {self.timeout}s waiting for checkout lock'
71+
)
72+
time.sleep(self.poll_interval)
73+
74+
def release(self) -> None:
75+
if self._fd is not None:
76+
try:
77+
_unlock_fd(self._fd)
78+
finally:
79+
os.close(self._fd)
80+
self._fd = None
81+
82+
def __enter__(self) -> 'CheckoutLock':
83+
self.acquire()
84+
return self
85+
86+
def __exit__(
87+
self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
88+
) -> bool:
89+
self.release()
90+
return False

src/pybritive/options/federation_provider.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
'--federation-provider',
55
'-P',
66
help='Use a federation provider available in the Britive Python SDK for auto token creation. '
7-
'See CLI documentation at https://britive.github.io/python-cli/ for acceptable values.',
7+
'Valid providers: aws, awsstsjwt, azuresmi, azureumi, bitbucket, gcp, github, gitlab, spacelift. '
8+
'See CLI documentation at https://britive.github.io/python-cli/ for details.',
89
default=None,
910
show_default=True,
1011
)

0 commit comments

Comments
 (0)