Skip to content

Commit 47573c7

Browse files
committed
test(auth): cover validation and concurrent failure paths
Exercise malformed inputs, shared failures, waiter deadlines, and persistent HTTPS connections. Collect fresh-process import coverage through coverage.py's subprocess patch for pytest-cov 7.
1 parent 5ca2e01 commit 47573c7

8 files changed

Lines changed: 226 additions & 8 deletions

File tree

‎poetry.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pyproject.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ kafka-consumer-avro = ["avro"]
8787
kafka-consumer-protobuf = ["protobuf"]
8888

8989
[tool.poetry.group.dev.dependencies]
90-
coverage = { extras = ["toml"], version = "^7.6" }
90+
coverage = { extras = ["toml"], version = "^7.10.6" }
9191
pytest = ">=8.3.4,<10.0.0"
9292
boto3 = "^1.26.164"
9393
isort = ">=5.13.2,<10.0.0"
@@ -148,6 +148,8 @@ omit = [
148148
"aws_lambda_powertools/metrics/metric.py" # barrel import (export-only)
149149
]
150150
branch = true
151+
# pytest-cov 7 delegates subprocess measurement to coverage.py.
152+
patch = ["subprocess"]
151153

152154
[tool.coverage.html]
153155
directory = "test_report"

‎tests/functional/auth/test_authorizer.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,35 @@ def test_invalid_request_arns_raise_instead_of_returning_an_invalid_policy(jwks,
190190
event["methodArn"] = arn
191191
with pytest.raises(ValueError, match="concrete API Gateway"):
192192
verifier(jwks).authorize(event)
193+
194+
195+
@pytest.mark.parametrize("malformed", [False, True])
196+
@pytest.mark.parametrize("field", ["headers", "multiValueHeaders"])
197+
def test_authorizer_denies_malformed_or_ambiguous_header_maps(jwks, issue_token, malformed, field):
198+
token = "Bearer " + issue_token()
199+
value = [token] if field == "multiValueHeaders" else token
200+
headers = [("Authorization", value)] if malformed else {"Authorization": value, "authorization": value}
201+
event = {"type": "REQUEST", "methodArn": ARN, field: headers}
202+
response = verifier(jwks).authorize(event)
203+
assert response["principalId"] == "unauthorized"
204+
assert response["policyDocument"]["Statement"][0]["Effect"] == "Deny"
205+
assert "context" not in response
206+
207+
208+
@pytest.mark.parametrize("event", [None, [], {}, {"type": "OTHER"}])
209+
def test_authorizer_rejects_unsupported_events(jwks, event):
210+
with pytest.raises(ValueError, match="TOKEN or REQUEST"):
211+
verifier(jwks).authorize(event)
212+
213+
214+
@pytest.mark.parametrize(
215+
"options,message",
216+
[
217+
({"response_format": "unsupported"}, "response_format"),
218+
({"context_claims": ["claims"]}, "claims is reserved"),
219+
],
220+
)
221+
def test_authorizer_rejects_invalid_response_configuration(jwks, issue_token, options, message):
222+
event = {"type": "TOKEN", "methodArn": ARN, "authorizationToken": "Bearer " + issue_token()}
223+
with pytest.raises(ValueError, match=message):
224+
verifier(jwks).authorize(event, **options)

‎tests/functional/auth/test_jwks_cache.py‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ def test_invalid_discovery_never_falls_back_or_fetches_untrusted_keys(http, issu
127127
assert len(http.requests) == 1
128128

129129

130-
@pytest.mark.parametrize("body", [{}, {"keys": None}, {"keys": ["bad-key"]}, b"not json", b"x" * (1024 * 1024 + 1)])
130+
@pytest.mark.parametrize(
131+
"body",
132+
[{}, {"keys": None}, {"keys": ["bad-key"]}, [], None, b"not json", b"x" * (1024 * 1024 + 1)],
133+
)
131134
def test_malformed_key_sets_fail_closed(http, issue_token, body):
132135
http.serve(JWKS_URL, body)
133136

@@ -213,3 +216,32 @@ def test_failed_unknown_key_refresh_preserves_only_still_fresh_keys(http, jwks,
213216
clock.advance(299)
214217
with pytest.raises(JWKSFetchError):
215218
subject.verify(issue_token())
219+
220+
221+
def test_waiting_verifier_timeout_does_not_cancel_the_shared_key_fetch(http, jwks, issue_token):
222+
entered = threading.Event()
223+
release = threading.Event()
224+
225+
def fetch():
226+
entered.set()
227+
assert release.wait(5)
228+
return jwks
229+
230+
http.serve(JWKS_URL, fetch)
231+
owner = verifier(jwks_uri=JWKS_URL, timeout_seconds=5)
232+
waiter = verifier(jwks_uri=JWKS_URL, timeout_seconds=0.1)
233+
token = issue_token()
234+
with ThreadPoolExecutor(max_workers=1) as executor:
235+
result = executor.submit(owner.verify, token)
236+
try:
237+
assert entered.wait(5)
238+
with pytest.raises(JWKSFetchError) as error:
239+
waiter.verify(token)
240+
assert error.value.__context__ is None
241+
assert not result.done()
242+
finally:
243+
release.set()
244+
assert result.result(timeout=5)["sub"] == "user-123"
245+
246+
assert waiter.verify(token)["sub"] == "user-123"
247+
assert len(http.requests) == 1

‎tests/functional/auth/test_oauth2.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,9 @@ def load_secret():
230230
"options",
231231
[
232232
{"audience": "one", "resource": "two"},
233+
{"audience": " "},
234+
{"resource": ""},
235+
{"resource": 42},
233236
{"token_url": "http://idp.example.com/token"},
234237
{"token_url": "https://user:secret@idp.example.com/token"},
235238
{"client_id": ""},
@@ -273,6 +276,7 @@ def test_request_attaches_resource_token_without_forwarding_client_credentials(h
273276
("https://api.example.com/orders", {"redirect": True}),
274277
("https://api.example.com/orders", {"retries": 3}),
275278
("https://api.example.com/orders", {"timeout": 0}),
279+
("https://api.example.com/orders", {"headers": [("Accept", "application/json")]}),
276280
],
277281
)
278282
def test_request_rejects_unsafe_overrides_before_acquiring_credentials(http, url, options):
@@ -290,3 +294,107 @@ def test_request_does_not_follow_redirects_or_retry_downstream_failures(http):
290294
http.serve("https://api.example.com/orders", {}, status=503)
291295
assert subject.request("GET", "https://api.example.com/orders").status == 503
292296
assert len(http.requests) == 3
297+
298+
299+
@pytest.mark.parametrize("method", [None, "", "GET /", "GET\r\nInjected"])
300+
def test_invalid_http_methods_are_rejected_before_loading_credentials(http, method):
301+
calls = []
302+
303+
def load_secret():
304+
calls.append(True)
305+
return "test-secret"
306+
307+
with pytest.raises(ValueError, match="HTTP method"):
308+
client(client_secret=load_secret).request(method, "https://api.example.com/orders")
309+
assert calls == []
310+
assert http.requests == []
311+
312+
313+
@pytest.mark.parametrize("secret", [None, "", 42])
314+
def test_invalid_secret_loader_results_are_rejected_before_sending_credentials(http, secret):
315+
with pytest.raises(TokenExchangeError) as error:
316+
client(client_secret=lambda: secret).auth_headers()
317+
assert error.value.__context__ is None
318+
assert http.requests == []
319+
320+
321+
def test_retry_stops_when_the_backoff_exceeds_the_remaining_budget(http, clock, monkeypatch):
322+
sleeps = []
323+
monkeypatch.setattr(time, "sleep", sleeps.append)
324+
http.serve(TOKEN_URL, {}, status=503, method="POST")
325+
326+
with pytest.raises(TokenExchangeError):
327+
client(timeout_seconds=0.05).auth_headers()
328+
assert sleeps == []
329+
assert len(http.requests) == 1
330+
331+
332+
def test_waiting_callers_share_a_failed_exchange_and_can_recover(http, monkeypatch):
333+
entered = threading.Event()
334+
release = threading.Event()
335+
joined = threading.Event()
336+
337+
def exchange():
338+
entered.set()
339+
assert release.wait(5)
340+
return {"error": "invalid_client"}
341+
342+
http.serve(TOKEN_URL, exchange, status=401, method="POST")
343+
subject = client()
344+
with ThreadPoolExecutor(max_workers=2) as executor:
345+
owner = executor.submit(subject.auth_headers)
346+
try:
347+
assert entered.wait(5)
348+
flight = subject._flight
349+
assert flight is not None
350+
wait = flight.done.wait
351+
352+
def observe_wait(timeout):
353+
joined.set()
354+
return wait(timeout)
355+
356+
# Keep the real Event; observe it so the provider is released only
357+
# after the second caller has joined the active exchange.
358+
monkeypatch.setattr(flight.done, "wait", observe_wait)
359+
waiter = executor.submit(subject.auth_headers)
360+
assert joined.wait(5)
361+
finally:
362+
release.set()
363+
for result in (owner, waiter):
364+
with pytest.raises(TokenExchangeError) as error:
365+
result.result(timeout=5)
366+
assert error.value.__context__ is None
367+
assert len(http.requests) == 1
368+
369+
http.serve(TOKEN_URL, {"access_token": "recovered", "token_type": "Bearer", "expires_in": 100}, method="POST")
370+
assert subject.auth_headers() == {"Authorization": "Bearer recovered"}
371+
assert len(http.requests) == 2
372+
373+
374+
def test_waiting_callers_timeout_without_returning_the_late_token(http):
375+
entered = threading.Event()
376+
release = threading.Event()
377+
378+
def exchange():
379+
entered.set()
380+
assert release.wait(5)
381+
return {"access_token": "too-late", "token_type": "Bearer", "expires_in": 100}
382+
383+
http.serve(TOKEN_URL, exchange, method="POST")
384+
subject = client(timeout_seconds=0.1)
385+
with ThreadPoolExecutor(max_workers=1) as executor:
386+
owner = executor.submit(subject.auth_headers)
387+
try:
388+
assert entered.wait(5)
389+
with pytest.raises(TokenExchangeError):
390+
subject.auth_headers()
391+
assert not owner.done()
392+
assert len(http.requests) == 1
393+
finally:
394+
release.set()
395+
with pytest.raises(TokenExchangeError):
396+
owner.result(timeout=5)
397+
398+
http.serve(TOKEN_URL, {"access_token": "recovered", "token_type": "Bearer", "expires_in": 100}, method="POST")
399+
assert subject.auth_headers() == {"Authorization": "Bearer recovered"}
400+
assert len(http.requests) == 2

‎tests/functional/auth/test_profiles.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,31 @@ def test_any_of_rejects_ambiguous_issuer_configuration(jwks):
114114

115115
with pytest.raises(ValueError):
116116
JWTVerifier.any_of(verifier, verifier)
117+
118+
119+
@pytest.mark.parametrize(
120+
"options",
121+
[
122+
{"user_pool_id": "invalid"},
123+
{"user_pool_id": None},
124+
{"client_id": " "},
125+
{"client_id": None},
126+
{"issuer": "https://untrusted.example.com"},
127+
{"algorithms": ["HS256"]},
128+
{"jwks_uri": "https://untrusted.example.com/keys"},
129+
],
130+
)
131+
def test_cognito_rejects_invalid_or_overridden_trust_configuration(options):
132+
config = {
133+
"user_pool_id": "us-east-1_pool",
134+
"client_id": "desktop-client",
135+
"audience": "https://api.example.com",
136+
}
137+
with pytest.raises(ValueError):
138+
JWTVerifier.cognito(**{**config, **options})
139+
140+
141+
@pytest.mark.parametrize("verifiers", [(), (None,), ("https://idp.example.com",)])
142+
def test_issuer_groups_require_explicit_verifier_instances(verifiers):
143+
with pytest.raises(ValueError):
144+
JWTVerifier.any_of(*verifiers)

‎tests/functional/auth/test_verifier.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def test_expired_token_is_rejected(jwks, claims, issue_token):
6565
("exp", float("inf")),
6666
("exp", float("nan")),
6767
("exp", True),
68+
("exp", 10**400),
6869
("nbf", "0"),
6970
("nbf", 9999999999),
7071
],
@@ -88,6 +89,9 @@ def test_invalid_claim_values_are_rejected(jwks, claims, issue_token, claim, val
8889
("issuer", "http://idp.example.com"),
8990
("issuer", "https://user:secret@idp.example.com"),
9091
("issuer", "https://idp.example.com/#fragment"),
92+
("issuer", "https://idp.example.com:invalid"),
93+
("issuer", "https://[invalid"),
94+
("issuer", 42),
9195
("audience", ""),
9296
("audience", []),
9397
("algorithms", []),
@@ -96,6 +100,8 @@ def test_invalid_claim_values_are_rejected(jwks, claims, issue_token, claim, val
96100
("algorithms", ["RS256", "HS256"]),
97101
("clock_skew_seconds", -1),
98102
("clock_skew_seconds", float("inf")),
103+
("clock_skew_seconds", 10**400),
104+
("jwks_uri", "https://idp.example.com/keys"),
99105
("required_claims", ""),
100106
],
101107
)
@@ -112,15 +118,18 @@ def test_invalid_verifier_configuration_is_rejected(jwks, option, value):
112118
JWTVerifier(**options)
113119

114120

121+
@pytest.mark.parametrize("issuer_group", [False, True])
115122
@pytest.mark.parametrize("token", ["", "not-a-jwt", "a.b.c", None, 42])
116-
def test_malformed_tokens_raise_redacted_errors(jwks, token):
123+
def test_malformed_tokens_raise_redacted_errors(jwks, token, issuer_group):
117124
verifier = JWTVerifier(
118125
issuer="https://idp.example.com/",
119126
audience="https://api.example.com",
120127
algorithms=["RS256"],
121128
jwks=jwks,
122129
)
123130

131+
if issuer_group:
132+
verifier = JWTVerifier.any_of(verifier)
124133
with pytest.raises(InvalidTokenError) as error:
125134
verifier.verify(token)
126135
assert str(error.value) == "Invalid access token"

‎tests/integration/auth/conftest.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,20 @@ def tls_files(tmp_path_factory):
7070
return certificate_path, key_path
7171

7272

73-
@pytest.fixture
74-
def https_server(tls_files, monkeypatch):
73+
@pytest.fixture(params=[False, True], ids=["connection-close", "keep-alive"])
74+
def https_server(tls_files, monkeypatch, request):
7575
endpoint = LocalHTTPS()
7676

7777
class Handler(BaseHTTPRequestHandler):
7878
protocol_version = "HTTP/1.1"
7979

80+
def handle(self):
81+
try:
82+
super().handle()
83+
except (ConnectionResetError, ssl.SSLEOFError):
84+
# The client may reject a response without draining its body.
85+
self.close_connection = True
86+
8087
def do_GET(self): # noqa: N802
8188
self.respond()
8289

@@ -90,7 +97,7 @@ def respond(self):
9097
self.send_response(reply.status)
9198
self.send_header("Content-Type", "application/json")
9299
self.send_header("Content-Length", str(len(reply.body)))
93-
self.send_header("Connection", "close")
100+
self.send_header("Connection", "keep-alive" if request.param else "close")
94101
for name, value in reply.headers.items():
95102
self.send_header(name, value)
96103
self.end_headers()
@@ -109,7 +116,7 @@ def respond(self):
109116
# Timeout and oversized-body tests deliberately close early.
110117
pass
111118
finally:
112-
self.close_connection = True
119+
self.close_connection = not request.param
113120

114121
def log_message(self, format, *args): # noqa: A002
115122
pass

0 commit comments

Comments
 (0)