From 56a6604cfd674f51ef829e30ef0bc44bbbba3066 Mon Sep 17 00:00:00 2001 From: Ben Freiberg <9841563+bfreiberg@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:59:16 +0200 Subject: [PATCH] feat(auth): add JWT verification and OAuth client credentials Add JWT verification, coordinated JWKS caching, API Gateway authorization, and OAuth client credentials with optional dependencies, documentation, examples, and tests. Include exception-safe claims cleanup, sanitized provider errors, and lazy imports for OAuth-only clients and static-key verification. --- .../utilities/auth/__init__.py | 25 ++ .../utilities/auth/_authorization.py | 79 ++++ .../utilities/auth/_authorizer.py | 122 ++++++ aws_lambda_powertools/utilities/auth/_base.py | 98 +++++ .../utilities/auth/_deadline.py | 26 ++ .../utilities/auth/_errors.py | 30 ++ aws_lambda_powertools/utilities/auth/_http.py | 77 ++++ aws_lambda_powertools/utilities/auth/_jwks.py | 141 +++++++ .../utilities/auth/_middleware.py | 88 +++++ .../utilities/auth/_validation.py | 57 +++ .../utilities/auth/exceptions.py | 46 +++ .../utilities/auth/oauth2.py | 330 ++++++++++++++++ .../utilities/auth/testing.py | 32 ++ .../utilities/auth/verifier.py | 319 ++++++++++++++++ docs/api_doc/auth.md | 7 + docs/getting-started/install.md | 1 + docs/index.md | 1 + docs/utilities/auth.md | 351 ++++++++++++++++++ examples/auth/src/authorizer.py | 24 ++ examples/auth/src/backend.py | 6 + examples/auth/src/middleware.py | 22 ++ examples/auth/src/outbound.py | 30 ++ examples/auth/src/requirements.txt | 1 + examples/auth/template.yaml | 88 +++++ mkdocs.yml | 3 + noxfile.py | 10 + poetry.lock | 162 ++++---- pyproject.toml | 9 +- tests/functional/auth/__init__.py | 1 + tests/functional/auth/_auth_import_probe.py | 88 +++++ tests/functional/auth/conftest.py | 94 +++++ tests/functional/auth/test_authorizer.py | 192 ++++++++++ tests/functional/auth/test_errors.py | 114 ++++++ tests/functional/auth/test_imports.py | 35 ++ tests/functional/auth/test_jwks_cache.py | 215 +++++++++++ tests/functional/auth/test_middleware.py | 294 +++++++++++++++ tests/functional/auth/test_oauth2.py | 292 +++++++++++++++ tests/functional/auth/test_profiles.py | 116 ++++++ tests/functional/auth/test_testing.py | 34 ++ tests/functional/auth/test_verifier.py | 271 ++++++++++++++ tests/integration/auth/conftest.py | 133 +++++++ tests/integration/auth/test_https.py | 149 ++++++++ 42 files changed, 4141 insertions(+), 72 deletions(-) create mode 100644 aws_lambda_powertools/utilities/auth/__init__.py create mode 100644 aws_lambda_powertools/utilities/auth/_authorization.py create mode 100644 aws_lambda_powertools/utilities/auth/_authorizer.py create mode 100644 aws_lambda_powertools/utilities/auth/_base.py create mode 100644 aws_lambda_powertools/utilities/auth/_deadline.py create mode 100644 aws_lambda_powertools/utilities/auth/_errors.py create mode 100644 aws_lambda_powertools/utilities/auth/_http.py create mode 100644 aws_lambda_powertools/utilities/auth/_jwks.py create mode 100644 aws_lambda_powertools/utilities/auth/_middleware.py create mode 100644 aws_lambda_powertools/utilities/auth/_validation.py create mode 100644 aws_lambda_powertools/utilities/auth/exceptions.py create mode 100644 aws_lambda_powertools/utilities/auth/oauth2.py create mode 100644 aws_lambda_powertools/utilities/auth/testing.py create mode 100644 aws_lambda_powertools/utilities/auth/verifier.py create mode 100644 docs/api_doc/auth.md create mode 100644 docs/utilities/auth.md create mode 100644 examples/auth/src/authorizer.py create mode 100644 examples/auth/src/backend.py create mode 100644 examples/auth/src/middleware.py create mode 100644 examples/auth/src/outbound.py create mode 100644 examples/auth/src/requirements.txt create mode 100644 examples/auth/template.yaml create mode 100644 tests/functional/auth/__init__.py create mode 100644 tests/functional/auth/_auth_import_probe.py create mode 100644 tests/functional/auth/conftest.py create mode 100644 tests/functional/auth/test_authorizer.py create mode 100644 tests/functional/auth/test_errors.py create mode 100644 tests/functional/auth/test_imports.py create mode 100644 tests/functional/auth/test_jwks_cache.py create mode 100644 tests/functional/auth/test_middleware.py create mode 100644 tests/functional/auth/test_oauth2.py create mode 100644 tests/functional/auth/test_profiles.py create mode 100644 tests/functional/auth/test_testing.py create mode 100644 tests/functional/auth/test_verifier.py create mode 100644 tests/integration/auth/conftest.py create mode 100644 tests/integration/auth/test_https.py diff --git a/aws_lambda_powertools/utilities/auth/__init__.py b/aws_lambda_powertools/utilities/auth/__init__.py new file mode 100644 index 00000000000..eb2e8fd6622 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/__init__.py @@ -0,0 +1,25 @@ +"""JWT verification and OAuth2 client credentials for AWS Lambda.""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from aws_lambda_powertools.utilities.auth.oauth2 import OAuth2Client as OAuth2Client + from aws_lambda_powertools.utilities.auth.verifier import JWTVerifier as JWTVerifier + +__all__ = ["JWTVerifier", "OAuth2Client"] + + +def __getattr__(name: str) -> object: + modules = {"JWTVerifier": "verifier", "OAuth2Client": "oauth2"} + 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..e7241250c88 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_authorization.py @@ -0,0 +1,79 @@ +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, InvalidClaimsError, InvalidTokenError + + +class MissingTokenError(InvalidTokenError): + """No authorization header was supplied.""" + + +class ForbiddenError(AuthError): + """A verified caller does not have permission for this operation.""" + + +class InsufficientScopeError(ForbiddenError): + """A verified caller is missing a required 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..9c5544156b4 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_authorizer.py @@ -0,0 +1,122 @@ +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 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 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, +) -> 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, 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, ...], + *, + 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 (InvalidTokenError, ForbiddenError): + return None + + +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..5f6a03e42b9 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_base.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Literal + +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.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 and headers and returns an Event Handler Response. + + 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) + + 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, + ) -> 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. + + 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) 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..738c942edc3 --- /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, 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] + + +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): + context = AuthErrorContext(401, {"WWW-Authenticate": "Bearer"}) + elif isinstance(error, InvalidTokenError): + context = AuthErrorContext(401, {"WWW-Authenticate": 'Bearer error="invalid_token"'}) + elif isinstance(error, InsufficientScopeError): + scopes = " ".join(self._scopes) + context = AuthErrorContext( + 403, + {"WWW-Authenticate": f'Bearer error="insufficient_scope", scope="{scopes}"'}, + ) + elif isinstance(error, ForbiddenError): + context = AuthErrorContext(403, {}) + else: + context = AuthErrorContext(503, {}) + 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..3ccc7460ccf --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_validation.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import math +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()) diff --git a/aws_lambda_powertools/utilities/auth/exceptions.py b/aws_lambda_powertools/utilities/auth/exceptions.py new file mode 100644 index 00000000000..bd2553f0a2f --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/exceptions.py @@ -0,0 +1,46 @@ +"""Credential-free errors raised by the Auth utility.""" + + +class AuthError(Exception): + """Base error with a fixed message that never includes credential material.""" + + message = "Authentication failed" + + 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" + + +class TokenExpiredError(InvalidTokenError): + """The access token has expired beyond the configured clock tolerance.""" + + message = "Access token expired" + + +class InvalidSignatureError(InvalidTokenError): + """The access token signature does not match the configured signing key.""" + + message = "Invalid access token signature" + + +class JWKSFetchError(AuthError): + """Required signing keys could not be retrieved or refreshed.""" + + message = "Unable to retrieve verification keys" + + +class TokenExchangeError(AuthError): + """Client credentials could not be exchanged for a usable bearer token.""" + + message = "Unable to acquire an access token" diff --git a/aws_lambda_powertools/utilities/auth/oauth2.py b/aws_lambda_powertools/utilities/auth/oauth2.py new file mode 100644 index 00000000000..228952f068c --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/oauth2.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import base64 +import re +import threading +import time +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any +from urllib.parse import quote_plus, urlencode + +import urllib3 + +from aws_lambda_powertools.utilities.auth._authorization import required_scopes +from aws_lambda_powertools.utilities.auth._errors import sanitize_errors +from aws_lambda_powertools.utilities.auth._http import Deadline, HTTPClient, RequestError +from aws_lambda_powertools.utilities.auth._validation import finite_seconds, https_url +from aws_lambda_powertools.utilities.auth.exceptions import AuthError, TokenExchangeError + +if TYPE_CHECKING: + from collections.abc import Callable + +_BEARER_TOKEN = re.compile(r"[-A-Za-z0-9._~+/]+=*") + + +@dataclass(frozen=True) +class _AccessToken: + value: str = field(repr=False) + expires_at: float | None + + def cacheable(self) -> bool: + return self.expires_at is not None and time.monotonic() < self.expires_at - 30 + + def usable(self) -> bool: + return self.expires_at is None or time.monotonic() < self.expires_at + + +@dataclass +class _Exchange: + done: threading.Event = field(default_factory=threading.Event, repr=False) + token: _AccessToken | None = field(default=None, repr=False) + + +class OAuth2Client: + """Acquire bearer tokens using client credentials for one configured resource. + + Parameters + ---------- + token_url : str + Trusted HTTPS OAuth token endpoint. + client_id : str + Identifier for a client supporting ``client_secret_basic``. + client_secret : str | Callable[[], str] + Secret or loader invoked for each exchange attempt. + scopes : list[str], optional + Scopes requested on every exchange. + audience : str, optional + Provider-specific audience request field, mutually exclusive with resource. + resource : str, optional + RFC 8707 resource request field, mutually exclusive with audience. + timeout_seconds : float + Positive acquisition budget including retries, by default 3. + + Notes + ----- + Instances do not share tokens. Tokens are reacquired 30 seconds before + expiration. Short-lived tokens and tokens without a lifetime are not cached. + Configure timeouts on application-provided secret loaders. + + Examples + -------- + ```python + client = OAuth2Client( + token_url="https://idp.example.com/token", + client_id="orders", + client_secret=load_secret, + resource="https://inventory.example.com", + scopes=["inventory:read"], + ) + headers = client.auth_headers() + ``` + """ + + def __init__( + self, + *, + token_url: str, + client_id: str, + client_secret: str | Callable[[], str], + scopes: list[str] | None = None, + audience: str | None = None, + resource: str | None = None, + timeout_seconds: float = 3, + ) -> None: + self._token_url = https_url(token_url) + if not isinstance(client_id, str) or not client_id.strip(): + raise ValueError("A nonempty OAuth client ID is required") + if not callable(client_secret) and (not isinstance(client_secret, str) or not client_secret): + raise ValueError("client_secret must be a nonempty string or a callable") + if audience is not None and resource is not None: + raise ValueError("audience and resource are mutually exclusive") + self._client_id = client_id + self._client_secret = client_secret + self._scopes = required_scopes(scopes) + self._timeout = finite_seconds(timeout_seconds, positive=True) + self._fields = {"grant_type": "client_credentials"} + if self._scopes: + self._fields["scope"] = " ".join(self._scopes) + for name, value in (("audience", audience), ("resource", resource)): + if value is not None: + if not isinstance(value, str) or not value.strip(): + raise ValueError("Resource selection must be a nonempty string") + self._fields[name] = value + self._http = HTTPClient() + self._cached_token: _AccessToken | None = None + self._flight: _Exchange | None = None + self._lock = threading.Lock() + + def __repr__(self) -> str: + return "" + + @sanitize_errors + def auth_headers(self) -> dict[str, str]: + """Return an Authorization header for this client's configured resource. + + Raises + ------ + TokenExchangeError + A usable bearer token could not be obtained within the budget. + + Examples + -------- + ```python + headers = client.auth_headers() + response = http.request("GET", trusted_inventory_url, headers=headers) + ``` + """ + try: + token = self._get_token(Deadline(self._timeout)) + except RequestError: + raise TokenExchangeError() from None + return {"Authorization": f"Bearer {token.value}"} + + @sanitize_errors + def request( + self, + method: str, + url: str, + *, + timeout: float = 5, + headers: Mapping[str, str] | None = None, + **options: Any, + ) -> urllib3.response.BaseHTTPResponse: + """Send a synchronous HTTPS request using this resource's bearer token. + + Only trusted destination URLs should be supplied. Redirects and retries + are disabled, and an existing Authorization header is rejected. + ``body``, ``fields``, ``json``, ``encode_multipart`` and + ``multipart_boundary`` are forwarded to urllib3. + + Parameters + ---------- + method : str + HTTP method. + url : str + Trusted HTTPS destination for this resource's credentials. + timeout : float + Positive downstream timeout, separate from acquisition, by default 5. + headers : Mapping[str, str], optional + Additional headers, excluding Authorization. + + Returns + ------- + urllib3.response.BaseHTTPResponse + Downstream response; inspect its status before consuming its body. + + Raises + ------ + TokenExchangeError + Token acquisition failed. + AuthError + Downstream transport failed. + ValueError + Request configuration is invalid. + + Examples + -------- + ```python + response = client.request("GET", "https://inventory.example.com/items") + if response.status == 200: + items = response.json() + ``` + """ + target = https_url(url) + duration = finite_seconds(timeout, positive=True) + allowed = {"body", "fields", "json", "encode_multipart", "multipart_boundary"} + if not options.keys() <= allowed: + raise ValueError("Unsupported authenticated request option") + if not isinstance(method, str) or not re.fullmatch(r"[A-Za-z]+", method): + raise ValueError("A valid HTTP method is required") + request_headers = self._request_headers(headers) + request_headers.update(self.auth_headers()) + deadline = Deadline(duration) + try: + response = self._http.pool.request( + method.upper(), + target, + headers=request_headers, + timeout=urllib3.Timeout(total=deadline.remaining()), + redirect=False, + retries=False, + **options, + ) + deadline.remaining() + return response + except (urllib3.exceptions.HTTPError, OSError, ValueError, TypeError, RequestError): + raise AuthError() from None + + @staticmethod + def _request_headers(headers: Mapping[str, str] | None) -> dict[str, str]: + if headers is None: + return {} + if not isinstance(headers, Mapping): + raise ValueError("Request headers must be a mapping of strings") + for name, value in headers.items(): + if ( + not isinstance(name, str) + or not isinstance(value, str) + or name.lower() == "authorization" + or any(character in name + value for character in ("\r", "\n")) + ): + raise ValueError("Request headers must be valid and must not include Authorization") + return dict(headers) + + def _get_token(self, deadline: Deadline) -> _AccessToken: + with self._lock: + if self._cached_token is not None and self._cached_token.cacheable(): + return self._cached_token + self._cached_token = None + owner = self._flight is None + if self._flight is None: + self._flight = _Exchange() + flight = self._flight + if owner: + self._run_exchange(flight, deadline) + elif not flight.done.wait(timeout=deadline.remaining()): + raise TokenExchangeError() + deadline.remaining() + if flight.token is None or not flight.token.usable(): + raise TokenExchangeError() + return flight.token + + def _run_exchange(self, flight: _Exchange, deadline: Deadline) -> None: + try: + token = self._exchange(deadline) + with self._lock: + if token.cacheable(): + self._cached_token = token + flight.token = token + finally: + # Waiters keep this flight's result, including uncacheable short + # tokens. Calls starting after completion must acquire their own. + with self._lock: + self._flight = None + flight.done.set() + + def _exchange(self, deadline: Deadline) -> _AccessToken: + for attempt in range(3): + try: + return self._exchange_once(deadline) + except RequestError as error: + if not error.retryable or attempt == 2: + raise TokenExchangeError() from None + delay = 0.1 * 2**attempt + if deadline.remaining() <= delay: + raise TokenExchangeError() from None + time.sleep(delay) + raise TokenExchangeError() + + def _credentials(self) -> str: + try: + secret = self._client_secret if isinstance(self._client_secret, str) else self._client_secret() + except Exception: + # Secret providers can raise arbitrary exceptions containing their + # configuration or response data. None of it crosses this boundary. + raise TokenExchangeError() from None + if not isinstance(secret, str) or not secret: + raise TokenExchangeError() + credentials = f"{quote_plus(self._client_id)}:{quote_plus(secret)}" + return base64.b64encode(credentials.encode()).decode() + + def _exchange_once(self, deadline: Deadline) -> _AccessToken: + started = time.monotonic() + authorization = self._credentials() + status, payload = self._http.json_request( + "POST", + self._token_url, + deadline, + body=urlencode(self._fields).encode(), + headers={ + "Authorization": f"Basic {authorization}", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + if status != 200: + raise RequestError(retryable=status == 429 or 500 <= status <= 599) + return self._parse_token(payload, started) + + @staticmethod + def _parse_token(payload: dict[str, Any], started: float) -> _AccessToken: + value = payload.get("access_token") + token_type = payload.get("token_type") + if ( + not isinstance(value, str) + or not _BEARER_TOKEN.fullmatch(value) + or not isinstance(token_type, str) + or token_type.lower() != "bearer" + ): + raise TokenExchangeError() + expires_at = None + if "expires_in" in payload: + try: + lifetime = finite_seconds(payload["expires_in"], positive=True) + except ValueError: + raise TokenExchangeError() from None + expires_at = started + lifetime + token = _AccessToken(value, expires_at) + if not token.usable(): + raise TokenExchangeError() + return token 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..1c16f80a60f --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/verifier.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import math +import re +import time +from typing import 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 +from aws_lambda_powertools.utilities.auth.exceptions import ( + InvalidClaimsError, + InvalidSignatureError, + InvalidTokenError, + TokenExpiredError, +) + +_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``. + 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, + 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._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) + 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 _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..f7556e6aead --- /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.oauth2 +::: aws_lambda_powertools.utilities.auth.exceptions +::: aws_lambda_powertools.utilities.auth.testing 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..590c3982df1 --- /dev/null +++ b/docs/utilities/auth.md @@ -0,0 +1,351 @@ +--- +title: Auth +description: JWT access-token verification and OAuth client credentials for Lambda +--- + +Auth verifies incoming JWT access tokens and obtains separate OAuth bearer tokens for downstream APIs. +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. +* Acquire and cache resource-specific client-credentials tokens, including rotating client secrets. +* 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). + +### 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. + +### Call a downstream API + +Create one `OAuth2Client` per downstream resource. This example loads a client secret from Secrets Manager and requests a distinct Inventory access token. +The Lambda role needs permission to read the configured secret. + +```python title="outbound.py" +--8<-- "examples/auth/src/outbound.py" +``` + +Use `auth_headers()` to integrate with an application-owned HTTP client. Pass only trusted destination URLs. +`request()` requires HTTPS, rejects another Authorization header, and disables redirects and downstream retries. +It returns a urllib3 response with `.status`, `.data`, and `.json()`; check the downstream status before using the body. + +## 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. +Require and validate provider-specific claims when an issuer can mint other token types with the same audience. +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 an object with `status_code` and `headers` and must return an Event Handler `Response`. +Preserve those fields when customizing the body. This callback replaces the error response; it does not invoke the protected handler. + +### 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. + +`prefetch()` fetches absent or expired keys during initialization. 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.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`. + +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`. +HTTP simple responses also require payload version 2.0 and `EnableSimpleResponses: true`. + +```yaml title="template.yaml" +--8<-- "examples/auth/template.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. + +### OAuth client credentials + +Only `client_secret_basic` is supported. Client ID and secret are individually form-encoded before constructing HTTP Basic credentials. +They are never added to the request body. `audience` and RFC 8707 `resource` are optional, mutually exclusive request fields; choose the one your provider supports. +Scopes and resource selection are fixed per client, and separate instances never share tokens. + +Tokens are cached until 30 seconds before their advertised expiration, measured conservatively from request start using a monotonic clock. +Tokens with 30 seconds or less remaining, or no `expires_in`, are returned without caching. Already elapsed lifetimes and malformed responses are rejected. +Concurrent acquisition shares one exchange, including short-lived tokens for callers already waiting on that exchange. + +A secret callable is invoked on each exchange attempt. Existing access tokens remain usable until their own refresh boundary. +In the Parameters example, the provider's `max_age=300` can delay observation of a changed secret by five minutes. + +`timeout_seconds` defaults to 3 for acquisition, including at most two retries with backoff for network failures, HTTP 429, and HTTP 5xx. +Other error responses and malformed successful responses are not retried. The `request(timeout=5)` budget is separate and applies to the downstream operation. +Synchronous OS name resolution and application-provided secret callables cannot be forcibly interrupted; configure secret-provider timeouts accordingly. + +### 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 maps Keycloak-style `azp`, `sub`, and `scope` claims; other providers require their own mapping. + +```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.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import InvalidTokenError, JWKSFetchError + +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"], +) + + +class PowertoolsTokenVerifier(TokenVerifier): + async def verify_token(self, token: str) -> AccessToken | None: + try: + claims = await asyncio.to_thread(verifier.verify, token) + except (InvalidTokenError, JWKSFetchError): + 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. +A distinct availability response 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. +For downstream calls, use a separate `OAuth2Client` and offload its synchronous operation: + +```python +from urllib.parse import quote + + +@mcp.tool() +async def check_stock(sku: str) -> dict: + require_scope("inventory:read") + response = await asyncio.to_thread( + inventory_api.request, + "GET", + f"https://inventory.example.com/stock/{quote(sku, safe='')}", + timeout=5, + ) + if response.status != 200: + raise RuntimeError("Inventory lookup failed") + return response.json() +``` + +Configure `inventory_api` as in the outbound example. Never forward the incoming MCP bearer token to another resource. +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. +`TokenExchangeError` covers unsuccessful token acquisition. + +Errors have fixed credential-free messages. Public verification, prefetch, and OAuth operations detach underlying exception causes and contexts, +including errors raised by secret loaders. Utility representations omit tokens and secrets. +Do not log token dictionaries, request headers, secret-provider errors, or token-endpoint response bodies in application code. + +Opaque-token introspection, delegated token exchange, interactive grants, SigV4, additional OAuth client-authentication methods, and native async clients are outside this utility. + +## 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.py b/examples/auth/src/authorizer.py new file mode 100644 index 00000000000..8592ab7ddd9 --- /dev/null +++ b/examples/auth/src/authorizer.py @@ -0,0 +1,24 @@ +import os + +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.typing import LambdaContext + +verifier = JWTVerifier( + issuer=os.environ["ISSUER_URL"], + audience=os.environ["RESOURCE_URL"], + algorithms=["RS256"], + required_claims=["sub"], +) + + +def iam_handler(event: dict, context: LambdaContext): + return verifier.authorize( + event, + scopes=["orders:read"], + response_format="iam", + context_claims=["sub"], + ) + + +def simple_handler(event: dict, context: LambdaContext): + return verifier.authorize(event, scopes=["orders:read"], response_format="simple") diff --git a/examples/auth/src/backend.py b/examples/auth/src/backend.py new file mode 100644 index 00000000000..881b36e9c3d --- /dev/null +++ b/examples/auth/src/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/middleware.py b/examples/auth/src/middleware.py new file mode 100644 index 00000000000..3e006e27fb7 --- /dev/null +++ b/examples/auth/src/middleware.py @@ -0,0 +1,22 @@ +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"], +) + + +@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/src/outbound.py b/examples/auth/src/outbound.py new file mode 100644 index 00000000000..87f44d84780 --- /dev/null +++ b/examples/auth/src/outbound.py @@ -0,0 +1,30 @@ +import os +from urllib.parse import quote + +from aws_lambda_powertools.utilities import parameters +from aws_lambda_powertools.utilities.auth import OAuth2Client +from aws_lambda_powertools.utilities.typing import LambdaContext + + +def load_secret() -> str: + secret = parameters.get_secret(os.environ["CLIENT_SECRET_NAME"], max_age=300) + if not isinstance(secret, str): + raise ValueError("Expected a string client secret") + return secret + + +inventory_api = OAuth2Client( + token_url=os.environ["TOKEN_URL"], + client_id=os.environ["CLIENT_ID"], + client_secret=load_secret, + scopes=["inventory:read"], + audience="https://inventory.example.com", +) + + +def lambda_handler(event: dict, context: LambdaContext): + sku = quote(event["sku"], safe="") + response = inventory_api.request("GET", f"https://inventory.example.com/stock/{sku}", timeout=5) + if response.status != 200: + raise RuntimeError("Inventory lookup failed") + return response.json() diff --git a/examples/auth/src/requirements.txt b/examples/auth/src/requirements.txt new file mode 100644 index 00000000000..5f017438d3d --- /dev/null +++ b/examples/auth/src/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools[auth] diff --git a/examples/auth/template.yaml b/examples/auth/template.yaml new file mode 100644 index 00000000000..3009e39f106 --- /dev/null +++ b/examples/auth/template.yaml @@ -0,0 +1,88 @@ +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 + CodeUri: src/ + 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 + + HttpAuthorizer: + Type: AWS::Serverless::Function + Properties: + Handler: authorizer.simple_handler + + 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 + Events: + Orders: + Type: Api + Properties: + RestApiId: !Ref RestApi + Path: /orders + Method: GET + + HttpBackend: + Type: AWS::Serverless::Function + Properties: + Handler: backend.lambda_handler + Events: + Orders: + Type: HttpApi + Properties: + ApiId: !Ref HttpApi + Path: /orders + Method: GET 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 735a816831e..9df0e9c7c03 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 = "b1ad2045da51e106fb390b6192c34e60cf52601fb9de45c40a6270689a059ea6" diff --git a/pyproject.toml b/pyproject.toml index ae52f2ddd13..bfd8bece9b6 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"] 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..2b41a6db25a --- /dev/null +++ b/tests/functional/auth/_auth_import_probe.py @@ -0,0 +1,88 @@ +"""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 == "oauth": + sys.meta_path.insert(0, BlockImports("jwt", "cryptography")) + + from aws_lambda_powertools.utilities.auth import OAuth2Client + + client = OAuth2Client( + token_url="https://idp.example.com/token", + client_id="test-client", + client_secret="test-secret", + ) + assert "jwt" not in sys.modules + assert "cryptography" not in sys.modules +elif 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", "OAuth2Client"} <= 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["OAuth2Client"] is auth.OAuth2Client +elif scenario == "star": + from aws_lambda_powertools.utilities.auth import * # noqa: E402,F403 + + assert {"JWTVerifier", "OAuth2Client"} <= 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..524229149ab --- /dev/null +++ b/tests/functional/auth/conftest.py @@ -0,0 +1,94 @@ +import io +import json +import time +from collections import deque + +import jwt +import pytest +import urllib3 +from cryptography.hazmat.primitives.asymmetric import rsa + + +@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"): + return jwt.encode( + claims if payload is None else payload, + signing_key if key is None else key, + algorithm=algorithm, + headers={"kid": kid}, + ) + + return issue + + +class FakeHTTP: + """In-memory token and 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): + 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..29273dc3765 --- /dev/null +++ b/tests/functional/auth/test_authorizer.py @@ -0,0 +1,192 @@ +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) diff --git a/tests/functional/auth/test_errors.py b/tests/functional/auth/test_errors.py new file mode 100644 index 00000000000..2493727d533 --- /dev/null +++ b/tests/functional/auth/test_errors.py @@ -0,0 +1,114 @@ +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, OAuth2Client +from aws_lambda_powertools.utilities.auth.exceptions import ( + AuthError, + InvalidClaimsError, + InvalidSignatureError, + InvalidTokenError, + JWKSFetchError, + TokenExchangeError, +) + +ISSUER = "https://idp.example.com/" +TOKEN_URL = ISSUER + "token" +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", ["auth_headers", "request"]) +def test_secret_loader_errors_have_no_chain_even_inside_a_callers_exception_handler(method): + def load_secret(): + raise RuntimeError(PRIVATE_DATA) + + client = OAuth2Client(token_url=TOKEN_URL, client_id="orders", client_secret=load_secret) + operation = client.auth_headers if method == "auth_headers" else lambda: client.request("GET", RESOURCE_URL) + try: + raise LookupError(PRIVATE_DATA) + except LookupError: + assert_sanitized(operation, TokenExchangeError) + + +@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) + + +@pytest.mark.parametrize("failure", ["transport", "json", "expires_in", "downstream"]) +def test_oauth_errors_detach_transport_and_response_exceptions(http, failure): + client = OAuth2Client(token_url=TOKEN_URL, client_id="orders", client_secret="test-secret") + payload = {"access_token": "test-token", "token_type": "Bearer", "expires_in": 600} + if failure == "transport": + response = urllib3.exceptions.SSLError(PRIVATE_DATA) + elif failure == "json": + response = PRIVATE_DATA.encode() + elif failure == "expires_in": + response = {**payload, "expires_in": PRIVATE_DATA} + else: + response = payload + http.serve(TOKEN_URL, response, method="POST") + http.serve(RESOURCE_URL, urllib3.exceptions.SSLError(PRIVATE_DATA)) + operation = (lambda: client.request("GET", RESOURCE_URL)) if failure == "downstream" else client.auth_headers + assert_sanitized(operation, AuthError if failure == "downstream" else TokenExchangeError) diff --git a/tests/functional/auth/test_imports.py b/tests/functional/auth/test_imports.py new file mode 100644 index 00000000000..272a82f26a0 --- /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", ["oauth", "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..806f4b576c3 --- /dev/null +++ b/tests/functional/auth/test_jwks_cache.py @@ -0,0 +1,215 @@ +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"]}, 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()) 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_oauth2.py b/tests/functional/auth/test_oauth2.py new file mode 100644 index 00000000000..25fba43168f --- /dev/null +++ b/tests/functional/auth/test_oauth2.py @@ -0,0 +1,292 @@ +import base64 +import threading +import time +import traceback +from collections import deque +from concurrent.futures import ThreadPoolExecutor +from urllib.parse import parse_qs + +import pytest + +from aws_lambda_powertools.utilities.auth import OAuth2Client +from aws_lambda_powertools.utilities.auth.exceptions import TokenExchangeError + +TOKEN_URL = "https://idp.example.com/oauth/token" + + +def client(**options): + config = { + "token_url": TOKEN_URL, + "client_id": "orders-client", + "client_secret": "test-client-secret", + "scopes": ["orders:read"], + } + return OAuth2Client(**{**config, **options}) + + +def test_client_credentials_exchange_selects_resource_and_caches_token(http): + http.serve( + TOKEN_URL, + {"access_token": "opaque-access-token", "token_type": "Bearer", "expires_in": 3600}, + method="POST", + ) + subject = client(audience="https://api.example.com") + + assert subject.auth_headers() == {"Authorization": "Bearer opaque-access-token"} + assert subject.auth_headers() == {"Authorization": "Bearer opaque-access-token"} + assert len(http.requests) == 1 + method, url, request = http.requests[0] + assert method == "POST" + assert url == TOKEN_URL + assert parse_qs(request["body"].decode()) == { + "grant_type": ["client_credentials"], + "scope": ["orders:read"], + "audience": ["https://api.example.com"], + } + assert request["headers"]["Content-Type"] == "application/x-www-form-urlencoded" + + +def test_basic_auth_encodes_each_credential_before_base64(http): + http.serve(TOKEN_URL, {"access_token": "token", "token_type": "bearer", "expires_in": 3600}, method="POST") + subject = client(client_id="client:id", client_secret="secret:value with space") + subject.auth_headers() + request = http.requests[0][2] + encoded = request["headers"]["Authorization"].removeprefix("Basic ") + + assert base64.b64decode(encoded).decode() == "client%3Aid:secret%3Avalue+with+space" + assert "client_secret" not in parse_qs(request["body"].decode()) + + +def test_token_is_reacquired_before_expiry_using_the_current_secret(http, clock): + secret = ["initial-secret"] + observed = [] + + def load_secret(): + observed.append(secret[0]) + return secret[0] + + http.serve(TOKEN_URL, {"access_token": "first", "token_type": "Bearer", "expires_in": 100}, method="POST") + subject = client(client_secret=load_secret) + assert subject.auth_headers()["Authorization"] == "Bearer first" + clock.advance(69) + assert subject.auth_headers()["Authorization"] == "Bearer first" + assert observed == ["initial-secret"] + secret[0] = "rotated-secret" + http.serve(TOKEN_URL, {"access_token": "second", "token_type": "Bearer", "expires_in": 100}, method="POST") + clock.advance(1) + + assert subject.auth_headers()["Authorization"] == "Bearer second" + assert observed == ["initial-secret", "rotated-secret"] + + +@pytest.mark.parametrize("lifetime", [1, 30, None]) +def test_short_lived_tokens_and_tokens_without_lifetimes_are_not_cached(http, lifetime): + payload = {"access_token": "first", "token_type": "Bearer"} + if lifetime is not None: + payload["expires_in"] = lifetime + http.serve(TOKEN_URL, payload, method="POST") + subject = client() + assert subject.auth_headers()["Authorization"] == "Bearer first" + http.serve(TOKEN_URL, {**payload, "access_token": "second"}, method="POST") + + assert subject.auth_headers()["Authorization"] == "Bearer second" + assert len(http.requests) == 2 + + +@pytest.mark.parametrize( + "override", + [ + {"access_token": ""}, + {"access_token": None}, + {"access_token": "token\r\ninjected"}, + {"token_type": "DPoP"}, + {"token_type": None}, + {"expires_in": "3600"}, + {"expires_in": 0}, + {"expires_in": -1}, + {"expires_in": True}, + {"expires_in": None}, + {"expires_in": float("inf")}, + ], +) +def test_invalid_token_responses_are_rejected_without_retry(http, override): + payload = {"access_token": "token", "token_type": "Bearer", "expires_in": 3600, **override} + http.serve(TOKEN_URL, payload, method="POST") + + with pytest.raises(TokenExchangeError): + client().auth_headers() + assert len(http.requests) == 1 + + +def test_resources_have_separate_token_caches_and_request_parameters(http): + http.responses[("POST", TOKEN_URL)] = deque( + [ + (200, {"access_token": "orders-token", "token_type": "Bearer", "expires_in": 3600}), + (200, {"access_token": "inventory-token", "token_type": "Bearer", "expires_in": 3600}), + ], + ) + orders = client(audience="https://orders.example.com") + inventory = client(resource="https://inventory.example.com") + + assert orders.auth_headers()["Authorization"] == "Bearer orders-token" + assert inventory.auth_headers()["Authorization"] == "Bearer inventory-token" + assert orders.auth_headers()["Authorization"] == "Bearer orders-token" + assert len(http.requests) == 2 + assert parse_qs(http.requests[0][2]["body"].decode())["audience"] == ["https://orders.example.com"] + assert parse_qs(http.requests[1][2]["body"].decode())["resource"] == ["https://inventory.example.com"] + + +@pytest.mark.parametrize("status", [400, 401, 403]) +def test_permanent_exchange_errors_are_not_retried(http, status): + http.serve( + TOKEN_URL, + {"error": "invalid_client", "error_description": "private details"}, + status=status, + method="POST", + ) + + with pytest.raises(TokenExchangeError): + client().auth_headers() + assert len(http.requests) == 1 + + +def test_transient_exchange_errors_have_at_most_two_retries(http, clock, monkeypatch): + http.serve(TOKEN_URL, b"temporarily unavailable", status=503, method="POST") + monkeypatch.setattr(time, "sleep", clock.advance) + secrets = [] + + def load_secret(): + secrets.append("secret") + return secrets[-1] + + with pytest.raises(TokenExchangeError): + client(client_secret=load_secret).auth_headers() + assert len(http.requests) == 3 + assert len(secrets) == 3 + + +def test_transient_exchange_can_recover_within_the_same_budget(http, clock, monkeypatch): + http.responses[("POST", TOKEN_URL)] = deque( + [(429, {}), (200, {"access_token": "recovered", "token_type": "Bearer", "expires_in": 100})], + ) + monkeypatch.setattr(time, "sleep", clock.advance) + + assert client().auth_headers() == {"Authorization": "Bearer recovered"} + assert len(http.requests) == 2 + + +def test_exchange_cannot_accept_a_response_after_its_deadline(http, clock): + def slow_endpoint(): + clock.advance(4) + return {"access_token": "too-late", "token_type": "Bearer", "expires_in": 3600} + + http.serve(TOKEN_URL, slow_endpoint, method="POST") + with pytest.raises(TokenExchangeError): + client(timeout_seconds=3).auth_headers() + assert len(http.requests) == 1 + + +def test_exchange_cannot_return_a_token_that_expired_during_the_request(http, clock): + def slow_endpoint(): + clock.advance(2) + return {"access_token": "already-expired", "token_type": "Bearer", "expires_in": 1} + + http.serve(TOKEN_URL, slow_endpoint, method="POST") + with pytest.raises(TokenExchangeError): + client().auth_headers() + + +def test_concurrent_requests_share_one_token_exchange(http): + entered = threading.Event() + release = threading.Event() + + def exchange(): + entered.set() + assert release.wait(2) + return {"access_token": "shared-token", "token_type": "Bearer", "expires_in": 100} + + http.serve(TOKEN_URL, exchange, method="POST") + subject = client() + with ThreadPoolExecutor(max_workers=8) as executor: + results = [executor.submit(subject.auth_headers) for _ in range(8)] + assert entered.wait(2) + release.set() + assert all(result.result(timeout=2) == {"Authorization": "Bearer shared-token"} for result in results) + assert len(http.requests) == 1 + + +def test_secret_loader_errors_and_representations_are_redacted(http): + def load_secret(): + raise RuntimeError("sensitive-loader-data") + + subject = client(client_secret=load_secret) + with pytest.raises(TokenExchangeError) as error: + subject.auth_headers() + assert "sensitive-loader-data" not in "".join(traceback.format_exception(error.value)) + assert repr(subject) == "" + + +@pytest.mark.parametrize( + "options", + [ + {"audience": "one", "resource": "two"}, + {"token_url": "http://idp.example.com/token"}, + {"token_url": "https://user:secret@idp.example.com/token"}, + {"client_id": ""}, + {"client_secret": ""}, + {"timeout_seconds": 0}, + {"timeout_seconds": float("inf")}, + {"scopes": ["scope\ninjection"]}, + ], +) +def test_invalid_client_configuration_is_rejected(options): + with pytest.raises(ValueError): + client(**options) + + +def test_request_attaches_resource_token_without_forwarding_client_credentials(http): + http.serve(TOKEN_URL, {"access_token": "resource-token", "token_type": "Bearer", "expires_in": 100}, method="POST") + http.serve("https://api.example.com/orders", {"orders": [123]}) + subject = client(audience="https://api.example.com") + + response = subject.request( + "GET", + "https://api.example.com/orders", + headers={"Accept": "application/json"}, + timeout=5, + ) + + assert response.json() == {"orders": [123]} + request = http.requests[-1][2] + assert request["headers"] == {"Accept": "application/json", "Authorization": "Bearer resource-token"} + assert request["redirect"] is False + assert request["retries"] is False + assert "test-client-secret" not in repr(subject) + assert "resource-token" not in repr(subject) + + +@pytest.mark.parametrize( + "url,options", + [ + ("http://api.example.com/orders", {}), + ("https://api.example.com/orders", {"headers": {"authorization": "other-token"}}), + ("https://api.example.com/orders", {"redirect": True}), + ("https://api.example.com/orders", {"retries": 3}), + ("https://api.example.com/orders", {"timeout": 0}), + ], +) +def test_request_rejects_unsafe_overrides_before_acquiring_credentials(http, url, options): + with pytest.raises(ValueError): + client().request("GET", url, **options) + assert http.requests == [] + + +def test_request_does_not_follow_redirects_or_retry_downstream_failures(http): + http.serve(TOKEN_URL, {"access_token": "token", "token_type": "Bearer", "expires_in": 100}, method="POST") + http.serve("https://api.example.com/orders", {}, status=302) + subject = client() + + assert subject.request("GET", "https://api.example.com/orders").status == 302 + http.serve("https://api.example.com/orders", {}, status=503) + assert subject.request("GET", "https://api.example.com/orders").status == 503 + assert len(http.requests) == 3 diff --git a/tests/functional/auth/test_profiles.py b/tests/functional/auth/test_profiles.py new file mode 100644 index 00000000000..4859ac1e32e --- /dev/null +++ b/tests/functional/auth/test_profiles.py @@ -0,0 +1,116 @@ +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) 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_verifier.py b/tests/functional/auth/test_verifier.py new file mode 100644 index 00000000000..9ac22acba60 --- /dev/null +++ b/tests/functional/auth/test_verifier.py @@ -0,0 +1,271 @@ +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), + ("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"), + ("audience", ""), + ("audience", []), + ("algorithms", []), + ("algorithms", ["none"]), + ("algorithms", ["HS256"]), + ("algorithms", ["RS256", "HS256"]), + ("clock_skew_seconds", -1), + ("clock_skew_seconds", float("inf")), + ("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("token", ["", "not-a-jwt", "a.b.c", None, 42]) +def test_malformed_tokens_raise_redacted_errors(jwks, token): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + + 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..a437efadd75 --- /dev/null +++ b/tests/integration/auth/conftest.py @@ -0,0 +1,133 @@ +"""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 +def https_server(tls_files, monkeypatch): + endpoint = LocalHTTPS() + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + 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", "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 = True + + 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..8074c066d6d --- /dev/null +++ b/tests/integration/auth/test_https.py @@ -0,0 +1,149 @@ +import base64 +import time +from urllib.parse import parse_qs + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from aws_lambda_powertools.utilities.auth import JWTVerifier, OAuth2Client +from aws_lambda_powertools.utilities.auth.exceptions import AuthError, JWKSFetchError, TokenExchangeError + +TOKEN_RESPONSE = {"access_token": "local-test-token", "token_type": "Bearer", "expires_in": 600} + + +def client(endpoint, **options): + return OAuth2Client( + token_url=endpoint.url + "/token", + client_id="orders", + client_secret="test-only-secret", + scopes=["inventory:read"], + **options, + ) + + +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_token_exchange_and_authenticated_request_over_trusted_tls(https_server): + https_server.serve("/token", TOKEN_RESPONSE) + https_server.serve("/inventory", {"items": [123]}) + subject = client(https_server) + + response = subject.request("GET", https_server.url + "/inventory") + + assert response.status == 200 + assert response.json() == {"items": [123]} + assert subject.auth_headers() == {"Authorization": "Bearer local-test-token"} + exchange, resource = https_server.requests + assert exchange[:2] == ("POST", "/token") + assert base64.b64decode(exchange[2]["Authorization"].removeprefix("Basic ")).decode() == "orders:test-only-secret" + assert parse_qs(exchange[3].decode()) == {"grant_type": ["client_credentials"], "scope": ["inventory:read"]} + assert resource[2]["Authorization"] == "Bearer local-test-token" + assert "test-only-secret" not in str(resource) + + +@pytest.mark.parametrize("operation", ["prefetch", "auth_headers"]) +def test_untrusted_certificates_fail_closed_without_sending_credentials(https_server, monkeypatch, operation): + monkeypatch.delenv("SSL_CERT_FILE") + https_server.serve("/token", TOKEN_RESPONSE) + https_server.serve("/keys", {"keys": []}) + if operation == "prefetch": + subject = verifier(https_server, jwks_uri=https_server.url + "/keys") + expected_error = JWKSFetchError + else: + subject = client(https_server, timeout_seconds=0.5) + expected_error = TokenExchangeError + with pytest.raises(expected_error) as error: + getattr(subject, operation)() + assert error.value.__context__ is None + assert error.value.__cause__ is None + assert https_server.requests == [] + + +@pytest.mark.parametrize("endpoint", ["keys", "token"]) +@pytest.mark.parametrize("failure", ["oversized", "redirect", "stall", "trickle"]) +def test_auth_endpoint_failures_are_bounded_and_do_not_follow_redirects(https_server, endpoint, failure): + payload = {"keys": []} if endpoint == "keys" else TOKEN_RESPONSE + if failure == "oversized": + https_server.serve("/" + endpoint, b'{"padding":"' + b"x" * (1024 * 1024) + b'"}') + elif failure == "redirect": + https_server.serve("/" + endpoint, {}, status=307, headers={"Location": https_server.url + "/redirected"}) + https_server.serve("/redirected", payload) + else: + https_server.serve( + "/" + endpoint, + payload, + stall=failure == "stall", + interval=0.04 if failure == "trickle" else 0, + ) + if endpoint == "keys": + subject = verifier(https_server, jwks_uri=https_server.url + "/keys", timeout_seconds=0.2) + operation, expected_error = subject.prefetch, JWKSFetchError + else: + subject = client(https_server, timeout_seconds=0.2) + operation, expected_error = subject.auth_headers, TokenExchangeError + + started = time.monotonic() + with pytest.raises(expected_error) as error: + operation() + assert time.monotonic() - started < 1 + assert error.value.__context__ is None + assert [request[1] for request in https_server.requests] == ["/" + endpoint] + + +def test_downstream_redirects_are_returned_without_forwarding_bearer_tokens(https_server): + https_server.serve("/token", TOKEN_RESPONSE) + https_server.serve("/inventory", {}, status=307, headers={"Location": https_server.url + "/other"}) + https_server.serve("/other", {}) + + response = client(https_server).request("GET", https_server.url + "/inventory") + + assert response.status == 307 + assert [request[1] for request in https_server.requests] == ["/token", "/inventory"] + + +def test_downstream_failures_are_not_retried(https_server): + https_server.serve("/token", TOKEN_RESPONSE) + https_server.serve("/inventory", {}, status=503) + assert client(https_server).request("POST", https_server.url + "/inventory").status == 503 + assert [request[1] for request in https_server.requests] == ["/token", "/inventory"] + + +def test_downstream_timeout_has_a_separate_budget_and_a_sanitized_error(https_server): + https_server.serve("/token", TOKEN_RESPONSE) + https_server.serve("/inventory", {"items": []}, stall=True) + subject = client(https_server, timeout_seconds=3) + started = time.monotonic() + + with pytest.raises(AuthError) as error: + subject.request("GET", https_server.url + "/inventory", timeout=0.2) + + assert time.monotonic() - started < 1 + assert error.value.__context__ is None + assert [request[1] for request in https_server.requests] == ["/token", "/inventory"]