diff --git a/aws_lambda_powertools/utilities/auth/__init__.py b/aws_lambda_powertools/utilities/auth/__init__.py new file mode 100644 index 00000000000..71ea7d3fa89 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/__init__.py @@ -0,0 +1,26 @@ +"""JWT access-token verification for AWS Lambda.""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from aws_lambda_powertools.utilities.auth._middleware import AuthErrorContext as AuthErrorContext + from aws_lambda_powertools.utilities.auth.exceptions import AuthFailureReason as AuthFailureReason + from aws_lambda_powertools.utilities.auth.verifier import JWTVerifier as JWTVerifier + +__all__ = ["AuthErrorContext", "AuthFailureReason", "JWTVerifier"] + + +def __getattr__(name: str) -> object: + modules = {"AuthErrorContext": "_middleware", "AuthFailureReason": "exceptions", "JWTVerifier": "verifier"} + if name in modules: + value = getattr(importlib.import_module(f"{__name__}.{modules[name]}"), name) + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/aws_lambda_powertools/utilities/auth/_authorization.py b/aws_lambda_powertools/utilities/auth/_authorization.py new file mode 100644 index 00000000000..3b384c593db --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_authorization.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aws_lambda_powertools.utilities.auth._validation import string_list +from aws_lambda_powertools.utilities.auth.exceptions import ( + AuthError, + AuthFailureReason, + InvalidClaimsError, + InvalidTokenError, +) + + +class MissingTokenError(InvalidTokenError): + """No authorization header was supplied.""" + + reason = AuthFailureReason.MISSING_TOKEN + + +class ForbiddenError(AuthError): + """A verified caller does not have permission for this operation.""" + + reason = AuthFailureReason.FORBIDDEN + + +class InsufficientScopeError(ForbiddenError): + """A verified caller is missing a required scope.""" + + reason = AuthFailureReason.INSUFFICIENT_SCOPE + + +def bearer_token(value: Any) -> str: + if value is None: + raise MissingTokenError() + if not isinstance(value, str): + raise InvalidTokenError() + parts = value.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + raise InvalidTokenError() + return parts[1] + + +def header_token(headers: Any, multi_value_headers: Any = None) -> str: + values = _authorization_values(headers) + multi_values = _authorization_values(multi_value_headers) + if multi_values: + entries = multi_values[0] + if not isinstance(entries, list) or len(entries) != 1: + raise InvalidTokenError() + if values and values[0] != entries[0]: + raise InvalidTokenError() + return bearer_token(entries[0]) + return bearer_token(values[0] if values else None) + + +def _authorization_values(headers: Any) -> list[Any]: + if headers is None: + return [] + if not isinstance(headers, Mapping): + raise InvalidTokenError() + values = [value for name, value in headers.items() if isinstance(name, str) and name.lower() == "authorization"] + if len(values) > 1: + raise InvalidTokenError() + return values + + +def valid_scope(value: str) -> bool: + return bool(value) and all(33 <= ord(character) <= 126 and character not in {'"', "\\"} for character in value) + + +def required_scopes(scopes: list[str] | None) -> tuple[str, ...]: + values = string_list(scopes if scopes is not None else []) + if not all(valid_scope(value) for value in values): + raise ValueError("Scopes must be valid OAuth scope tokens") + return values + + +def enforce_scopes(claims: dict[str, Any], expected: tuple[str, ...]) -> None: + value: Any = next((claims[name] for name in ("scope", "scp", "scopes") if name in claims), []) + if isinstance(value, str): + values = [part for part in value.split(" ") if part] + elif isinstance(value, list): + values = value + else: + raise InvalidClaimsError() + if any(not isinstance(scope, str) or not valid_scope(scope) for scope in values): + raise InvalidClaimsError() + if not set(expected).issubset(values): + raise InsufficientScopeError() diff --git a/aws_lambda_powertools/utilities/auth/_authorizer.py b/aws_lambda_powertools/utilities/auth/_authorizer.py new file mode 100644 index 00000000000..21edb718dc1 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_authorizer.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import math +import re +from typing import TYPE_CHECKING, Any, Literal + +from aws_lambda_powertools.utilities.auth._authorization import ( + ForbiddenError, + bearer_token, + enforce_scopes, + header_token, + required_scopes, +) +from aws_lambda_powertools.utilities.auth._validation import string_list +from aws_lambda_powertools.utilities.auth.exceptions import AuthError, InvalidClaimsError, InvalidTokenError +from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import APIGatewayAuthorizerResponseV2 +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + +if TYPE_CHECKING: + from collections.abc import Callable + + from aws_lambda_powertools.utilities.auth._base import Verifier + +_ARN = re.compile(r"arn:[a-z0-9-]+:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+/[^/]+/[A-Z]+/.*") + + +def authorize_event( + verifier: Verifier, + event: dict[str, Any] | DictWrapper, + scopes: list[str] | None, + response_format: Literal["iam", "simple"], + context_claims: list[str] | None, + on_error: Callable[[AuthError], None] | None, +) -> dict[str, Any]: + raw = event.raw_event if isinstance(event, DictWrapper) else event + _validate_event(raw, response_format) + arn = _request_arn(raw) if response_format == "iam" else None + expected = required_scopes(scopes) + selected = string_list(context_claims if context_claims is not None else []) + if "claims" in selected: + raise ValueError("claims is reserved in API Gateway authorizer context") + claims = _verified_claims(verifier, raw, expected, on_error, require_principal=response_format == "iam") + context = _context(claims, selected) if claims is not None else {} + if response_format == "simple": + return APIGatewayAuthorizerResponseV2(authorize=claims is not None, context=context).asdict() + return _iam_response(claims, arn, context) + + +def _validate_event(raw: dict[str, Any], response_format: str) -> None: + if not isinstance(raw, dict) or raw.get("type") not in ("TOKEN", "REQUEST"): + raise ValueError("An API Gateway TOKEN or REQUEST authorizer event is required") + if response_format not in ("iam", "simple"): + raise ValueError("response_format must be iam or simple") + if response_format == "simple" and (raw.get("version") != "2.0" or raw["type"] != "REQUEST"): + raise ValueError("Simple authorizer responses require HTTP API payload version 2.0") + + +def _verified_claims( + verifier: Verifier, + raw: dict[str, Any], + expected: tuple[str, ...], + on_error: Callable[[AuthError], None] | None, + *, + require_principal: bool, +) -> dict[str, Any] | None: + try: + candidate = verifier.verify(_token(raw)) + enforce_scopes(candidate, expected) + if require_principal: + _validate_principal(candidate) + return candidate + except AuthError as error: + # The callback observes a public, sanitized error, including failures + # raised while the caller is already handling another exception. + error.__context__ = None + error.__cause__ = None + if on_error is not None: + on_error(error) + if isinstance(error, (InvalidTokenError, ForbiddenError)): + return None + raise + + +def _token(raw: dict[str, Any]) -> str: + if raw["type"] == "TOKEN": + return bearer_token(raw.get("authorizationToken")) + return header_token(raw.get("headers"), raw.get("multiValueHeaders")) + + +def _validate_principal(claims: dict[str, Any]) -> None: + if not isinstance(claims.get("sub"), str) or not claims["sub"].strip(): + raise InvalidClaimsError() + + +def _iam_response(claims: dict[str, Any] | None, arn: str | None, context: dict[str, Any]) -> dict[str, Any]: + # Preserve the exact supplied resource, including its partition and encoded + # path. Route builders normalize paths and cannot represent every ARN here. + result: dict[str, Any] = { + "principalId": claims["sub"] if claims is not None else "unauthorized", + "policyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": "Allow" if claims is not None else "Deny", + "Resource": [arn], + }, + ], + }, + } + if context: + result["context"] = context + return result + + +def _request_arn(event: dict[str, Any]) -> str: + arn = event.get("routeArn") if event.get("version") == "2.0" else event.get("methodArn") + if ( + not isinstance(arn, str) + or len(arn) > 512 + or not _ARN.fullmatch(arn) + or any(character in arn for character in ("*", "?", "\r", "\n")) + ): + raise ValueError("A concrete API Gateway method or route ARN of at most 512 characters is required") + return arn + + +def _context(claims: dict[str, Any], selected: tuple[str, ...]) -> dict[str, Any]: + context = {} + for name in selected: + value = claims.get(name) + if isinstance(value, (str, bool, int)) or isinstance(value, float) and math.isfinite(value): + context[name] = value + return context diff --git a/aws_lambda_powertools/utilities/auth/_base.py b/aws_lambda_powertools/utilities/auth/_base.py new file mode 100644 index 00000000000..e031d15d42a --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_base.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Literal + +from aws_lambda_powertools.utilities.auth._errors import sanitize_errors + +if TYPE_CHECKING: + from collections.abc import Callable + + from aws_lambda_powertools.event_handler import Response + from aws_lambda_powertools.utilities.auth._middleware import AuthErrorContext, AuthMiddleware + from aws_lambda_powertools.utilities.auth.exceptions import AuthError + from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class Verifier(ABC): + """Shared verification interface used by issuer-specific and routed verifiers.""" + + @abstractmethod + def verify(self, token: str) -> dict[str, Any]: + """Return verified claims or raise an Auth utility error.""" + + @abstractmethod + def prefetch(self) -> None: + """Populate remote key caches without accepting a token.""" + + def require( + self, + *, + scopes: list[str] | None = None, + authorize: Callable[[dict[str, Any]], bool] | None = None, + on_error: Callable[[AuthErrorContext], Response] | None = None, + ) -> AuthMiddleware: + """Create Event Handler middleware enforcing token validity and all scopes. + + Successful verification stores claims in ``app.context["claims"]`` + while the downstream middleware and handler execute. Claims are + removed when they return or raise. + Missing/invalid tokens return 401, missing permissions return 403, and + unavailable signing keys return 503. A custom error callback replaces + the response, never execution of the protected handler. + + Parameters + ---------- + scopes : list[str], optional + Every listed scope must be present in the token. + authorize : Callable, optional + Additional policy receiving verified claims; must return True. + on_error : Callable, optional + Receives status_code, headers, a fixed reason, and retryable, and + returns an Event Handler Response. No automatic logging is performed. + + Examples + -------- + ```python + @app.get("/orders", middlewares=[verifier.require(scopes=["orders:read"])]) + def orders(): + return {"subject": app.context["claims"]["sub"]} + ``` + """ + from aws_lambda_powertools.utilities.auth._middleware import AuthMiddleware + + return AuthMiddleware(self, scopes, authorize, on_error) + + @sanitize_errors + def authorize( + self, + event: dict[str, Any] | DictWrapper, + *, + scopes: list[str] | None = None, + response_format: Literal["iam", "simple"] = "iam", + context_claims: list[str] | None = None, + on_error: Callable[[AuthError], None] | None = None, + ) -> dict[str, Any]: + """Return an API Gateway authorizer response for the current request. + + IAM allows require a nonempty ``sub`` and target the supplied ARN only. + Simple responses require payload version 2.0 and must also be enabled + in the Gateway deployment. Disable Gateway result caching when each + request must be verified; this method cannot change Gateway's TTL. + + Parameters + ---------- + event : dict | DictWrapper + REST TOKEN/REQUEST or HTTP REQUEST authorizer event. + scopes : list[str], optional + Every listed scope must be present in the token. + response_format : Literal["iam", "simple"] + Response format configured in Gateway, by default iam. + context_claims : list[str], optional + Selected scalar claims to include; no claims are copied by default. + on_error : Callable, optional + Records a failure using the error's fixed reason and retryable fields. + Its return value is ignored: invalid credentials still deny access, + and unavailable keys still raise JWKSFetchError. Callback exceptions + fail the invocation. No automatic logging is performed. + + Examples + -------- + ```python + return verifier.authorize( + event, scopes=["orders:read"], response_format="iam", context_claims=["sub"], + ) + ``` + """ + from aws_lambda_powertools.utilities.auth._authorizer import authorize_event + + return authorize_event(self, event, scopes, response_format, context_claims, on_error) diff --git a/aws_lambda_powertools/utilities/auth/_deadline.py b/aws_lambda_powertools/utilities/auth/_deadline.py new file mode 100644 index 00000000000..212f1c8ee5c --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_deadline.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import time + +from aws_lambda_powertools.utilities.auth._validation import finite_seconds + + +class RequestError(Exception): + """Internal, credential-free transport failure.""" + + def __init__(self, *, retryable: bool = False) -> None: + self.retryable = retryable + super().__init__("Authentication endpoint request failed") + + +class Deadline: + """One monotonic budget shared across a fetch and any subsequent requests.""" + + def __init__(self, seconds: float) -> None: + self._expires_at = time.monotonic() + finite_seconds(seconds, positive=True) + + def remaining(self) -> float: + remaining = self._expires_at - time.monotonic() + if remaining <= 0: + raise RequestError(retryable=True) + return remaining diff --git a/aws_lambda_powertools/utilities/auth/_errors.py b/aws_lambda_powertools/utilities/auth/_errors.py new file mode 100644 index 00000000000..9bbcacb75c7 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_errors.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from functools import wraps +from typing import TYPE_CHECKING, ParamSpec, TypeVar + +from aws_lambda_powertools.utilities.auth.exceptions import AuthError + +if TYPE_CHECKING: + from collections.abc import Callable + +_P = ParamSpec("_P") +_T = TypeVar("_T") + + +def sanitize_errors(operation: Callable[_P, _T]) -> Callable[_P, _T]: + """Detach provider exceptions before an Auth error leaves a public operation.""" + + @wraps(operation) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + try: + return operation(*args, **kwargs) + except AuthError as error: + # `raise ... from None` only suppresses display of the context. + # Clear both references and use a bare re-raise so Python does not + # attach the active exception again. + error.__context__ = None + error.__cause__ = None + raise + + return wrapper diff --git a/aws_lambda_powertools/utilities/auth/_http.py b/aws_lambda_powertools/utilities/auth/_http.py new file mode 100644 index 00000000000..e832223b479 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_http.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import urllib3 +from urllib3.connection import HTTPConnection + +from aws_lambda_powertools.utilities.auth._deadline import Deadline, RequestError + +if TYPE_CHECKING: + from collections.abc import Mapping + +_MAX_JSON_BYTES = 1024 * 1024 + + +class HTTPClient: + """HTTPS transport with bounded JSON responses and no implicit redirects/retries.""" + + def __init__(self) -> None: + self.pool = urllib3.PoolManager(cert_reqs="CERT_REQUIRED") + + def json_request( + self, + method: str, + url: str, + deadline: Deadline, + *, + body: bytes | None = None, + headers: Mapping[str, str] | None = None, + ) -> tuple[int, dict[str, Any]]: + response = None + try: + response = self.pool.request( + method, + url, + body=body, + headers=headers, + timeout=urllib3.Timeout(total=deadline.remaining()), + retries=False, + redirect=False, + preload_content=False, + ) + if response.status != 200: + deadline.remaining() + return response.status, {} + data = self._read_json(response, deadline) + return response.status, data + except (urllib3.exceptions.HTTPError, OSError): + raise RequestError(retryable=True) from None + finally: + if response is not None: + response.close() + response.release_conn() + + @staticmethod + def _read_json(response: urllib3.response.BaseHTTPResponse, deadline: Deadline) -> dict[str, Any]: + chunks = bytearray() + while True: + remaining = deadline.remaining() + connection = response.connection + if isinstance(connection, HTTPConnection) and connection.sock is not None: + connection.sock.settimeout(remaining) + chunk = response.read1(min(65536, _MAX_JSON_BYTES + 1 - len(chunks)), decode_content=False) + deadline.remaining() + if not chunk: + break + chunks.extend(chunk) + if len(chunks) > _MAX_JSON_BYTES: + raise RequestError() + try: + data = json.loads(chunks) + except (ValueError, UnicodeError, RecursionError): + raise RequestError() from None + if not isinstance(data, dict): + raise RequestError() + return data diff --git a/aws_lambda_powertools/utilities/auth/_jwks.py b/aws_lambda_powertools/utilities/auth/_jwks.py new file mode 100644 index 00000000000..a7aeb4c9ca9 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_jwks.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import copy +import threading +import time +import weakref +from typing import Any + +import jwt + +from aws_lambda_powertools.utilities.auth._deadline import Deadline, RequestError +from aws_lambda_powertools.utilities.auth._validation import https_url +from aws_lambda_powertools.utilities.auth.exceptions import InvalidTokenError, JWKSFetchError + + +def copy_key_set(value: dict[str, Any]) -> dict[str, Any]: + if not isinstance(value, dict) or not isinstance(value.get("keys"), list): + raise ValueError("JWKS must contain a keys array") + if any(not isinstance(key, dict) for key in value["keys"]): + raise ValueError("JWKS keys must be objects") + return copy.deepcopy(value) + + +def signing_key(keys: dict[str, Any], header: dict[str, Any]) -> jwt.PyJWK: + """Select one verification key, respecting provider-supplied restrictions.""" + matches = [key for key in keys["keys"] if _matches(key, header)] + if len(matches) != 1: + raise InvalidTokenError() + try: + return jwt.PyJWK.from_dict(matches[0], algorithm=header["alg"]) + except (jwt.PyJWTError, ValueError, TypeError, KeyError): + raise InvalidTokenError() from None + + +def _matches(key: dict[str, Any], header: dict[str, Any]) -> bool: + return ( + key.get("kid") == header["kid"] + and key.get("kty") in ("RSA", "EC", "OKP") + and key.get("alg") in (None, header["alg"]) + and key.get("use") in (None, "sig") + and ("key_ops" not in key or isinstance(key["key_ops"], list) and "verify" in key["key_ops"]) + ) + + +class JWKSCache: + """A key-set snapshot whose maximum age is independent of miss throttling.""" + + def __init__(self, issuer: str, uri: str | None, max_age: float, cooldown: float) -> None: + from aws_lambda_powertools.utilities.auth._http import HTTPClient + + self._issuer = issuer + self._uri = uri + self._max_age = max_age + self._cooldown = cooldown + self._http = HTTPClient() + self._condition = threading.Condition() + self._keys: dict[str, Any] | None = None + self._expires_at = 0.0 + self._next_unknown_refresh = 0.0 + self._retry_at = 0.0 + self._failures = 0 + self._refreshing = False + + def get_keys(self, kid: str | None, deadline: Deadline) -> dict[str, Any]: + try: + return self._get_keys(kid, deadline) + except RequestError: + raise JWKSFetchError() from None + + def _get_keys(self, kid: str | None, deadline: Deadline) -> dict[str, Any]: + joined_refresh = False + with self._condition: + while True: + now = time.monotonic() + fresh = self._keys is not None and now < self._expires_at + if fresh and self._keys is not None: + if kid is None or any(key.get("kid") == kid for key in self._keys["keys"]): + return self._keys + if self._refreshing: + self._condition.wait(timeout=deadline.remaining()) + joined_refresh = True + continue + if fresh and (joined_refresh or now < self._next_unknown_refresh): + raise InvalidTokenError() + if now < self._retry_at: + raise JWKSFetchError() + deadline.remaining() + self._refreshing = True + self._next_unknown_refresh = now + self._cooldown + break + return self._refresh(deadline) + + def _refresh(self, deadline: Deadline) -> dict[str, Any]: + try: + keys = self._fetch(deadline) + deadline.remaining() + with self._condition: + # Replacement discards every previously published key. There is + # deliberately no independent, indefinitely lived per-key cache. + self._keys = keys + self._expires_at = time.monotonic() + self._max_age + self._retry_at = 0.0 + self._failures = 0 + return keys + except (RequestError, ValueError, TypeError, KeyError): + with self._condition: + self._retry_at = time.monotonic() + min(2**self._failures, 30) + self._failures = min(self._failures + 1, 5) + raise JWKSFetchError() from None + finally: + with self._condition: + self._refreshing = False + self._condition.notify_all() + + def _fetch(self, deadline: Deadline) -> dict[str, Any]: + uri = self._uri + if uri is None: + discovery = self._issuer.rstrip("/") + "/.well-known/openid-configuration" + status, metadata = self._http.json_request("GET", discovery, deadline) + if status != 200 or metadata.get("issuer") != self._issuer: + raise RequestError() + uri = https_url(metadata["jwks_uri"]) + status, data = self._http.json_request("GET", uri, deadline) + if status != 200: + raise RequestError() + return copy_key_set(data) + + +_caches: weakref.WeakValueDictionary[tuple[str, str | None, float, float], JWKSCache] = weakref.WeakValueDictionary() +_cache_lock = threading.Lock() + + +def shared_cache(issuer: str, uri: str | None, max_age: float, cooldown: float) -> JWKSCache: + """Share compatible key caches while at least one verifier uses them.""" + identity = (issuer, uri, max_age, cooldown) + with _cache_lock: + cache = _caches.get(identity) + if cache is None: + cache = JWKSCache(issuer, uri, max_age, cooldown) + _caches[identity] = cache + return cache diff --git a/aws_lambda_powertools/utilities/auth/_middleware.py b/aws_lambda_powertools/utilities/auth/_middleware.py new file mode 100644 index 00000000000..127bea2d6c2 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_middleware.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from aws_lambda_powertools.event_handler import ApiGatewayResolver, Response +from aws_lambda_powertools.event_handler.middlewares import BaseMiddlewareHandler +from aws_lambda_powertools.utilities.auth._authorization import ( + ForbiddenError, + InsufficientScopeError, + MissingTokenError, + enforce_scopes, + header_token, + required_scopes, +) +from aws_lambda_powertools.utilities.auth.exceptions import AuthError, AuthFailureReason, InvalidTokenError + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + + from aws_lambda_powertools.event_handler.middlewares import NextMiddleware + from aws_lambda_powertools.utilities.auth._base import Verifier + + +@dataclass(frozen=True) +class AuthErrorContext: + """Mapped HTTP failure available to a route's custom error response callback.""" + + status_code: int + headers: dict[str, str] + reason: AuthFailureReason + retryable: bool + + +class AuthMiddleware(BaseMiddlewareHandler[ApiGatewayResolver]): + def __init__( + self, + verifier: Verifier, + scopes: list[str] | None, + authorize: Callable[[dict[str, Any]], bool] | None, + on_error: Callable[[AuthErrorContext], Response] | None, + ) -> None: + self._verifier = verifier + self._scopes = required_scopes(scopes) + self._authorize = authorize + self._on_error = on_error + + def handler(self, app: ApiGatewayResolver, next_middleware: NextMiddleware) -> Response: + try: + raw = app.current_event.raw_event + token = header_token(raw.get("headers"), raw.get("multiValueHeaders")) + claims = self._verifier.verify(token) + enforce_scopes(claims, self._scopes) + if self._authorize is not None and self._authorize(claims) is not True: + raise ForbiddenError() + except AuthError as error: + return self._failure(error) + app.append_context(claims=claims) + try: + return next_middleware(app) + finally: + # Resolver cleanup can be skipped when a handler raises. Claims + # belong to this middleware invocation, including on that path. + app.context.pop("claims", None) + + def _failure(self, error: AuthError) -> Response: + if isinstance(error, MissingTokenError): + status, headers = 401, {"WWW-Authenticate": "Bearer"} + elif isinstance(error, InvalidTokenError): + status, headers = 401, {"WWW-Authenticate": 'Bearer error="invalid_token"'} + elif isinstance(error, InsufficientScopeError): + scopes = " ".join(self._scopes) + status, headers = 403, {"WWW-Authenticate": f'Bearer error="insufficient_scope", scope="{scopes}"'} + elif isinstance(error, ForbiddenError): + status, headers = 403, {} + else: + status, headers = 503, {} + context = AuthErrorContext(status, headers, error.reason, error.retryable) + if self._on_error is not None: + return self._on_error(context) + messages = {401: "Unauthorized", 403: "Forbidden", 503: "Service Unavailable"} + return Response( + status_code=context.status_code, + content_type="application/json", + body={"message": messages[context.status_code]}, + headers=context.headers, + ) diff --git a/aws_lambda_powertools/utilities/auth/_validation.py b/aws_lambda_powertools/utilities/auth/_validation.py new file mode 100644 index 00000000000..60f4a658396 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_validation.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import math +from collections.abc import Mapping +from typing import Any +from urllib.parse import urlsplit + + +def https_url(value: str, *, issuer: bool = False) -> str: + """Validate configured URLs without echoing their contents in errors.""" + try: + parts = urlsplit(value) + valid = isinstance(value, str) and all( + ( + _valid_url_characters(value), + parts.scheme == "https", + bool(parts.hostname), + parts.username is None, + parts.password is None, + not parts.fragment, + not issuer or not parts.query, + ), + ) + _ = parts.port # Accessing the property validates a supplied port. + except (AttributeError, TypeError, ValueError): + valid = False + if not valid: + raise ValueError("An HTTPS URL without user information or a fragment is required") from None + return value + + +def _valid_url_characters(value: str) -> bool: + return not any(character.isspace() or ord(character) < 32 for character in value) + + +def finite_seconds(value: float, *, positive: bool = False) -> float: + """Validate a duration; booleans and non-finite values are not durations.""" + try: + valid = type(value) in (int, float) and math.isfinite(value) and value >= 0 and (not positive or value > 0) + except OverflowError: + valid = False + if not valid: + message = "A finite positive duration is required" if positive else "A finite nonnegative duration is required" + raise ValueError(message) + return value + + +def string_list(values: list[str] | tuple[str, ...], *, nonempty: bool = False) -> tuple[str, ...]: + """Copy a sequence of nonempty strings so configuration cannot be mutated.""" + if not isinstance(values, (list, tuple)) or (nonempty and not values): + raise ValueError("A list of nonempty strings is required") + if not all(is_nonempty_string(value) for value in values): + raise ValueError("A list of nonempty strings is required") + return tuple(dict.fromkeys(values)) + + +def is_nonempty_string(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def string_mapping(values: Mapping[str, str] | None) -> dict[str, str]: + """Copy exact token-profile constraints without exposing their contents.""" + if values is None: + return {} + if not isinstance(values, Mapping) or not all( + is_nonempty_string(name) and is_nonempty_string(value) for name, value in values.items() + ): + raise ValueError("Expected claims and headers must map nonempty strings to nonempty strings") + return dict(values) diff --git a/aws_lambda_powertools/utilities/auth/exceptions.py b/aws_lambda_powertools/utilities/auth/exceptions.py new file mode 100644 index 00000000000..6c460b34790 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/exceptions.py @@ -0,0 +1,62 @@ +"""Credential-free errors raised by the Auth utility.""" + +from enum import Enum + + +class AuthFailureReason(str, Enum): + """Stable, credential-free reasons suitable for application logs and metrics.""" + + MISSING_TOKEN = "missing_token" # nosec B105 + INVALID_TOKEN = "invalid_token" # nosec B105 + INVALID_CLAIMS = "invalid_claims" + TOKEN_EXPIRED = "token_expired" # nosec B105 + INVALID_SIGNATURE = "invalid_signature" + INSUFFICIENT_SCOPE = "insufficient_scope" + FORBIDDEN = "forbidden" + JWKS_UNAVAILABLE = "jwks_unavailable" + + +class AuthError(Exception): + """Base error with a fixed message that never includes credential material.""" + + message = "Authentication failed" + reason = AuthFailureReason.INVALID_TOKEN + retryable = False + + def __init__(self) -> None: + super().__init__(self.message) + + +class InvalidTokenError(AuthError): + """The bearer token could not be verified.""" + + message = "Invalid access token" + + +class InvalidClaimsError(InvalidTokenError): + """A required claim is missing or a claim does not match the token profile.""" + + message = "Invalid access token claims" + reason = AuthFailureReason.INVALID_CLAIMS + + +class TokenExpiredError(InvalidTokenError): + """The access token has expired beyond the configured clock tolerance.""" + + message = "Access token expired" + reason = AuthFailureReason.TOKEN_EXPIRED + + +class InvalidSignatureError(InvalidTokenError): + """The access token signature does not match the configured signing key.""" + + message = "Invalid access token signature" + reason = AuthFailureReason.INVALID_SIGNATURE + + +class JWKSFetchError(AuthError): + """Required signing keys could not be retrieved or refreshed.""" + + message = "Unable to retrieve verification keys" + reason = AuthFailureReason.JWKS_UNAVAILABLE + retryable = True diff --git a/aws_lambda_powertools/utilities/auth/testing.py b/aws_lambda_powertools/utilities/auth/testing.py new file mode 100644 index 00000000000..c9c2b52a4ec --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/testing.py @@ -0,0 +1,32 @@ +"""Helpers for application tests that intentionally bypass token verification.""" + +from __future__ import annotations + +import copy +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +if TYPE_CHECKING: + from collections.abc import Iterator + + from aws_lambda_powertools.utilities.auth._base import Verifier + + +@contextmanager +def mock_claims(verifier: Verifier, claims: dict[str, Any]) -> Iterator[None]: + """Temporarily return supplied claims without cryptography or network calls. + + This helper bypasses the verifier's security checks. Use it only in + application tests; retain separate tests for real token verification. + + Examples + -------- + ```python + with mock_claims(verifier, {"sub": "test-user", "scope": "orders:read"}): + response = app.resolve(event, context) + ``` + """ + snapshot = copy.deepcopy(claims) + with patch.object(verifier, "verify", side_effect=lambda token: copy.deepcopy(snapshot)): + yield diff --git a/aws_lambda_powertools/utilities/auth/verifier.py b/aws_lambda_powertools/utilities/auth/verifier.py new file mode 100644 index 00000000000..dcd6bdebebf --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/verifier.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import math +import re +import time +from typing import TYPE_CHECKING, Any + +import jwt + +from aws_lambda_powertools.utilities.auth._base import Verifier +from aws_lambda_powertools.utilities.auth._deadline import Deadline +from aws_lambda_powertools.utilities.auth._errors import sanitize_errors +from aws_lambda_powertools.utilities.auth._jwks import copy_key_set, shared_cache, signing_key +from aws_lambda_powertools.utilities.auth._validation import ( + finite_seconds, + https_url, + is_nonempty_string, + string_list, + string_mapping, +) +from aws_lambda_powertools.utilities.auth.exceptions import ( + InvalidClaimsError, + InvalidSignatureError, + InvalidTokenError, + TokenExpiredError, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + +_ASYMMETRIC_ALGORITHMS = frozenset( + {"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "ES256K", "EdDSA"}, +) + + +class JWTVerifier(Verifier): + """Verify JWT access tokens for a configured issuer and resource audience. + + Parameters + ---------- + issuer : str + Exact trusted HTTPS issuer. Discovery must advertise this issuer. + audience : str | list[str] + Accepted resource audiences; at least one must match the token. + algorithms : list[str] + Explicit allowlist of asymmetric signing algorithms. + jwks : dict, optional + Static key-set snapshot. Its rotation is the application's responsibility. + jwks_uri : str, optional + HTTPS key-set endpoint, mutually exclusive with ``jwks``. Without either, + discover keys from the configured issuer. + required_claims : list[str], optional + Claims required in addition to ``iss``, ``aud``, and ``exp``. + expected_claims : Mapping[str, str], optional + Exact, case-sensitive string values required in verified claims, + for example ``{"token_use": "access"}``. Missing values are rejected. + expected_headers : Mapping[str, str], optional + Exact string values required in the signed header, for example + ``{"typ": "at+jwt"}``. These checks cannot weaken signature validation. + clock_skew_seconds : float + Nonnegative allowance for temporal claims, by default 60. + timeout_seconds : float + Positive discovery/key-fetch and refresh-wait budget, by default 3. + jwks_max_age_seconds : float + Positive maximum lifetime of fetched keys, by default 300. + unknown_kid_cooldown_seconds : float + Nonnegative interval between unknown-key refreshes, by default 300. + + Raises + ------ + ValueError + Configuration is invalid or weakens the required verification profile. + + Examples + -------- + ```python + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://orders.example.com", + algorithms=["RS256"], + required_claims=["sub"], + ) + claims = verifier.verify(token) + ``` + """ + + def __init__( + self, + *, + issuer: str, + audience: str | list[str], + algorithms: list[str], + jwks: dict[str, Any] | None = None, + jwks_uri: str | None = None, + required_claims: list[str] | None = None, + expected_claims: Mapping[str, str] | None = None, + expected_headers: Mapping[str, str] | None = None, + clock_skew_seconds: float = 60, + timeout_seconds: float = 3, + jwks_max_age_seconds: float = 300, + unknown_kid_cooldown_seconds: float = 300, + ) -> None: + self._issuer = https_url(issuer, issuer=True) + self._audience = string_list([audience] if isinstance(audience, str) else audience, nonempty=True) + self._algorithms = string_list(algorithms, nonempty=True) + if not set(self._algorithms) <= _ASYMMETRIC_ALGORITHMS: + raise ValueError("Only asymmetric JWT signing algorithms are supported") + if jwks is not None and jwks_uri is not None: + raise ValueError("jwks and jwks_uri are mutually exclusive") + self._jwks = copy_key_set(jwks) if jwks is not None else None + self._jwks_uri = https_url(jwks_uri) if jwks_uri is not None else None + self._timeout = finite_seconds(timeout_seconds, positive=True) + max_age = finite_seconds(jwks_max_age_seconds, positive=True) + cooldown = finite_seconds(unknown_kid_cooldown_seconds) + self._cache = shared_cache(self._issuer, self._jwks_uri, max_age, cooldown) if jwks is None else None + additional_claims = string_list(required_claims if required_claims is not None else []) + self._required_claims = sorted({"iss", "aud", "exp"} | set(additional_claims)) + self._expected_claims = string_mapping(expected_claims) + self._expected_headers = string_mapping(expected_headers) + self._clock_skew = finite_seconds(clock_skew_seconds) + self._cognito_client_id: str | None = None + + @classmethod + def cognito( + cls, + *, + user_pool_id: str, + client_id: str, + audience: str | list[str], + **options: Any, + ) -> JWTVerifier: + """Verify resource-bound Cognito access tokens, never Cognito ID tokens. + + Additional keyword arguments configure caching, static keys and claim + requirements in the same way as ``JWTVerifier``. + + Parameters + ---------- + user_pool_id : str + Cognito user pool identifier, including its Region. + client_id : str + App client identifier required in the ``client_id`` claim. + audience : str | list[str] + Resource audience required in ``aud``. Request resource binding + when obtaining the access token. + + Examples + -------- + ```python + verifier = JWTVerifier.cognito( + user_pool_id="us-east-1_abc123", + client_id="orders-client", + audience="https://orders.example.com", + ) + ``` + """ + if not isinstance(user_pool_id, str) or not re.fullmatch( + r"[a-z]{2}(?:-[a-z]+)+-\d+_[A-Za-z0-9]+", + user_pool_id, + ): + raise ValueError("A valid Cognito user pool ID is required") + if not is_nonempty_string(client_id): + raise ValueError("A nonempty Cognito app client ID is required") + if {"issuer", "algorithms", "jwks_uri"} & options.keys(): + raise ValueError("Cognito issuer, algorithm and JWKS endpoint cannot be overridden") + region = user_pool_id.split("_", 1)[0] + domain = "amazonaws.com.cn" if region.startswith("cn-") else "amazonaws.com" + issuer = f"https://cognito-idp.{region}.{domain}/{user_pool_id}" + if options.get("jwks") is None: + options["jwks_uri"] = issuer + "/.well-known/jwks.json" + verifier = cls(issuer=issuer, audience=audience, algorithms=["RS256"], **options) + verifier._cognito_client_id = client_id + return verifier + + @classmethod + def any_of(cls, *verifiers: JWTVerifier) -> Verifier: + """Route an untrusted issuer claim only to explicitly configured verifiers. + + Unknown issuers trigger no discovery. Duplicate issuer configurations + are rejected. The returned verifier has the same verification, + middleware, authorizer, and prefetch interface. + + Examples + -------- + ```python + combined = JWTVerifier.any_of(corporate_verifier, cognito_verifier) + claims = combined.verify(token) + ``` + """ + if not verifiers or any(not isinstance(verifier, JWTVerifier) for verifier in verifiers): + raise ValueError("At least one issuer-specific JWTVerifier is required") + issuers = {verifier._issuer: verifier for verifier in verifiers} + if len(issuers) != len(verifiers): + raise ValueError("Duplicate issuer configurations are ambiguous") + return _IssuerVerifier(issuers) + + def __repr__(self) -> str: + return "" + + @sanitize_errors + def prefetch(self) -> None: + """Populate an absent or expired remote key set; static keys need no I/O. + + Raises + ------ + JWKSFetchError + Trusted keys could not be fetched within the configured budget. + + Examples + -------- + ```python + verifier.prefetch() # Optional initialization work outside the handler. + ``` + """ + if self._cache is not None: + self._cache.get_keys(None, Deadline(self._timeout)) + + @sanitize_errors + def verify(self, token: str) -> dict[str, Any]: + """Return verified access-token claims. + + Parameters + ---------- + token : str + JWT access token without the ``Bearer`` prefix. + + Returns + ------- + dict[str, Any] + Claims after signature, issuer, resource, and time validation. + + Raises + ------ + InvalidTokenError + Token, key, signature, or required claims are invalid. + JWKSFetchError + Current trusted keys could not be obtained. + + Examples + -------- + ```python + claims = verifier.verify(token) + subject = claims["sub"] + ``` + """ + header = self._header(token) + key = self._signing_key(header) + try: + claims = jwt.decode( + token, + key.key, + algorithms=self._algorithms, + issuer=self._issuer, + audience=self._audience, + options={ + "require": self._required_claims, + "verify_exp": False, + "verify_nbf": False, + "verify_iat": False, + }, + ) + except jwt.InvalidSignatureError: + raise InvalidSignatureError() from None + except (jwt.PyJWTError, TypeError, ValueError, OverflowError, RecursionError): + raise InvalidClaimsError() from None + self._validate_times(claims) + self._validate_profile(claims, header) + if self._cognito_client_id is not None: + if claims.get("token_use") != "access" or claims.get("client_id") != self._cognito_client_id: + raise InvalidClaimsError() + return claims + + def _validate_profile(self, claims: dict[str, Any], header: dict[str, Any]) -> None: + for values, expected in ((claims, self._expected_claims), (header, self._expected_headers)): + if any(values.get(name) != value for name, value in expected.items()): + raise InvalidClaimsError() + + def _header(self, token: str) -> dict[str, Any]: + if not isinstance(token, str) or not token: + raise InvalidTokenError() + try: + header = jwt.get_unverified_header(token) + except (jwt.InvalidTokenError, ValueError, TypeError): + raise InvalidTokenError() from None + if ( + header.get("alg") not in self._algorithms + or not isinstance(header.get("kid"), str) + or not header["kid"] + or header.get("crit") + or header.get("b64") is False + ): + raise InvalidTokenError() + return header + + def _signing_key(self, header: dict[str, Any]) -> jwt.PyJWK: + keys = self._cache.get_keys(header["kid"], Deadline(self._timeout)) if self._cache is not None else self._jwks + if keys is None: + raise InvalidTokenError() + return signing_key(keys, header) + + def _validate_times(self, claims: dict[str, Any]) -> None: + for name in ("exp", "nbf", "iat"): + if name not in claims: + continue + value = claims[name] + try: + valid = type(value) in (int, float) and math.isfinite(value) + except OverflowError: + valid = False + if not valid: + raise InvalidClaimsError() + now = time.time() + if claims["exp"] <= now - self._clock_skew: + raise TokenExpiredError() + if claims.get("nbf", 0) > now + self._clock_skew or claims.get("iat", 0) > now + self._clock_skew: + raise InvalidClaimsError() + + +class _IssuerVerifier(Verifier): + def __init__(self, issuers: dict[str, JWTVerifier]) -> None: + self._issuers = issuers + + def __repr__(self) -> str: + return "" + + @sanitize_errors + def verify(self, token: str) -> dict[str, Any]: + if not isinstance(token, str) or not token: + raise InvalidTokenError() + try: + # This payload selects a configured verifier. No unverified claim + # is returned to callers or used to discover another provider. + payload = jwt.decode(token, options={"verify_signature": False}) + issuer = payload.get("iss") + except (jwt.PyJWTError, ValueError, TypeError, RecursionError): + raise InvalidTokenError() from None + if not isinstance(issuer, str) or issuer not in self._issuers: + raise InvalidTokenError() + return self._issuers[issuer].verify(token) + + @sanitize_errors + def prefetch(self) -> None: + for verifier in self._issuers.values(): + verifier.prefetch() diff --git a/docs/api_doc/auth.md b/docs/api_doc/auth.md new file mode 100644 index 00000000000..1f63cd03147 --- /dev/null +++ b/docs/api_doc/auth.md @@ -0,0 +1,7 @@ + +::: aws_lambda_powertools.utilities.auth.verifier + options: + inherited_members: true +::: aws_lambda_powertools.utilities.auth.AuthErrorContext +::: aws_lambda_powertools.utilities.auth.exceptions +::: aws_lambda_powertools.utilities.auth.testing diff --git a/docs/build_recipes/cross-platform.md b/docs/build_recipes/cross-platform.md index bdc1b7c0904..bb124a83009 100644 --- a/docs/build_recipes/cross-platform.md +++ b/docs/build_recipes/cross-platform.md @@ -18,6 +18,7 @@ Taking into consideration Powertools for AWS dependencies and common Python pack |---------|----------|------------|--------|-------------------| | **pydantic** | Rust | Core validation engine | High - Core functionality affected | ✅ Core dependency | | **aws-encryption-sdk** | C | Encryption/decryption | High - Data masking fails | ✅ Optional (datamasking extra) | +| **cryptography** | Rust/C | Asymmetric signature verification | High - JWT verification fails | ✅ Optional (auth extra) | | **protobuf** | C++ | Protocol buffer serialization | High - Message parsing fails | ✅ Optional (kafka-consumer-protobuf) | | **redis** | C | Redis client with hiredis | Medium - Falls back to pure Python | ✅ Optional (redis extra) | | **valkey-glide** | Rust | High-performance Redis client | High - Client completely broken | ✅ Optional (valkey extra) | @@ -44,6 +45,7 @@ Different Powertools for AWS extras dependencies have varying levels of architec ```txt title="requirements.txt - Requires Linux builds" # These extras include compiled dependencies + aws-lambda-powertools[auth] # cryptography (Rust/C) aws-lambda-powertools[parser]==3.18.0 # pydantic (Rust) aws-lambda-powertools[validation]==3.18.0 # fastjsonschema (C) aws-lambda-powertools[datamasking]==3.18.0 # aws-encryption-sdk (C) diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 94b3b790a05..f2b215c10da 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -42,6 +42,7 @@ Some features require additional dependencies. Install them as needed: | [Tracer](../core/tracer.md) | `pip install "aws-lambda-powertools[tracer]"` | `aws-xray-sdk` | | [Validation](../utilities/validation.md) | `pip install "aws-lambda-powertools[validation]"` | `fastjsonschema` | | [Parser](../utilities/parser.md) | `pip install "aws-lambda-powertools[parser]"` | `pydantic` | +| [Auth](../utilities/auth.md) | `pip install "aws-lambda-powertools[auth]"` | `PyJWT`, `cryptography`, `urllib3` | | [Data Masking](../utilities/data_masking.md) | `pip install "aws-lambda-powertools[datamasking]"` | `aws-encryption-sdk`, `jsonpath-ng` | | [Datadog Metrics](../core/metrics/datadog.md) | `pip install "aws-lambda-powertools[datadog]"` | `datadog-lambda` | | [Kafka (Avro)](../utilities/kafka.md) | `pip install "aws-lambda-powertools[kafka-consumer-avro]"` | `avro` | diff --git a/docs/index.md b/docs/index.md index 887b35b23fa..24c77c33cb5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,6 +54,7 @@ Powertools for AWS Lambda (Python) is a developer toolkit to implement Serverles | [Metrics](./core/metrics.md) | Custom Metrics created asynchronously via CloudWatch Embedded Metric Format (EMF) | | [Event Handler](./core/event_handler/api_gateway.md) | Event handler for API Gateway, ALB, Lambda Function URL, VPC Lattice, AppSync, and Bedrock Agents | | [Parameters](./utilities/parameters.md) | Retrieve and cache parameter values from Parameter Store, Secrets Manager, AppConfig, or DynamoDB | +| [Auth](./utilities/auth.md) | Verify JWT access tokens, protect Lambda routes, and acquire OAuth client-credentials tokens | | [Parser](./utilities/parser.md) | Data parsing and deep validation using Pydantic | | [Batch Processing](./utilities/batch.md) | Handle partial failures for SQS, Kinesis Data Streams, and DynamoDB Streams | | [Idempotency](./utilities/idempotency.md) | Make your Lambda functions idempotent and prevent duplicate execution | diff --git a/docs/utilities/auth.md b/docs/utilities/auth.md new file mode 100644 index 00000000000..2caac2e1d42 --- /dev/null +++ b/docs/utilities/auth.md @@ -0,0 +1,376 @@ +--- +title: Auth +description: JWT access-token verification for Lambda +--- + +Auth verifies incoming JWT access tokens. +Use it inside a Lambda function or a Lambda authorizer. Prefer an API Gateway managed JWT authorizer when it meets your token profile and deployment requirements. + +## Key features + +* Verify asymmetric signatures, exact issuer, resource audience, expiration, and additional required claims. +* Coordinate discovery and signing-key refresh across threads with bounded key freshness. +* Protect Event Handler routes and create API Gateway IAM or simple authorizer responses. +* Validate resource-bound Cognito access tokens and combine explicitly trusted issuers. +* Adapt verification to the MCP Python SDK without a Powertools dependency on MCP. + +## Getting started + +### Install + +```shell +pip install "aws-lambda-powertools[auth]" +``` + +The optional `auth` extra includes PyJWT, cryptography, and urllib3. It adds no dependencies to the base installation. +Build cryptography dependencies for your Lambda Python version and architecture; see [cross-platform builds](../build_recipes/cross-platform.md). +The Powertools Layer retains urllib3 from the declared dependency range instead of relying on the runtime's copy. +Applications pinning a different AWS SDK must validate that SDK's urllib3 requirements against the Layer or bundle a compatible dependency set. + +### Protect an HTTP route + +Create a verifier outside the handler so warm invocations reuse its key cache. Configure an issuer, resource audience, and explicit algorithm allowlist. +Set `ISSUER_URL` and `RESOURCE_URL` to your provider's exact issuer and this API's identifier. + +```python title="middleware.py" +--8<-- "examples/auth/src/middleware.py" +``` + +`require()` validates the Bearer token and all requested scopes before executing the route. Verified claims are available through `app.context["claims"]`. +Claims remain available while downstream middleware and the handler execute, then are removed even if either raises an exception. +Event Handler clears context after resolving the invocation. The same middleware works with REST API, ALB, and Lambda Function URL resolvers. +Configure CORS preflight and public routes separately. + +| Failure | Response | `WWW-Authenticate` | +| ------- | -------- | ------------------ | +| Missing Authorization | 401 | `Bearer` | +| Invalid token or malformed scope claim | 401 | `Bearer error="invalid_token"` | +| Missing required scope | 403 | `Bearer error="insufficient_scope", scope="orders:read"` | +| Additional authorization denied | 403 | None | +| Signing keys unavailable | 503 | None | + +### Verify directly + +`verify(token)` accepts the token without the `Bearer` prefix and returns a dictionary of verified claims. +It always requires `iss`, `aud`, and `exp`. `required_claims` adds requirements without replacing these baseline checks. + +```python +from aws_lambda_powertools.utilities.auth import JWTVerifier + +verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://orders.example.com", + algorithms=["RS256"], + required_claims=["sub"], +) +``` + +Absent an explicit `jwks_uri` or static `jwks`, discovery uses the configured issuer's `/.well-known/openid-configuration`. +Discovery must advertise that exact issuer and an HTTPS JWKS URL. URLs supplied by token headers are never used for discovery. + +## Advanced + +### Token profiles and scope checks + +The generic profile checks signature, exact issuer, at least one configured audience, and finite numeric `exp`, `nbf`, and `iat` claims when present. +Expiration is required. The default clock allowance is 60 seconds, configurable with `clock_skew_seconds`. +Supported algorithms are RS256/384/512, PS256/384/512, ES256/384/512, ES256K, and EdDSA. HMAC and unsigned JWTs are rejected. +Keys must have a matching `kid`, compatible algorithm and key type, and signing/verification metadata when supplied. + +Applications must select access tokens for their resource; the generic profile cannot infer a provider's token purpose. +Configure `expected_claims` and/or `expected_headers` when an issuer can mint other token types with the same audience: + +```python +verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://orders.example.com", + algorithms=["RS256"], + expected_claims={"token_use": "access"}, + expected_headers={"typ": "at+jwt"}, +) +``` + +Use the values defined by your provider; not every provider uses both fields. +These mappings require exact, case-sensitive, nonempty string values. Missing or different values raise `InvalidClaimsError`. +They are copied during construction and checked after signature, issuer, audience, and time validation. +The constraints apply to direct verification, middleware, authorizers, and issuer groups and cannot disable any baseline check. +`required_claims` checks presence only. +Local JWT verification does not check individual-token revocation. + +Scopes come from the first present claim in this order: `scope`, `scp`, `scopes`. +A claim can be a space-separated string or a list of strings. A malformed higher-priority claim is rejected without falling back to another claim. +All required scopes must be present. + +An optional `authorize` callback receives verified claims and must return `True`: + +```python +middleware = verifier.require( + scopes=["orders:read"], + authorize=lambda claims: claims.get("tenant") == "example", +) +``` + +An `on_error` callback receives `AuthErrorContext` with `status_code`, `headers`, `reason`, and `retryable`. +It must return an Event Handler `Response`. Preserve the status and challenge headers when customizing the body. +The reason is an `AuthFailureReason` string enum; `retryable` is true for unavailable JWKS infrastructure and false for credential/policy failures. +The utility does not log failures automatically or add diagnostics to default responses. Applications choose logging, metrics, and sampling: + +```python +from aws_lambda_powertools import Logger +from aws_lambda_powertools.event_handler import Response +from aws_lambda_powertools.utilities.auth import AuthErrorContext + +logger = Logger() + + +def on_error(error: AuthErrorContext) -> Response: + logger.warning("Authorization failed", reason=error.reason.value, retryable=error.retryable) + return Response( + status_code=error.status_code, + content_type="application/json", + body={"message": "Access denied"}, + headers=error.headers, + ) + + +middleware = verifier.require(on_error=on_error) +``` + +The callback replaces the error response; it never invokes the protected handler. Callback exceptions propagate to the application. + +### Key freshness, rotation, and outages + +| Setting | Default | Behavior | +| ------- | ------- | -------- | +| `timeout_seconds` | 3 | Budget for discovery, JWKS requests, and waiting for another refresh | +| `jwks_max_age_seconds` | 300 | Maximum age of a successfully fetched key set | +| `unknown_kid_cooldown_seconds` | 300 | Minimum interval between fetches triggered by unknown key IDs | + +Compatible verifiers in one process share a key-set cache; distinct issuers or cache policies are isolated. +Concurrent misses share a refresh. Expiration requires a fresh key set even when the unknown-key cooldown has not elapsed. +A successful refresh replaces the entire set, including removal of previously trusted keys. No independent parsed-key cache retains removed keys. + +A failed refresh backs off for 1, 2, 4, 8, 16, then 30 seconds. During that interval, known keys can still be used within their original maximum age. +Expired keys are never used after a failed refresh. Unknown keys during a cooldown are rejected, so a newly published key may take time to become usable. +Choose freshness and cooldown settings together with your provider's key rotation policy. + +Construction performs no network I/O. By default the first verification fetches the keys, adding latency to that invocation. +Calling `prefetch()` at module level moves the first fetch into Lambda INIT, but an identity-provider outage can then fail the cold start. +Prefetch is an explicit option, not a default recommendation; choose based on your latency and availability requirements. +Later rotation, expiration, and outages can still cause network I/O. +Static `jwks` is copied when constructing the verifier and performs no discovery or refresh: + +```python +import json + +from aws_lambda_powertools.utilities import parameters + +key_set = parameters.get_parameter("/orders/jwks", max_age=3600) +verifier = JWTVerifier( + issuer="https://idp.internal", + audience="https://orders.internal", + algorithms=["ES256"], + jwks=json.loads(key_set), +) +``` + +Parameters' cache lifetime does not refresh that static snapshot. Recreate the verifier or recycle its execution environment when keys change. +You own static-key rotation and removal. + +### Cognito and multiple issuers + +```python +cognito = JWTVerifier.cognito( + user_pool_id="us-east-1_abc123", + client_id="orders-client", + audience="https://orders.example.com", +) +combined = JWTVerifier.any_of(verifier, cognito) +``` + +The Cognito profile requires RS256, `token_use="access"`, the configured `client_id`, and the resource `aud`. +The client must request resource binding. ID tokens and Cognito access tokens without `aud` are rejected. + +`any_of()` uses the unverified issuer only to select an explicitly configured verifier, then performs all verification through it. +Unknown issuers trigger no discovery. Duplicate issuer configurations are rejected as ambiguous. +The combined verifier supports `verify()`, `prefetch()`, `require()`, and `authorize()`. + +### Lambda authorizers + +```python title="authorizer.py" +--8<-- "examples/auth/src/authorizer/authorizer.py" +``` + +The helper accepts raw dictionaries or the corresponding Powertools authorizer Data Classes. + +| Event | `response_format` | Result | +| ----- | ----------------- | ------ | +| REST API TOKEN or REQUEST | `iam` | Serialized IAM policy | +| HTTP API REQUEST payload 1.0 | `iam` | Serialized IAM policy | +| HTTP API REQUEST payload 2.0 | `iam` | Serialized IAM policy | +| HTTP API REQUEST payload 2.0, simple responses enabled | `simple` | Serialized `isAuthorized` response | + +IAM allows require a nonempty string `sub` as principal and cover only the supplied request ARN. +Wildcard, missing, or malformed ARNs raise `ValueError`; the helper cannot construct a request-specific IAM policy without a valid ARN. +Other routes need their own decision. +Invalid tokens and insufficient scopes produce a Deny or `isAuthorized=False`; unavailable signing keys raise `JWKSFetchError`. +For an outage, middleware returns HTTP 503 directly. A Lambda authorizer fails its invocation instead, and API Gateway normally returns a 5xx response. +API callers should treat this as an availability failure rather than repeatedly obtaining new credentials; configure retries and alarms accordingly. + +Pass `on_error` to `authorize()` to record a rejection or unavailable keys, as shown in the example above. +It receives an `AuthError` with the same fixed `reason` and `retryable` attributes exposed by middleware. +Its return value is ignored: invalid credentials still deny access, and `JWKSFetchError` still propagates after the callback. +A callback exception fails the invocation. Successful authorizations do not call it. The default response includes neither diagnostic field. + +No claims are copied to context by default. `context_claims` copies only selected scalar values, omitting arrays, objects, and nulls. +The name `claims` is reserved in authorizer context. + +#### Deployment and Gateway caching + +Disable authorizer-result caching to verify each request. This SAM example sets `ReauthorizeEvery: 0` for both REST and HTTP authorizers; +the underlying API Gateway setting is `AuthorizerResultTtlInSeconds: 0`. +The template is under `examples/auth/templates/`; its `CodeUri` values are relative to that directory. +Authorizer functions build from `src/authorizer/` with the Auth extra. Backends build independently from `src/backend/` with base Powertools only, +so PyJWT and cryptography are not included in the backend artifacts. +HTTP simple responses also require payload version 2.0 and `EnableSimpleResponses: true`. + +```yaml title="templates/sam.yaml" +--8<-- "examples/auth/templates/sam.yaml" +``` + +If you enable result caching later, a cached decision can outlive the JWT's expiration or a signing key's removal. +The verifier's key-cache settings do not control Gateway's result cache. +HTTP simple responses can apply to multiple routes sharing an identity cache key; include `$context.routeKey` for route-specific decisions. +Route-aware keys still do not recheck an expired token. Cached IAM policies must cover exactly the routes they authorize; this helper deliberately returns one concrete resource. + +### MCP Python SDK adapter + +The following adapter targets the `MCPServer` interface in MCP Python SDK 2.2.0 (`mcp==2.2.0`), +following the [MCP authorization tutorial](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/authorization). +Install that SDK separately. This example requires the Keycloak access-token claim `typ="Bearer"` and maps `azp`, `sub`, and `scope`. +Adapt the expected purpose and claim mapping to your provider and token configuration. + +```python +import asyncio + +from mcp.server import MCPServer +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from pydantic import AnyHttpUrl + +from aws_lambda_powertools import Logger +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import InvalidTokenError, JWKSFetchError + +logger = Logger() +RESOURCE_URL = "https://mcp.example.com" +ISSUER_URL = "https://keycloak.example.com/realms/mcp" +verifier = JWTVerifier( + issuer=ISSUER_URL, + audience=RESOURCE_URL, + algorithms=["RS256"], + required_claims=["azp", "sub", "scope"], + expected_claims={"typ": "Bearer"}, +) + + +class PowertoolsTokenVerifier(TokenVerifier): + async def verify_token(self, token: str) -> AccessToken | None: + try: + claims = await asyncio.to_thread(verifier.verify, token) + except JWKSFetchError as error: + logger.error("Verification keys unavailable", reason=error.reason.value, retryable=error.retryable) + return None + except InvalidTokenError: + return None + if not all(isinstance(claims[name], str) for name in ("azp", "sub", "scope")): + return None + if not claims["azp"] or not claims["sub"]: + return None + return AccessToken( + token=token, + client_id=claims["azp"], + subject=claims["sub"], + scopes=claims["scope"].split(), + expires_at=claims["exp"], + resource=RESOURCE_URL, + ) + + +mcp = MCPServer( + name="orders", + token_verifier=PowertoolsTokenVerifier(), + auth=AuthSettings( + issuer_url=AnyHttpUrl(ISSUER_URL), + resource_server_url=AnyHttpUrl(RESOURCE_URL), + validate_token_resource=True, + required_scopes=["mcp:tools"], + ), +) +``` + +The SDK owns transport, Protected Resource Metadata, and authentication challenges. This adapter maps both invalid tokens and unavailable keys to failed authentication. +The adapter records unavailable keys separately for Lambda-owner alarms and metrics before returning `None`. +A distinct availability response to the API caller requires integration at the SDK transport boundary. +`asyncio.to_thread()` keeps synchronous key fetches off the event loop; cancelling the await does not terminate a running request. + +Tools can enforce permissions using the verified SDK access token: + +```python +from mcp.server.auth.middleware.auth_context import get_access_token + + +def require_scope(scope: str): + caller = get_access_token() + if caller is None or scope not in caller.scopes: + raise PermissionError("Required tool permission is missing") +``` + +Use the targeted SDK's supported tool-error handling for permission failures. Raising `PermissionError` alone does not implement an HTTP challenge or a scope-upgrade flow. +API Gateway authorizers in front of an MCP server also require deployment-specific metadata routes and discovery/challenge behavior; +an authorizer Deny response alone does not implement MCP authorization. + +### Errors and diagnostics + +`AuthError` is the base error. `InvalidTokenError` includes `InvalidClaimsError`, `TokenExpiredError`, and `InvalidSignatureError`. +`JWKSFetchError` is separate from invalid-token errors so applications can distinguish unavailable verification infrastructure. +Every error exposes `reason: AuthFailureReason` and `retryable: bool`. `AuthFailureReason` uses `str, Enum` for Python 3.10 compatibility. +Use `.value` for log fields and metric dimensions; do not parse exception messages. + +| Reason | Retryable | +| ------ | --------- | +| `missing_token` | false | +| `invalid_token` | false | +| `invalid_claims` | false | +| `token_expired` | false | +| `invalid_signature` | false | +| `insufficient_scope` | false | +| `forbidden` | false | +| `jwks_unavailable` | true | + +Retryability identifies failures where retrying after the provider recovers may help; it does not bypass cache backoff or guarantee success. +Reasons and messages are fixed and never contain token data, claims, key IDs, URLs, or provider responses. +Public verification, prefetch, and authorizer operations detach underlying exception causes and contexts. +Log only the fixed diagnostic fields; do not log token dictionaries, request headers, or provider errors. + +Outbound token acquisition, opaque-token introspection, delegated token exchange, interactive grants, SigV4, and native async clients are outside this PR. + +## Testing your code + +Use `mock_claims` to test route behavior without cryptography or network calls. Supply an Authorization header so the middleware still exercises credential extraction. + +```python +from aws_lambda_powertools.utilities.auth.testing import mock_claims + +from middleware import app, verifier + + +def test_orders(http_api_event, lambda_context): + http_api_event["headers"]["authorization"] = "Bearer application-test" + with mock_claims(verifier, {"sub": "test-user", "scope": "orders:read"}): + response = app.resolve(http_api_event, lambda_context) + assert response["statusCode"] == 200 +``` + +The helper restores `verify()` on exit and returns independent copies of the supplied claims. +It deliberately bypasses signature and claim validation. Keep separate tests for real verification, key rotation, and authorization policy. diff --git a/examples/auth/src/authorizer/authorizer.py b/examples/auth/src/authorizer/authorizer.py new file mode 100644 index 00000000000..f6575f16d97 --- /dev/null +++ b/examples/auth/src/authorizer/authorizer.py @@ -0,0 +1,35 @@ +import os + +from aws_lambda_powertools import Logger +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import AuthError +from aws_lambda_powertools.utilities.typing import LambdaContext + +logger = Logger() +verifier = JWTVerifier( + issuer=os.environ["ISSUER_URL"], + audience=os.environ["RESOURCE_URL"], + algorithms=["RS256"], + required_claims=["sub"], + # Adapt this constraint to the access-token profile issued by your provider. + expected_claims={"token_use": "access"}, +) + + +def record_failure(error: AuthError) -> None: + # Opt-in application logging; never log the event, token, or claims. + logger.warning("Authorization failed", reason=error.reason.value, retryable=error.retryable) + + +def iam_handler(event: dict, context: LambdaContext): + return verifier.authorize( + event, + scopes=["orders:read"], + response_format="iam", + context_claims=["sub"], + on_error=record_failure, + ) + + +def simple_handler(event: dict, context: LambdaContext): + return verifier.authorize(event, scopes=["orders:read"], response_format="simple", on_error=record_failure) diff --git a/examples/auth/src/authorizer/requirements.txt b/examples/auth/src/authorizer/requirements.txt new file mode 100644 index 00000000000..5f017438d3d --- /dev/null +++ b/examples/auth/src/authorizer/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools[auth] diff --git a/examples/auth/src/backend/backend.py b/examples/auth/src/backend/backend.py new file mode 100644 index 00000000000..881b36e9c3d --- /dev/null +++ b/examples/auth/src/backend/backend.py @@ -0,0 +1,6 @@ +from aws_lambda_powertools.utilities.typing import LambdaContext + + +def lambda_handler(event: dict, context: LambdaContext): + # API Gateway invokes this function only after the authorizer allows it. + return {"statusCode": 200, "body": '{"orders":[]}', "headers": {"Content-Type": "application/json"}} diff --git a/examples/auth/src/backend/requirements.txt b/examples/auth/src/backend/requirements.txt new file mode 100644 index 00000000000..56fd45918ce --- /dev/null +++ b/examples/auth/src/backend/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools diff --git a/examples/auth/src/middleware.py b/examples/auth/src/middleware.py new file mode 100644 index 00000000000..57b8b9f7eae --- /dev/null +++ b/examples/auth/src/middleware.py @@ -0,0 +1,24 @@ +import os + +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = APIGatewayHttpResolver() +verifier = JWTVerifier( + issuer=os.environ["ISSUER_URL"], + audience=os.environ["RESOURCE_URL"], + algorithms=["RS256"], + required_claims=["sub"], + # Adapt this constraint to your provider's access-token profile. + expected_claims={"token_use": "access"}, +) + + +@app.get("/orders", middlewares=[verifier.require(scopes=["orders:read"])]) +def list_orders(): + return {"subject": app.context["claims"]["sub"], "orders": []} + + +def lambda_handler(event: dict, context: LambdaContext): + return app.resolve(event, context) diff --git a/examples/auth/templates/sam.yaml b/examples/auth/templates/sam.yaml new file mode 100644 index 00000000000..bc41dc7422f --- /dev/null +++ b/examples/auth/templates/sam.yaml @@ -0,0 +1,91 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: JWT authorizers for REST and HTTP APIs with result caching disabled + +Parameters: + IssuerUrl: + Type: String + Description: HTTPS issuer issuing RS256 access tokens + ResourceUrl: + Type: String + Description: Expected access token audience + +Globals: + Function: + Runtime: python3.12 + Timeout: 10 + MemorySize: 256 + Environment: + Variables: + ISSUER_URL: !Ref IssuerUrl + RESOURCE_URL: !Ref ResourceUrl + +Resources: + RestAuthorizer: + Type: AWS::Serverless::Function + Properties: + Handler: authorizer.iam_handler + CodeUri: ../src/authorizer/ + + HttpAuthorizer: + Type: AWS::Serverless::Function + Properties: + Handler: authorizer.simple_handler + CodeUri: ../src/authorizer/ + + RestApi: + Type: AWS::Serverless::Api + Properties: + StageName: prod + Auth: + DefaultAuthorizer: JwtAuthorizer + Authorizers: + JwtAuthorizer: + FunctionArn: !GetAtt RestAuthorizer.Arn + FunctionPayloadType: REQUEST + Identity: + Headers: + - Authorization + ReauthorizeEvery: 0 + + HttpApi: + Type: AWS::Serverless::HttpApi + Properties: + Auth: + DefaultAuthorizer: JwtAuthorizer + Authorizers: + JwtAuthorizer: + FunctionArn: !GetAtt HttpAuthorizer.Arn + AuthorizerPayloadFormatVersion: "2.0" + EnableSimpleResponses: true + EnableFunctionDefaultPermissions: true + Identity: + Headers: + - Authorization + ReauthorizeEvery: 0 + + RestBackend: + Type: AWS::Serverless::Function + Properties: + Handler: backend.lambda_handler + CodeUri: ../src/backend/ + Events: + Orders: + Type: Api + Properties: + RestApiId: !Ref RestApi + Path: /orders + Method: GET + + HttpBackend: + Type: AWS::Serverless::Function + Properties: + Handler: backend.lambda_handler + CodeUri: ../src/backend/ + Events: + Orders: + Type: HttpApi + Properties: + ApiId: !Ref HttpApi + Path: /orders + Method: GET diff --git a/layer_v3/docker/Dockerfile b/layer_v3/docker/Dockerfile index a72feb5e2c8..2695fb34a5b 100644 --- a/layer_v3/docker/Dockerfile +++ b/layer_v3/docker/Dockerfile @@ -35,8 +35,8 @@ RUN CFLAGS="-Os -g0 -s" pip install -t /asset/python "aws-lambda-powertools${PAC RUN cd /asset/python && \ # remove boto3 and botocore (already available in Lambda Runtime) rm -rf boto* && \ - # remove boto3 dependencies - rm -rf s3transfer* *dateutil* urllib3* six* jmespath* && \ + # retain urllib3: Auth requires its declared version, independently of the runtime SDK + rm -rf s3transfer* *dateutil* six* jmespath* && \ # remove debugging symbols find . -name '*.so' -type f -exec strip "{}" \; && \ # remove tests diff --git a/mkdocs.yml b/mkdocs.yml index 265560a55d5..87cee2b4724 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,7 @@ nav: - core/event_handler/appsync_events.md - core/event_handler/bedrock_agents.md - utilities/parameters.md + - utilities/auth.md - utilities/batch.md - utilities/kafka.md - utilities/typing.md @@ -85,6 +86,7 @@ nav: # - Casual to regular contributor: contributing/tracks/casual_regular_contributor.md # - Customer to advocate: contributing/tracks/customer_advocate.md - API Documentation: + - Auth: api_doc/auth.md - Batch Processing: - Base: api_doc/batch/base.md - Decorators: api_doc/batch/decorators.md @@ -247,6 +249,7 @@ plugins: - core/event_handler/appsync_events.md - core/event_handler/bedrock_agents.md Utilities: + - utilities/auth.md - utilities/parameters.md - utilities/batch.md - utilities/typing.md diff --git a/noxfile.py b/noxfile.py index 9a648cf37fb..cfb7e8849b0 100644 --- a/noxfile.py +++ b/noxfile.py @@ -225,3 +225,13 @@ def test_with_protobuf_required_package(session: nox.Session): ], extras="kafka-consumer-protobuf", ) + + +@nox.session() +def test_with_auth_required_packages(session: nox.Session): + """Verify the Auth utility using only its declared optional dependencies.""" + build_and_run_test( + session, + folders=[f"{PREFIX_TESTS_FUNCTIONAL}/auth/"], + extras="auth", + ) diff --git a/poetry.lock b/poetry.lock index 32b15f749b5..6423f9a7e77 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -11,7 +11,7 @@ files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] -markers = {main = "extra == \"all\" or extra == \"parser\""} +markers = {main = "extra == \"parser\" or extra == \"all\""} [[package]] name = "anyio" @@ -390,7 +390,7 @@ description = "The AWS X-Ray SDK for Python (the SDK) enables Python developers optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"all\" or extra == \"tracer\"" +markers = "extra == \"tracer\" or extra == \"all\"" files = [ {file = "aws_xray_sdk-2.15.0-py2.py3-none-any.whl", hash = "sha256:422d62ad7d52e373eebb90b642eb1bb24657afe03b22a8df4a8b2e5108e278a3"}, {file = "aws_xray_sdk-2.15.0.tar.gz", hash = "sha256:794381b96e835314345068ae1dd3b9120bd8b4e21295066c37e8814dbb341365"}, @@ -507,7 +507,7 @@ files = [ {file = "boto3-1.42.67-py3-none-any.whl", hash = "sha256:aa900216bdc48bbd0115ed7128a4baed5548c6a60673160a38df8a8566df57cd"}, {file = "boto3-1.42.67.tar.gz", hash = "sha256:d4123ceb3be36c5cb7ddccc7a7c43701e1fb6af612ef46e3b5d667daf5447d4b"}, ] -markers = {main = "extra == \"all\" or extra == \"datamasking\" or extra == \"aws-sdk\""} +markers = {main = "extra == \"aws-sdk\" or extra == \"all\" or extra == \"datamasking\""} [package.dependencies] botocore = ">=1.42.67,<1.43.0" @@ -992,7 +992,7 @@ files = [ {file = "botocore-1.42.67-py3-none-any.whl", hash = "sha256:a94317d2ce83deae230964beb2729639455de65595d0154f285b0ccfd29780cd"}, {file = "botocore-1.42.67.tar.gz", hash = "sha256:ee307f30fcb798d244fb35a87847b274e1e1f72cd5f7f2e31bd1826df0c45295"}, ] -markers = {main = "extra == \"all\" or extra == \"datamasking\" or extra == \"aws-sdk\" or extra == \"tracer\""} +markers = {main = "extra == \"tracer\" or extra == \"all\" or extra == \"aws-sdk\" or extra == \"datamasking\""} [package.dependencies] jmespath = ">=0.7.1,<2.0.0" @@ -1204,7 +1204,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] -markers = {main = "(extra == \"all\" or extra == \"datamasking\" or extra == \"valkey\") and (platform_python_implementation != \"PyPy\" or extra == \"valkey\")", dev = "platform_python_implementation != \"PyPy\""} +markers = {main = "(extra == \"all\" or extra == \"datamasking\" or extra == \"auth\") and platform_python_implementation != \"PyPy\" or extra == \"valkey\"", dev = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -1561,60 +1561,60 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "50.0.0" +version = "50.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.9" groups = ["main", "dev"] files = [ - {file = "cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169"}, - {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f"}, - {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105"}, - {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef"}, - {file = "cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30"}, - {file = "cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d"}, - {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c"}, - {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c"}, - {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95"}, - {file = "cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269"}, - {file = "cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708"}, - {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47"}, - {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9"}, - {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7"}, - {file = "cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437"}, - {file = "cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9"}, - {file = "cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9"}, -] -markers = {main = "extra == \"all\" or extra == \"datamasking\""} + {file = "cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a"}, + {file = "cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959"}, + {file = "cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b"}, + {file = "cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648"}, + {file = "cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3"}, + {file = "cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6"}, + {file = "cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149"}, + {file = "cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf"}, + {file = "cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239"}, + {file = "cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558"}, + {file = "cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e"}, + {file = "cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b"}, + {file = "cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20"}, +] +markers = {main = "extra == \"all\" or extra == \"datamasking\" or extra == \"auth\""} [package.dependencies] cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} @@ -1917,7 +1917,7 @@ description = "Fastest Python implementation of JSON schema" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"validation\"" +markers = "extra == \"validation\" or extra == \"all\"" files = [ {file = "fastjsonschema-2.22.1-py3-none-any.whl", hash = "sha256:cf377ff5c9a6f4f3125fb35f75a2c5767bd824ffbcf62c209a93cd48d1453999"}, {file = "fastjsonschema-2.22.1.tar.gz", hash = "sha256:0b83d1ce8d7845b959dcb20e1a5c3c8883b6541d9c52ab02cce5166b75ec805f"}, @@ -3499,7 +3499,7 @@ files = [ {file = "protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9"}, {file = "protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a"}, ] -markers = {main = "extra == \"kafka-consumer-protobuf\" or extra == \"valkey\""} +markers = {main = "extra == \"valkey\" or extra == \"kafka-consumer-protobuf\""} [[package]] name = "publication" @@ -3536,7 +3536,7 @@ files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] -markers = {main = "(extra == \"all\" or extra == \"datamasking\" or extra == \"valkey\") and (platform_python_implementation != \"PyPy\" or extra == \"valkey\") and implementation_name != \"PyPy\"", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""} +markers = {main = "((extra == \"all\" or extra == \"datamasking\" or extra == \"auth\") and platform_python_implementation != \"PyPy\" or extra == \"valkey\") and implementation_name != \"PyPy\"", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""} [[package]] name = "pydantic" @@ -3549,7 +3549,7 @@ files = [ {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, ] -markers = {main = "extra == \"all\" or extra == \"parser\""} +markers = {main = "extra == \"parser\" or extra == \"all\""} [package.dependencies] annotated-types = ">=0.6.0" @@ -3690,7 +3690,7 @@ files = [ {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, ] -markers = {main = "extra == \"all\" or extra == \"parser\""} +markers = {main = "extra == \"parser\" or extra == \"all\""} [package.dependencies] typing-extensions = ">=4.14.1" @@ -3735,6 +3735,25 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.14.0" +description = "JSON Web Token implementation in Python" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"auth\" or extra == \"all\"" +files = [ + {file = "pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc"}, + {file = "pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86"}, +] + +[package.dependencies] +typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] + [[package]] name = "pymdown-extensions" version = "11.0.1" @@ -3905,7 +3924,7 @@ files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] -markers = {main = "extra == \"all\" or extra == \"datamasking\" or extra == \"aws-sdk\" or extra == \"tracer\""} +markers = {main = "extra == \"tracer\" or extra == \"all\" or extra == \"aws-sdk\" or extra == \"datamasking\""} [package.dependencies] six = ">=1.5" @@ -4479,7 +4498,7 @@ files = [ {file = "s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe"}, {file = "s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920"}, ] -markers = {main = "extra == \"all\" or extra == \"datamasking\" or extra == \"aws-sdk\""} +markers = {main = "extra == \"aws-sdk\" or extra == \"all\" or extra == \"datamasking\""} [package.dependencies] botocore = ">=1.37.4,<2.0a0" @@ -4578,7 +4597,7 @@ files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] -markers = {main = "extra == \"all\" or extra == \"datamasking\" or extra == \"aws-sdk\" or extra == \"tracer\""} +markers = {main = "extra == \"tracer\" or extra == \"all\" or extra == \"aws-sdk\" or extra == \"datamasking\""} [[package]] name = "smmap" @@ -4986,7 +5005,7 @@ files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] -markers = {main = "extra == \"all\" or extra == \"parser\""} +markers = {main = "extra == \"parser\" or extra == \"all\""} [package.dependencies] typing-extensions = ">=4.12.0" @@ -5097,16 +5116,16 @@ files = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, - {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, + {file = "urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3"}, + {file = "urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63"}, ] -markers = {main = "extra == \"all\" or extra == \"datamasking\" or extra == \"aws-sdk\" or extra == \"tracer\" or extra == \"datadog\""} +markers = {main = "extra == \"tracer\" or extra == \"all\" or extra == \"aws-sdk\" or extra == \"datamasking\" or extra == \"datadog\" or extra == \"auth\""} [package.extras] brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] @@ -5313,7 +5332,7 @@ files = [ {file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"}, {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"}, ] -markers = {main = "extra == \"all\" or extra == \"datamasking\" or extra == \"tracer\" or extra == \"datadog\""} +markers = {main = "extra == \"tracer\" or extra == \"all\" or extra == \"datamasking\" or extra == \"datadog\""} [[package]] name = "xenon" @@ -5354,7 +5373,8 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it type = ["pytest-mypy"] [extras] -all = ["aws-encryption-sdk", "aws-xray-sdk", "fastjsonschema", "jsonpath-ng", "pydantic", "pydantic-settings"] +all = ["aws-encryption-sdk", "aws-xray-sdk", "cryptography", "fastjsonschema", "jsonpath-ng", "pydantic", "pydantic-settings", "pyjwt", "urllib3"] +auth = ["cryptography", "pyjwt", "urllib3"] aws-sdk = ["boto3"] datadog = ["datadog-lambda"] datamasking = ["aws-encryption-sdk", "jsonpath-ng"] @@ -5369,4 +5389,4 @@ valkey = ["valkey-glide"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0.0" -content-hash = "a1cb841a8e4f46c26475db828a295a8e05f59c6cd174a0a43b6605275ca5e424" +content-hash = "d1be888618d485c538c744c46441d09297f7dab01353fb5b1054d1c46d1bb523" diff --git a/pyproject.toml b/pyproject.toml index ae52f2ddd13..719b4eaabf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,9 @@ jsonpath-ng = { version = "^1.6.0", optional = true } datadog-lambda = { version = ">=8.114.0,<9.0.0", optional = true } avro = { version = "^1.12.0", optional = true } protobuf = {version = ">=6.30.2,<8.0.0", optional = true } +pyjwt = { version = "^2.14.0", optional = true } +cryptography = { version = "^50.0.1", optional = true } +urllib3 = { version = "^2.8.0", optional = true } [tool.poetry.extras] parser = ["pydantic"] @@ -64,13 +67,17 @@ validation = ["fastjsonschema"] tracer = ["aws-xray-sdk"] redis = ["redis"] valkey = ["valkey-glide"] +auth = ["pyjwt", "cryptography", "urllib3"] all = [ "pydantic", "pydantic-settings", "aws-xray-sdk", "fastjsonschema", "aws-encryption-sdk", - "jsonpath-ng" + "jsonpath-ng", + "pyjwt", + "cryptography", + "urllib3" ] # allow customers to run code locally without emulators (SAM CLI, etc.) aws-sdk = ["boto3"] @@ -80,7 +87,7 @@ kafka-consumer-avro = ["avro"] kafka-consumer-protobuf = ["protobuf"] [tool.poetry.group.dev.dependencies] -coverage = { extras = ["toml"], version = "^7.6" } +coverage = { extras = ["toml"], version = "^7.10.6" } pytest = ">=8.3.4,<10.0.0" boto3 = "^1.26.164" isort = ">=5.13.2,<10.0.0" @@ -141,6 +148,8 @@ omit = [ "aws_lambda_powertools/metrics/metric.py" # barrel import (export-only) ] branch = true +# pytest-cov 7 delegates subprocess measurement to coverage.py. +patch = ["subprocess"] [tool.coverage.html] directory = "test_report" diff --git a/tests/e2e/utils/lambda_layer/powertools_layer.py b/tests/e2e/utils/lambda_layer/powertools_layer.py index 4fadd94ea74..dd2026a04cc 100644 --- a/tests/e2e/utils/lambda_layer/powertools_layer.py +++ b/tests/e2e/utils/lambda_layer/powertools_layer.py @@ -30,7 +30,8 @@ def __init__(self, output_dir: Path = CDK_OUT_PATH, architecture: Architecture = self.build_command = f"python -m pip install {self.package} {self.build_args} --target {self.target_dir}" self.cleanup_command = ( f"rm -rf {self.target_dir}/boto* {self.target_dir}/s3transfer* && " - f"rm -rf {self.target_dir}/*dateutil* {self.target_dir}/urllib3* {self.target_dir}/six* && " + # Auth's declared urllib3 dependency must survive Layer cleanup. + f"rm -rf {self.target_dir}/*dateutil* {self.target_dir}/six* && " f"rm -rf {self.target_dir}/jmespath* && " f"find {self.target_dir} -name '*.so' -type f -exec strip '{{}}' \\; && " f"find {self.target_dir} -wholename '*/tests/*' -type f -delete && " diff --git a/tests/functional/auth/__init__.py b/tests/functional/auth/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/functional/auth/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/functional/auth/_auth_import_probe.py b/tests/functional/auth/_auth_import_probe.py new file mode 100644 index 00000000000..4a4c5a92c1d --- /dev/null +++ b/tests/functional/auth/_auth_import_probe.py @@ -0,0 +1,77 @@ +"""Exercise public Auth imports without dependencies preloaded by pytest.""" + +import importlib +import importlib.abc +import inspect +import json +import sys + + +class BlockImports(importlib.abc.MetaPathFinder): + def __init__(self, *names): + self.names = names + + def find_spec(self, fullname, path=None, target=None): + if fullname.split(".")[0] in self.names: + raise ImportError(f"Unexpected optional dependency: {fullname}") + + +scenario = sys.argv[1] + +if scenario == "static": + sys.meta_path.insert(0, BlockImports("urllib3")) + + from aws_lambda_powertools.utilities.auth import JWTVerifier + from aws_lambda_powertools.utilities.auth.exceptions import InvalidSignatureError + + fixture = json.load(sys.stdin) + verifier = JWTVerifier( + issuer=fixture["issuer"], + audience=fixture["audience"], + algorithms=["RS256"], + jwks=fixture["jwks"], + ) + assert verifier.verify(fixture["token"])["sub"] == fixture["subject"] + + signed, signature = fixture["token"].rsplit(".", 1) + invalid_signature = ("A" if signature[0] != "A" else "B") + signature[1:] + try: + verifier.verify(f"{signed}.{invalid_signature}") + except InvalidSignatureError: + pass + else: + raise AssertionError("Invalid signature was accepted") + assert "urllib3" not in sys.modules +elif scenario == "remote": + from aws_lambda_powertools.utilities.auth import JWTVerifier + + assert "urllib3" not in sys.modules + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + ) + assert "urllib3" in sys.modules +elif scenario == "exports": + auth = importlib.import_module("aws_lambda_powertools.utilities.auth") + + assert {"JWTVerifier", "AuthFailureReason", "AuthErrorContext"} <= set(dir(auth)) + assert not {"jwt", "cryptography", "urllib3"} & sys.modules.keys() + try: + _ = auth.unknown_attribute + except AttributeError: + pass + else: + raise AssertionError("An unknown attribute did not raise AttributeError") + assert not {"jwt", "cryptography", "urllib3"} & sys.modules.keys() + + members = dict(inspect.getmembers(auth)) + assert members["JWTVerifier"] is auth.JWTVerifier + assert members["AuthFailureReason"] is auth.AuthFailureReason + assert members["AuthErrorContext"] is auth.AuthErrorContext +elif scenario == "star": + from aws_lambda_powertools.utilities.auth import * # noqa: E402,F403 + + assert {"JWTVerifier", "AuthFailureReason", "AuthErrorContext"} <= globals().keys() +else: + raise ValueError(f"Unknown scenario: {scenario}") diff --git a/tests/functional/auth/conftest.py b/tests/functional/auth/conftest.py new file mode 100644 index 00000000000..e529a405914 --- /dev/null +++ b/tests/functional/auth/conftest.py @@ -0,0 +1,100 @@ +import io +import json +import time +import weakref +from collections import deque + +import jwt +import pytest +import urllib3 +from cryptography.hazmat.primitives.asymmetric import rsa + +from aws_lambda_powertools.utilities.auth import _jwks + + +@pytest.fixture(scope="session") +def signing_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture +def jwks(signing_key): + key = jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key(), as_dict=True) + return {"keys": [{**key, "kid": "key-1", "use": "sig", "alg": "RS256"}]} + + +@pytest.fixture +def claims(): + return { + "iss": "https://idp.example.com/", + "aud": "https://api.example.com", + "exp": int(time.time()) + 600, + "sub": "user-123", + "scope": "orders:read", + } + + +@pytest.fixture +def issue_token(signing_key, claims): + def issue(payload=None, *, key=None, kid="key-1", algorithm="RS256", headers=None): + return jwt.encode( + claims if payload is None else payload, + signing_key if key is None else key, + algorithm=algorithm, + headers={"kid": kid, **(headers or {})}, + ) + + return issue + + +class FakeHTTP: + """In-memory JWKS endpoints at the HTTP transport boundary.""" + + def __init__(self): + self.responses = {} + self.requests = [] + + def serve(self, url, body, *, status=200, method="GET"): + self.responses[(method, url)] = deque([(status, body)]) + + def request(self, method, url, **kwargs): + self.requests.append((method, url, kwargs)) + responses = self.responses[(method, url)] + status, body = responses[0] if len(responses) == 1 else responses.popleft() + if callable(body): + body = body() + if isinstance(body, Exception): + raise body + payload = body if isinstance(body, bytes) else json.dumps(body).encode() + return urllib3.HTTPResponse( + body=io.BytesIO(payload), + headers={"content-type": "application/json"}, + status=status, + preload_content=False, + ) + + +@pytest.fixture +def http(monkeypatch): + # Each fake provider belongs to one test. Error tracebacks can keep a + # previous verifier alive; retain sharing only within the current test. + monkeypatch.setattr(_jwks, "_caches", weakref.WeakValueDictionary()) + transport = FakeHTTP() + monkeypatch.setattr(urllib3, "PoolManager", lambda **kwargs: transport) + return transport + + +@pytest.fixture +def clock(monkeypatch): + class Clock: + now = 1000.0 + + def __call__(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + clock = Clock() + monkeypatch.setattr(time, "monotonic", clock) + return clock diff --git a/tests/functional/auth/test_authorizer.py b/tests/functional/auth/test_authorizer.py new file mode 100644 index 00000000000..f192baf427e --- /dev/null +++ b/tests/functional/auth/test_authorizer.py @@ -0,0 +1,224 @@ +import copy + +import pytest + +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import JWKSFetchError +from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import ( + APIGatewayAuthorizerEventV2, + APIGatewayAuthorizerRequestEvent, + APIGatewayAuthorizerTokenEvent, +) +from tests.functional.utils import load_event + +ARN = "arn:aws:execute-api:us-east-1:123456789012:api123/prod/GET/orders/123" + + +@pytest.fixture(params=["token", "rest-request", "http-v1", "http-v2"]) +def authorizer_event(request, issue_token): + if request.param == "token": + return APIGatewayAuthorizerTokenEvent( + {"type": "TOKEN", "methodArn": ARN, "authorizationToken": "Bearer " + issue_token()}, + ) + event = {"type": "REQUEST", "headers": {"Authorization": "Bearer " + issue_token()}} + if request.param == "http-v2": + return APIGatewayAuthorizerEventV2({**event, "version": "2.0", "routeArn": ARN}) + if request.param == "http-v1": + event["version"] = "1.0" + return APIGatewayAuthorizerRequestEvent({**event, "methodArn": ARN}) + + +def verifier(jwks): + return JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + + +def test_iam_authorizer_allows_only_the_requested_arn(authorizer_event, jwks): + response = verifier(jwks).authorize(authorizer_event, scopes=["orders:read"], context_claims=["sub"]) + + assert response == { + "principalId": "user-123", + "policyDocument": { + "Version": "2012-10-17", + "Statement": [{"Action": "execute-api:Invoke", "Effect": "Allow", "Resource": [ARN]}], + }, + "context": {"sub": "user-123"}, + } + + +def test_iam_authorizer_denies_missing_scopes_without_forwarding_claims(authorizer_event, jwks): + response = verifier(jwks).authorize(authorizer_event, scopes=["orders:write"], context_claims=["sub"]) + + assert response["policyDocument"]["Statement"] == [ + {"Action": "execute-api:Invoke", "Effect": "Deny", "Resource": [ARN]}, + ] + assert "context" not in response + + +def test_iam_authorizer_requires_a_nonempty_subject(jwks, claims, issue_token): + claims.pop("sub") + event = {"type": "TOKEN", "methodArn": ARN, "authorizationToken": "Bearer " + issue_token(claims)} + + assert verifier(jwks).authorize(event)["policyDocument"]["Statement"][0]["Effect"] == "Deny" + + +@pytest.mark.parametrize("authorization", [None, "Basic secret", "Bearer invalid"]) +def test_iam_authorizer_denies_invalid_tokens(jwks, authorization): + event = {"type": "TOKEN", "methodArn": ARN, "authorizationToken": authorization} + + assert verifier(jwks).authorize(event)["policyDocument"]["Statement"][0]["Effect"] == "Deny" + + +def test_simple_authorizer_uses_boolean_response(jwks, issue_token): + event = { + "type": "REQUEST", + "version": "2.0", + "routeArn": ARN, + "headers": {"authorization": "Bearer " + issue_token()}, + } + + assert verifier(jwks).authorize(event, response_format="simple", context_claims=["sub"]) == { + "isAuthorized": True, + "context": {"sub": "user-123"}, + } + assert verifier(jwks).authorize(event, response_format="simple", scopes=["admin"]) == {"isAuthorized": False} + + +def test_simple_responses_require_payload_version_two(jwks, issue_token): + event = { + "type": "REQUEST", + "version": "1.0", + "methodArn": ARN, + "headers": {"authorization": "Bearer " + issue_token()}, + } + + with pytest.raises(ValueError): + verifier(jwks).authorize(event, response_format="simple") + + +def test_context_is_opt_in_and_copies_only_selected_scalar_claims(jwks, claims, issue_token): + claims.update(roles=["admin"], profile={"private": "data"}, enabled=True, limit=3, ratio=0.5) + event = {"type": "TOKEN", "methodArn": ARN, "authorizationToken": "Bearer " + issue_token(claims)} + subject = verifier(jwks) + + assert "context" not in subject.authorize(event) + assert subject.authorize(event, context_claims=["sub", "roles", "profile", "enabled", "limit", "ratio", "missing"])[ + "context" + ] == {"sub": "user-123", "enabled": True, "limit": 3, "ratio": 0.5} + + +def test_preserves_partition_and_encoded_resource_paths(jwks, issue_token): + arn = "arn:aws-cn:execute-api:cn-north-1:123456789012:api123/$default/GET/orders/a%20b:detail" + event = {"type": "TOKEN", "methodArn": arn, "authorizationToken": "Bearer " + issue_token()} + + assert verifier(jwks).authorize(event)["policyDocument"]["Statement"][0]["Resource"] == [arn] + + +def test_authorizer_does_not_convert_unavailable_keys_into_an_allow(http, issue_token): + http.serve("https://idp.example.com/keys", {}, status=503) + subject = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks_uri="https://idp.example.com/keys", + ) + event = {"type": "TOKEN", "methodArn": ARN, "authorizationToken": "Bearer " + issue_token()} + + with pytest.raises(JWKSFetchError): + subject.authorize(event) + + +@pytest.mark.parametrize("wrapped", [False, True]) +@pytest.mark.parametrize( + "fixture,wrapper", + [ + ("apiGatewayAuthorizerTokenEvent.json", APIGatewayAuthorizerTokenEvent), + ("apiGatewayAuthorizerRequestEvent.json", APIGatewayAuthorizerRequestEvent), + ("apiGatewayAuthorizerV2Event.json", APIGatewayAuthorizerEventV2), + ], +) +def test_gateway_event_fixtures_produce_exact_allow_and_deny_policies(jwks, issue_token, wrapped, fixture, wrapper): + event = copy.deepcopy(load_event(fixture)) + is_token = event["type"] == "TOKEN" + if is_token: + # The existing TOKEN fixture uses a policy wildcard. Incoming requests + # need a concrete stage for this helper's request-specific policy. + event["methodArn"] = event["methodArn"].replace("/*/", "/test/") + event["authorizationToken"] = "Bearer " + issue_token() + else: + event["headers"]["Authorization"] = "Bearer " + issue_token() + arn = event.get("routeArn", event.get("methodArn")) + subject = verifier(jwks) + + response = subject.authorize(wrapper(event) if wrapped else event, context_claims=["sub"]) + assert response["policyDocument"]["Statement"] == [ + {"Action": "execute-api:Invoke", "Effect": "Allow", "Resource": [arn]}, + ] + assert response["context"] == {"sub": "user-123"} + + if is_token: + event["authorizationToken"] = "Bearer invalid" + else: + event["headers"]["Authorization"] = "Bearer invalid" + response = subject.authorize(wrapper(event) if wrapped else event, context_claims=["sub"]) + assert response["policyDocument"]["Statement"] == [ + {"Action": "execute-api:Invoke", "Effect": "Deny", "Resource": [arn]}, + ] + assert "context" not in response + + +@pytest.mark.parametrize("route_key", ["GET /merchants", "$default"]) +def test_http_v2_fixture_supports_simple_responses_and_keeps_route_arn(jwks, issue_token, route_key): + event = copy.deepcopy(load_event("apiGatewayAuthorizerV2Event.json")) + event["routeKey"] = event["requestContext"]["routeKey"] = route_key + event["headers"]["Authorization"] = "Bearer " + issue_token() + subject = verifier(jwks) + assert subject.authorize(event, response_format="simple") == {"isAuthorized": True} + assert subject.authorize(event)["policyDocument"]["Statement"][0]["Resource"] == [event["routeArn"]] + event["headers"].pop("Authorization") + assert subject.authorize(event, response_format="simple") == {"isAuthorized": False} + + +@pytest.mark.parametrize("arn", [None, "", "not-an-arn", ARN.replace("/prod/", "/*/"), ARN + "?"]) +def test_invalid_request_arns_raise_instead_of_returning_an_invalid_policy(jwks, issue_token, arn): + event = copy.deepcopy(load_event("apiGatewayAuthorizerTokenEvent.json")) + event["authorizationToken"] = "Bearer " + issue_token() + event["methodArn"] = arn + with pytest.raises(ValueError, match="concrete API Gateway"): + verifier(jwks).authorize(event) + + +@pytest.mark.parametrize("malformed", [False, True]) +@pytest.mark.parametrize("field", ["headers", "multiValueHeaders"]) +def test_authorizer_denies_malformed_or_ambiguous_header_maps(jwks, issue_token, malformed, field): + token = "Bearer " + issue_token() + value = [token] if field == "multiValueHeaders" else token + headers = [("Authorization", value)] if malformed else {"Authorization": value, "authorization": value} + event = {"type": "REQUEST", "methodArn": ARN, field: headers} + response = verifier(jwks).authorize(event) + assert response["principalId"] == "unauthorized" + assert response["policyDocument"]["Statement"][0]["Effect"] == "Deny" + assert "context" not in response + + +@pytest.mark.parametrize("event", [None, [], {}, {"type": "OTHER"}]) +def test_authorizer_rejects_unsupported_events(jwks, event): + with pytest.raises(ValueError, match="TOKEN or REQUEST"): + verifier(jwks).authorize(event) + + +@pytest.mark.parametrize( + "options,message", + [ + ({"response_format": "unsupported"}, "response_format"), + ({"context_claims": ["claims"]}, "claims is reserved"), + ], +) +def test_authorizer_rejects_invalid_response_configuration(jwks, issue_token, options, message): + event = {"type": "TOKEN", "methodArn": ARN, "authorizationToken": "Bearer " + issue_token()} + with pytest.raises(ValueError, match=message): + verifier(jwks).authorize(event, **options) diff --git a/tests/functional/auth/test_errors.py b/tests/functional/auth/test_errors.py new file mode 100644 index 00000000000..ec451a8fb64 --- /dev/null +++ b/tests/functional/auth/test_errors.py @@ -0,0 +1,80 @@ +import io +import json +import traceback +from functools import partial +from uuid import uuid4 + +import pytest +import urllib3 + +from aws_lambda_powertools import Logger +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import ( + InvalidClaimsError, + InvalidSignatureError, + InvalidTokenError, + JWKSFetchError, +) + +ISSUER = "https://idp.example.com/" +RESOURCE_URL = "https://api.example.com" +PRIVATE_DATA = "test-only-sensitive-provider-data" + + +def assert_sanitized(operation, expected_error): + stream = io.StringIO() + logger = Logger(service=f"auth-error-test-{uuid4()}", stream=stream) + try: + operation() + except expected_error as error: + logger.exception("Auth failed") + assert error.__context__ is None + assert error.__cause__ is None + assert PRIVATE_DATA not in str(error) + assert PRIVATE_DATA not in repr(error) + assert PRIVATE_DATA not in "".join(traceback.format_exception(type(error), error, error.__traceback__)) + else: + pytest.fail("Expected a sanitized Auth error") + log = json.loads(stream.getvalue()) + assert log["exception_name"] == expected_error.__name__ + assert PRIVATE_DATA not in stream.getvalue() + + +@pytest.mark.parametrize("method", ["verify", "prefetch", "group_verify", "group_prefetch", "authorize"]) +@pytest.mark.parametrize("failure", ["transport", "json"]) +def test_remote_key_failures_detach_provider_exceptions(http, issue_token, method, failure): + keys_url = ISSUER + f"keys/{uuid4()}" + response = urllib3.exceptions.SSLError(PRIVATE_DATA) if failure == "transport" else PRIVATE_DATA.encode() + http.serve(keys_url, response) + verifier = JWTVerifier(issuer=ISSUER, audience=RESOURCE_URL, algorithms=["RS256"], jwks_uri=keys_url) + subject = JWTVerifier.any_of(verifier) if method.startswith("group_") else verifier + token = issue_token() + event = { + "type": "TOKEN", + "methodArn": "arn:aws:execute-api:us-east-1:123456789012:api123/prod/GET/orders", + "authorizationToken": "Bearer " + token, + } + if method.endswith("prefetch"): + operation = subject.prefetch + elif method == "authorize": + operation = partial(subject.authorize, event) + else: + operation = partial(subject.verify, token) + assert_sanitized(operation, JWKSFetchError) + + +@pytest.mark.parametrize("group", [False, True]) +@pytest.mark.parametrize("failure", ["header", "claims", "signature"]) +def test_verification_errors_detach_parser_and_crypto_exceptions(jwks, issue_token, claims, group, failure): + subject = JWTVerifier(issuer=ISSUER, audience=RESOURCE_URL, algorithms=["RS256"], jwks=jwks) + if group: + subject = JWTVerifier.any_of(subject) + if failure == "header": + token, expected_error = PRIVATE_DATA, InvalidTokenError + elif failure == "claims": + claims["aud"] = PRIVATE_DATA + token, expected_error = issue_token(claims), InvalidClaimsError + else: + encoded, _ = issue_token().rsplit(".", 1) + token, expected_error = encoded + ".AAAA", InvalidSignatureError + assert_sanitized(lambda: subject.verify(token), expected_error) diff --git a/tests/functional/auth/test_failure_visibility.py b/tests/functional/auth/test_failure_visibility.py new file mode 100644 index 00000000000..14861af0c25 --- /dev/null +++ b/tests/functional/auth/test_failure_visibility.py @@ -0,0 +1,162 @@ +import copy +import json +import time + +import pytest + +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response +from aws_lambda_powertools.utilities.auth import AuthErrorContext, AuthFailureReason, JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import JWKSFetchError +from tests.functional.utils import load_event + +ARN = "arn:aws:execute-api:us-east-1:123456789012:api123/prod/GET/orders" +REASONS = [ + "missing_token", + "invalid_token", + "invalid_claims", + "token_expired", + "invalid_signature", + "insufficient_scope", + "forbidden", + "jwks_unavailable", +] + + +def failure_case(reason, jwks, claims, issue_token, http): + options = {"jwks": jwks} + scopes = ["admin"] if reason == "insufficient_scope" else [] + if reason == "invalid_claims": + claims["aud"] = "private-incorrect-audience" + elif reason == "token_expired": + claims["exp"] = int(time.time()) - 120 + elif reason == "jwks_unavailable": + url = "https://idp.example.com/keys" + http.serve(url, {"private": "provider-response"}, status=503) + options = {"jwks_uri": url} + token = issue_token(claims) + if reason == "missing_token": + token = None + elif reason == "invalid_token": + token = "private-invalid-token" + elif reason == "invalid_signature": + token = token.rsplit(".", 1)[0] + ".AAAA" + subject = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + **options, + ) + return subject, token, scopes + + +@pytest.mark.parametrize("reason", REASONS) +def test_middleware_reports_safe_reasons_without_exposing_them_in_responses( + jwks, + claims, + issue_token, + http, + reason, + caplog, +): + subject, token, scopes = failure_case(reason, jwks, claims, issue_token, http) + app = APIGatewayHttpResolver() + observations = [] + + def on_error(context: AuthErrorContext): + observations.append(context) + return Response( + status_code=context.status_code, + content_type="application/json", + body={"message": "Denied"}, + headers=context.headers, + ) + + middleware = subject.require( + scopes=scopes, + authorize=lambda claims: reason != "forbidden", + on_error=on_error, + ) + + @app.get("/my/path", middlewares=[middleware]) + def protected(): + pytest.fail("A rejected request must never reach the protected handler") + + event = copy.deepcopy(load_event("apiGatewayProxyV2Event_GET.json")) + event["headers"] = {} if token is None else {"authorization": "Bearer " + token} + response = app.resolve(event, {}) + assert len(observations) == 1 + context = observations[0] + assert context.reason is AuthFailureReason(reason) + assert isinstance(context.reason, str) + assert json.dumps(context.reason) == json.dumps(reason) + assert context.retryable is (reason == "jwks_unavailable") + assert response["statusCode"] == ( + 503 if context.retryable else 403 if reason in ("insufficient_scope", "forbidden") else 401 + ) + assert json.loads(response["body"]) == {"message": "Denied"} + assert "private" not in repr(context) + assert "claims" not in app.context + assert caplog.records == [] + + +@pytest.mark.parametrize("reason", [reason for reason in REASONS if reason != "forbidden"]) +@pytest.mark.parametrize("response_format", ["iam", "simple"]) +def test_authorizer_reports_safe_reasons_and_preserves_denial_or_invocation_failure( + jwks, + claims, + issue_token, + http, + reason, + response_format, + caplog, +): + subject, token, scopes = failure_case(reason, jwks, claims, issue_token, http) + event = {"type": "REQUEST", "version": "2.0", "routeArn": ARN} + event["headers"] = {} if token is None else {"authorization": "Bearer " + token} + observations = [] + + def on_error(error): + assert error.__context__ is None + assert error.__cause__ is None + observations.append((error.reason, error.retryable)) + return {"isAuthorized": True} # A callback cannot turn a failure into an Allow. + + # Sanitization must also hold when the owner is handling another exception. + try: + raise ValueError("private-caller-error") + except ValueError: + if reason == "jwks_unavailable": + with pytest.raises(JWKSFetchError) as error: + subject.authorize(event, scopes=scopes, response_format=response_format, on_error=on_error) + assert error.value.__context__ is None + else: + response = subject.authorize(event, scopes=scopes, response_format=response_format, on_error=on_error) + if response_format == "simple": + assert response["isAuthorized"] is False + else: + assert response["policyDocument"]["Statement"][0]["Effect"] == "Deny" + assert "context" not in response or response["context"] == {} + assert "reason" not in response + assert "retryable" not in response + assert observations == [(AuthFailureReason(reason), reason == "jwks_unavailable")] + assert caplog.records == [] + + +def test_authorizer_does_not_report_success_as_an_error(jwks, claims, issue_token, http): + subject, token, _ = failure_case("valid", jwks, claims, issue_token, http) + event = {"type": "TOKEN", "methodArn": ARN, "authorizationToken": "Bearer " + token} + errors = [] + response = subject.authorize(event, on_error=errors.append) + assert response["policyDocument"]["Statement"][0]["Effect"] == "Allow" + assert errors == [] + + +def test_authorizer_error_callback_failure_cannot_allow_a_request(jwks, claims, issue_token, http): + subject, _, _ = failure_case("missing_token", jwks, claims, issue_token, http) + + def on_error(error): + raise RuntimeError("Application metrics failed") + + event = {"type": "TOKEN", "methodArn": ARN} + with pytest.raises(RuntimeError, match="Application metrics failed"): + subject.authorize(event, on_error=on_error) diff --git a/tests/functional/auth/test_imports.py b/tests/functional/auth/test_imports.py new file mode 100644 index 00000000000..a61a8c67535 --- /dev/null +++ b/tests/functional/auth/test_imports.py @@ -0,0 +1,35 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("scenario", ["static", "remote", "exports", "star"]) +def test_auth_imports_in_clean_interpreter(scenario, jwks, claims, issue_token): + project_root = Path(__file__).parents[3] + probe = Path(__file__).with_name("_auth_import_probe.py") + env = os.environ.copy() + env["PYTHONPATH"] = str(project_root) + fixture = { + "issuer": claims["iss"], + "audience": claims["aud"], + "subject": claims["sub"], + "jwks": jwks, + "token": issue_token(), + } + + result = subprocess.run( + [sys.executable, str(probe), scenario], + cwd=project_root, + env=env, + input=json.dumps(fixture), + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/functional/auth/test_jwks_cache.py b/tests/functional/auth/test_jwks_cache.py new file mode 100644 index 00000000000..8e1c2ec9bf5 --- /dev/null +++ b/tests/functional/auth/test_jwks_cache.py @@ -0,0 +1,247 @@ +import asyncio +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import InvalidTokenError, JWKSFetchError + +JWKS_URL = "https://idp.example.com/keys" +ISSUER = "https://idp.example.com/" + + +def verifier(**options): + return JWTVerifier(issuer=ISSUER, audience="https://api.example.com", algorithms=["RS256"], **options) + + +def test_fetch_keys_once_and_reuse_for_warm_invocations(http, jwks, issue_token): + http.serve(JWKS_URL, jwks) + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks_uri=JWKS_URL, + ) + + assert verifier.verify(issue_token())["sub"] == "user-123" + assert verifier.verify(issue_token())["sub"] == "user-123" + assert len(http.requests) == 1 + + +def test_known_keys_are_removed_after_the_key_set_expires(http, jwks, issue_token, clock): + http.serve(JWKS_URL, jwks) + subject = verifier(jwks_uri=JWKS_URL) + subject.verify(issue_token()) + http.serve(JWKS_URL, {"keys": []}) + clock.advance(300) + + with pytest.raises(InvalidTokenError): + subject.verify(issue_token()) + assert len(http.requests) == 2 + + +def test_refresh_failure_cannot_extend_key_trust_and_uses_backoff(http, jwks, issue_token, clock): + http.serve(JWKS_URL, jwks) + subject = verifier(jwks_uri=JWKS_URL) + subject.verify(issue_token()) + clock.advance(300) + http.serve(JWKS_URL, {"error": "unavailable"}, status=503) + + for _ in range(3): + with pytest.raises(JWKSFetchError): + subject.verify(issue_token()) + assert len(http.requests) == 2 + + clock.advance(1) + http.serve(JWKS_URL, jwks) + assert subject.verify(issue_token())["sub"] == "user-123" + assert len(http.requests) == 3 + + +def test_unknown_key_refresh_is_rate_limited_separately_from_freshness(http, jwks, issue_token, clock): + http.serve(JWKS_URL, jwks) + subject = verifier(jwks_uri=JWKS_URL, jwks_max_age_seconds=3000, unknown_kid_cooldown_seconds=5) + subject.verify(issue_token()) + + with pytest.raises(InvalidTokenError): + subject.verify(issue_token(kid="new-key")) + assert len(http.requests) == 1 + + clock.advance(5) + http.serve(JWKS_URL, {"keys": [{**jwks["keys"][0], "kid": "new-key"}]}) + assert subject.verify(issue_token(kid="new-key"))["sub"] == "user-123" + with pytest.raises(InvalidTokenError): + subject.verify(issue_token()) + assert len(http.requests) == 2 + + +def test_unknown_key_cooldown_does_not_prevent_age_required_refresh(http, jwks, issue_token, clock): + http.serve(JWKS_URL, jwks) + subject = verifier(jwks_uri=JWKS_URL, jwks_max_age_seconds=2, unknown_kid_cooldown_seconds=300) + subject.verify(issue_token()) + clock.advance(2) + http.serve(JWKS_URL, {"keys": []}) + + with pytest.raises(InvalidTokenError): + subject.verify(issue_token()) + assert len(http.requests) == 2 + + +def test_prefetch_does_not_reset_key_age_without_a_fetch(http, jwks, issue_token, clock): + http.serve(JWKS_URL, jwks) + subject = verifier(jwks_uri=JWKS_URL) + subject.prefetch() + clock.advance(299) + subject.prefetch() + http.serve(JWKS_URL, {"keys": []}) + clock.advance(1) + + with pytest.raises(InvalidTokenError): + subject.verify(issue_token()) + assert len(http.requests) == 2 + + +def test_discovery_validates_issuer_before_retrieving_keys(http, jwks, issue_token): + http.serve(ISSUER + ".well-known/openid-configuration", {"issuer": ISSUER, "jwks_uri": JWKS_URL}) + http.serve(JWKS_URL, jwks) + + assert verifier().verify(issue_token())["sub"] == "user-123" + assert [request[1] for request in http.requests] == [ISSUER + ".well-known/openid-configuration", JWKS_URL] + + +@pytest.mark.parametrize( + "metadata", + [ + {"issuer": "https://other.example.com/", "jwks_uri": JWKS_URL}, + {"issuer": ISSUER, "jwks_uri": "http://idp.example.com/keys"}, + {"jwks_uri": JWKS_URL}, + {"issuer": ISSUER}, + ], +) +def test_invalid_discovery_never_falls_back_or_fetches_untrusted_keys(http, issue_token, metadata): + http.serve(ISSUER + ".well-known/openid-configuration", metadata) + + with pytest.raises(JWKSFetchError): + verifier().verify(issue_token()) + assert len(http.requests) == 1 + + +@pytest.mark.parametrize( + "body", + [{}, {"keys": None}, {"keys": ["bad-key"]}, [], None, b"not json", b"x" * (1024 * 1024 + 1)], +) +def test_malformed_key_sets_fail_closed(http, issue_token, body): + http.serve(JWKS_URL, body) + + with pytest.raises(JWKSFetchError): + verifier(jwks_uri=JWKS_URL).verify(issue_token()) + + +def test_concurrent_requests_share_one_key_fetch(http, jwks, issue_token): + entered = threading.Event() + release = threading.Event() + + def fetch(): + entered.set() + assert release.wait(2) + return jwks + + http.serve(JWKS_URL, fetch) + subject = verifier(jwks_uri=JWKS_URL) + token = issue_token() + with ThreadPoolExecutor(max_workers=8) as executor: + results = [executor.submit(subject.verify, token) for _ in range(8)] + assert entered.wait(2) + release.set() + assert all(result.result(timeout=2)["sub"] == "user-123" for result in results) + assert len(http.requests) == 1 + + +def test_verifiers_for_the_same_issuer_and_key_source_share_refresh(http, jwks, issue_token): + http.serve(JWKS_URL, jwks) + first = verifier(jwks_uri=JWKS_URL) + second = verifier(jwks_uri=JWKS_URL) + + first.verify(issue_token()) + second.verify(issue_token()) + assert len(http.requests) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reason", ["initial", "expiry", "unknown-key"]) +async def test_thread_adapter_keeps_the_event_loop_responsive_during_fetch(http, jwks, issue_token, clock, reason): + entered = threading.Event() + release = threading.Event() + subject = verifier(jwks_uri=JWKS_URL, jwks_max_age_seconds=300, unknown_kid_cooldown_seconds=1) + kid = "key-1" + if reason != "initial": + http.serve(JWKS_URL, jwks) + subject.prefetch() + clock.advance(300 if reason == "expiry" else 1) + if reason == "unknown-key": + kid = "new-key" + jwks["keys"][0]["kid"] = kid + + def fetch(): + entered.set() + assert release.wait(2) + return jwks + + http.serve(JWKS_URL, fetch) + verifications = [asyncio.create_task(asyncio.to_thread(subject.verify, issue_token(kid=kid))) for _ in range(3)] + try: + assert await asyncio.to_thread(entered.wait, 2) + await asyncio.sleep(0) + assert not any(task.done() for task in verifications) + finally: + release.set() + assert all(claims["sub"] == "user-123" for claims in await asyncio.gather(*verifications)) + assert len(http.requests) == (1 if reason == "initial" else 2) + assert 0 < http.requests[-1][2]["timeout"].total <= 3 + + +def test_failed_unknown_key_refresh_preserves_only_still_fresh_keys(http, jwks, issue_token, clock): + subject = verifier(jwks_uri=JWKS_URL, unknown_kid_cooldown_seconds=1) + http.serve(JWKS_URL, jwks) + subject.prefetch() + clock.advance(1) + http.serve(JWKS_URL, {}, status=503) + + with pytest.raises(JWKSFetchError): + subject.verify(issue_token(kid="new-key")) + assert subject.verify(issue_token())["sub"] == "user-123" + assert len(http.requests) == 2 + + clock.advance(299) + with pytest.raises(JWKSFetchError): + subject.verify(issue_token()) + + +def test_waiting_verifier_timeout_does_not_cancel_the_shared_key_fetch(http, jwks, issue_token): + entered = threading.Event() + release = threading.Event() + + def fetch(): + entered.set() + assert release.wait(5) + return jwks + + http.serve(JWKS_URL, fetch) + owner = verifier(jwks_uri=JWKS_URL, timeout_seconds=5) + waiter = verifier(jwks_uri=JWKS_URL, timeout_seconds=0.1) + token = issue_token() + with ThreadPoolExecutor(max_workers=1) as executor: + result = executor.submit(owner.verify, token) + try: + assert entered.wait(5) + with pytest.raises(JWKSFetchError) as error: + waiter.verify(token) + assert error.value.__context__ is None + assert not result.done() + finally: + release.set() + assert result.result(timeout=5)["sub"] == "user-123" + + assert waiter.verify(token)["sub"] == "user-123" + assert len(http.requests) == 1 diff --git a/tests/functional/auth/test_middleware.py b/tests/functional/auth/test_middleware.py new file mode 100644 index 00000000000..6cc0340b227 --- /dev/null +++ b/tests/functional/auth/test_middleware.py @@ -0,0 +1,294 @@ +import copy +import json + +import pytest + +from aws_lambda_powertools.event_handler import ( + ALBResolver, + APIGatewayHttpResolver, + APIGatewayRestResolver, + LambdaFunctionUrlResolver, + Response, +) +from aws_lambda_powertools.utilities.auth import JWTVerifier +from tests.functional.utils import load_event + + +@pytest.fixture(params=["http", "rest"]) +def resolver_event(request): + if request.param == "http": + return APIGatewayHttpResolver(), copy.deepcopy(load_event("apiGatewayProxyV2Event_GET.json")) + return APIGatewayRestResolver(), copy.deepcopy(load_event("apiGatewayProxyEvent.json")) + + +def make_verifier(jwks): + return JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + + +def challenge(response): + if "headers" in response: + return response["headers"]["WWW-Authenticate"] + return response["multiValueHeaders"]["WWW-Authenticate"][0] + + +def test_middleware_exposes_only_verified_claims_and_clears_context(resolver_event, jwks, issue_token): + app, event = resolver_event + verifier = make_verifier(jwks) + + @app.get("/my/path", middlewares=[verifier.require(scopes=["orders:read"])]) + def orders(): + return {"subject": app.context["claims"]["sub"]} + + event["headers"] = {"AUTHORIZATION": "bEaReR " + issue_token()} + response = app.resolve(event, {}) + assert response["statusCode"] == 200 + assert json.loads(response["body"]) == {"subject": "user-123"} + assert "claims" not in app.context + + event["headers"] = {} + assert app.resolve(event, {})["statusCode"] == 401 + + +@pytest.mark.parametrize("public_first", [True, False]) +def test_failed_handler_cannot_leak_claims_into_later_invocations( + resolver_event, + jwks, + issue_token, + claims, + public_first, +): + app, event = resolver_event + should_fail = True + error_contexts = [] + + def on_error(error): + error_contexts.append(dict(app.context)) + return Response(status_code=error.status_code, content_type="application/json", body={}) + + @app.get("/my/path", middlewares=[make_verifier(jwks).require(on_error=on_error)]) + def orders(): + if should_fail: + app.append_context(application_value="preserved") + raise RuntimeError("handler failed") + return {"subject": app.context["claims"]["sub"]} + + @app.get("/public") + def public(): + return {"claims": app.context.get("claims")} + + event["headers"] = {"authorization": "Bearer " + issue_token()} + with pytest.raises(RuntimeError, match="handler failed"): + app.resolve(event, {}) + assert "claims" not in app.context + assert app.context["application_value"] == "preserved" + + event["headers"] = {} + public_event = copy.deepcopy(event) + public_event["path"] = public_event["rawPath"] = "/public" + if "http" in public_event["requestContext"]: + public_event["requestContext"]["http"]["path"] = "/public" + following_requests = [(public_event, 200), (event, 401)] + if not public_first: + following_requests.reverse() + for next_event, status in following_requests: + response = app.resolve(next_event, {}) + assert response["statusCode"] == status + if status == 200: + assert json.loads(response["body"]) == {"claims": None} + assert all("claims" not in context for context in error_contexts) + + should_fail = False + claims["sub"] = "another-user" + event["headers"] = {"authorization": "Bearer " + issue_token(claims)} + response = app.resolve(event, {}) + assert json.loads(response["body"]) == {"subject": "another-user"} + assert "claims" not in app.context + + +def test_downstream_middleware_can_use_claims_before_and_after_handler(resolver_event, jwks, issue_token): + app, event = resolver_event + subjects = [] + + def downstream(app, next_middleware): + subjects.append(app.context["claims"]["sub"]) + response = next_middleware(app) + subjects.append(app.context["claims"]["sub"]) + return response + + @app.get("/my/path", middlewares=[make_verifier(jwks).require(), downstream]) + def orders(): + return {"subject": app.context["claims"]["sub"]} + + event["headers"] = {"authorization": "Bearer " + issue_token()} + assert app.resolve(event, {})["statusCode"] == 200 + assert subjects == ["user-123", "user-123"] + assert "claims" not in app.context + + +@pytest.mark.parametrize( + "header,status,expected_challenge", + [ + (None, 401, "Bearer"), + ("Basic credentials", 401, 'Bearer error="invalid_token"'), + ("Bearer not-a-token", 401, 'Bearer error="invalid_token"'), + ("Bearer one two", 401, 'Bearer error="invalid_token"'), + (["Bearer token"], 401, 'Bearer error="invalid_token"'), + ], +) +def test_middleware_denies_invalid_credentials_without_calling_handler( + resolver_event, + jwks, + header, + status, + expected_challenge, +): + app, event = resolver_event + + @app.get("/my/path", middlewares=[make_verifier(jwks).require()]) + def orders(): + pytest.fail("An unauthenticated handler must not run") + + event["headers"] = {} if header is None else {"authorization": header} + response = app.resolve(event, {}) + assert response["statusCode"] == status + assert challenge(response) == expected_challenge + assert json.loads(response["body"]) == {"message": "Unauthorized"} + + +@pytest.mark.parametrize( + "scope_claims,status", + [ + ({"scope": "orders:read orders:write"}, 200), + ({"scp": "orders:read"}, 200), + ({"scopes": ["orders:read"]}, 200), + ({"scope": ["orders:read"]}, 200), + ({"scope": "orders:write"}, 403), + ({}, 403), + ({"scope": None, "scp": "orders:read"}, 401), + ({"scope": ["orders:read", 42]}, 401), + ({"scope": "orders:write", "scp": "orders:read"}, 403), + ], +) +def test_scope_formats_and_precedence(resolver_event, jwks, claims, issue_token, scope_claims, status): + app, event = resolver_event + claims.pop("scope") + claims.update(scope_claims) + + @app.get("/my/path", middlewares=[make_verifier(jwks).require(scopes=["orders:read"])]) + def orders(): + return {"ok": True} + + event["headers"] = {"authorization": "Bearer " + issue_token(claims)} + response = app.resolve(event, {}) + assert response["statusCode"] == status + if status == 403: + assert challenge(response) == 'Bearer error="insufficient_scope", scope="orders:read"' + + +def test_custom_error_response_preserves_status_and_challenge(resolver_event, jwks, issue_token): + app, event = resolver_event + verifier = make_verifier(jwks) + + def on_error(error): + return Response( + status_code=error.status_code, + content_type="application/json", + body={"error": "access_denied"}, + headers=error.headers, + ) + + @app.get("/my/path", middlewares=[verifier.require(scopes=["admin"], on_error=on_error)]) + def orders(): + pytest.fail("An error callback must not execute the protected route") + + event["headers"] = {"authorization": "Bearer " + issue_token()} + response = app.resolve(event, {}) + assert response["statusCode"] == 403 + assert json.loads(response["body"]) == {"error": "access_denied"} + assert "insufficient_scope" in challenge(response) + + +def test_additional_authorization_must_return_true(resolver_event, jwks, issue_token): + app, event = resolver_event + + @app.get("/my/path", middlewares=[make_verifier(jwks).require(authorize=lambda claims: False)]) + def orders(): + pytest.fail("A forbidden handler must not run") + + event["headers"] = {"authorization": "Bearer " + issue_token()} + assert app.resolve(event, {})["statusCode"] == 403 + + +def test_unavailable_keys_return_generic_503(resolver_event, http, issue_token): + app, event = resolver_event + http.serve("https://idp.example.com/keys", {"error": "private provider diagnostics"}, status=503) + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks_uri="https://idp.example.com/keys", + ) + + @app.get("/my/path", middlewares=[verifier.require()]) + def orders(): + pytest.fail("A handler must not run without trusted keys") + + event["headers"] = {"authorization": "Bearer " + issue_token()} + response = app.resolve(event, {}) + assert response["statusCode"] == 503 + assert json.loads(response["body"]) == {"message": "Service Unavailable"} + + +def test_public_routes_do_not_require_credentials(resolver_event): + app, event = resolver_event + + @app.get("/my/path") + def health(): + return {"status": "ok"} + + event["headers"] = {} + assert app.resolve(event, {})["statusCode"] == 200 + + +@pytest.mark.parametrize( + "headers,multi_headers,status", + [ + (None, {"Authorization": ["TOKEN"]}, 200), + ({"authorization": "TOKEN"}, {"Authorization": ["TOKEN"]}, 200), + (None, {"Authorization": ["TOKEN", "TOKEN"]}, 401), + ({"authorization": "TOKEN"}, {"Authorization": ["Bearer another"]}, 401), + (None, {"Authorization": "TOKEN"}, 401), + ], +) +def test_alb_multi_value_authorization_is_unambiguous(jwks, issue_token, headers, multi_headers, status): + app = ALBResolver() + event = copy.deepcopy(load_event("albMultiValueHeadersEvent.json")) + token = "Bearer " + issue_token() + event["headers"] = json.loads(json.dumps(headers).replace("TOKEN", token)) + event["multiValueHeaders"] = json.loads(json.dumps(multi_headers).replace("TOKEN", token)) + + @app.get("/todos", middlewares=[make_verifier(jwks).require()]) + def orders(): + return {"subject": app.context["claims"]["sub"]} + + response = app.resolve(event, {}) + assert response["statusCode"] == status + if status == 200: + assert json.loads(response["body"]) == {"subject": "user-123"} + + +def test_function_url_middleware(jwks, issue_token): + app = LambdaFunctionUrlResolver() + event = copy.deepcopy(load_event("lambdaFunctionUrlEvent.json")) + event["headers"] = {"authorization": "Bearer " + issue_token()} + + @app.get("/", middlewares=[make_verifier(jwks).require()]) + def orders(): + return {"subject": app.context["claims"]["sub"]} + + assert app.resolve(event, {})["statusCode"] == 200 diff --git a/tests/functional/auth/test_profiles.py b/tests/functional/auth/test_profiles.py new file mode 100644 index 00000000000..d7ef0b218d7 --- /dev/null +++ b/tests/functional/auth/test_profiles.py @@ -0,0 +1,144 @@ +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import InvalidClaimsError, InvalidSignatureError, InvalidTokenError + + +def test_cognito_checks_app_client_and_resource_separately(jwks, claims, issue_token): + verifier = JWTVerifier.cognito( + user_pool_id="us-east-1_pool", + client_id="desktop-client", + audience="https://api.example.com", + jwks=jwks, + ) + claims.update( + iss="https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pool", + token_use="access", + client_id="desktop-client", + ) + + assert verifier.verify(issue_token(claims))["token_use"] == "access" + + +@pytest.mark.parametrize( + "override,missing", + [ + ({"token_use": "id", "aud": "desktop-client"}, None), + ({"token_use": "id"}, None), + ({"client_id": "other-client"}, None), + ({}, "aud"), + ({}, "client_id"), + ({}, "token_use"), + ], +) +def test_cognito_rejects_wrong_token_profile(jwks, claims, issue_token, override, missing): + verifier = JWTVerifier.cognito( + user_pool_id="us-east-1_pool", + client_id="desktop-client", + audience="https://api.example.com", + jwks=jwks, + ) + claims.update( + iss="https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pool", + token_use="access", + client_id="desktop-client", + ) + claims.update(override) + if missing: + del claims[missing] + + with pytest.raises(InvalidClaimsError): + verifier.verify(issue_token(claims)) + + +def test_cognito_derives_china_partition_endpoint(http, jwks, claims, issue_token): + issuer = "https://cognito-idp.cn-north-1.amazonaws.com.cn/cn-north-1_pool" + http.serve(issuer + "/.well-known/jwks.json", jwks) + verifier = JWTVerifier.cognito( + user_pool_id="cn-north-1_pool", + client_id="desktop-client", + audience="https://api.example.com", + ) + claims.update(iss=issuer, token_use="access", client_id="desktop-client") + + assert verifier.verify(issue_token(claims))["iss"] == issuer + + +def test_any_of_never_uses_another_issuers_keys(jwks, signing_key, claims, issue_token): + other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + other_jwk = jwt.algorithms.RSAAlgorithm.to_jwk(other_key.public_key(), as_dict=True) + first = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + second = JWTVerifier( + issuer="https://other.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks={"keys": [{**other_jwk, "kid": "key-1"}]}, + ) + verifier = JWTVerifier.any_of(first, second) + verifier.prefetch() + assert verifier.verify(issue_token())["iss"] == "https://idp.example.com/" + claims["iss"] = "https://other.example.com/" + assert verifier.verify(issue_token(claims, key=other_key))["iss"] == "https://other.example.com/" + with pytest.raises(InvalidSignatureError): + verifier.verify(issue_token(claims, key=signing_key)) + + +def test_any_of_rejects_unknown_issuers_without_network_requests(http, claims, issue_token): + verifier = JWTVerifier.any_of( + JWTVerifier( + issuer="https://trusted.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + ), + ) + + with pytest.raises(InvalidTokenError): + verifier.verify(issue_token(claims)) + assert http.requests == [] + + +def test_any_of_rejects_ambiguous_issuer_configuration(jwks): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + + with pytest.raises(ValueError): + JWTVerifier.any_of(verifier, verifier) + + +@pytest.mark.parametrize( + "options", + [ + {"user_pool_id": "invalid"}, + {"user_pool_id": None}, + {"client_id": " "}, + {"client_id": None}, + {"issuer": "https://untrusted.example.com"}, + {"algorithms": ["HS256"]}, + {"jwks_uri": "https://untrusted.example.com/keys"}, + ], +) +def test_cognito_rejects_invalid_or_overridden_trust_configuration(options): + config = { + "user_pool_id": "us-east-1_pool", + "client_id": "desktop-client", + "audience": "https://api.example.com", + } + with pytest.raises(ValueError): + JWTVerifier.cognito(**{**config, **options}) + + +@pytest.mark.parametrize("verifiers", [(), (None,), ("https://idp.example.com",)]) +def test_issuer_groups_require_explicit_verifier_instances(verifiers): + with pytest.raises(ValueError): + JWTVerifier.any_of(*verifiers) diff --git a/tests/functional/auth/test_testing.py b/tests/functional/auth/test_testing.py new file mode 100644 index 00000000000..f3f7b10b6f8 --- /dev/null +++ b/tests/functional/auth/test_testing.py @@ -0,0 +1,34 @@ +import pytest + +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import InvalidTokenError +from aws_lambda_powertools.utilities.auth.testing import mock_claims + + +def test_mock_claims_is_scoped_and_restores_real_verification(jwks): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + + with mock_claims(verifier, {"sub": "test-user", "scope": "orders:read"}): + assert verifier.verify("not-a-real-token") == {"sub": "test-user", "scope": "orders:read"} + with pytest.raises(InvalidTokenError): + verifier.verify("not-a-real-token") + + +def test_mock_claims_returns_independent_snapshots_and_avoids_network(http): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + ) + claims = {"sub": "test-user", "roles": ["reader"]} + + with mock_claims(verifier, claims): + first = verifier.verify("token") + first["roles"].append("admin") + assert verifier.verify("token") == {"sub": "test-user", "roles": ["reader"]} + assert http.requests == [] diff --git a/tests/functional/auth/test_token_profile.py b/tests/functional/auth/test_token_profile.py new file mode 100644 index 00000000000..f8745f34a0d --- /dev/null +++ b/tests/functional/auth/test_token_profile.py @@ -0,0 +1,110 @@ +import copy +import json + +import pytest + +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import InvalidClaimsError, InvalidSignatureError +from tests.functional.utils import load_event + +ARN = "arn:aws:execute-api:us-east-1:123456789012:api123/prod/GET/orders" + + +def verifier(jwks, **options): + return JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + **options, + ) + + +@pytest.mark.parametrize("surface", ["direct", "group", "middleware", "iam", "simple"]) +@pytest.mark.parametrize("profile", ["valid", "wrong-purpose", "missing-purpose", "wrong-header", "missing-header"]) +def test_profile_constraints_apply_to_every_verification_surface(jwks, claims, issue_token, surface, profile): + subject = verifier( + jwks, + expected_claims={"token_use": "access"}, + expected_headers={"typ": "at+jwt"}, + ) + claims["token_use"] = "id" if profile == "wrong-purpose" else "access" + if profile == "missing-purpose": + del claims["token_use"] + header_type = "JWT" if profile == "wrong-header" else None if profile == "missing-header" else "at+jwt" + token = issue_token(claims, headers={"typ": header_type}) + accepted = profile == "valid" + if surface in ("direct", "group"): + subject = JWTVerifier.any_of(subject) if surface == "group" else subject + if accepted: + assert subject.verify(token)["sub"] == claims["sub"] + else: + with pytest.raises(InvalidClaimsError): + subject.verify(token) + elif surface == "middleware": + app = APIGatewayHttpResolver() + + @app.get("/my/path", middlewares=[subject.require()]) + def protected(): + assert accepted, "A token for another purpose reached the protected handler" + return {"subject": app.context["claims"]["sub"]} + + event = copy.deepcopy(load_event("apiGatewayProxyV2Event_GET.json")) + event["headers"] = {"authorization": "Bearer " + token} + response = app.resolve(event, {}) + assert response["statusCode"] == (200 if accepted else 401) + if accepted: + assert json.loads(response["body"]) == {"subject": claims["sub"]} + else: + event = { + "type": "REQUEST", + "version": "2.0", + "routeArn": ARN, + "headers": {"authorization": "Bearer " + token}, + } + response = subject.authorize(event, response_format=surface) + if surface == "simple": + assert response["isAuthorized"] is accepted + else: + assert response["policyDocument"]["Statement"][0]["Effect"] == ("Allow" if accepted else "Deny") + + +@pytest.mark.parametrize("field", ["expected_claims", "expected_headers"]) +@pytest.mark.parametrize("value", [[], "token_use", {"": "access"}, {"token_use": ""}, {"typ": 1}, {1: "access"}]) +def test_profile_configuration_requires_named_string_values(jwks, field, value): + with pytest.raises(ValueError, match="Expected claims and headers"): + verifier(jwks, **{field: value}) + + +def test_profile_configuration_is_copied_and_never_weakens_signature_checks(jwks, claims, issue_token): + expected_claims = {"token_use": "access"} + expected_headers = {"typ": "at+jwt"} + subject = verifier(jwks, expected_claims=expected_claims, expected_headers=expected_headers) + expected_claims["token_use"] = "id" + expected_headers["typ"] = "JWT" + claims["token_use"] = "access" + token = issue_token(claims, headers={"typ": "at+jwt"}) + assert subject.verify(token)["token_use"] == "access" + tampered = token.rsplit(".", 1)[0] + ".AAAA" + with pytest.raises(InvalidSignatureError): + subject.verify(tampered) + claims["token_use"] = "id" + wrong_purpose = issue_token(claims, headers={"typ": "at+jwt"}) + with pytest.raises(InvalidClaimsError): + subject.verify(wrong_purpose) + + +def test_generic_constraints_cannot_override_the_cognito_profile(jwks, claims, issue_token): + issuer = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pool" + subject = JWTVerifier.cognito( + user_pool_id="us-east-1_pool", + client_id="desktop-client", + audience=claims["aud"], + jwks=jwks, + expected_claims={"token_use": "id"}, + ) + claims.update(iss=issuer, token_use="id", client_id="desktop-client") + token = issue_token(claims) + with pytest.raises(InvalidClaimsError): + subject.verify(token) diff --git a/tests/functional/auth/test_verifier.py b/tests/functional/auth/test_verifier.py new file mode 100644 index 00000000000..c05da03acff --- /dev/null +++ b/tests/functional/auth/test_verifier.py @@ -0,0 +1,280 @@ +import time + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import ( + InvalidClaimsError, + InvalidSignatureError, + InvalidTokenError, + TokenExpiredError, +) + + +def test_verify_access_token_with_static_keys(jwks, claims, issue_token): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + + assert verifier.verify(issue_token()) == claims + + +@pytest.mark.parametrize("missing", ["iss", "aud", "exp", "sub"]) +def test_required_claims_are_additive(jwks, claims, issue_token, missing): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + required_claims=["sub"], + ) + del claims[missing] + + with pytest.raises(InvalidClaimsError): + verifier.verify(issue_token(claims)) + + +def test_expired_token_is_rejected(jwks, claims, issue_token): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + clock_skew_seconds=0, + ) + claims["exp"] = int(time.time()) - 1 + + with pytest.raises(TokenExpiredError): + verifier.verify(issue_token(claims)) + + +@pytest.mark.parametrize( + "claim,value", + [ + ("iss", "https://another.example.com/"), + ("iss", "https://idp.example.com"), + ("aud", "https://another.example.com"), + ("aud", []), + ("aud", ["https://api.example.com", 42]), + ("exp", "9999999999"), + ("exp", float("inf")), + ("exp", float("nan")), + ("exp", True), + ("exp", 10**400), + ("nbf", "0"), + ("nbf", 9999999999), + ], +) +def test_invalid_claim_values_are_rejected(jwks, claims, issue_token, claim, value): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + claims[claim] = value + + with pytest.raises(InvalidClaimsError): + verifier.verify(issue_token(claims)) + + +@pytest.mark.parametrize( + "option,value", + [ + ("issuer", "http://idp.example.com"), + ("issuer", "https://user:secret@idp.example.com"), + ("issuer", "https://idp.example.com/#fragment"), + ("issuer", "https://idp.example.com:invalid"), + ("issuer", "https://[invalid"), + ("issuer", 42), + ("audience", ""), + ("audience", []), + ("algorithms", []), + ("algorithms", ["none"]), + ("algorithms", ["HS256"]), + ("algorithms", ["RS256", "HS256"]), + ("clock_skew_seconds", -1), + ("clock_skew_seconds", float("inf")), + ("clock_skew_seconds", 10**400), + ("jwks_uri", "https://idp.example.com/keys"), + ("required_claims", ""), + ], +) +def test_invalid_verifier_configuration_is_rejected(jwks, option, value): + options = { + "issuer": "https://idp.example.com/", + "audience": "https://api.example.com", + "algorithms": ["RS256"], + "jwks": jwks, + option: value, + } + + with pytest.raises(ValueError): + JWTVerifier(**options) + + +@pytest.mark.parametrize("issuer_group", [False, True]) +@pytest.mark.parametrize("token", ["", "not-a-jwt", "a.b.c", None, 42]) +def test_malformed_tokens_raise_redacted_errors(jwks, token, issuer_group): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + + if issuer_group: + verifier = JWTVerifier.any_of(verifier) + with pytest.raises(InvalidTokenError) as error: + verifier.verify(token) + assert str(error.value) == "Invalid access token" + + +@pytest.mark.parametrize("key_change", [{"alg": "RS512"}, {"use": "enc"}, {"key_ops": ["sign"]}]) +def test_signing_key_metadata_is_enforced(jwks, issue_token, key_change): + jwks["keys"][0].update(key_change) + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + + with pytest.raises(InvalidTokenError): + verifier.verify(issue_token()) + + +def test_disallowed_token_algorithm_is_rejected(jwks, claims): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + token = jwt.encode(claims, "a-separate-signing-secret-with-32-bytes", algorithm="HS256", headers={"kid": "key-1"}) + + with pytest.raises(InvalidTokenError): + verifier.verify(token) + + +def test_static_key_configuration_is_copied(jwks, issue_token): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + jwks["keys"].clear() + + assert verifier.verify(issue_token())["sub"] == "user-123" + + +@pytest.mark.parametrize("algorithm", ["PS256", "ES256", "EdDSA"]) +def test_asymmetric_algorithm_families(algorithm, signing_key, claims): + if algorithm == "ES256": + key = ec.generate_private_key(ec.SECP256R1()) + elif algorithm == "EdDSA": + key = ed25519.Ed25519PrivateKey.generate() + else: + key = signing_key + algorithm_impl = jwt.get_algorithm_by_name(algorithm) + public_jwk = algorithm_impl.to_jwk(key.public_key(), as_dict=True) + verifier = JWTVerifier( + issuer=claims["iss"], + audience=claims["aud"], + algorithms=[algorithm], + jwks={"keys": [{**public_jwk, "kid": "key-1"}]}, + ) + token = jwt.encode(claims, key, algorithm=algorithm, headers={"kid": "key-1"}) + + assert verifier.verify(token) == claims + + +def test_private_jwk_is_rejected_without_exposing_key(signing_key, claims, issue_token): + private_jwk = jwt.algorithms.RSAAlgorithm.to_jwk(signing_key, as_dict=True) + verifier = JWTVerifier( + issuer=claims["iss"], + audience=claims["aud"], + algorithms=["RS256"], + jwks={"keys": [{**private_jwk, "kid": "key-1"}]}, + ) + + with pytest.raises(InvalidTokenError) as error: + verifier.verify(issue_token()) + assert private_jwk["d"] not in str(error.value) + + +def test_invalid_signature_has_stable_error(jwks, claims, signing_key, issue_token): + # This payload is valid, but the signature belongs to the original payload. + token = issue_token().split(".") + claims["sub"] = "another-user" + token[1] = jwt.encode(claims, signing_key, algorithm="RS256").split(".")[1] + verifier = JWTVerifier( + issuer=claims["iss"], + audience=claims["aud"], + algorithms=["RS256"], + jwks=jwks, + ) + + with pytest.raises(InvalidSignatureError) as error: + verifier.verify(".".join(token)) + assert str(error.value) == "Invalid access token signature" + + +@pytest.mark.parametrize("key_change", [{"n": None}, {"kty": []}, {"crv": "P-384", "kty": "EC"}]) +def test_malformed_key_material_fails_closed(jwks, issue_token, key_change): + jwks["keys"][0].update(key_change) + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + with pytest.raises(InvalidTokenError): + verifier.verify(issue_token()) + + +def test_ec_curve_must_match_algorithm(claims, issue_token): + key = ec.generate_private_key(ec.SECP384R1()) + public_jwk = jwt.algorithms.ECAlgorithm.to_jwk(key.public_key(), as_dict=True) + verifier = JWTVerifier( + issuer=claims["iss"], + audience=claims["aud"], + algorithms=["ES256"], + jwks={"keys": [{**public_jwk, "kid": "key-1"}]}, + ) + header = jwt.utils.base64url_encode(b'{"alg":"ES256","kid":"key-1"}') + payload = issue_token().split(".")[1].encode() + message = header + b"." + payload + signature = jwt.algorithms.ECAlgorithm(jwt.algorithms.ECAlgorithm.SHA256).sign(message, key) + token = (message + b"." + jwt.utils.base64url_encode(signature)).decode() + + with pytest.raises(InvalidTokenError): + verifier.verify(token) + + +@pytest.mark.parametrize("issuer_group", [False, True]) +@pytest.mark.parametrize("nested_part", ["header", "payload"]) +def test_excessively_nested_token_json_raises_sanitized_error(jwks, signing_key, issuer_group, nested_part): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + if issuer_group: + verifier = JWTVerifier.any_of(verifier) + nested = b'{"nested":' + b"[" * 2000 + b"0" + b"]" * 2000 + b"}" + header = nested if nested_part == "header" else b'{"alg":"RS256","kid":"key-1"}' + payload = nested if nested_part == "payload" else b'{"iss":"https://idp.example.com/"}' + message = b".".join((jwt.utils.base64url_encode(header), jwt.utils.base64url_encode(payload))) + signature = jwt.get_algorithm_by_name("RS256").sign(message, signing_key) + token = (message + b"." + jwt.utils.base64url_encode(signature)).decode() + + with pytest.raises(InvalidTokenError): + verifier.verify(token) diff --git a/tests/integration/auth/conftest.py b/tests/integration/auth/conftest.py new file mode 100644 index 00000000000..78304d5c565 --- /dev/null +++ b/tests/integration/auth/conftest.py @@ -0,0 +1,140 @@ +"""A local TLS endpoint exercising the production transport without HTTP mocks.""" + +import ipaddress +import json +import ssl +import threading +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + + +@dataclass +class Reply: + body: bytes + status: int = 200 + headers: dict = field(default_factory=dict) + interval: float = 0 + stall: bool = False + + +class LocalHTTPS: + def __init__(self): + self.routes = {} + self.requests = [] + self.stop = threading.Event() + self.url = "" + + def serve(self, path, payload, *, status=200, headers=None, interval=0, stall=False): + body = payload if isinstance(payload, bytes) else json.dumps(payload).encode() + self.routes[path] = Reply(body, status, headers or {}, interval, stall) + + +@pytest.fixture(scope="session") +def tls_files(tmp_path_factory): + directory = tmp_path_factory.mktemp("auth-tls") + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Powertools local test CA")]) + now = datetime.now(timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + certificate_path = directory / "certificate.pem" + key_path = directory / "key.pem" + certificate_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ), + ) + return certificate_path, key_path + + +@pytest.fixture(params=[False, True], ids=["connection-close", "keep-alive"]) +def https_server(tls_files, monkeypatch, request): + endpoint = LocalHTTPS() + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def handle(self): + try: + super().handle() + except (ConnectionResetError, ssl.SSLEOFError): + # The client may reject a response without draining its body. + self.close_connection = True + + def do_GET(self): # noqa: N802 + self.respond() + + def do_POST(self): # noqa: N802 + self.respond() + + def respond(self): + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + endpoint.requests.append((self.command, self.path, dict(self.headers), body)) + reply = endpoint.routes.get(self.path, Reply(b"{}", status=404)) + self.send_response(reply.status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(reply.body))) + self.send_header("Connection", "keep-alive" if request.param else "close") + for name, value in reply.headers.items(): + self.send_header(name, value) + self.end_headers() + try: + if reply.stall: + endpoint.stop.wait(5) + elif reply.interval: + for value in reply.body: + if endpoint.stop.wait(reply.interval): + break + self.wfile.write(bytes([value])) + self.wfile.flush() + else: + self.wfile.write(reply.body) + except (OSError, ssl.SSLError): + # Timeout and oversized-body tests deliberately close early. + pass + finally: + self.close_connection = not request.param + + def log_message(self, format, *args): # noqa: A002 + pass + + certificate_path, key_path = tls_files + monkeypatch.setenv("SSL_CERT_FILE", str(certificate_path)) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certificate_path, key_path) + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server.socket = context.wrap_socket(server.socket, server_side=True) + endpoint.url = f"https://127.0.0.1:{server.server_port}" + thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True) + thread.start() + try: + yield endpoint + finally: + endpoint.stop.set() + server.shutdown() + server.server_close() + thread.join(timeout=2) + assert not thread.is_alive() diff --git a/tests/integration/auth/test_https.py b/tests/integration/auth/test_https.py new file mode 100644 index 00000000000..42bb01addcb --- /dev/null +++ b/tests/integration/auth/test_https.py @@ -0,0 +1,72 @@ +import time + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import JWKSFetchError + + +def verifier(endpoint, **options): + return JWTVerifier(issuer=endpoint.url, audience="orders", algorithms=["RS256"], **options) + + +def test_discovery_and_jwks_verify_a_real_signature_over_trusted_tls(https_server): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + jwk = jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key(), as_dict=True) + https_server.serve( + "/.well-known/openid-configuration", + { + "issuer": https_server.url, + "jwks_uri": https_server.url + "/keys", + }, + ) + https_server.serve("/keys", {"keys": [{**jwk, "kid": "test-key", "alg": "RS256", "use": "sig"}]}) + token = jwt.encode( + {"iss": https_server.url, "aud": "orders", "exp": int(time.time()) + 600, "sub": "test-user"}, + key, + algorithm="RS256", + headers={"kid": "test-key"}, + ) + subject = verifier(https_server) + subject.prefetch() + assert subject.verify(token)["sub"] == "test-user" + assert subject.verify(token)["sub"] == "test-user" + assert [request[1] for request in https_server.requests] == ["/.well-known/openid-configuration", "/keys"] + + +def test_untrusted_certificates_fail_before_sending_a_request(https_server, monkeypatch): + monkeypatch.delenv("SSL_CERT_FILE") + https_server.serve("/keys", {"keys": []}) + subject = verifier(https_server, jwks_uri=https_server.url + "/keys") + with pytest.raises(JWKSFetchError) as error: + subject.prefetch() + assert error.value.__context__ is None + assert error.value.__cause__ is None + assert https_server.requests == [] + + +@pytest.mark.parametrize("failure", ["oversized", "redirect", "stall", "trickle"]) +def test_key_endpoint_failures_are_bounded_and_do_not_follow_redirects(https_server, failure): + payload = {"keys": []} + if failure == "oversized": + https_server.serve("/keys", b'{"padding":"' + b"x" * (1024 * 1024) + b'"}') + elif failure == "redirect": + https_server.serve("/keys", {}, status=307, headers={"Location": https_server.url + "/redirected"}) + https_server.serve("/redirected", payload) + else: + https_server.serve( + "/keys", + payload, + stall=failure == "stall", + interval=0.04 if failure == "trickle" else 0, + ) + subject = verifier(https_server, jwks_uri=https_server.url + "/keys", timeout_seconds=0.2) + + started = time.monotonic() + with pytest.raises(JWKSFetchError) as error: + subject.prefetch() + assert time.monotonic() - started < 1 + assert error.value.__context__ is None + assert [request[1] for request in https_server.requests] == ["/keys"]