Skip to content

Commit e1d64db

Browse files
Merge branch 'develop' into dependabot/pip/develop/pydantic-2.13.5
2 parents 41e9c29 + cc3198b commit e1d64db

58 files changed

Lines changed: 4112 additions & 76 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Alpha authentication and authorization utilities for AWS Lambda."""
2+
3+
from __future__ import annotations
4+
5+
import importlib
6+
from typing import TYPE_CHECKING
7+
8+
if TYPE_CHECKING:
9+
from aws_lambda_powertools.utilities.auth_alpha.jwt import AuthErrorContext as AuthErrorContext
10+
from aws_lambda_powertools.utilities.auth_alpha.jwt import AuthFailureReason as AuthFailureReason
11+
from aws_lambda_powertools.utilities.auth_alpha.jwt import JWTVerifier as JWTVerifier
12+
13+
__all__ = ["AuthErrorContext", "AuthFailureReason", "JWTVerifier"]
14+
15+
16+
def __getattr__(name: str) -> object:
17+
modules = {"AuthErrorContext": "jwt", "AuthFailureReason": "jwt", "JWTVerifier": "jwt"}
18+
if name in modules:
19+
value = getattr(importlib.import_module(f"{__name__}.{modules[name]}"), name)
20+
globals()[name] = value
21+
return value
22+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
23+
24+
25+
def __dir__() -> list[str]:
26+
return sorted(set(globals()) | set(__all__))
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from __future__ import annotations
2+
3+
import time
4+
5+
from aws_lambda_powertools.utilities.auth_alpha._internal.validation import finite_seconds
6+
7+
8+
class RequestError(Exception):
9+
"""Internal, credential-free transport failure."""
10+
11+
def __init__(self, *, retryable: bool = False) -> None:
12+
self.retryable = retryable
13+
super().__init__("Authentication endpoint request failed")
14+
15+
16+
class Deadline:
17+
"""One monotonic budget shared across a fetch and any subsequent requests."""
18+
19+
def __init__(self, seconds: float) -> None:
20+
self._expires_at = time.monotonic() + finite_seconds(seconds, positive=True)
21+
22+
def remaining(self) -> float:
23+
remaining = self._expires_at - time.monotonic()
24+
if remaining <= 0:
25+
raise RequestError(retryable=True)
26+
return remaining
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from typing import TYPE_CHECKING, Any
5+
6+
import urllib3
7+
from urllib3.connection import HTTPConnection
8+
9+
from aws_lambda_powertools.utilities.auth_alpha._internal.deadline import Deadline, RequestError
10+
11+
if TYPE_CHECKING:
12+
from collections.abc import Mapping
13+
14+
_MAX_JSON_BYTES = 1024 * 1024
15+
16+
17+
class HTTPClient:
18+
"""HTTPS transport with bounded JSON responses and no implicit redirects/retries."""
19+
20+
def __init__(self) -> None:
21+
self.pool = urllib3.PoolManager(cert_reqs="CERT_REQUIRED")
22+
23+
def json_request(
24+
self,
25+
method: str,
26+
url: str,
27+
deadline: Deadline,
28+
*,
29+
body: bytes | None = None,
30+
headers: Mapping[str, str] | None = None,
31+
) -> tuple[int, dict[str, Any]]:
32+
response = None
33+
try:
34+
response = self.pool.request(
35+
method,
36+
url,
37+
body=body,
38+
headers=headers,
39+
timeout=urllib3.Timeout(total=deadline.remaining()),
40+
retries=False,
41+
redirect=False,
42+
preload_content=False,
43+
)
44+
if response.status != 200:
45+
deadline.remaining()
46+
return response.status, {}
47+
data = self._read_json(response, deadline)
48+
return response.status, data
49+
except (urllib3.exceptions.HTTPError, OSError):
50+
raise RequestError(retryable=True) from None
51+
finally:
52+
if response is not None:
53+
response.close()
54+
response.release_conn()
55+
56+
@staticmethod
57+
def _read_json(response: urllib3.response.BaseHTTPResponse, deadline: Deadline) -> dict[str, Any]:
58+
chunks = bytearray()
59+
while True:
60+
remaining = deadline.remaining()
61+
connection = response.connection
62+
if isinstance(connection, HTTPConnection) and connection.sock is not None:
63+
connection.sock.settimeout(remaining)
64+
chunk = response.read1(min(65536, _MAX_JSON_BYTES + 1 - len(chunks)), decode_content=False)
65+
deadline.remaining()
66+
if not chunk:
67+
break
68+
chunks.extend(chunk)
69+
if len(chunks) > _MAX_JSON_BYTES:
70+
raise RequestError()
71+
try:
72+
data = json.loads(chunks)
73+
except (ValueError, UnicodeError, RecursionError):
74+
raise RequestError() from None
75+
if not isinstance(data, dict):
76+
raise RequestError()
77+
return data
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from __future__ import annotations
2+
3+
import math
4+
from collections.abc import Mapping
5+
from typing import Any
6+
from urllib.parse import urlsplit
7+
8+
9+
def https_url(value: str, *, issuer: bool = False) -> str:
10+
"""Validate configured URLs without echoing their contents in errors."""
11+
try:
12+
parts = urlsplit(value)
13+
valid = isinstance(value, str) and all(
14+
(
15+
_valid_url_characters(value),
16+
parts.scheme == "https",
17+
bool(parts.hostname),
18+
parts.username is None,
19+
parts.password is None,
20+
not parts.fragment,
21+
not issuer or not parts.query,
22+
),
23+
)
24+
_ = parts.port # Accessing the property validates a supplied port.
25+
except (AttributeError, TypeError, ValueError):
26+
valid = False
27+
if not valid:
28+
raise ValueError("An HTTPS URL without user information or a fragment is required") from None
29+
return value
30+
31+
32+
def _valid_url_characters(value: str) -> bool:
33+
return not any(character.isspace() or ord(character) < 32 for character in value)
34+
35+
36+
def finite_seconds(value: float, *, positive: bool = False) -> float:
37+
"""Validate a duration; booleans and non-finite values are not durations."""
38+
try:
39+
valid = type(value) in (int, float) and math.isfinite(value) and value >= 0 and (not positive or value > 0)
40+
except OverflowError:
41+
valid = False
42+
if not valid:
43+
message = "A finite positive duration is required" if positive else "A finite nonnegative duration is required"
44+
raise ValueError(message)
45+
return value
46+
47+
48+
def string_list(values: list[str] | tuple[str, ...], *, nonempty: bool = False) -> tuple[str, ...]:
49+
"""Copy a sequence of nonempty strings so configuration cannot be mutated."""
50+
if not isinstance(values, (list, tuple)) or (nonempty and not values):
51+
raise ValueError("A list of nonempty strings is required")
52+
if not all(is_nonempty_string(value) for value in values):
53+
raise ValueError("A list of nonempty strings is required")
54+
return tuple(dict.fromkeys(values))
55+
56+
57+
def is_nonempty_string(value: Any) -> bool:
58+
return isinstance(value, str) and bool(value.strip())
59+
60+
61+
def string_mapping(values: Mapping[str, str] | None) -> dict[str, str]:
62+
"""Copy exact token-profile constraints without exposing their contents."""
63+
if values is None:
64+
return {}
65+
if not isinstance(values, Mapping) or not all(
66+
is_nonempty_string(name) and is_nonempty_string(value) for name, value in values.items()
67+
):
68+
raise ValueError("Expected claims and headers must map nonempty strings to nonempty strings")
69+
return dict(values)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""JWT access-token verification."""
2+
3+
from __future__ import annotations
4+
5+
import importlib
6+
from typing import TYPE_CHECKING
7+
8+
if TYPE_CHECKING:
9+
from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import AuthFailureReason as AuthFailureReason
10+
from aws_lambda_powertools.utilities.auth_alpha.jwt.integrations.event_handler import (
11+
AuthErrorContext as AuthErrorContext,
12+
)
13+
from aws_lambda_powertools.utilities.auth_alpha.jwt.verifier import JWTVerifier as JWTVerifier
14+
15+
__all__ = ["AuthErrorContext", "AuthFailureReason", "JWTVerifier"]
16+
17+
18+
def __getattr__(name: str) -> object:
19+
modules = {
20+
"AuthErrorContext": "integrations.event_handler",
21+
"AuthFailureReason": "exceptions",
22+
"JWTVerifier": "verifier",
23+
}
24+
if name in modules:
25+
value = getattr(importlib.import_module(f"{__name__}.{modules[name]}"), name)
26+
globals()[name] = value
27+
return value
28+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
29+
30+
31+
def __dir__() -> list[str]:
32+
return sorted(set(globals()) | set(__all__))

‎aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/__init__.py‎

Whitespace-only changes.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Mapping
4+
from typing import Any
5+
6+
from aws_lambda_powertools.utilities.auth_alpha._internal.validation import string_list
7+
from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import (
8+
AuthError,
9+
AuthFailureReason,
10+
InvalidClaimsError,
11+
InvalidTokenError,
12+
)
13+
14+
15+
class MissingTokenError(InvalidTokenError):
16+
"""No authorization header was supplied."""
17+
18+
reason = AuthFailureReason.MISSING_TOKEN
19+
20+
21+
class ForbiddenError(AuthError):
22+
"""A verified caller does not have permission for this operation."""
23+
24+
reason = AuthFailureReason.FORBIDDEN
25+
26+
27+
class InsufficientScopeError(ForbiddenError):
28+
"""A verified caller is missing a required scope."""
29+
30+
reason = AuthFailureReason.INSUFFICIENT_SCOPE
31+
32+
33+
def bearer_token(value: Any) -> str:
34+
if value is None:
35+
raise MissingTokenError()
36+
if not isinstance(value, str):
37+
raise InvalidTokenError()
38+
parts = value.split()
39+
if len(parts) != 2 or parts[0].lower() != "bearer":
40+
raise InvalidTokenError()
41+
return parts[1]
42+
43+
44+
def header_token(headers: Any, multi_value_headers: Any = None) -> str:
45+
values = _authorization_values(headers)
46+
multi_values = _authorization_values(multi_value_headers)
47+
if multi_values:
48+
entries = multi_values[0]
49+
if not isinstance(entries, list) or len(entries) != 1:
50+
raise InvalidTokenError()
51+
if values and values[0] != entries[0]:
52+
raise InvalidTokenError()
53+
return bearer_token(entries[0])
54+
return bearer_token(values[0] if values else None)
55+
56+
57+
def _authorization_values(headers: Any) -> list[Any]:
58+
if headers is None:
59+
return []
60+
if not isinstance(headers, Mapping):
61+
raise InvalidTokenError()
62+
values = [value for name, value in headers.items() if isinstance(name, str) and name.lower() == "authorization"]
63+
if len(values) > 1:
64+
raise InvalidTokenError()
65+
return values
66+
67+
68+
def valid_scope(value: str) -> bool:
69+
return bool(value) and all(33 <= ord(character) <= 126 and character not in {'"', "\\"} for character in value)
70+
71+
72+
def required_scopes(scopes: list[str] | None) -> tuple[str, ...]:
73+
values = string_list(scopes if scopes is not None else [])
74+
if not all(valid_scope(value) for value in values):
75+
raise ValueError("Scopes must be valid OAuth scope tokens")
76+
return values
77+
78+
79+
def enforce_scopes(claims: dict[str, Any], expected: tuple[str, ...]) -> None:
80+
value: Any = next((claims[name] for name in ("scope", "scp", "scopes") if name in claims), [])
81+
if isinstance(value, str):
82+
values = [part for part in value.split(" ") if part]
83+
elif isinstance(value, list):
84+
values = value
85+
else:
86+
raise InvalidClaimsError()
87+
if any(not isinstance(scope, str) or not valid_scope(scope) for scope in values):
88+
raise InvalidClaimsError()
89+
if not set(expected).issubset(values):
90+
raise InsufficientScopeError()

0 commit comments

Comments
 (0)