Skip to content

Commit 246f957

Browse files
committed
Fix process credentials timezone handling, JSON error wrapping
1 parent 5d7f607 commit 246f957

File tree

2 files changed

+89
-55
lines changed

2 files changed

+89
-55
lines changed

packages/smithy-aws-core/src/smithy_aws_core/identity/process.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,12 @@ async def get_identity(
8282
raise SmithyIdentityError(
8383
f"Credential process failed with non-zero exit code: {stderr.decode('utf-8')}"
8484
)
85-
creds = json.loads(stdout.decode("utf-8"))
85+
try:
86+
creds = json.loads(stdout.decode("utf-8"))
87+
except json.JSONDecodeError as e:
88+
raise SmithyIdentityError(
89+
f"Failed to parse credential process output: {e}"
90+
) from e
8691

8792
version = creds.get("Version")
8893
if version is None or version != 1:
@@ -96,7 +101,8 @@ async def get_identity(
96101
account_id = creds.get("AccountId")
97102

98103
if isinstance(expiration, str):
99-
expiration = datetime.fromisoformat(expiration).replace(tzinfo=UTC)
104+
dt = datetime.fromisoformat(expiration)
105+
expiration = dt.astimezone(UTC) if dt.tzinfo else dt.replace(tzinfo=UTC)
100106

101107
if access_key_id is None or secret_access_key is None:
102108
raise SmithyIdentityError(

packages/smithy-aws-core/tests/unit/identity/test_process.py

Lines changed: 81 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,10 @@
88
from unittest.mock import AsyncMock, Mock, patch
99

1010
import pytest
11-
from smithy_aws_core.identity.components import (
12-
AWSCredentialsIdentity,
13-
AWSIdentityProperties,
14-
)
1511
from smithy_aws_core.identity.process import (
1612
ProcessCredentialsConfig,
1713
ProcessCredentialsResolver,
1814
)
19-
from smithy_core.aio.identity import ChainedIdentityResolver
2015
from smithy_core.exceptions import SmithyIdentityError
2116

2217
ISO8601 = "%Y-%m-%dT%H:%M:%SZ"
@@ -39,19 +34,10 @@ def test_config_custom_values():
3934
assert config.timeout == 60
4035

4136

42-
def test_resolver_empty_command():
43-
with pytest.raises(ValueError, match="command must be a non-empty string or list"):
44-
ProcessCredentialsResolver([])
45-
46-
47-
def test_resolver_none_command():
48-
with pytest.raises(ValueError, match="command must be a non-empty string or list"):
49-
ProcessCredentialsResolver(None) # type: ignore[arg-type]
50-
51-
52-
def test_resolver_empty_command_string():
37+
@pytest.mark.parametrize("command", [[], "", None])
38+
def test_resolver_invalid_command(command: object):
5339
with pytest.raises(ValueError, match="command must be a non-empty string or list"):
54-
ProcessCredentialsResolver("")
40+
ProcessCredentialsResolver(command) # type: ignore[arg-type]
5541

5642

5743
def mock_subprocess(returncode: int, stdout: bytes, stderr: bytes = b""):
@@ -97,6 +83,41 @@ async def test_valid_credentials_without_session_token():
9783
assert identity.session_token is None
9884

9985

86+
@pytest.mark.asyncio
87+
async def test_missing_expiration():
88+
resp_body = json.dumps(DEFAULT_RESPONSE_DATA)
89+
process = mock_subprocess(0, resp_body.encode("utf-8"))
90+
91+
with patch("asyncio.create_subprocess_exec", return_value=process):
92+
resolver = ProcessCredentialsResolver(["mock-process"])
93+
identity = await resolver.get_identity(properties={})
94+
95+
assert identity.access_key_id == "foo"
96+
assert identity.secret_access_key == "bar"
97+
assert identity.session_token == "baz"
98+
assert identity.expiration is None
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_missing_expiration_and_session_token():
103+
resp_data = {
104+
"Version": 1,
105+
"AccessKeyId": "foo",
106+
"SecretAccessKey": "bar",
107+
}
108+
resp_body = json.dumps(resp_data)
109+
process = mock_subprocess(0, resp_body.encode("utf-8"))
110+
111+
with patch("asyncio.create_subprocess_exec", return_value=process):
112+
resolver = ProcessCredentialsResolver(["mock-process"])
113+
identity = await resolver.get_identity(properties={})
114+
115+
assert identity.access_key_id == "foo"
116+
assert identity.secret_access_key == "bar"
117+
assert identity.session_token is None
118+
assert identity.expiration is None
119+
120+
100121
@pytest.mark.asyncio
101122
async def test_credentials_with_expiration():
102123
current_time = datetime.now(UTC) + timedelta(minutes=10)
@@ -114,6 +135,25 @@ async def test_credentials_with_expiration():
114135
assert identity.expiration.tzinfo == UTC
115136

116137

138+
@pytest.mark.asyncio
139+
async def test_credentials_with_non_utc_expiration():
140+
"""Test that non-UTC expiration timestamps are correctly converted to UTC."""
141+
# 2026-03-16T10:00:00+05:00 should become 2026-03-16T05:00:00 UTC
142+
resp_data = dict(DEFAULT_RESPONSE_DATA)
143+
resp_data["Expiration"] = "2026-03-16T10:00:00+05:00"
144+
145+
resp_body = json.dumps(resp_data)
146+
process = mock_subprocess(0, resp_body.encode("utf-8"))
147+
148+
with patch("asyncio.create_subprocess_exec", return_value=process):
149+
resolver = ProcessCredentialsResolver(["mock-process"])
150+
identity = await resolver.get_identity(properties={})
151+
152+
assert identity.expiration is not None
153+
assert identity.expiration.tzinfo == UTC
154+
assert identity.expiration == datetime(2026, 3, 16, 5, 0, 0, tzinfo=UTC)
155+
156+
117157
@pytest.mark.asyncio
118158
async def test_credentials_with_account_id():
119159
resp_data = dict(DEFAULT_RESPONSE_DATA)
@@ -210,7 +250,7 @@ async def test_invalid_json():
210250

211251
with patch("asyncio.create_subprocess_exec", return_value=process):
212252
resolver = ProcessCredentialsResolver(["mock-process"])
213-
with pytest.raises(json.JSONDecodeError):
253+
with pytest.raises(SmithyIdentityError, match="Failed to parse"):
214254
await resolver.get_identity(properties={})
215255

216256

@@ -244,33 +284,6 @@ async def test_process_startup_failure_raises_smithy_identity_error():
244284
await resolver.get_identity(properties={})
245285

246286

247-
@pytest.mark.asyncio
248-
async def test_process_startup_failure_allows_chained_fallback():
249-
class SuccessfulResolver:
250-
async def get_identity(
251-
self, *, properties: AWSIdentityProperties
252-
) -> AWSCredentialsIdentity:
253-
return AWSCredentialsIdentity(
254-
access_key_id="fallback-akid",
255-
secret_access_key="fallback-secret",
256-
)
257-
258-
with patch(
259-
"asyncio.create_subprocess_exec",
260-
side_effect=FileNotFoundError("No such file or directory"),
261-
):
262-
resolver = ChainedIdentityResolver(
263-
[
264-
ProcessCredentialsResolver(["missing-process"]),
265-
SuccessfulResolver(),
266-
]
267-
)
268-
identity = await resolver.get_identity(properties={})
269-
270-
assert identity.access_key_id == "fallback-akid"
271-
assert identity.secret_access_key == "fallback-secret"
272-
273-
274287
@pytest.mark.asyncio
275288
async def test_long_term_credentials_cached():
276289
"""Test that credentials without expiration are cached indefinitely."""
@@ -313,13 +326,25 @@ async def test_temporary_credentials_cached_when_valid():
313326
async def test_expired_credentials_refreshed():
314327
"""Test that expired credentials are refreshed."""
315328
expired_time = datetime.now(UTC) - timedelta(minutes=10)
316-
resp_data = dict(DEFAULT_RESPONSE_DATA)
317-
resp_data["Expiration"] = expired_time.strftime(ISO8601)
329+
initial_data = dict(DEFAULT_RESPONSE_DATA)
330+
initial_data["Expiration"] = expired_time.strftime(ISO8601)
318331

319-
resp_body = json.dumps(resp_data)
320-
process = mock_subprocess(0, resp_body.encode("utf-8"))
332+
refreshed_time = datetime.now(UTC) + timedelta(minutes=10)
333+
refreshed_data = {
334+
"Version": 1,
335+
"AccessKeyId": "foo-refreshed",
336+
"SecretAccessKey": "bar-refreshed",
337+
"SessionToken": "baz-refreshed",
338+
"Expiration": refreshed_time.strftime(ISO8601),
339+
}
321340

322-
with patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec:
341+
first_process = mock_subprocess(0, json.dumps(initial_data).encode("utf-8"))
342+
second_process = mock_subprocess(0, json.dumps(refreshed_data).encode("utf-8"))
343+
344+
with patch(
345+
"asyncio.create_subprocess_exec",
346+
side_effect=[first_process, second_process],
347+
) as mock_exec:
323348
resolver = ProcessCredentialsResolver(["mock-process"])
324349
identity_one = await resolver.get_identity(properties={})
325350
identity_two = await resolver.get_identity(properties={})
@@ -328,9 +353,12 @@ async def test_expired_credentials_refreshed():
328353
assert mock_exec.call_count == 2
329354
# Should be different instances
330355
assert identity_one is not identity_two
331-
# But have the same values
332-
assert identity_one.access_key_id == identity_two.access_key_id
333-
assert identity_one.secret_access_key == identity_two.secret_access_key
356+
assert identity_one.access_key_id == "foo"
357+
assert identity_one.secret_access_key == "bar"
358+
assert identity_one.session_token == "baz"
359+
assert identity_two.access_key_id == "foo-refreshed"
360+
assert identity_two.secret_access_key == "bar-refreshed"
361+
assert identity_two.session_token == "baz-refreshed"
334362

335363

336364
@pytest.mark.asyncio

0 commit comments

Comments
 (0)