From 5ca2e01e39238757efeb2d91351239907b79fd08 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 01/15] 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 32b15f749b5..03e54597da9 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"] From 47573c76fefa8cf3e31e4114321414b3f64a1beb Mon Sep 17 00:00:00 2001 From: Ben Freiberg <9841563+bfreiberg@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:03:05 +0200 Subject: [PATCH 02/15] test(auth): cover validation and concurrent failure paths Exercise malformed inputs, shared failures, waiter deadlines, and persistent HTTPS connections. Collect fresh-process import coverage through coverage.py's subprocess patch for pytest-cov 7. --- poetry.lock | 2 +- pyproject.toml | 4 +- tests/functional/auth/test_authorizer.py | 32 +++++++ tests/functional/auth/test_jwks_cache.py | 34 ++++++- tests/functional/auth/test_oauth2.py | 108 +++++++++++++++++++++++ tests/functional/auth/test_profiles.py | 28 ++++++ tests/functional/auth/test_verifier.py | 11 ++- tests/integration/auth/conftest.py | 15 +++- 8 files changed, 226 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index 03e54597da9..6423f9a7e77 100644 --- a/poetry.lock +++ b/poetry.lock @@ -5389,4 +5389,4 @@ valkey = ["valkey-glide"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0.0" -content-hash = "b1ad2045da51e106fb390b6192c34e60cf52601fb9de45c40a6270689a059ea6" +content-hash = "d1be888618d485c538c744c46441d09297f7dab01353fb5b1054d1c46d1bb523" diff --git a/pyproject.toml b/pyproject.toml index bfd8bece9b6..719b4eaabf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ kafka-consumer-avro = ["avro"] kafka-consumer-protobuf = ["protobuf"] [tool.poetry.group.dev.dependencies] -coverage = { extras = ["toml"], version = "^7.6" } +coverage = { extras = ["toml"], version = "^7.10.6" } pytest = ">=8.3.4,<10.0.0" boto3 = "^1.26.164" isort = ">=5.13.2,<10.0.0" @@ -148,6 +148,8 @@ omit = [ "aws_lambda_powertools/metrics/metric.py" # barrel import (export-only) ] branch = true +# pytest-cov 7 delegates subprocess measurement to coverage.py. +patch = ["subprocess"] [tool.coverage.html] directory = "test_report" diff --git a/tests/functional/auth/test_authorizer.py b/tests/functional/auth/test_authorizer.py index 29273dc3765..f192baf427e 100644 --- a/tests/functional/auth/test_authorizer.py +++ b/tests/functional/auth/test_authorizer.py @@ -190,3 +190,35 @@ def test_invalid_request_arns_raise_instead_of_returning_an_invalid_policy(jwks, event["methodArn"] = arn with pytest.raises(ValueError, match="concrete API Gateway"): verifier(jwks).authorize(event) + + +@pytest.mark.parametrize("malformed", [False, True]) +@pytest.mark.parametrize("field", ["headers", "multiValueHeaders"]) +def test_authorizer_denies_malformed_or_ambiguous_header_maps(jwks, issue_token, malformed, field): + token = "Bearer " + issue_token() + value = [token] if field == "multiValueHeaders" else token + headers = [("Authorization", value)] if malformed else {"Authorization": value, "authorization": value} + event = {"type": "REQUEST", "methodArn": ARN, field: headers} + response = verifier(jwks).authorize(event) + assert response["principalId"] == "unauthorized" + assert response["policyDocument"]["Statement"][0]["Effect"] == "Deny" + assert "context" not in response + + +@pytest.mark.parametrize("event", [None, [], {}, {"type": "OTHER"}]) +def test_authorizer_rejects_unsupported_events(jwks, event): + with pytest.raises(ValueError, match="TOKEN or REQUEST"): + verifier(jwks).authorize(event) + + +@pytest.mark.parametrize( + "options,message", + [ + ({"response_format": "unsupported"}, "response_format"), + ({"context_claims": ["claims"]}, "claims is reserved"), + ], +) +def test_authorizer_rejects_invalid_response_configuration(jwks, issue_token, options, message): + event = {"type": "TOKEN", "methodArn": ARN, "authorizationToken": "Bearer " + issue_token()} + with pytest.raises(ValueError, match=message): + verifier(jwks).authorize(event, **options) diff --git a/tests/functional/auth/test_jwks_cache.py b/tests/functional/auth/test_jwks_cache.py index 806f4b576c3..8e1c2ec9bf5 100644 --- a/tests/functional/auth/test_jwks_cache.py +++ b/tests/functional/auth/test_jwks_cache.py @@ -127,7 +127,10 @@ def test_invalid_discovery_never_falls_back_or_fetches_untrusted_keys(http, issu assert len(http.requests) == 1 -@pytest.mark.parametrize("body", [{}, {"keys": None}, {"keys": ["bad-key"]}, b"not json", b"x" * (1024 * 1024 + 1)]) +@pytest.mark.parametrize( + "body", + [{}, {"keys": None}, {"keys": ["bad-key"]}, [], None, b"not json", b"x" * (1024 * 1024 + 1)], +) def test_malformed_key_sets_fail_closed(http, issue_token, body): http.serve(JWKS_URL, body) @@ -213,3 +216,32 @@ def test_failed_unknown_key_refresh_preserves_only_still_fresh_keys(http, jwks, clock.advance(299) with pytest.raises(JWKSFetchError): subject.verify(issue_token()) + + +def test_waiting_verifier_timeout_does_not_cancel_the_shared_key_fetch(http, jwks, issue_token): + entered = threading.Event() + release = threading.Event() + + def fetch(): + entered.set() + assert release.wait(5) + return jwks + + http.serve(JWKS_URL, fetch) + owner = verifier(jwks_uri=JWKS_URL, timeout_seconds=5) + waiter = verifier(jwks_uri=JWKS_URL, timeout_seconds=0.1) + token = issue_token() + with ThreadPoolExecutor(max_workers=1) as executor: + result = executor.submit(owner.verify, token) + try: + assert entered.wait(5) + with pytest.raises(JWKSFetchError) as error: + waiter.verify(token) + assert error.value.__context__ is None + assert not result.done() + finally: + release.set() + assert result.result(timeout=5)["sub"] == "user-123" + + assert waiter.verify(token)["sub"] == "user-123" + assert len(http.requests) == 1 diff --git a/tests/functional/auth/test_oauth2.py b/tests/functional/auth/test_oauth2.py index 25fba43168f..e356928a3ac 100644 --- a/tests/functional/auth/test_oauth2.py +++ b/tests/functional/auth/test_oauth2.py @@ -230,6 +230,9 @@ def load_secret(): "options", [ {"audience": "one", "resource": "two"}, + {"audience": " "}, + {"resource": ""}, + {"resource": 42}, {"token_url": "http://idp.example.com/token"}, {"token_url": "https://user:secret@idp.example.com/token"}, {"client_id": ""}, @@ -273,6 +276,7 @@ def test_request_attaches_resource_token_without_forwarding_client_credentials(h ("https://api.example.com/orders", {"redirect": True}), ("https://api.example.com/orders", {"retries": 3}), ("https://api.example.com/orders", {"timeout": 0}), + ("https://api.example.com/orders", {"headers": [("Accept", "application/json")]}), ], ) def test_request_rejects_unsafe_overrides_before_acquiring_credentials(http, url, options): @@ -290,3 +294,107 @@ def test_request_does_not_follow_redirects_or_retry_downstream_failures(http): 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 + + +@pytest.mark.parametrize("method", [None, "", "GET /", "GET\r\nInjected"]) +def test_invalid_http_methods_are_rejected_before_loading_credentials(http, method): + calls = [] + + def load_secret(): + calls.append(True) + return "test-secret" + + with pytest.raises(ValueError, match="HTTP method"): + client(client_secret=load_secret).request(method, "https://api.example.com/orders") + assert calls == [] + assert http.requests == [] + + +@pytest.mark.parametrize("secret", [None, "", 42]) +def test_invalid_secret_loader_results_are_rejected_before_sending_credentials(http, secret): + with pytest.raises(TokenExchangeError) as error: + client(client_secret=lambda: secret).auth_headers() + assert error.value.__context__ is None + assert http.requests == [] + + +def test_retry_stops_when_the_backoff_exceeds_the_remaining_budget(http, clock, monkeypatch): + sleeps = [] + monkeypatch.setattr(time, "sleep", sleeps.append) + http.serve(TOKEN_URL, {}, status=503, method="POST") + + with pytest.raises(TokenExchangeError): + client(timeout_seconds=0.05).auth_headers() + assert sleeps == [] + assert len(http.requests) == 1 + + +def test_waiting_callers_share_a_failed_exchange_and_can_recover(http, monkeypatch): + entered = threading.Event() + release = threading.Event() + joined = threading.Event() + + def exchange(): + entered.set() + assert release.wait(5) + return {"error": "invalid_client"} + + http.serve(TOKEN_URL, exchange, status=401, method="POST") + subject = client() + with ThreadPoolExecutor(max_workers=2) as executor: + owner = executor.submit(subject.auth_headers) + try: + assert entered.wait(5) + flight = subject._flight + assert flight is not None + wait = flight.done.wait + + def observe_wait(timeout): + joined.set() + return wait(timeout) + + # Keep the real Event; observe it so the provider is released only + # after the second caller has joined the active exchange. + monkeypatch.setattr(flight.done, "wait", observe_wait) + waiter = executor.submit(subject.auth_headers) + assert joined.wait(5) + finally: + release.set() + for result in (owner, waiter): + with pytest.raises(TokenExchangeError) as error: + result.result(timeout=5) + assert error.value.__context__ is None + assert len(http.requests) == 1 + + http.serve(TOKEN_URL, {"access_token": "recovered", "token_type": "Bearer", "expires_in": 100}, method="POST") + assert subject.auth_headers() == {"Authorization": "Bearer recovered"} + assert len(http.requests) == 2 + + +def test_waiting_callers_timeout_without_returning_the_late_token(http): + entered = threading.Event() + release = threading.Event() + + def exchange(): + entered.set() + assert release.wait(5) + return {"access_token": "too-late", "token_type": "Bearer", "expires_in": 100} + + http.serve(TOKEN_URL, exchange, method="POST") + subject = client(timeout_seconds=0.1) + with ThreadPoolExecutor(max_workers=1) as executor: + owner = executor.submit(subject.auth_headers) + try: + assert entered.wait(5) + with pytest.raises(TokenExchangeError): + subject.auth_headers() + assert not owner.done() + assert len(http.requests) == 1 + finally: + release.set() + with pytest.raises(TokenExchangeError): + owner.result(timeout=5) + + http.serve(TOKEN_URL, {"access_token": "recovered", "token_type": "Bearer", "expires_in": 100}, method="POST") + assert subject.auth_headers() == {"Authorization": "Bearer recovered"} + assert len(http.requests) == 2 diff --git a/tests/functional/auth/test_profiles.py b/tests/functional/auth/test_profiles.py index 4859ac1e32e..d7ef0b218d7 100644 --- a/tests/functional/auth/test_profiles.py +++ b/tests/functional/auth/test_profiles.py @@ -114,3 +114,31 @@ def test_any_of_rejects_ambiguous_issuer_configuration(jwks): with pytest.raises(ValueError): JWTVerifier.any_of(verifier, verifier) + + +@pytest.mark.parametrize( + "options", + [ + {"user_pool_id": "invalid"}, + {"user_pool_id": None}, + {"client_id": " "}, + {"client_id": None}, + {"issuer": "https://untrusted.example.com"}, + {"algorithms": ["HS256"]}, + {"jwks_uri": "https://untrusted.example.com/keys"}, + ], +) +def test_cognito_rejects_invalid_or_overridden_trust_configuration(options): + config = { + "user_pool_id": "us-east-1_pool", + "client_id": "desktop-client", + "audience": "https://api.example.com", + } + with pytest.raises(ValueError): + JWTVerifier.cognito(**{**config, **options}) + + +@pytest.mark.parametrize("verifiers", [(), (None,), ("https://idp.example.com",)]) +def test_issuer_groups_require_explicit_verifier_instances(verifiers): + with pytest.raises(ValueError): + JWTVerifier.any_of(*verifiers) diff --git a/tests/functional/auth/test_verifier.py b/tests/functional/auth/test_verifier.py index 9ac22acba60..c05da03acff 100644 --- a/tests/functional/auth/test_verifier.py +++ b/tests/functional/auth/test_verifier.py @@ -65,6 +65,7 @@ def test_expired_token_is_rejected(jwks, claims, issue_token): ("exp", float("inf")), ("exp", float("nan")), ("exp", True), + ("exp", 10**400), ("nbf", "0"), ("nbf", 9999999999), ], @@ -88,6 +89,9 @@ def test_invalid_claim_values_are_rejected(jwks, claims, issue_token, claim, val ("issuer", "http://idp.example.com"), ("issuer", "https://user:secret@idp.example.com"), ("issuer", "https://idp.example.com/#fragment"), + ("issuer", "https://idp.example.com:invalid"), + ("issuer", "https://[invalid"), + ("issuer", 42), ("audience", ""), ("audience", []), ("algorithms", []), @@ -96,6 +100,8 @@ def test_invalid_claim_values_are_rejected(jwks, claims, issue_token, claim, val ("algorithms", ["RS256", "HS256"]), ("clock_skew_seconds", -1), ("clock_skew_seconds", float("inf")), + ("clock_skew_seconds", 10**400), + ("jwks_uri", "https://idp.example.com/keys"), ("required_claims", ""), ], ) @@ -112,8 +118,9 @@ def test_invalid_verifier_configuration_is_rejected(jwks, option, value): JWTVerifier(**options) +@pytest.mark.parametrize("issuer_group", [False, True]) @pytest.mark.parametrize("token", ["", "not-a-jwt", "a.b.c", None, 42]) -def test_malformed_tokens_raise_redacted_errors(jwks, token): +def test_malformed_tokens_raise_redacted_errors(jwks, token, issuer_group): verifier = JWTVerifier( issuer="https://idp.example.com/", audience="https://api.example.com", @@ -121,6 +128,8 @@ def test_malformed_tokens_raise_redacted_errors(jwks, token): jwks=jwks, ) + if issuer_group: + verifier = JWTVerifier.any_of(verifier) with pytest.raises(InvalidTokenError) as error: verifier.verify(token) assert str(error.value) == "Invalid access token" diff --git a/tests/integration/auth/conftest.py b/tests/integration/auth/conftest.py index a437efadd75..78304d5c565 100644 --- a/tests/integration/auth/conftest.py +++ b/tests/integration/auth/conftest.py @@ -70,13 +70,20 @@ def tls_files(tmp_path_factory): return certificate_path, key_path -@pytest.fixture -def https_server(tls_files, monkeypatch): +@pytest.fixture(params=[False, True], ids=["connection-close", "keep-alive"]) +def https_server(tls_files, monkeypatch, request): endpoint = LocalHTTPS() class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" + def handle(self): + try: + super().handle() + except (ConnectionResetError, ssl.SSLEOFError): + # The client may reject a response without draining its body. + self.close_connection = True + def do_GET(self): # noqa: N802 self.respond() @@ -90,7 +97,7 @@ def respond(self): 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") + self.send_header("Connection", "keep-alive" if request.param else "close") for name, value in reply.headers.items(): self.send_header(name, value) self.end_headers() @@ -109,7 +116,7 @@ def respond(self): # Timeout and oversized-body tests deliberately close early. pass finally: - self.close_connection = True + self.close_connection = not request.param def log_message(self, format, *args): # noqa: A002 pass From ec3225ccbf5c10326bba5bffd12e855db96200bf Mon Sep 17 00:00:00 2001 From: Ben Freiberg <9841563+bfreiberg@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:28:21 +0200 Subject: [PATCH 03/15] feat(auth): address inbound verification review Defer OAuth client credentials to a follow-up. Add fixed failure reasons, authorizer diagnostics, and signed claim/header profile constraints. Retain urllib3 in Layers and separate the SAM authorizer and backend artifacts, with expanded tests and documentation. --- .../utilities/auth/__init__.py | 9 +- .../utilities/auth/_authorization.py | 13 +- .../utilities/auth/_authorizer.py | 20 +- aws_lambda_powertools/utilities/auth/_base.py | 15 +- .../utilities/auth/_middleware.py | 18 +- .../utilities/auth/_validation.py | 12 + .../utilities/auth/exceptions.py | 28 +- .../utilities/auth/oauth2.py | 330 --------------- .../utilities/auth/verifier.py | 29 +- docs/api_doc/auth.md | 2 +- docs/build_recipes/cross-platform.md | 2 + docs/utilities/auth.md | 167 ++++---- .../auth/src/{ => authorizer}/authorizer.py | 13 +- .../src/{ => authorizer}/requirements.txt | 0 examples/auth/src/{ => backend}/backend.py | 0 examples/auth/src/backend/requirements.txt | 1 + examples/auth/src/middleware.py | 2 + examples/auth/src/outbound.py | 30 -- .../{template.yaml => templates/sam.yaml} | 5 +- layer_v3/docker/Dockerfile | 4 +- .../utils/lambda_layer/powertools_layer.py | 3 +- tests/functional/auth/_auth_import_probe.py | 21 +- tests/functional/auth/conftest.py | 12 +- tests/functional/auth/test_errors.py | 36 +- .../auth/test_failure_visibility.py | 162 +++++++ tests/functional/auth/test_imports.py | 2 +- tests/functional/auth/test_oauth2.py | 400 ------------------ tests/functional/auth/test_token_profile.py | 110 +++++ tests/integration/auth/test_https.py | 107 +---- 29 files changed, 541 insertions(+), 1012 deletions(-) delete mode 100644 aws_lambda_powertools/utilities/auth/oauth2.py rename examples/auth/src/{ => authorizer}/authorizer.py (52%) rename examples/auth/src/{ => authorizer}/requirements.txt (100%) rename examples/auth/src/{ => backend}/backend.py (100%) create mode 100644 examples/auth/src/backend/requirements.txt delete mode 100644 examples/auth/src/outbound.py rename examples/auth/{template.yaml => templates/sam.yaml} (94%) create mode 100644 tests/functional/auth/test_failure_visibility.py delete mode 100644 tests/functional/auth/test_oauth2.py create mode 100644 tests/functional/auth/test_token_profile.py diff --git a/aws_lambda_powertools/utilities/auth/__init__.py b/aws_lambda_powertools/utilities/auth/__init__.py index eb2e8fd6622..71ea7d3fa89 100644 --- a/aws_lambda_powertools/utilities/auth/__init__.py +++ b/aws_lambda_powertools/utilities/auth/__init__.py @@ -1,4 +1,4 @@ -"""JWT verification and OAuth2 client credentials for AWS Lambda.""" +"""JWT access-token verification for AWS Lambda.""" from __future__ import annotations @@ -6,14 +6,15 @@ 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._middleware import AuthErrorContext as AuthErrorContext + from aws_lambda_powertools.utilities.auth.exceptions import AuthFailureReason as AuthFailureReason from aws_lambda_powertools.utilities.auth.verifier import JWTVerifier as JWTVerifier -__all__ = ["JWTVerifier", "OAuth2Client"] +__all__ = ["AuthErrorContext", "AuthFailureReason", "JWTVerifier"] def __getattr__(name: str) -> object: - modules = {"JWTVerifier": "verifier", "OAuth2Client": "oauth2"} + modules = {"AuthErrorContext": "_middleware", "AuthFailureReason": "exceptions", "JWTVerifier": "verifier"} if name in modules: value = getattr(importlib.import_module(f"{__name__}.{modules[name]}"), name) globals()[name] = value diff --git a/aws_lambda_powertools/utilities/auth/_authorization.py b/aws_lambda_powertools/utilities/auth/_authorization.py index e7241250c88..3b384c593db 100644 --- a/aws_lambda_powertools/utilities/auth/_authorization.py +++ b/aws_lambda_powertools/utilities/auth/_authorization.py @@ -4,20 +4,31 @@ 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 +from aws_lambda_powertools.utilities.auth.exceptions import ( + AuthError, + AuthFailureReason, + InvalidClaimsError, + InvalidTokenError, +) class MissingTokenError(InvalidTokenError): """No authorization header was supplied.""" + reason = AuthFailureReason.MISSING_TOKEN + class ForbiddenError(AuthError): """A verified caller does not have permission for this operation.""" + reason = AuthFailureReason.FORBIDDEN + class InsufficientScopeError(ForbiddenError): """A verified caller is missing a required scope.""" + reason = AuthFailureReason.INSUFFICIENT_SCOPE + def bearer_token(value: Any) -> str: if value is None: diff --git a/aws_lambda_powertools/utilities/auth/_authorizer.py b/aws_lambda_powertools/utilities/auth/_authorizer.py index 9c5544156b4..21edb718dc1 100644 --- a/aws_lambda_powertools/utilities/auth/_authorizer.py +++ b/aws_lambda_powertools/utilities/auth/_authorizer.py @@ -12,11 +12,13 @@ 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.auth.exceptions import AuthError, InvalidClaimsError, InvalidTokenError from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import APIGatewayAuthorizerResponseV2 from aws_lambda_powertools.utilities.data_classes.common import DictWrapper if TYPE_CHECKING: + from collections.abc import Callable + from aws_lambda_powertools.utilities.auth._base import Verifier _ARN = re.compile(r"arn:[a-z0-9-]+:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+/[^/]+/[A-Z]+/.*") @@ -28,6 +30,7 @@ def authorize_event( scopes: list[str] | None, response_format: Literal["iam", "simple"], context_claims: list[str] | None, + on_error: Callable[[AuthError], None] | None, ) -> dict[str, Any]: raw = event.raw_event if isinstance(event, DictWrapper) else event _validate_event(raw, response_format) @@ -36,7 +39,7 @@ def authorize_event( 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") + claims = _verified_claims(verifier, raw, expected, on_error, require_principal=response_format == "iam") context = _context(claims, selected) if claims is not None else {} if response_format == "simple": return APIGatewayAuthorizerResponseV2(authorize=claims is not None, context=context).asdict() @@ -56,6 +59,7 @@ def _verified_claims( verifier: Verifier, raw: dict[str, Any], expected: tuple[str, ...], + on_error: Callable[[AuthError], None] | None, *, require_principal: bool, ) -> dict[str, Any] | None: @@ -65,8 +69,16 @@ def _verified_claims( if require_principal: _validate_principal(candidate) return candidate - except (InvalidTokenError, ForbiddenError): - return None + except AuthError as error: + # The callback observes a public, sanitized error, including failures + # raised while the caller is already handling another exception. + error.__context__ = None + error.__cause__ = None + if on_error is not None: + on_error(error) + if isinstance(error, (InvalidTokenError, ForbiddenError)): + return None + raise def _token(raw: dict[str, Any]) -> str: diff --git a/aws_lambda_powertools/utilities/auth/_base.py b/aws_lambda_powertools/utilities/auth/_base.py index 5f6a03e42b9..e031d15d42a 100644 --- a/aws_lambda_powertools/utilities/auth/_base.py +++ b/aws_lambda_powertools/utilities/auth/_base.py @@ -3,11 +3,14 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Literal +from aws_lambda_powertools.utilities.auth._errors import sanitize_errors + if TYPE_CHECKING: from collections.abc import Callable from aws_lambda_powertools.event_handler import Response from aws_lambda_powertools.utilities.auth._middleware import AuthErrorContext, AuthMiddleware + from aws_lambda_powertools.utilities.auth.exceptions import AuthError from aws_lambda_powertools.utilities.data_classes.common import DictWrapper @@ -45,7 +48,8 @@ def require( 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. + Receives status_code, headers, a fixed reason, and retryable, and + returns an Event Handler Response. No automatic logging is performed. Examples -------- @@ -59,6 +63,7 @@ def orders(): return AuthMiddleware(self, scopes, authorize, on_error) + @sanitize_errors def authorize( self, event: dict[str, Any] | DictWrapper, @@ -66,6 +71,7 @@ def authorize( scopes: list[str] | None = None, response_format: Literal["iam", "simple"] = "iam", context_claims: list[str] | None = None, + on_error: Callable[[AuthError], None] | None = None, ) -> dict[str, Any]: """Return an API Gateway authorizer response for the current request. @@ -84,6 +90,11 @@ def authorize( Response format configured in Gateway, by default iam. context_claims : list[str], optional Selected scalar claims to include; no claims are copied by default. + on_error : Callable, optional + Records a failure using the error's fixed reason and retryable fields. + Its return value is ignored: invalid credentials still deny access, + and unavailable keys still raise JWKSFetchError. Callback exceptions + fail the invocation. No automatic logging is performed. Examples -------- @@ -95,4 +106,4 @@ def authorize( """ from aws_lambda_powertools.utilities.auth._authorizer import authorize_event - return authorize_event(self, event, scopes, response_format, context_claims) + return authorize_event(self, event, scopes, response_format, context_claims, on_error) diff --git a/aws_lambda_powertools/utilities/auth/_middleware.py b/aws_lambda_powertools/utilities/auth/_middleware.py index 738c942edc3..127bea2d6c2 100644 --- a/aws_lambda_powertools/utilities/auth/_middleware.py +++ b/aws_lambda_powertools/utilities/auth/_middleware.py @@ -13,7 +13,7 @@ header_token, required_scopes, ) -from aws_lambda_powertools.utilities.auth.exceptions import AuthError, InvalidTokenError +from aws_lambda_powertools.utilities.auth.exceptions import AuthError, AuthFailureReason, InvalidTokenError if TYPE_CHECKING: from collections.abc import Callable @@ -29,6 +29,8 @@ class AuthErrorContext: status_code: int headers: dict[str, str] + reason: AuthFailureReason + retryable: bool class AuthMiddleware(BaseMiddlewareHandler[ApiGatewayResolver]): @@ -64,19 +66,17 @@ def handler(self, app: ApiGatewayResolver, next_middleware: NextMiddleware) -> R def _failure(self, error: AuthError) -> Response: if isinstance(error, MissingTokenError): - context = AuthErrorContext(401, {"WWW-Authenticate": "Bearer"}) + status, headers = 401, {"WWW-Authenticate": "Bearer"} elif isinstance(error, InvalidTokenError): - context = AuthErrorContext(401, {"WWW-Authenticate": 'Bearer error="invalid_token"'}) + status, headers = 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}"'}, - ) + status, headers = 403, {"WWW-Authenticate": f'Bearer error="insufficient_scope", scope="{scopes}"'} elif isinstance(error, ForbiddenError): - context = AuthErrorContext(403, {}) + status, headers = 403, {} else: - context = AuthErrorContext(503, {}) + status, headers = 503, {} + context = AuthErrorContext(status, headers, error.reason, error.retryable) if self._on_error is not None: return self._on_error(context) messages = {401: "Unauthorized", 403: "Forbidden", 503: "Service Unavailable"} diff --git a/aws_lambda_powertools/utilities/auth/_validation.py b/aws_lambda_powertools/utilities/auth/_validation.py index 3ccc7460ccf..60f4a658396 100644 --- a/aws_lambda_powertools/utilities/auth/_validation.py +++ b/aws_lambda_powertools/utilities/auth/_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from collections.abc import Mapping from typing import Any from urllib.parse import urlsplit @@ -55,3 +56,14 @@ def string_list(values: list[str] | tuple[str, ...], *, nonempty: bool = False) def is_nonempty_string(value: Any) -> bool: return isinstance(value, str) and bool(value.strip()) + + +def string_mapping(values: Mapping[str, str] | None) -> dict[str, str]: + """Copy exact token-profile constraints without exposing their contents.""" + if values is None: + return {} + if not isinstance(values, Mapping) or not all( + is_nonempty_string(name) and is_nonempty_string(value) for name, value in values.items() + ): + raise ValueError("Expected claims and headers must map nonempty strings to nonempty strings") + return dict(values) diff --git a/aws_lambda_powertools/utilities/auth/exceptions.py b/aws_lambda_powertools/utilities/auth/exceptions.py index bd2553f0a2f..6c460b34790 100644 --- a/aws_lambda_powertools/utilities/auth/exceptions.py +++ b/aws_lambda_powertools/utilities/auth/exceptions.py @@ -1,10 +1,27 @@ """Credential-free errors raised by the Auth utility.""" +from enum import Enum + + +class AuthFailureReason(str, Enum): + """Stable, credential-free reasons suitable for application logs and metrics.""" + + MISSING_TOKEN = "missing_token" # nosec B105 + INVALID_TOKEN = "invalid_token" # nosec B105 + INVALID_CLAIMS = "invalid_claims" + TOKEN_EXPIRED = "token_expired" # nosec B105 + INVALID_SIGNATURE = "invalid_signature" + INSUFFICIENT_SCOPE = "insufficient_scope" + FORBIDDEN = "forbidden" + JWKS_UNAVAILABLE = "jwks_unavailable" + class AuthError(Exception): """Base error with a fixed message that never includes credential material.""" message = "Authentication failed" + reason = AuthFailureReason.INVALID_TOKEN + retryable = False def __init__(self) -> None: super().__init__(self.message) @@ -20,27 +37,26 @@ class InvalidClaimsError(InvalidTokenError): """A required claim is missing or a claim does not match the token profile.""" message = "Invalid access token claims" + reason = AuthFailureReason.INVALID_CLAIMS class TokenExpiredError(InvalidTokenError): """The access token has expired beyond the configured clock tolerance.""" message = "Access token expired" + reason = AuthFailureReason.TOKEN_EXPIRED class InvalidSignatureError(InvalidTokenError): """The access token signature does not match the configured signing key.""" message = "Invalid access token signature" + reason = AuthFailureReason.INVALID_SIGNATURE class JWKSFetchError(AuthError): """Required signing keys could not be retrieved or refreshed.""" message = "Unable to retrieve verification keys" - - -class TokenExchangeError(AuthError): - """Client credentials could not be exchanged for a usable bearer token.""" - - message = "Unable to acquire an access token" + reason = AuthFailureReason.JWKS_UNAVAILABLE + retryable = True diff --git a/aws_lambda_powertools/utilities/auth/oauth2.py b/aws_lambda_powertools/utilities/auth/oauth2.py deleted file mode 100644 index 228952f068c..00000000000 --- a/aws_lambda_powertools/utilities/auth/oauth2.py +++ /dev/null @@ -1,330 +0,0 @@ -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/verifier.py b/aws_lambda_powertools/utilities/auth/verifier.py index 1c16f80a60f..dcd6bdebebf 100644 --- a/aws_lambda_powertools/utilities/auth/verifier.py +++ b/aws_lambda_powertools/utilities/auth/verifier.py @@ -3,7 +3,7 @@ import math import re import time -from typing import Any +from typing import TYPE_CHECKING, Any import jwt @@ -11,7 +11,13 @@ 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._validation import ( + finite_seconds, + https_url, + is_nonempty_string, + string_list, + string_mapping, +) from aws_lambda_powertools.utilities.auth.exceptions import ( InvalidClaimsError, InvalidSignatureError, @@ -19,6 +25,9 @@ TokenExpiredError, ) +if TYPE_CHECKING: + from collections.abc import Mapping + _ASYMMETRIC_ALGORITHMS = frozenset( {"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "ES256K", "EdDSA"}, ) @@ -42,6 +51,12 @@ class JWTVerifier(Verifier): discover keys from the configured issuer. required_claims : list[str], optional Claims required in addition to ``iss``, ``aud``, and ``exp``. + expected_claims : Mapping[str, str], optional + Exact, case-sensitive string values required in verified claims, + for example ``{"token_use": "access"}``. Missing values are rejected. + expected_headers : Mapping[str, str], optional + Exact string values required in the signed header, for example + ``{"typ": "at+jwt"}``. These checks cannot weaken signature validation. clock_skew_seconds : float Nonnegative allowance for temporal claims, by default 60. timeout_seconds : float @@ -78,6 +93,8 @@ def __init__( jwks: dict[str, Any] | None = None, jwks_uri: str | None = None, required_claims: list[str] | None = None, + expected_claims: Mapping[str, str] | None = None, + expected_headers: Mapping[str, str] | None = None, clock_skew_seconds: float = 60, timeout_seconds: float = 3, jwks_max_age_seconds: float = 300, @@ -98,6 +115,8 @@ def __init__( self._cache = shared_cache(self._issuer, self._jwks_uri, max_age, cooldown) if jwks is None else None additional_claims = string_list(required_claims if required_claims is not None else []) self._required_claims = sorted({"iss", "aud", "exp"} | set(additional_claims)) + self._expected_claims = string_mapping(expected_claims) + self._expected_headers = string_mapping(expected_headers) self._clock_skew = finite_seconds(clock_skew_seconds) self._cognito_client_id: str | None = None @@ -245,11 +264,17 @@ def verify(self, token: str) -> dict[str, Any]: except (jwt.PyJWTError, TypeError, ValueError, OverflowError, RecursionError): raise InvalidClaimsError() from None self._validate_times(claims) + self._validate_profile(claims, header) if self._cognito_client_id is not None: if claims.get("token_use") != "access" or claims.get("client_id") != self._cognito_client_id: raise InvalidClaimsError() return claims + def _validate_profile(self, claims: dict[str, Any], header: dict[str, Any]) -> None: + for values, expected in ((claims, self._expected_claims), (header, self._expected_headers)): + if any(values.get(name) != value for name, value in expected.items()): + raise InvalidClaimsError() + def _header(self, token: str) -> dict[str, Any]: if not isinstance(token, str) or not token: raise InvalidTokenError() diff --git a/docs/api_doc/auth.md b/docs/api_doc/auth.md index f7556e6aead..1f63cd03147 100644 --- a/docs/api_doc/auth.md +++ b/docs/api_doc/auth.md @@ -2,6 +2,6 @@ ::: aws_lambda_powertools.utilities.auth.verifier options: inherited_members: true -::: aws_lambda_powertools.utilities.auth.oauth2 +::: aws_lambda_powertools.utilities.auth.AuthErrorContext ::: aws_lambda_powertools.utilities.auth.exceptions ::: aws_lambda_powertools.utilities.auth.testing diff --git a/docs/build_recipes/cross-platform.md b/docs/build_recipes/cross-platform.md index bdc1b7c0904..bb124a83009 100644 --- a/docs/build_recipes/cross-platform.md +++ b/docs/build_recipes/cross-platform.md @@ -18,6 +18,7 @@ Taking into consideration Powertools for AWS dependencies and common Python pack |---------|----------|------------|--------|-------------------| | **pydantic** | Rust | Core validation engine | High - Core functionality affected | ✅ Core dependency | | **aws-encryption-sdk** | C | Encryption/decryption | High - Data masking fails | ✅ Optional (datamasking extra) | +| **cryptography** | Rust/C | Asymmetric signature verification | High - JWT verification fails | ✅ Optional (auth extra) | | **protobuf** | C++ | Protocol buffer serialization | High - Message parsing fails | ✅ Optional (kafka-consumer-protobuf) | | **redis** | C | Redis client with hiredis | Medium - Falls back to pure Python | ✅ Optional (redis extra) | | **valkey-glide** | Rust | High-performance Redis client | High - Client completely broken | ✅ Optional (valkey extra) | @@ -44,6 +45,7 @@ Different Powertools for AWS extras dependencies have varying levels of architec ```txt title="requirements.txt - Requires Linux builds" # These extras include compiled dependencies + aws-lambda-powertools[auth] # cryptography (Rust/C) aws-lambda-powertools[parser]==3.18.0 # pydantic (Rust) aws-lambda-powertools[validation]==3.18.0 # fastjsonschema (C) aws-lambda-powertools[datamasking]==3.18.0 # aws-encryption-sdk (C) diff --git a/docs/utilities/auth.md b/docs/utilities/auth.md index 590c3982df1..2caac2e1d42 100644 --- a/docs/utilities/auth.md +++ b/docs/utilities/auth.md @@ -1,9 +1,9 @@ --- title: Auth -description: JWT access-token verification and OAuth client credentials for Lambda +description: JWT access-token verification for Lambda --- -Auth verifies incoming JWT access tokens and obtains separate OAuth bearer tokens for downstream APIs. +Auth verifies incoming JWT access tokens. Use it inside a Lambda function or a Lambda authorizer. Prefer an API Gateway managed JWT authorizer when it meets your token profile and deployment requirements. ## Key features @@ -12,7 +12,6 @@ Use it inside a Lambda function or a Lambda authorizer. Prefer an API Gateway ma * 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 @@ -25,6 +24,8 @@ pip install "aws-lambda-powertools[auth]" The optional `auth` extra includes PyJWT, cryptography, and urllib3. It adds no dependencies to the base installation. Build cryptography dependencies for your Lambda Python version and architecture; see [cross-platform builds](../build_recipes/cross-platform.md). +The Powertools Layer retains urllib3 from the declared dependency range instead of relying on the runtime's copy. +Applications pinning a different AWS SDK must validate that SDK's urllib3 requirements against the Layer or bundle a compatible dependency set. ### Protect an HTTP route @@ -67,19 +68,6 @@ verifier = JWTVerifier( 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 @@ -90,7 +78,23 @@ Supported algorithms are RS256/384/512, PS256/384/512, ES256/384/512, ES256K, an 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. +Configure `expected_claims` and/or `expected_headers` when an issuer can mint other token types with the same audience: + +```python +verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://orders.example.com", + algorithms=["RS256"], + expected_claims={"token_use": "access"}, + expected_headers={"typ": "at+jwt"}, +) +``` + +Use the values defined by your provider; not every provider uses both fields. +These mappings require exact, case-sensitive, nonempty string values. Missing or different values raise `InvalidClaimsError`. +They are copied during construction and checked after signature, issuer, audience, and time validation. +The constraints apply to direct verification, middleware, authorizers, and issuer groups and cannot disable any baseline check. +`required_claims` checks presence only. Local JWT verification does not check individual-token revocation. Scopes come from the first present claim in this order: `scope`, `scp`, `scopes`. @@ -106,8 +110,33 @@ middleware = verifier.require( ) ``` -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. +An `on_error` callback receives `AuthErrorContext` with `status_code`, `headers`, `reason`, and `retryable`. +It must return an Event Handler `Response`. Preserve the status and challenge headers when customizing the body. +The reason is an `AuthFailureReason` string enum; `retryable` is true for unavailable JWKS infrastructure and false for credential/policy failures. +The utility does not log failures automatically or add diagnostics to default responses. Applications choose logging, metrics, and sampling: + +```python +from aws_lambda_powertools import Logger +from aws_lambda_powertools.event_handler import Response +from aws_lambda_powertools.utilities.auth import AuthErrorContext + +logger = Logger() + + +def on_error(error: AuthErrorContext) -> Response: + logger.warning("Authorization failed", reason=error.reason.value, retryable=error.retryable) + return Response( + status_code=error.status_code, + content_type="application/json", + body={"message": "Access denied"}, + headers=error.headers, + ) + + +middleware = verifier.require(on_error=on_error) +``` + +The callback replaces the error response; it never invokes the protected handler. Callback exceptions propagate to the application. ### Key freshness, rotation, and outages @@ -125,7 +154,10 @@ A failed refresh backs off for 1, 2, 4, 8, 16, then 30 seconds. During that inte 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. +Construction performs no network I/O. By default the first verification fetches the keys, adding latency to that invocation. +Calling `prefetch()` at module level moves the first fetch into Lambda INIT, but an identity-provider outage can then fail the cold start. +Prefetch is an explicit option, not a default recommendation; choose based on your latency and availability requirements. +Later rotation, expiration, and outages can still cause network I/O. Static `jwks` is copied when constructing the verifier and performs no discovery or refresh: ```python @@ -166,7 +198,7 @@ The combined verifier supports `verify()`, `prefetch()`, `require()`, and `autho ### Lambda authorizers ```python title="authorizer.py" ---8<-- "examples/auth/src/authorizer.py" +--8<-- "examples/auth/src/authorizer/authorizer.py" ``` The helper accepts raw dictionaries or the corresponding Powertools authorizer Data Classes. @@ -182,6 +214,13 @@ IAM allows require a nonempty string `sub` as principal and cover only the suppl Wildcard, missing, or malformed ARNs raise `ValueError`; the helper cannot construct a request-specific IAM policy without a valid ARN. Other routes need their own decision. Invalid tokens and insufficient scopes produce a Deny or `isAuthorized=False`; unavailable signing keys raise `JWKSFetchError`. +For an outage, middleware returns HTTP 503 directly. A Lambda authorizer fails its invocation instead, and API Gateway normally returns a 5xx response. +API callers should treat this as an availability failure rather than repeatedly obtaining new credentials; configure retries and alarms accordingly. + +Pass `on_error` to `authorize()` to record a rejection or unavailable keys, as shown in the example above. +It receives an `AuthError` with the same fixed `reason` and `retryable` attributes exposed by middleware. +Its return value is ignored: invalid credentials still deny access, and `JWKSFetchError` still propagates after the callback. +A callback exception fails the invocation. Successful authorizations do not call it. The default response includes neither diagnostic field. No claims are copied to context by default. `context_claims` copies only selected scalar values, omitting arrays, objects, and nulls. The name `claims` is reserved in authorizer context. @@ -190,10 +229,13 @@ The name `claims` is reserved in authorizer context. Disable authorizer-result caching to verify each request. This SAM example sets `ReauthorizeEvery: 0` for both REST and HTTP authorizers; the underlying API Gateway setting is `AuthorizerResultTtlInSeconds: 0`. +The template is under `examples/auth/templates/`; its `CodeUri` values are relative to that directory. +Authorizer functions build from `src/authorizer/` with the Auth extra. Backends build independently from `src/backend/` with base Powertools only, +so PyJWT and cryptography are not included in the backend artifacts. HTTP simple responses also require payload version 2.0 and `EnableSimpleResponses: true`. -```yaml title="template.yaml" ---8<-- "examples/auth/template.yaml" +```yaml title="templates/sam.yaml" +--8<-- "examples/auth/templates/sam.yaml" ``` If you enable result caching later, a cached decision can outlive the JWT's expiration or a signing key's removal. @@ -201,28 +243,12 @@ 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. +Install that SDK separately. This example requires the Keycloak access-token claim `typ="Bearer"` and maps `azp`, `sub`, and `scope`. +Adapt the expected purpose and claim mapping to your provider and token configuration. ```python import asyncio @@ -232,9 +258,11 @@ from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.settings import AuthSettings from pydantic import AnyHttpUrl +from aws_lambda_powertools import Logger from aws_lambda_powertools.utilities.auth import JWTVerifier from aws_lambda_powertools.utilities.auth.exceptions import InvalidTokenError, JWKSFetchError +logger = Logger() RESOURCE_URL = "https://mcp.example.com" ISSUER_URL = "https://keycloak.example.com/realms/mcp" verifier = JWTVerifier( @@ -242,6 +270,7 @@ verifier = JWTVerifier( audience=RESOURCE_URL, algorithms=["RS256"], required_claims=["azp", "sub", "scope"], + expected_claims={"typ": "Bearer"}, ) @@ -249,7 +278,10 @@ class PowertoolsTokenVerifier(TokenVerifier): async def verify_token(self, token: str) -> AccessToken | None: try: claims = await asyncio.to_thread(verifier.verify, token) - except (InvalidTokenError, JWKSFetchError): + except JWKSFetchError as error: + logger.error("Verification keys unavailable", reason=error.reason.value, retryable=error.retryable) + return None + except InvalidTokenError: return None if not all(isinstance(claims[name], str) for name in ("azp", "sub", "scope")): return None @@ -278,7 +310,8 @@ mcp = MCPServer( ``` 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. +The adapter records unavailable keys separately for Lambda-owner alarms and metrics before returning `None`. +A distinct availability response to the API caller requires integration at the SDK transport boundary. `asyncio.to_thread()` keeps synchronous key fetches off the event loop; cancelling the await does not terminate a running request. Tools can enforce permissions using the verified SDK access token: @@ -294,27 +327,6 @@ def require_scope(scope: str): ``` 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. @@ -322,13 +334,26 @@ an authorizer Deny response alone does not implement MCP authorization. `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. +Every error exposes `reason: AuthFailureReason` and `retryable: bool`. `AuthFailureReason` uses `str, Enum` for Python 3.10 compatibility. +Use `.value` for log fields and metric dimensions; do not parse exception messages. + +| Reason | Retryable | +| ------ | --------- | +| `missing_token` | false | +| `invalid_token` | false | +| `invalid_claims` | false | +| `token_expired` | false | +| `invalid_signature` | false | +| `insufficient_scope` | false | +| `forbidden` | false | +| `jwks_unavailable` | true | + +Retryability identifies failures where retrying after the provider recovers may help; it does not bypass cache backoff or guarantee success. +Reasons and messages are fixed and never contain token data, claims, key IDs, URLs, or provider responses. +Public verification, prefetch, and authorizer operations detach underlying exception causes and contexts. +Log only the fixed diagnostic fields; do not log token dictionaries, request headers, or provider errors. + +Outbound token acquisition, opaque-token introspection, delegated token exchange, interactive grants, SigV4, and native async clients are outside this PR. ## Testing your code diff --git a/examples/auth/src/authorizer.py b/examples/auth/src/authorizer/authorizer.py similarity index 52% rename from examples/auth/src/authorizer.py rename to examples/auth/src/authorizer/authorizer.py index 8592ab7ddd9..f6575f16d97 100644 --- a/examples/auth/src/authorizer.py +++ b/examples/auth/src/authorizer/authorizer.py @@ -1,24 +1,35 @@ import os +from aws_lambda_powertools import Logger from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import AuthError from aws_lambda_powertools.utilities.typing import LambdaContext +logger = Logger() verifier = JWTVerifier( issuer=os.environ["ISSUER_URL"], audience=os.environ["RESOURCE_URL"], algorithms=["RS256"], required_claims=["sub"], + # Adapt this constraint to the access-token profile issued by your provider. + expected_claims={"token_use": "access"}, ) +def record_failure(error: AuthError) -> None: + # Opt-in application logging; never log the event, token, or claims. + logger.warning("Authorization failed", reason=error.reason.value, retryable=error.retryable) + + def iam_handler(event: dict, context: LambdaContext): return verifier.authorize( event, scopes=["orders:read"], response_format="iam", context_claims=["sub"], + on_error=record_failure, ) def simple_handler(event: dict, context: LambdaContext): - return verifier.authorize(event, scopes=["orders:read"], response_format="simple") + return verifier.authorize(event, scopes=["orders:read"], response_format="simple", on_error=record_failure) diff --git a/examples/auth/src/requirements.txt b/examples/auth/src/authorizer/requirements.txt similarity index 100% rename from examples/auth/src/requirements.txt rename to examples/auth/src/authorizer/requirements.txt diff --git a/examples/auth/src/backend.py b/examples/auth/src/backend/backend.py similarity index 100% rename from examples/auth/src/backend.py rename to examples/auth/src/backend/backend.py diff --git a/examples/auth/src/backend/requirements.txt b/examples/auth/src/backend/requirements.txt new file mode 100644 index 00000000000..56fd45918ce --- /dev/null +++ b/examples/auth/src/backend/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools diff --git a/examples/auth/src/middleware.py b/examples/auth/src/middleware.py index 3e006e27fb7..57b8b9f7eae 100644 --- a/examples/auth/src/middleware.py +++ b/examples/auth/src/middleware.py @@ -10,6 +10,8 @@ audience=os.environ["RESOURCE_URL"], algorithms=["RS256"], required_claims=["sub"], + # Adapt this constraint to your provider's access-token profile. + expected_claims={"token_use": "access"}, ) diff --git a/examples/auth/src/outbound.py b/examples/auth/src/outbound.py deleted file mode 100644 index 87f44d84780..00000000000 --- a/examples/auth/src/outbound.py +++ /dev/null @@ -1,30 +0,0 @@ -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/template.yaml b/examples/auth/templates/sam.yaml similarity index 94% rename from examples/auth/template.yaml rename to examples/auth/templates/sam.yaml index 3009e39f106..bc41dc7422f 100644 --- a/examples/auth/template.yaml +++ b/examples/auth/templates/sam.yaml @@ -13,7 +13,6 @@ Parameters: Globals: Function: Runtime: python3.12 - CodeUri: src/ Timeout: 10 MemorySize: 256 Environment: @@ -26,11 +25,13 @@ Resources: Type: AWS::Serverless::Function Properties: Handler: authorizer.iam_handler + CodeUri: ../src/authorizer/ HttpAuthorizer: Type: AWS::Serverless::Function Properties: Handler: authorizer.simple_handler + CodeUri: ../src/authorizer/ RestApi: Type: AWS::Serverless::Api @@ -67,6 +68,7 @@ Resources: Type: AWS::Serverless::Function Properties: Handler: backend.lambda_handler + CodeUri: ../src/backend/ Events: Orders: Type: Api @@ -79,6 +81,7 @@ Resources: Type: AWS::Serverless::Function Properties: Handler: backend.lambda_handler + CodeUri: ../src/backend/ Events: Orders: Type: HttpApi diff --git a/layer_v3/docker/Dockerfile b/layer_v3/docker/Dockerfile index a72feb5e2c8..2695fb34a5b 100644 --- a/layer_v3/docker/Dockerfile +++ b/layer_v3/docker/Dockerfile @@ -35,8 +35,8 @@ RUN CFLAGS="-Os -g0 -s" pip install -t /asset/python "aws-lambda-powertools${PAC RUN cd /asset/python && \ # remove boto3 and botocore (already available in Lambda Runtime) rm -rf boto* && \ - # remove boto3 dependencies - rm -rf s3transfer* *dateutil* urllib3* six* jmespath* && \ + # retain urllib3: Auth requires its declared version, independently of the runtime SDK + rm -rf s3transfer* *dateutil* six* jmespath* && \ # remove debugging symbols find . -name '*.so' -type f -exec strip "{}" \; && \ # remove tests diff --git a/tests/e2e/utils/lambda_layer/powertools_layer.py b/tests/e2e/utils/lambda_layer/powertools_layer.py index 4fadd94ea74..dd2026a04cc 100644 --- a/tests/e2e/utils/lambda_layer/powertools_layer.py +++ b/tests/e2e/utils/lambda_layer/powertools_layer.py @@ -30,7 +30,8 @@ def __init__(self, output_dir: Path = CDK_OUT_PATH, architecture: Architecture = self.build_command = f"python -m pip install {self.package} {self.build_args} --target {self.target_dir}" self.cleanup_command = ( f"rm -rf {self.target_dir}/boto* {self.target_dir}/s3transfer* && " - f"rm -rf {self.target_dir}/*dateutil* {self.target_dir}/urllib3* {self.target_dir}/six* && " + # Auth's declared urllib3 dependency must survive Layer cleanup. + f"rm -rf {self.target_dir}/*dateutil* {self.target_dir}/six* && " f"rm -rf {self.target_dir}/jmespath* && " f"find {self.target_dir} -name '*.so' -type f -exec strip '{{}}' \\; && " f"find {self.target_dir} -wholename '*/tests/*' -type f -delete && " diff --git a/tests/functional/auth/_auth_import_probe.py b/tests/functional/auth/_auth_import_probe.py index 2b41a6db25a..4a4c5a92c1d 100644 --- a/tests/functional/auth/_auth_import_probe.py +++ b/tests/functional/auth/_auth_import_probe.py @@ -18,19 +18,7 @@ def find_spec(self, fullname, path=None, target=None): 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": +if scenario == "static": sys.meta_path.insert(0, BlockImports("urllib3")) from aws_lambda_powertools.utilities.auth import JWTVerifier @@ -67,7 +55,7 @@ def find_spec(self, fullname, path=None, target=None): elif scenario == "exports": auth = importlib.import_module("aws_lambda_powertools.utilities.auth") - assert {"JWTVerifier", "OAuth2Client"} <= set(dir(auth)) + assert {"JWTVerifier", "AuthFailureReason", "AuthErrorContext"} <= set(dir(auth)) assert not {"jwt", "cryptography", "urllib3"} & sys.modules.keys() try: _ = auth.unknown_attribute @@ -79,10 +67,11 @@ def find_spec(self, fullname, path=None, target=None): members = dict(inspect.getmembers(auth)) assert members["JWTVerifier"] is auth.JWTVerifier - assert members["OAuth2Client"] is auth.OAuth2Client + assert members["AuthFailureReason"] is auth.AuthFailureReason + assert members["AuthErrorContext"] is auth.AuthErrorContext elif scenario == "star": from aws_lambda_powertools.utilities.auth import * # noqa: E402,F403 - assert {"JWTVerifier", "OAuth2Client"} <= globals().keys() + assert {"JWTVerifier", "AuthFailureReason", "AuthErrorContext"} <= globals().keys() else: raise ValueError(f"Unknown scenario: {scenario}") diff --git a/tests/functional/auth/conftest.py b/tests/functional/auth/conftest.py index 524229149ab..e529a405914 100644 --- a/tests/functional/auth/conftest.py +++ b/tests/functional/auth/conftest.py @@ -1,6 +1,7 @@ import io import json import time +import weakref from collections import deque import jwt @@ -8,6 +9,8 @@ import urllib3 from cryptography.hazmat.primitives.asymmetric import rsa +from aws_lambda_powertools.utilities.auth import _jwks + @pytest.fixture(scope="session") def signing_key(): @@ -33,19 +36,19 @@ def claims(): @pytest.fixture def issue_token(signing_key, claims): - def issue(payload=None, *, key=None, kid="key-1", algorithm="RS256"): + def issue(payload=None, *, key=None, kid="key-1", algorithm="RS256", headers=None): return jwt.encode( claims if payload is None else payload, signing_key if key is None else key, algorithm=algorithm, - headers={"kid": kid}, + headers={"kid": kid, **(headers or {})}, ) return issue class FakeHTTP: - """In-memory token and JWKS endpoints at the HTTP transport boundary.""" + """In-memory JWKS endpoints at the HTTP transport boundary.""" def __init__(self): self.responses = {} @@ -73,6 +76,9 @@ def request(self, method, url, **kwargs): @pytest.fixture def http(monkeypatch): + # Each fake provider belongs to one test. Error tracebacks can keep a + # previous verifier alive; retain sharing only within the current test. + monkeypatch.setattr(_jwks, "_caches", weakref.WeakValueDictionary()) transport = FakeHTTP() monkeypatch.setattr(urllib3, "PoolManager", lambda **kwargs: transport) return transport diff --git a/tests/functional/auth/test_errors.py b/tests/functional/auth/test_errors.py index 2493727d533..ec451a8fb64 100644 --- a/tests/functional/auth/test_errors.py +++ b/tests/functional/auth/test_errors.py @@ -8,18 +8,15 @@ import urllib3 from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.auth import JWTVerifier, OAuth2Client +from aws_lambda_powertools.utilities.auth import JWTVerifier 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" @@ -43,19 +40,6 @@ def assert_sanitized(operation, expected_error): 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): @@ -94,21 +78,3 @@ def test_verification_errors_detach_parser_and_crypto_exceptions(jwks, issue_tok 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_failure_visibility.py b/tests/functional/auth/test_failure_visibility.py new file mode 100644 index 00000000000..14861af0c25 --- /dev/null +++ b/tests/functional/auth/test_failure_visibility.py @@ -0,0 +1,162 @@ +import copy +import json +import time + +import pytest + +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response +from aws_lambda_powertools.utilities.auth import AuthErrorContext, AuthFailureReason, JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import JWKSFetchError +from tests.functional.utils import load_event + +ARN = "arn:aws:execute-api:us-east-1:123456789012:api123/prod/GET/orders" +REASONS = [ + "missing_token", + "invalid_token", + "invalid_claims", + "token_expired", + "invalid_signature", + "insufficient_scope", + "forbidden", + "jwks_unavailable", +] + + +def failure_case(reason, jwks, claims, issue_token, http): + options = {"jwks": jwks} + scopes = ["admin"] if reason == "insufficient_scope" else [] + if reason == "invalid_claims": + claims["aud"] = "private-incorrect-audience" + elif reason == "token_expired": + claims["exp"] = int(time.time()) - 120 + elif reason == "jwks_unavailable": + url = "https://idp.example.com/keys" + http.serve(url, {"private": "provider-response"}, status=503) + options = {"jwks_uri": url} + token = issue_token(claims) + if reason == "missing_token": + token = None + elif reason == "invalid_token": + token = "private-invalid-token" + elif reason == "invalid_signature": + token = token.rsplit(".", 1)[0] + ".AAAA" + subject = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + **options, + ) + return subject, token, scopes + + +@pytest.mark.parametrize("reason", REASONS) +def test_middleware_reports_safe_reasons_without_exposing_them_in_responses( + jwks, + claims, + issue_token, + http, + reason, + caplog, +): + subject, token, scopes = failure_case(reason, jwks, claims, issue_token, http) + app = APIGatewayHttpResolver() + observations = [] + + def on_error(context: AuthErrorContext): + observations.append(context) + return Response( + status_code=context.status_code, + content_type="application/json", + body={"message": "Denied"}, + headers=context.headers, + ) + + middleware = subject.require( + scopes=scopes, + authorize=lambda claims: reason != "forbidden", + on_error=on_error, + ) + + @app.get("/my/path", middlewares=[middleware]) + def protected(): + pytest.fail("A rejected request must never reach the protected handler") + + event = copy.deepcopy(load_event("apiGatewayProxyV2Event_GET.json")) + event["headers"] = {} if token is None else {"authorization": "Bearer " + token} + response = app.resolve(event, {}) + assert len(observations) == 1 + context = observations[0] + assert context.reason is AuthFailureReason(reason) + assert isinstance(context.reason, str) + assert json.dumps(context.reason) == json.dumps(reason) + assert context.retryable is (reason == "jwks_unavailable") + assert response["statusCode"] == ( + 503 if context.retryable else 403 if reason in ("insufficient_scope", "forbidden") else 401 + ) + assert json.loads(response["body"]) == {"message": "Denied"} + assert "private" not in repr(context) + assert "claims" not in app.context + assert caplog.records == [] + + +@pytest.mark.parametrize("reason", [reason for reason in REASONS if reason != "forbidden"]) +@pytest.mark.parametrize("response_format", ["iam", "simple"]) +def test_authorizer_reports_safe_reasons_and_preserves_denial_or_invocation_failure( + jwks, + claims, + issue_token, + http, + reason, + response_format, + caplog, +): + subject, token, scopes = failure_case(reason, jwks, claims, issue_token, http) + event = {"type": "REQUEST", "version": "2.0", "routeArn": ARN} + event["headers"] = {} if token is None else {"authorization": "Bearer " + token} + observations = [] + + def on_error(error): + assert error.__context__ is None + assert error.__cause__ is None + observations.append((error.reason, error.retryable)) + return {"isAuthorized": True} # A callback cannot turn a failure into an Allow. + + # Sanitization must also hold when the owner is handling another exception. + try: + raise ValueError("private-caller-error") + except ValueError: + if reason == "jwks_unavailable": + with pytest.raises(JWKSFetchError) as error: + subject.authorize(event, scopes=scopes, response_format=response_format, on_error=on_error) + assert error.value.__context__ is None + else: + response = subject.authorize(event, scopes=scopes, response_format=response_format, on_error=on_error) + if response_format == "simple": + assert response["isAuthorized"] is False + else: + assert response["policyDocument"]["Statement"][0]["Effect"] == "Deny" + assert "context" not in response or response["context"] == {} + assert "reason" not in response + assert "retryable" not in response + assert observations == [(AuthFailureReason(reason), reason == "jwks_unavailable")] + assert caplog.records == [] + + +def test_authorizer_does_not_report_success_as_an_error(jwks, claims, issue_token, http): + subject, token, _ = failure_case("valid", jwks, claims, issue_token, http) + event = {"type": "TOKEN", "methodArn": ARN, "authorizationToken": "Bearer " + token} + errors = [] + response = subject.authorize(event, on_error=errors.append) + assert response["policyDocument"]["Statement"][0]["Effect"] == "Allow" + assert errors == [] + + +def test_authorizer_error_callback_failure_cannot_allow_a_request(jwks, claims, issue_token, http): + subject, _, _ = failure_case("missing_token", jwks, claims, issue_token, http) + + def on_error(error): + raise RuntimeError("Application metrics failed") + + event = {"type": "TOKEN", "methodArn": ARN} + with pytest.raises(RuntimeError, match="Application metrics failed"): + subject.authorize(event, on_error=on_error) diff --git a/tests/functional/auth/test_imports.py b/tests/functional/auth/test_imports.py index 272a82f26a0..a61a8c67535 100644 --- a/tests/functional/auth/test_imports.py +++ b/tests/functional/auth/test_imports.py @@ -7,7 +7,7 @@ import pytest -@pytest.mark.parametrize("scenario", ["oauth", "static", "remote", "exports", "star"]) +@pytest.mark.parametrize("scenario", ["static", "remote", "exports", "star"]) def test_auth_imports_in_clean_interpreter(scenario, jwks, claims, issue_token): project_root = Path(__file__).parents[3] probe = Path(__file__).with_name("_auth_import_probe.py") diff --git a/tests/functional/auth/test_oauth2.py b/tests/functional/auth/test_oauth2.py deleted file mode 100644 index e356928a3ac..00000000000 --- a/tests/functional/auth/test_oauth2.py +++ /dev/null @@ -1,400 +0,0 @@ -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"}, - {"audience": " "}, - {"resource": ""}, - {"resource": 42}, - {"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}), - ("https://api.example.com/orders", {"headers": [("Accept", "application/json")]}), - ], -) -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 - - -@pytest.mark.parametrize("method", [None, "", "GET /", "GET\r\nInjected"]) -def test_invalid_http_methods_are_rejected_before_loading_credentials(http, method): - calls = [] - - def load_secret(): - calls.append(True) - return "test-secret" - - with pytest.raises(ValueError, match="HTTP method"): - client(client_secret=load_secret).request(method, "https://api.example.com/orders") - assert calls == [] - assert http.requests == [] - - -@pytest.mark.parametrize("secret", [None, "", 42]) -def test_invalid_secret_loader_results_are_rejected_before_sending_credentials(http, secret): - with pytest.raises(TokenExchangeError) as error: - client(client_secret=lambda: secret).auth_headers() - assert error.value.__context__ is None - assert http.requests == [] - - -def test_retry_stops_when_the_backoff_exceeds_the_remaining_budget(http, clock, monkeypatch): - sleeps = [] - monkeypatch.setattr(time, "sleep", sleeps.append) - http.serve(TOKEN_URL, {}, status=503, method="POST") - - with pytest.raises(TokenExchangeError): - client(timeout_seconds=0.05).auth_headers() - assert sleeps == [] - assert len(http.requests) == 1 - - -def test_waiting_callers_share_a_failed_exchange_and_can_recover(http, monkeypatch): - entered = threading.Event() - release = threading.Event() - joined = threading.Event() - - def exchange(): - entered.set() - assert release.wait(5) - return {"error": "invalid_client"} - - http.serve(TOKEN_URL, exchange, status=401, method="POST") - subject = client() - with ThreadPoolExecutor(max_workers=2) as executor: - owner = executor.submit(subject.auth_headers) - try: - assert entered.wait(5) - flight = subject._flight - assert flight is not None - wait = flight.done.wait - - def observe_wait(timeout): - joined.set() - return wait(timeout) - - # Keep the real Event; observe it so the provider is released only - # after the second caller has joined the active exchange. - monkeypatch.setattr(flight.done, "wait", observe_wait) - waiter = executor.submit(subject.auth_headers) - assert joined.wait(5) - finally: - release.set() - for result in (owner, waiter): - with pytest.raises(TokenExchangeError) as error: - result.result(timeout=5) - assert error.value.__context__ is None - assert len(http.requests) == 1 - - http.serve(TOKEN_URL, {"access_token": "recovered", "token_type": "Bearer", "expires_in": 100}, method="POST") - assert subject.auth_headers() == {"Authorization": "Bearer recovered"} - assert len(http.requests) == 2 - - -def test_waiting_callers_timeout_without_returning_the_late_token(http): - entered = threading.Event() - release = threading.Event() - - def exchange(): - entered.set() - assert release.wait(5) - return {"access_token": "too-late", "token_type": "Bearer", "expires_in": 100} - - http.serve(TOKEN_URL, exchange, method="POST") - subject = client(timeout_seconds=0.1) - with ThreadPoolExecutor(max_workers=1) as executor: - owner = executor.submit(subject.auth_headers) - try: - assert entered.wait(5) - with pytest.raises(TokenExchangeError): - subject.auth_headers() - assert not owner.done() - assert len(http.requests) == 1 - finally: - release.set() - with pytest.raises(TokenExchangeError): - owner.result(timeout=5) - - http.serve(TOKEN_URL, {"access_token": "recovered", "token_type": "Bearer", "expires_in": 100}, method="POST") - assert subject.auth_headers() == {"Authorization": "Bearer recovered"} - assert len(http.requests) == 2 diff --git a/tests/functional/auth/test_token_profile.py b/tests/functional/auth/test_token_profile.py new file mode 100644 index 00000000000..f8745f34a0d --- /dev/null +++ b/tests/functional/auth/test_token_profile.py @@ -0,0 +1,110 @@ +import copy +import json + +import pytest + +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import InvalidClaimsError, InvalidSignatureError +from tests.functional.utils import load_event + +ARN = "arn:aws:execute-api:us-east-1:123456789012:api123/prod/GET/orders" + + +def verifier(jwks, **options): + return JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + **options, + ) + + +@pytest.mark.parametrize("surface", ["direct", "group", "middleware", "iam", "simple"]) +@pytest.mark.parametrize("profile", ["valid", "wrong-purpose", "missing-purpose", "wrong-header", "missing-header"]) +def test_profile_constraints_apply_to_every_verification_surface(jwks, claims, issue_token, surface, profile): + subject = verifier( + jwks, + expected_claims={"token_use": "access"}, + expected_headers={"typ": "at+jwt"}, + ) + claims["token_use"] = "id" if profile == "wrong-purpose" else "access" + if profile == "missing-purpose": + del claims["token_use"] + header_type = "JWT" if profile == "wrong-header" else None if profile == "missing-header" else "at+jwt" + token = issue_token(claims, headers={"typ": header_type}) + accepted = profile == "valid" + if surface in ("direct", "group"): + subject = JWTVerifier.any_of(subject) if surface == "group" else subject + if accepted: + assert subject.verify(token)["sub"] == claims["sub"] + else: + with pytest.raises(InvalidClaimsError): + subject.verify(token) + elif surface == "middleware": + app = APIGatewayHttpResolver() + + @app.get("/my/path", middlewares=[subject.require()]) + def protected(): + assert accepted, "A token for another purpose reached the protected handler" + return {"subject": app.context["claims"]["sub"]} + + event = copy.deepcopy(load_event("apiGatewayProxyV2Event_GET.json")) + event["headers"] = {"authorization": "Bearer " + token} + response = app.resolve(event, {}) + assert response["statusCode"] == (200 if accepted else 401) + if accepted: + assert json.loads(response["body"]) == {"subject": claims["sub"]} + else: + event = { + "type": "REQUEST", + "version": "2.0", + "routeArn": ARN, + "headers": {"authorization": "Bearer " + token}, + } + response = subject.authorize(event, response_format=surface) + if surface == "simple": + assert response["isAuthorized"] is accepted + else: + assert response["policyDocument"]["Statement"][0]["Effect"] == ("Allow" if accepted else "Deny") + + +@pytest.mark.parametrize("field", ["expected_claims", "expected_headers"]) +@pytest.mark.parametrize("value", [[], "token_use", {"": "access"}, {"token_use": ""}, {"typ": 1}, {1: "access"}]) +def test_profile_configuration_requires_named_string_values(jwks, field, value): + with pytest.raises(ValueError, match="Expected claims and headers"): + verifier(jwks, **{field: value}) + + +def test_profile_configuration_is_copied_and_never_weakens_signature_checks(jwks, claims, issue_token): + expected_claims = {"token_use": "access"} + expected_headers = {"typ": "at+jwt"} + subject = verifier(jwks, expected_claims=expected_claims, expected_headers=expected_headers) + expected_claims["token_use"] = "id" + expected_headers["typ"] = "JWT" + claims["token_use"] = "access" + token = issue_token(claims, headers={"typ": "at+jwt"}) + assert subject.verify(token)["token_use"] == "access" + tampered = token.rsplit(".", 1)[0] + ".AAAA" + with pytest.raises(InvalidSignatureError): + subject.verify(tampered) + claims["token_use"] = "id" + wrong_purpose = issue_token(claims, headers={"typ": "at+jwt"}) + with pytest.raises(InvalidClaimsError): + subject.verify(wrong_purpose) + + +def test_generic_constraints_cannot_override_the_cognito_profile(jwks, claims, issue_token): + issuer = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pool" + subject = JWTVerifier.cognito( + user_pool_id="us-east-1_pool", + client_id="desktop-client", + audience=claims["aud"], + jwks=jwks, + expected_claims={"token_use": "id"}, + ) + claims.update(iss=issuer, token_use="id", client_id="desktop-client") + token = issue_token(claims) + with pytest.raises(InvalidClaimsError): + subject.verify(token) diff --git a/tests/integration/auth/test_https.py b/tests/integration/auth/test_https.py index 8074c066d6d..42bb01addcb 100644 --- a/tests/integration/auth/test_https.py +++ b/tests/integration/auth/test_https.py @@ -1,25 +1,11 @@ -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, - ) +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.exceptions import JWKSFetchError def verifier(endpoint, **options): @@ -50,100 +36,37 @@ def test_discovery_and_jwks_verify_a_real_signature_over_trusted_tls(https_serve 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): +def test_untrusted_certificates_fail_before_sending_a_request(https_server, monkeypatch): 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)() + subject = verifier(https_server, jwks_uri=https_server.url + "/keys") + with pytest.raises(JWKSFetchError) as error: + subject.prefetch() assert error.value.__context__ is None assert error.value.__cause__ is None assert https_server.requests == [] -@pytest.mark.parametrize("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 +def test_key_endpoint_failures_are_bounded_and_do_not_follow_redirects(https_server, failure): + payload = {"keys": []} if failure == "oversized": - https_server.serve("/" + endpoint, b'{"padding":"' + b"x" * (1024 * 1024) + b'"}') + https_server.serve("/keys", 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("/keys", {}, status=307, headers={"Location": https_server.url + "/redirected"}) https_server.serve("/redirected", payload) else: https_server.serve( - "/" + endpoint, + "/keys", 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 + subject = verifier(https_server, jwks_uri=https_server.url + "/keys", timeout_seconds=0.2) 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) - + with pytest.raises(JWKSFetchError) as error: + subject.prefetch() assert time.monotonic() - started < 1 assert error.value.__context__ is None - assert [request[1] for request in https_server.requests] == ["/token", "/inventory"] + assert [request[1] for request in https_server.requests] == ["/keys"] From f9de59582bfe4803e629ffee613a915a2cfa48a7 Mon Sep 17 00:00:00 2001 From: Ben Freiberg <9841563+bfreiberg@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:37:55 +0200 Subject: [PATCH 04/15] test(auth): isolate operations in exception assertions --- tests/functional/auth/test_authorizer.py | 15 ++++++++--- tests/functional/auth/test_jwks_cache.py | 32 ++++++++++++++++-------- tests/functional/auth/test_profiles.py | 9 ++++--- tests/functional/auth/test_verifier.py | 22 ++++++++++------ 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/tests/functional/auth/test_authorizer.py b/tests/functional/auth/test_authorizer.py index f192baf427e..84e428834ca 100644 --- a/tests/functional/auth/test_authorizer.py +++ b/tests/functional/auth/test_authorizer.py @@ -95,9 +95,10 @@ def test_simple_responses_require_payload_version_two(jwks, issue_token): "methodArn": ARN, "headers": {"authorization": "Bearer " + issue_token()}, } + subject = verifier(jwks) with pytest.raises(ValueError): - verifier(jwks).authorize(event, response_format="simple") + subject.authorize(event, response_format="simple") def test_context_is_opt_in_and_copies_only_selected_scalar_claims(jwks, claims, issue_token): @@ -188,8 +189,10 @@ def test_invalid_request_arns_raise_instead_of_returning_an_invalid_policy(jwks, event = copy.deepcopy(load_event("apiGatewayAuthorizerTokenEvent.json")) event["authorizationToken"] = "Bearer " + issue_token() event["methodArn"] = arn + subject = verifier(jwks) + with pytest.raises(ValueError, match="concrete API Gateway"): - verifier(jwks).authorize(event) + subject.authorize(event) @pytest.mark.parametrize("malformed", [False, True]) @@ -207,8 +210,10 @@ def test_authorizer_denies_malformed_or_ambiguous_header_maps(jwks, issue_token, @pytest.mark.parametrize("event", [None, [], {}, {"type": "OTHER"}]) def test_authorizer_rejects_unsupported_events(jwks, event): + subject = verifier(jwks) + with pytest.raises(ValueError, match="TOKEN or REQUEST"): - verifier(jwks).authorize(event) + subject.authorize(event) @pytest.mark.parametrize( @@ -220,5 +225,7 @@ def test_authorizer_rejects_unsupported_events(jwks, event): ) def test_authorizer_rejects_invalid_response_configuration(jwks, issue_token, options, message): event = {"type": "TOKEN", "methodArn": ARN, "authorizationToken": "Bearer " + issue_token()} + subject = verifier(jwks) + with pytest.raises(ValueError, match=message): - verifier(jwks).authorize(event, **options) + subject.authorize(event, **options) diff --git a/tests/functional/auth/test_jwks_cache.py b/tests/functional/auth/test_jwks_cache.py index 8e1c2ec9bf5..3c5edcd5cc5 100644 --- a/tests/functional/auth/test_jwks_cache.py +++ b/tests/functional/auth/test_jwks_cache.py @@ -35,9 +35,10 @@ def test_known_keys_are_removed_after_the_key_set_expires(http, jwks, issue_toke subject.verify(issue_token()) http.serve(JWKS_URL, {"keys": []}) clock.advance(300) + token = issue_token() with pytest.raises(InvalidTokenError): - subject.verify(issue_token()) + subject.verify(token) assert len(http.requests) == 2 @@ -49,8 +50,9 @@ def test_refresh_failure_cannot_extend_key_trust_and_uses_backoff(http, jwks, is http.serve(JWKS_URL, {"error": "unavailable"}, status=503) for _ in range(3): + token = issue_token() with pytest.raises(JWKSFetchError): - subject.verify(issue_token()) + subject.verify(token) assert len(http.requests) == 2 clock.advance(1) @@ -63,16 +65,18 @@ def test_unknown_key_refresh_is_rate_limited_separately_from_freshness(http, jwk 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()) + unknown_key_token = issue_token(kid="new-key") with pytest.raises(InvalidTokenError): - subject.verify(issue_token(kid="new-key")) + subject.verify(unknown_key_token) 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" + previous_key_token = issue_token() with pytest.raises(InvalidTokenError): - subject.verify(issue_token()) + subject.verify(previous_key_token) assert len(http.requests) == 2 @@ -82,9 +86,10 @@ def test_unknown_key_cooldown_does_not_prevent_age_required_refresh(http, jwks, subject.verify(issue_token()) clock.advance(2) http.serve(JWKS_URL, {"keys": []}) + token = issue_token() with pytest.raises(InvalidTokenError): - subject.verify(issue_token()) + subject.verify(token) assert len(http.requests) == 2 @@ -96,9 +101,10 @@ def test_prefetch_does_not_reset_key_age_without_a_fetch(http, jwks, issue_token subject.prefetch() http.serve(JWKS_URL, {"keys": []}) clock.advance(1) + token = issue_token() with pytest.raises(InvalidTokenError): - subject.verify(issue_token()) + subject.verify(token) assert len(http.requests) == 2 @@ -121,9 +127,11 @@ def test_discovery_validates_issuer_before_retrieving_keys(http, jwks, issue_tok ) def test_invalid_discovery_never_falls_back_or_fetches_untrusted_keys(http, issue_token, metadata): http.serve(ISSUER + ".well-known/openid-configuration", metadata) + subject = verifier() + token = issue_token() with pytest.raises(JWKSFetchError): - verifier().verify(issue_token()) + subject.verify(token) assert len(http.requests) == 1 @@ -133,9 +141,11 @@ def test_invalid_discovery_never_falls_back_or_fetches_untrusted_keys(http, issu ) def test_malformed_key_sets_fail_closed(http, issue_token, body): http.serve(JWKS_URL, body) + subject = verifier(jwks_uri=JWKS_URL) + token = issue_token() with pytest.raises(JWKSFetchError): - verifier(jwks_uri=JWKS_URL).verify(issue_token()) + subject.verify(token) def test_concurrent_requests_share_one_key_fetch(http, jwks, issue_token): @@ -207,15 +217,17 @@ def test_failed_unknown_key_refresh_preserves_only_still_fresh_keys(http, jwks, subject.prefetch() clock.advance(1) http.serve(JWKS_URL, {}, status=503) + unknown_key_token = issue_token(kid="new-key") with pytest.raises(JWKSFetchError): - subject.verify(issue_token(kid="new-key")) + subject.verify(unknown_key_token) assert subject.verify(issue_token())["sub"] == "user-123" assert len(http.requests) == 2 clock.advance(299) + token = issue_token() with pytest.raises(JWKSFetchError): - subject.verify(issue_token()) + subject.verify(token) def test_waiting_verifier_timeout_does_not_cancel_the_shared_key_fetch(http, jwks, issue_token): diff --git a/tests/functional/auth/test_profiles.py b/tests/functional/auth/test_profiles.py index d7ef0b218d7..83a6d74fed6 100644 --- a/tests/functional/auth/test_profiles.py +++ b/tests/functional/auth/test_profiles.py @@ -48,9 +48,10 @@ def test_cognito_rejects_wrong_token_profile(jwks, claims, issue_token, override claims.update(override) if missing: del claims[missing] + token = issue_token(claims) with pytest.raises(InvalidClaimsError): - verifier.verify(issue_token(claims)) + verifier.verify(token) def test_cognito_derives_china_partition_endpoint(http, jwks, claims, issue_token): @@ -86,8 +87,9 @@ def test_any_of_never_uses_another_issuers_keys(jwks, signing_key, claims, issue 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/" + wrong_key_token = issue_token(claims, key=signing_key) with pytest.raises(InvalidSignatureError): - verifier.verify(issue_token(claims, key=signing_key)) + verifier.verify(wrong_key_token) def test_any_of_rejects_unknown_issuers_without_network_requests(http, claims, issue_token): @@ -98,9 +100,10 @@ def test_any_of_rejects_unknown_issuers_without_network_requests(http, claims, i algorithms=["RS256"], ), ) + token = issue_token(claims) with pytest.raises(InvalidTokenError): - verifier.verify(issue_token(claims)) + verifier.verify(token) assert http.requests == [] diff --git a/tests/functional/auth/test_verifier.py b/tests/functional/auth/test_verifier.py index c05da03acff..6552f4fab6c 100644 --- a/tests/functional/auth/test_verifier.py +++ b/tests/functional/auth/test_verifier.py @@ -34,9 +34,10 @@ def test_required_claims_are_additive(jwks, claims, issue_token, missing): required_claims=["sub"], ) del claims[missing] + token = issue_token(claims) with pytest.raises(InvalidClaimsError): - verifier.verify(issue_token(claims)) + verifier.verify(token) def test_expired_token_is_rejected(jwks, claims, issue_token): @@ -48,9 +49,10 @@ def test_expired_token_is_rejected(jwks, claims, issue_token): clock_skew_seconds=0, ) claims["exp"] = int(time.time()) - 1 + token = issue_token(claims) with pytest.raises(TokenExpiredError): - verifier.verify(issue_token(claims)) + verifier.verify(token) @pytest.mark.parametrize( @@ -78,9 +80,10 @@ def test_invalid_claim_values_are_rejected(jwks, claims, issue_token, claim, val jwks=jwks, ) claims[claim] = value + token = issue_token(claims) with pytest.raises(InvalidClaimsError): - verifier.verify(issue_token(claims)) + verifier.verify(token) @pytest.mark.parametrize( @@ -144,9 +147,10 @@ def test_signing_key_metadata_is_enforced(jwks, issue_token, key_change): algorithms=["RS256"], jwks=jwks, ) + token = issue_token() with pytest.raises(InvalidTokenError): - verifier.verify(issue_token()) + verifier.verify(token) def test_disallowed_token_algorithm_is_rejected(jwks, claims): @@ -203,9 +207,10 @@ def test_private_jwk_is_rejected_without_exposing_key(signing_key, claims, issue algorithms=["RS256"], jwks={"keys": [{**private_jwk, "kid": "key-1"}]}, ) + token = issue_token() with pytest.raises(InvalidTokenError) as error: - verifier.verify(issue_token()) + verifier.verify(token) assert private_jwk["d"] not in str(error.value) @@ -220,9 +225,10 @@ def test_invalid_signature_has_stable_error(jwks, claims, signing_key, issue_tok algorithms=["RS256"], jwks=jwks, ) + tampered_token = ".".join(token) with pytest.raises(InvalidSignatureError) as error: - verifier.verify(".".join(token)) + verifier.verify(tampered_token) assert str(error.value) == "Invalid access token signature" @@ -235,8 +241,10 @@ def test_malformed_key_material_fails_closed(jwks, issue_token, key_change): algorithms=["RS256"], jwks=jwks, ) + token = issue_token() + with pytest.raises(InvalidTokenError): - verifier.verify(issue_token()) + verifier.verify(token) def test_ec_curve_must_match_algorithm(claims, issue_token): From 5032ef00e0df5a340e99f1e3bc9e538561c978dc Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 23 Sep 2026 09:27:32 +1000 Subject: [PATCH 05/15] refactor(auth): separate JWT package structure --- .../utilities/auth/__init__.py | 10 +++---- .../utilities/auth/_internal/__init__.py | 1 + .../{_deadline.py => _internal/deadline.py} | 2 +- .../auth/{_http.py => _internal/http.py} | 2 +- .../validation.py} | 0 .../utilities/auth/jwt/__init__.py | 30 +++++++++++++++++++ .../utilities/auth/jwt/_internal/__init__.py | 0 .../_internal/authorization.py} | 4 +-- .../auth/{_base.py => jwt/_internal/base.py} | 10 +++---- .../{_errors.py => jwt/_internal/errors.py} | 2 +- .../auth/{_jwks.py => jwt/_internal/jwks.py} | 8 ++--- .../utilities/auth/{ => jwt}/exceptions.py | 0 .../auth/jwt/integrations/__init__.py | 1 + .../integrations/api_gateway.py} | 8 ++--- .../integrations/event_handler.py} | 6 ++-- .../utilities/auth/{ => jwt}/testing.py | 2 +- .../utilities/auth/{ => jwt}/verifier.py | 12 ++++---- docs/api_doc/auth.md | 6 ++-- docs/build_recipes/cross-platform.md | 4 +-- docs/getting-started/install.md | 2 +- docs/utilities/auth.md | 18 +++++------ .../{ => jwt}/src/authorizer/authorizer.py | 2 +- .../auth/jwt/src/authorizer/requirements.txt | 1 + .../auth/{ => jwt}/src/backend/backend.py | 0 .../{ => jwt}/src/backend/requirements.txt | 0 examples/auth/{ => jwt}/src/middleware.py | 0 examples/auth/{ => jwt}/templates/sam.yaml | 0 examples/auth/src/authorizer/requirements.txt | 1 - poetry.lock | 4 +-- pyproject.toml | 2 +- tests/functional/auth/jwt/__init__.py | 1 + tests/functional/auth/{ => jwt}/conftest.py | 4 +-- tests/functional/auth/jwt/imports/__init__.py | 1 + .../{ => jwt/imports}/_auth_import_probe.py | 10 +++++-- .../auth/{ => jwt/imports}/test_imports.py | 4 +-- .../auth/jwt/integrations/__init__.py | 1 + .../{ => jwt/integrations}/test_authorizer.py | 2 +- .../integrations}/test_failure_visibility.py | 2 +- .../{ => jwt/integrations}/test_middleware.py | 0 .../functional/auth/{ => jwt}/test_errors.py | 2 +- .../auth/{ => jwt}/test_jwks_cache.py | 2 +- .../auth/{ => jwt}/test_profiles.py | 6 +++- .../functional/auth/{ => jwt}/test_testing.py | 4 +-- .../auth/{ => jwt}/test_token_profile.py | 2 +- .../auth/{ => jwt}/test_verifier.py | 2 +- tests/integration/auth/{ => jwt}/conftest.py | 0 .../integration/auth/{ => jwt}/test_https.py | 2 +- 47 files changed, 114 insertions(+), 69 deletions(-) create mode 100644 aws_lambda_powertools/utilities/auth/_internal/__init__.py rename aws_lambda_powertools/utilities/auth/{_deadline.py => _internal/deadline.py} (89%) rename aws_lambda_powertools/utilities/auth/{_http.py => _internal/http.py} (96%) rename aws_lambda_powertools/utilities/auth/{_validation.py => _internal/validation.py} (100%) create mode 100644 aws_lambda_powertools/utilities/auth/jwt/__init__.py create mode 100644 aws_lambda_powertools/utilities/auth/jwt/_internal/__init__.py rename aws_lambda_powertools/utilities/auth/{_authorization.py => jwt/_internal/authorization.py} (95%) rename aws_lambda_powertools/utilities/auth/{_base.py => jwt/_internal/base.py} (89%) rename aws_lambda_powertools/utilities/auth/{_errors.py => jwt/_internal/errors.py} (92%) rename aws_lambda_powertools/utilities/auth/{_jwks.py => jwt/_internal/jwks.py} (93%) rename aws_lambda_powertools/utilities/auth/{ => jwt}/exceptions.py (100%) create mode 100644 aws_lambda_powertools/utilities/auth/jwt/integrations/__init__.py rename aws_lambda_powertools/utilities/auth/{_authorizer.py => jwt/integrations/api_gateway.py} (93%) rename aws_lambda_powertools/utilities/auth/{_middleware.py => jwt/integrations/event_handler.py} (92%) rename aws_lambda_powertools/utilities/auth/{ => jwt}/testing.py (92%) rename aws_lambda_powertools/utilities/auth/{ => jwt}/verifier.py (96%) rename examples/auth/{ => jwt}/src/authorizer/authorizer.py (93%) create mode 100644 examples/auth/jwt/src/authorizer/requirements.txt rename examples/auth/{ => jwt}/src/backend/backend.py (100%) rename examples/auth/{ => jwt}/src/backend/requirements.txt (100%) rename examples/auth/{ => jwt}/src/middleware.py (100%) rename examples/auth/{ => jwt}/templates/sam.yaml (100%) delete mode 100644 examples/auth/src/authorizer/requirements.txt create mode 100644 tests/functional/auth/jwt/__init__.py rename tests/functional/auth/{ => jwt}/conftest.py (94%) create mode 100644 tests/functional/auth/jwt/imports/__init__.py rename tests/functional/auth/{ => jwt/imports}/_auth_import_probe.py (82%) rename tests/functional/auth/{ => jwt/imports}/test_imports.py (86%) create mode 100644 tests/functional/auth/jwt/integrations/__init__.py rename tests/functional/auth/{ => jwt/integrations}/test_authorizer.py (99%) rename tests/functional/auth/{ => jwt/integrations}/test_failure_visibility.py (98%) rename tests/functional/auth/{ => jwt/integrations}/test_middleware.py (100%) rename tests/functional/auth/{ => jwt}/test_errors.py (97%) rename tests/functional/auth/{ => jwt}/test_jwks_cache.py (98%) rename tests/functional/auth/{ => jwt}/test_profiles.py (97%) rename tests/functional/auth/{ => jwt}/test_testing.py (87%) rename tests/functional/auth/{ => jwt}/test_token_profile.py (97%) rename tests/functional/auth/{ => jwt}/test_verifier.py (99%) rename tests/integration/auth/{ => jwt}/conftest.py (100%) rename tests/integration/auth/{ => jwt}/test_https.py (97%) diff --git a/aws_lambda_powertools/utilities/auth/__init__.py b/aws_lambda_powertools/utilities/auth/__init__.py index 71ea7d3fa89..1fd05f3cd8f 100644 --- a/aws_lambda_powertools/utilities/auth/__init__.py +++ b/aws_lambda_powertools/utilities/auth/__init__.py @@ -1,4 +1,4 @@ -"""JWT access-token verification for AWS Lambda.""" +"""Authentication and authorization utilities for AWS Lambda.""" from __future__ import annotations @@ -6,15 +6,15 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from aws_lambda_powertools.utilities.auth._middleware import AuthErrorContext as AuthErrorContext - from aws_lambda_powertools.utilities.auth.exceptions import AuthFailureReason as AuthFailureReason - from aws_lambda_powertools.utilities.auth.verifier import JWTVerifier as JWTVerifier + from aws_lambda_powertools.utilities.auth.jwt import AuthErrorContext as AuthErrorContext + from aws_lambda_powertools.utilities.auth.jwt import AuthFailureReason as AuthFailureReason + from aws_lambda_powertools.utilities.auth.jwt import JWTVerifier as JWTVerifier __all__ = ["AuthErrorContext", "AuthFailureReason", "JWTVerifier"] def __getattr__(name: str) -> object: - modules = {"AuthErrorContext": "_middleware", "AuthFailureReason": "exceptions", "JWTVerifier": "verifier"} + modules = {"AuthErrorContext": "jwt", "AuthFailureReason": "jwt", "JWTVerifier": "jwt"} if name in modules: value = getattr(importlib.import_module(f"{__name__}.{modules[name]}"), name) globals()[name] = value diff --git a/aws_lambda_powertools/utilities/auth/_internal/__init__.py b/aws_lambda_powertools/utilities/auth/_internal/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/_internal/__init__.py @@ -0,0 +1 @@ + diff --git a/aws_lambda_powertools/utilities/auth/_deadline.py b/aws_lambda_powertools/utilities/auth/_internal/deadline.py similarity index 89% rename from aws_lambda_powertools/utilities/auth/_deadline.py rename to aws_lambda_powertools/utilities/auth/_internal/deadline.py index 212f1c8ee5c..1db19e6dea7 100644 --- a/aws_lambda_powertools/utilities/auth/_deadline.py +++ b/aws_lambda_powertools/utilities/auth/_internal/deadline.py @@ -2,7 +2,7 @@ import time -from aws_lambda_powertools.utilities.auth._validation import finite_seconds +from aws_lambda_powertools.utilities.auth._internal.validation import finite_seconds class RequestError(Exception): diff --git a/aws_lambda_powertools/utilities/auth/_http.py b/aws_lambda_powertools/utilities/auth/_internal/http.py similarity index 96% rename from aws_lambda_powertools/utilities/auth/_http.py rename to aws_lambda_powertools/utilities/auth/_internal/http.py index e832223b479..340ff1e43d5 100644 --- a/aws_lambda_powertools/utilities/auth/_http.py +++ b/aws_lambda_powertools/utilities/auth/_internal/http.py @@ -6,7 +6,7 @@ import urllib3 from urllib3.connection import HTTPConnection -from aws_lambda_powertools.utilities.auth._deadline import Deadline, RequestError +from aws_lambda_powertools.utilities.auth._internal.deadline import Deadline, RequestError if TYPE_CHECKING: from collections.abc import Mapping diff --git a/aws_lambda_powertools/utilities/auth/_validation.py b/aws_lambda_powertools/utilities/auth/_internal/validation.py similarity index 100% rename from aws_lambda_powertools/utilities/auth/_validation.py rename to aws_lambda_powertools/utilities/auth/_internal/validation.py diff --git a/aws_lambda_powertools/utilities/auth/jwt/__init__.py b/aws_lambda_powertools/utilities/auth/jwt/__init__.py new file mode 100644 index 00000000000..c1005fe7811 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/jwt/__init__.py @@ -0,0 +1,30 @@ +"""JWT access-token verification.""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthFailureReason as AuthFailureReason + from aws_lambda_powertools.utilities.auth.jwt.integrations.event_handler import AuthErrorContext as AuthErrorContext + from aws_lambda_powertools.utilities.auth.jwt.verifier import JWTVerifier as JWTVerifier + +__all__ = ["AuthErrorContext", "AuthFailureReason", "JWTVerifier"] + + +def __getattr__(name: str) -> object: + modules = { + "AuthErrorContext": "integrations.event_handler", + "AuthFailureReason": "exceptions", + "JWTVerifier": "verifier", + } + if name in modules: + value = getattr(importlib.import_module(f"{__name__}.{modules[name]}"), name) + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/aws_lambda_powertools/utilities/auth/jwt/_internal/__init__.py b/aws_lambda_powertools/utilities/auth/jwt/_internal/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/aws_lambda_powertools/utilities/auth/_authorization.py b/aws_lambda_powertools/utilities/auth/jwt/_internal/authorization.py similarity index 95% rename from aws_lambda_powertools/utilities/auth/_authorization.py rename to aws_lambda_powertools/utilities/auth/jwt/_internal/authorization.py index 3b384c593db..a868a31a0d0 100644 --- a/aws_lambda_powertools/utilities/auth/_authorization.py +++ b/aws_lambda_powertools/utilities/auth/jwt/_internal/authorization.py @@ -3,8 +3,8 @@ 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 ( +from aws_lambda_powertools.utilities.auth._internal.validation import string_list +from aws_lambda_powertools.utilities.auth.jwt.exceptions import ( AuthError, AuthFailureReason, InvalidClaimsError, diff --git a/aws_lambda_powertools/utilities/auth/_base.py b/aws_lambda_powertools/utilities/auth/jwt/_internal/base.py similarity index 89% rename from aws_lambda_powertools/utilities/auth/_base.py rename to aws_lambda_powertools/utilities/auth/jwt/_internal/base.py index e031d15d42a..f9b16567094 100644 --- a/aws_lambda_powertools/utilities/auth/_base.py +++ b/aws_lambda_powertools/utilities/auth/jwt/_internal/base.py @@ -3,14 +3,14 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Literal -from aws_lambda_powertools.utilities.auth._errors import sanitize_errors +from aws_lambda_powertools.utilities.auth.jwt._internal.errors import sanitize_errors if TYPE_CHECKING: from collections.abc import Callable from aws_lambda_powertools.event_handler import Response - from aws_lambda_powertools.utilities.auth._middleware import AuthErrorContext, AuthMiddleware - from aws_lambda_powertools.utilities.auth.exceptions import AuthError + from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthError + from aws_lambda_powertools.utilities.auth.jwt.integrations.event_handler import AuthErrorContext, AuthMiddleware from aws_lambda_powertools.utilities.data_classes.common import DictWrapper @@ -59,7 +59,7 @@ def orders(): return {"subject": app.context["claims"]["sub"]} ``` """ - from aws_lambda_powertools.utilities.auth._middleware import AuthMiddleware + from aws_lambda_powertools.utilities.auth.jwt.integrations.event_handler import AuthMiddleware return AuthMiddleware(self, scopes, authorize, on_error) @@ -104,6 +104,6 @@ def authorize( ) ``` """ - from aws_lambda_powertools.utilities.auth._authorizer import authorize_event + from aws_lambda_powertools.utilities.auth.jwt.integrations.api_gateway import authorize_event return authorize_event(self, event, scopes, response_format, context_claims, on_error) diff --git a/aws_lambda_powertools/utilities/auth/_errors.py b/aws_lambda_powertools/utilities/auth/jwt/_internal/errors.py similarity index 92% rename from aws_lambda_powertools/utilities/auth/_errors.py rename to aws_lambda_powertools/utilities/auth/jwt/_internal/errors.py index 9bbcacb75c7..d33e003d0c4 100644 --- a/aws_lambda_powertools/utilities/auth/_errors.py +++ b/aws_lambda_powertools/utilities/auth/jwt/_internal/errors.py @@ -3,7 +3,7 @@ from functools import wraps from typing import TYPE_CHECKING, ParamSpec, TypeVar -from aws_lambda_powertools.utilities.auth.exceptions import AuthError +from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthError if TYPE_CHECKING: from collections.abc import Callable diff --git a/aws_lambda_powertools/utilities/auth/_jwks.py b/aws_lambda_powertools/utilities/auth/jwt/_internal/jwks.py similarity index 93% rename from aws_lambda_powertools/utilities/auth/_jwks.py rename to aws_lambda_powertools/utilities/auth/jwt/_internal/jwks.py index a7aeb4c9ca9..c544f4dc66d 100644 --- a/aws_lambda_powertools/utilities/auth/_jwks.py +++ b/aws_lambda_powertools/utilities/auth/jwt/_internal/jwks.py @@ -8,9 +8,9 @@ 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 +from aws_lambda_powertools.utilities.auth._internal.deadline import Deadline, RequestError +from aws_lambda_powertools.utilities.auth._internal.validation import https_url +from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError def copy_key_set(value: dict[str, Any]) -> dict[str, Any]: @@ -46,7 +46,7 @@ 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 + from aws_lambda_powertools.utilities.auth._internal.http import HTTPClient self._issuer = issuer self._uri = uri diff --git a/aws_lambda_powertools/utilities/auth/exceptions.py b/aws_lambda_powertools/utilities/auth/jwt/exceptions.py similarity index 100% rename from aws_lambda_powertools/utilities/auth/exceptions.py rename to aws_lambda_powertools/utilities/auth/jwt/exceptions.py diff --git a/aws_lambda_powertools/utilities/auth/jwt/integrations/__init__.py b/aws_lambda_powertools/utilities/auth/jwt/integrations/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/aws_lambda_powertools/utilities/auth/jwt/integrations/__init__.py @@ -0,0 +1 @@ + diff --git a/aws_lambda_powertools/utilities/auth/_authorizer.py b/aws_lambda_powertools/utilities/auth/jwt/integrations/api_gateway.py similarity index 93% rename from aws_lambda_powertools/utilities/auth/_authorizer.py rename to aws_lambda_powertools/utilities/auth/jwt/integrations/api_gateway.py index 21edb718dc1..d2857648f29 100644 --- a/aws_lambda_powertools/utilities/auth/_authorizer.py +++ b/aws_lambda_powertools/utilities/auth/jwt/integrations/api_gateway.py @@ -4,22 +4,22 @@ import re from typing import TYPE_CHECKING, Any, Literal -from aws_lambda_powertools.utilities.auth._authorization import ( +from aws_lambda_powertools.utilities.auth._internal.validation import string_list +from aws_lambda_powertools.utilities.auth.jwt._internal.authorization import ( ForbiddenError, bearer_token, enforce_scopes, header_token, required_scopes, ) -from aws_lambda_powertools.utilities.auth._validation import string_list -from aws_lambda_powertools.utilities.auth.exceptions import AuthError, InvalidClaimsError, InvalidTokenError +from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthError, InvalidClaimsError, InvalidTokenError from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import APIGatewayAuthorizerResponseV2 from aws_lambda_powertools.utilities.data_classes.common import DictWrapper if TYPE_CHECKING: from collections.abc import Callable - from aws_lambda_powertools.utilities.auth._base import Verifier + from aws_lambda_powertools.utilities.auth.jwt._internal.base import Verifier _ARN = re.compile(r"arn:[a-z0-9-]+:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+/[^/]+/[A-Z]+/.*") diff --git a/aws_lambda_powertools/utilities/auth/_middleware.py b/aws_lambda_powertools/utilities/auth/jwt/integrations/event_handler.py similarity index 92% rename from aws_lambda_powertools/utilities/auth/_middleware.py rename to aws_lambda_powertools/utilities/auth/jwt/integrations/event_handler.py index 127bea2d6c2..a2e79daf53b 100644 --- a/aws_lambda_powertools/utilities/auth/_middleware.py +++ b/aws_lambda_powertools/utilities/auth/jwt/integrations/event_handler.py @@ -5,7 +5,7 @@ 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 ( +from aws_lambda_powertools.utilities.auth.jwt._internal.authorization import ( ForbiddenError, InsufficientScopeError, MissingTokenError, @@ -13,14 +13,14 @@ header_token, required_scopes, ) -from aws_lambda_powertools.utilities.auth.exceptions import AuthError, AuthFailureReason, InvalidTokenError +from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthError, AuthFailureReason, InvalidTokenError if TYPE_CHECKING: from collections.abc import Callable from typing import Any from aws_lambda_powertools.event_handler.middlewares import NextMiddleware - from aws_lambda_powertools.utilities.auth._base import Verifier + from aws_lambda_powertools.utilities.auth.jwt._internal.base import Verifier @dataclass(frozen=True) diff --git a/aws_lambda_powertools/utilities/auth/testing.py b/aws_lambda_powertools/utilities/auth/jwt/testing.py similarity index 92% rename from aws_lambda_powertools/utilities/auth/testing.py rename to aws_lambda_powertools/utilities/auth/jwt/testing.py index c9c2b52a4ec..455fd58eb10 100644 --- a/aws_lambda_powertools/utilities/auth/testing.py +++ b/aws_lambda_powertools/utilities/auth/jwt/testing.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from collections.abc import Iterator - from aws_lambda_powertools.utilities.auth._base import Verifier + from aws_lambda_powertools.utilities.auth.jwt._internal.base import Verifier @contextmanager diff --git a/aws_lambda_powertools/utilities/auth/verifier.py b/aws_lambda_powertools/utilities/auth/jwt/verifier.py similarity index 96% rename from aws_lambda_powertools/utilities/auth/verifier.py rename to aws_lambda_powertools/utilities/auth/jwt/verifier.py index dcd6bdebebf..cd5a786d170 100644 --- a/aws_lambda_powertools/utilities/auth/verifier.py +++ b/aws_lambda_powertools/utilities/auth/jwt/verifier.py @@ -7,18 +7,18 @@ 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 ( +from aws_lambda_powertools.utilities.auth._internal.deadline import Deadline +from aws_lambda_powertools.utilities.auth._internal.validation import ( finite_seconds, https_url, is_nonempty_string, string_list, string_mapping, ) -from aws_lambda_powertools.utilities.auth.exceptions import ( +from aws_lambda_powertools.utilities.auth.jwt._internal.base import Verifier +from aws_lambda_powertools.utilities.auth.jwt._internal.errors import sanitize_errors +from aws_lambda_powertools.utilities.auth.jwt._internal.jwks import copy_key_set, shared_cache, signing_key +from aws_lambda_powertools.utilities.auth.jwt.exceptions import ( InvalidClaimsError, InvalidSignatureError, InvalidTokenError, diff --git a/docs/api_doc/auth.md b/docs/api_doc/auth.md index 1f63cd03147..bc21b5e170b 100644 --- a/docs/api_doc/auth.md +++ b/docs/api_doc/auth.md @@ -1,7 +1,7 @@ -::: aws_lambda_powertools.utilities.auth.verifier +::: aws_lambda_powertools.utilities.auth.jwt.verifier options: inherited_members: true ::: aws_lambda_powertools.utilities.auth.AuthErrorContext -::: aws_lambda_powertools.utilities.auth.exceptions -::: aws_lambda_powertools.utilities.auth.testing +::: aws_lambda_powertools.utilities.auth.jwt.exceptions +::: aws_lambda_powertools.utilities.auth.jwt.testing diff --git a/docs/build_recipes/cross-platform.md b/docs/build_recipes/cross-platform.md index bb124a83009..2dfed73a582 100644 --- a/docs/build_recipes/cross-platform.md +++ b/docs/build_recipes/cross-platform.md @@ -18,7 +18,7 @@ Taking into consideration Powertools for AWS dependencies and common Python pack |---------|----------|------------|--------|-------------------| | **pydantic** | Rust | Core validation engine | High - Core functionality affected | ✅ Core dependency | | **aws-encryption-sdk** | C | Encryption/decryption | High - Data masking fails | ✅ Optional (datamasking extra) | -| **cryptography** | Rust/C | Asymmetric signature verification | High - JWT verification fails | ✅ Optional (auth extra) | +| **cryptography** | Rust/C | Asymmetric signature verification | High - JWT verification fails | ✅ Optional (jwt extra) | | **protobuf** | C++ | Protocol buffer serialization | High - Message parsing fails | ✅ Optional (kafka-consumer-protobuf) | | **redis** | C | Redis client with hiredis | Medium - Falls back to pure Python | ✅ Optional (redis extra) | | **valkey-glide** | Rust | High-performance Redis client | High - Client completely broken | ✅ Optional (valkey extra) | @@ -45,7 +45,7 @@ Different Powertools for AWS extras dependencies have varying levels of architec ```txt title="requirements.txt - Requires Linux builds" # These extras include compiled dependencies - aws-lambda-powertools[auth] # cryptography (Rust/C) + aws-lambda-powertools[jwt] # cryptography (Rust/C) aws-lambda-powertools[parser]==3.18.0 # pydantic (Rust) aws-lambda-powertools[validation]==3.18.0 # fastjsonschema (C) aws-lambda-powertools[datamasking]==3.18.0 # aws-encryption-sdk (C) diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index f2b215c10da..1576bfce2d2 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -42,7 +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` | +| [JWT verification](../utilities/auth.md) | `pip install "aws-lambda-powertools[jwt]"` | `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/utilities/auth.md b/docs/utilities/auth.md index 2caac2e1d42..317a4f33e67 100644 --- a/docs/utilities/auth.md +++ b/docs/utilities/auth.md @@ -19,10 +19,10 @@ Use it inside a Lambda function or a Lambda authorizer. Prefer an API Gateway ma ### Install ```shell -pip install "aws-lambda-powertools[auth]" +pip install "aws-lambda-powertools[jwt]" ``` -The optional `auth` extra includes PyJWT, cryptography, and urllib3. It adds no dependencies to the base installation. +The optional `jwt` extra includes PyJWT, cryptography, and urllib3. It adds no dependencies to the base installation. Build cryptography dependencies for your Lambda Python version and architecture; see [cross-platform builds](../build_recipes/cross-platform.md). The Powertools Layer retains urllib3 from the declared dependency range instead of relying on the runtime's copy. Applications pinning a different AWS SDK must validate that SDK's urllib3 requirements against the Layer or bundle a compatible dependency set. @@ -33,7 +33,7 @@ Create a verifier outside the handler so warm invocations reuse its key cache. C 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" +--8<-- "examples/auth/jwt/src/middleware.py" ``` `require()` validates the Bearer token and all requested scopes before executing the route. Verified claims are available through `app.context["claims"]`. @@ -198,7 +198,7 @@ The combined verifier supports `verify()`, `prefetch()`, `require()`, and `autho ### Lambda authorizers ```python title="authorizer.py" ---8<-- "examples/auth/src/authorizer/authorizer.py" +--8<-- "examples/auth/jwt/src/authorizer/authorizer.py" ``` The helper accepts raw dictionaries or the corresponding Powertools authorizer Data Classes. @@ -229,13 +229,13 @@ The name `claims` is reserved in authorizer context. Disable authorizer-result caching to verify each request. This SAM example sets `ReauthorizeEvery: 0` for both REST and HTTP authorizers; the underlying API Gateway setting is `AuthorizerResultTtlInSeconds: 0`. -The template is under `examples/auth/templates/`; its `CodeUri` values are relative to that directory. -Authorizer functions build from `src/authorizer/` with the Auth extra. Backends build independently from `src/backend/` with base Powertools only, +The template is under `examples/auth/jwt/templates/`; its `CodeUri` values are relative to that directory. +Authorizer functions build from `src/authorizer/` with the JWT extra. Backends build independently from `src/backend/` with base Powertools only, so PyJWT and cryptography are not included in the backend artifacts. HTTP simple responses also require payload version 2.0 and `EnableSimpleResponses: true`. ```yaml title="templates/sam.yaml" ---8<-- "examples/auth/templates/sam.yaml" +--8<-- "examples/auth/jwt/templates/sam.yaml" ``` If you enable result caching later, a cached decision can outlive the JWT's expiration or a signing key's removal. @@ -260,7 +260,7 @@ from pydantic import AnyHttpUrl from aws_lambda_powertools import Logger from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.exceptions import InvalidTokenError, JWKSFetchError +from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError logger = Logger() RESOURCE_URL = "https://mcp.example.com" @@ -360,7 +360,7 @@ Outbound token acquisition, opaque-token introspection, delegated token exchange 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 aws_lambda_powertools.utilities.auth.jwt.testing import mock_claims from middleware import app, verifier diff --git a/examples/auth/src/authorizer/authorizer.py b/examples/auth/jwt/src/authorizer/authorizer.py similarity index 93% rename from examples/auth/src/authorizer/authorizer.py rename to examples/auth/jwt/src/authorizer/authorizer.py index f6575f16d97..b8133db6e12 100644 --- a/examples/auth/src/authorizer/authorizer.py +++ b/examples/auth/jwt/src/authorizer/authorizer.py @@ -2,7 +2,7 @@ from aws_lambda_powertools import Logger from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.exceptions import AuthError +from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthError from aws_lambda_powertools.utilities.typing import LambdaContext logger = Logger() diff --git a/examples/auth/jwt/src/authorizer/requirements.txt b/examples/auth/jwt/src/authorizer/requirements.txt new file mode 100644 index 00000000000..1fbc8909c71 --- /dev/null +++ b/examples/auth/jwt/src/authorizer/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools[jwt] diff --git a/examples/auth/src/backend/backend.py b/examples/auth/jwt/src/backend/backend.py similarity index 100% rename from examples/auth/src/backend/backend.py rename to examples/auth/jwt/src/backend/backend.py diff --git a/examples/auth/src/backend/requirements.txt b/examples/auth/jwt/src/backend/requirements.txt similarity index 100% rename from examples/auth/src/backend/requirements.txt rename to examples/auth/jwt/src/backend/requirements.txt diff --git a/examples/auth/src/middleware.py b/examples/auth/jwt/src/middleware.py similarity index 100% rename from examples/auth/src/middleware.py rename to examples/auth/jwt/src/middleware.py diff --git a/examples/auth/templates/sam.yaml b/examples/auth/jwt/templates/sam.yaml similarity index 100% rename from examples/auth/templates/sam.yaml rename to examples/auth/jwt/templates/sam.yaml diff --git a/examples/auth/src/authorizer/requirements.txt b/examples/auth/src/authorizer/requirements.txt deleted file mode 100644 index 5f017438d3d..00000000000 --- a/examples/auth/src/authorizer/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -aws-lambda-powertools[auth] diff --git a/poetry.lock b/poetry.lock index 6423f9a7e77..7d0f25b9e32 100644 --- a/poetry.lock +++ b/poetry.lock @@ -5374,7 +5374,7 @@ type = ["pytest-mypy"] [extras] all = ["aws-encryption-sdk", "aws-xray-sdk", "cryptography", "fastjsonschema", "jsonpath-ng", "pydantic", "pydantic-settings", "pyjwt", "urllib3"] -auth = ["cryptography", "pyjwt", "urllib3"] +jwt = ["cryptography", "pyjwt", "urllib3"] aws-sdk = ["boto3"] datadog = ["datadog-lambda"] datamasking = ["aws-encryption-sdk", "jsonpath-ng"] @@ -5389,4 +5389,4 @@ valkey = ["valkey-glide"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0.0" -content-hash = "d1be888618d485c538c744c46441d09297f7dab01353fb5b1054d1c46d1bb523" +content-hash = "906b66b0cba452e7bed8de1a7557359fd454b60525c1873964c3ca25a299887b" diff --git a/pyproject.toml b/pyproject.toml index 719b4eaabf3..e6a0d08ddd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ validation = ["fastjsonschema"] tracer = ["aws-xray-sdk"] redis = ["redis"] valkey = ["valkey-glide"] -auth = ["pyjwt", "cryptography", "urllib3"] +jwt = ["pyjwt", "cryptography", "urllib3"] all = [ "pydantic", "pydantic-settings", diff --git a/tests/functional/auth/jwt/__init__.py b/tests/functional/auth/jwt/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/functional/auth/jwt/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/functional/auth/conftest.py b/tests/functional/auth/jwt/conftest.py similarity index 94% rename from tests/functional/auth/conftest.py rename to tests/functional/auth/jwt/conftest.py index e529a405914..63ea3a5089f 100644 --- a/tests/functional/auth/conftest.py +++ b/tests/functional/auth/jwt/conftest.py @@ -9,7 +9,7 @@ import urllib3 from cryptography.hazmat.primitives.asymmetric import rsa -from aws_lambda_powertools.utilities.auth import _jwks +from aws_lambda_powertools.utilities.auth.jwt._internal import jwks as jwks_module @pytest.fixture(scope="session") @@ -78,7 +78,7 @@ def request(self, method, url, **kwargs): def http(monkeypatch): # Each fake provider belongs to one test. Error tracebacks can keep a # previous verifier alive; retain sharing only within the current test. - monkeypatch.setattr(_jwks, "_caches", weakref.WeakValueDictionary()) + monkeypatch.setattr(jwks_module, "_caches", weakref.WeakValueDictionary()) transport = FakeHTTP() monkeypatch.setattr(urllib3, "PoolManager", lambda **kwargs: transport) return transport diff --git a/tests/functional/auth/jwt/imports/__init__.py b/tests/functional/auth/jwt/imports/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/functional/auth/jwt/imports/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/functional/auth/_auth_import_probe.py b/tests/functional/auth/jwt/imports/_auth_import_probe.py similarity index 82% rename from tests/functional/auth/_auth_import_probe.py rename to tests/functional/auth/jwt/imports/_auth_import_probe.py index 4a4c5a92c1d..c53a536beb1 100644 --- a/tests/functional/auth/_auth_import_probe.py +++ b/tests/functional/auth/jwt/imports/_auth_import_probe.py @@ -22,7 +22,7 @@ def find_spec(self, fullname, path=None, target=None): sys.meta_path.insert(0, BlockImports("urllib3")) from aws_lambda_powertools.utilities.auth import JWTVerifier - from aws_lambda_powertools.utilities.auth.exceptions import InvalidSignatureError + from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidSignatureError fixture = json.load(sys.stdin) verifier = JWTVerifier( @@ -54,8 +54,11 @@ def find_spec(self, fullname, path=None, target=None): assert "urllib3" in sys.modules elif scenario == "exports": auth = importlib.import_module("aws_lambda_powertools.utilities.auth") + jwt_auth = importlib.import_module("aws_lambda_powertools.utilities.auth.jwt") - assert {"JWTVerifier", "AuthFailureReason", "AuthErrorContext"} <= set(dir(auth)) + exports = {"JWTVerifier", "AuthFailureReason", "AuthErrorContext"} + assert exports <= set(dir(auth)) + assert exports <= set(dir(jwt_auth)) assert not {"jwt", "cryptography", "urllib3"} & sys.modules.keys() try: _ = auth.unknown_attribute @@ -69,6 +72,9 @@ def find_spec(self, fullname, path=None, target=None): assert members["JWTVerifier"] is auth.JWTVerifier assert members["AuthFailureReason"] is auth.AuthFailureReason assert members["AuthErrorContext"] is auth.AuthErrorContext + assert members["JWTVerifier"] is jwt_auth.JWTVerifier + assert members["AuthFailureReason"] is jwt_auth.AuthFailureReason + assert members["AuthErrorContext"] is jwt_auth.AuthErrorContext elif scenario == "star": from aws_lambda_powertools.utilities.auth import * # noqa: E402,F403 diff --git a/tests/functional/auth/test_imports.py b/tests/functional/auth/jwt/imports/test_imports.py similarity index 86% rename from tests/functional/auth/test_imports.py rename to tests/functional/auth/jwt/imports/test_imports.py index a61a8c67535..3f7763a6b71 100644 --- a/tests/functional/auth/test_imports.py +++ b/tests/functional/auth/jwt/imports/test_imports.py @@ -9,10 +9,10 @@ @pytest.mark.parametrize("scenario", ["static", "remote", "exports", "star"]) def test_auth_imports_in_clean_interpreter(scenario, jwks, claims, issue_token): - project_root = Path(__file__).parents[3] + project_root = Path(__file__).parents[5] probe = Path(__file__).with_name("_auth_import_probe.py") env = os.environ.copy() - env["PYTHONPATH"] = str(project_root) + env["PYTHONPATH"] = os.pathsep.join((str(project_root), env.get("PYTHONPATH", ""))) fixture = { "issuer": claims["iss"], "audience": claims["aud"], diff --git a/tests/functional/auth/jwt/integrations/__init__.py b/tests/functional/auth/jwt/integrations/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/functional/auth/jwt/integrations/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/functional/auth/test_authorizer.py b/tests/functional/auth/jwt/integrations/test_authorizer.py similarity index 99% rename from tests/functional/auth/test_authorizer.py rename to tests/functional/auth/jwt/integrations/test_authorizer.py index 84e428834ca..74aa3a71d41 100644 --- a/tests/functional/auth/test_authorizer.py +++ b/tests/functional/auth/jwt/integrations/test_authorizer.py @@ -3,7 +3,7 @@ import pytest from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.exceptions import JWKSFetchError +from aws_lambda_powertools.utilities.auth.jwt.exceptions import JWKSFetchError from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import ( APIGatewayAuthorizerEventV2, APIGatewayAuthorizerRequestEvent, diff --git a/tests/functional/auth/test_failure_visibility.py b/tests/functional/auth/jwt/integrations/test_failure_visibility.py similarity index 98% rename from tests/functional/auth/test_failure_visibility.py rename to tests/functional/auth/jwt/integrations/test_failure_visibility.py index 14861af0c25..c5e1cc60f1b 100644 --- a/tests/functional/auth/test_failure_visibility.py +++ b/tests/functional/auth/jwt/integrations/test_failure_visibility.py @@ -6,7 +6,7 @@ from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response from aws_lambda_powertools.utilities.auth import AuthErrorContext, AuthFailureReason, JWTVerifier -from aws_lambda_powertools.utilities.auth.exceptions import JWKSFetchError +from aws_lambda_powertools.utilities.auth.jwt.exceptions import JWKSFetchError from tests.functional.utils import load_event ARN = "arn:aws:execute-api:us-east-1:123456789012:api123/prod/GET/orders" diff --git a/tests/functional/auth/test_middleware.py b/tests/functional/auth/jwt/integrations/test_middleware.py similarity index 100% rename from tests/functional/auth/test_middleware.py rename to tests/functional/auth/jwt/integrations/test_middleware.py diff --git a/tests/functional/auth/test_errors.py b/tests/functional/auth/jwt/test_errors.py similarity index 97% rename from tests/functional/auth/test_errors.py rename to tests/functional/auth/jwt/test_errors.py index ec451a8fb64..ec14a610ff0 100644 --- a/tests/functional/auth/test_errors.py +++ b/tests/functional/auth/jwt/test_errors.py @@ -9,7 +9,7 @@ from aws_lambda_powertools import Logger from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.exceptions import ( +from aws_lambda_powertools.utilities.auth.jwt.exceptions import ( InvalidClaimsError, InvalidSignatureError, InvalidTokenError, diff --git a/tests/functional/auth/test_jwks_cache.py b/tests/functional/auth/jwt/test_jwks_cache.py similarity index 98% rename from tests/functional/auth/test_jwks_cache.py rename to tests/functional/auth/jwt/test_jwks_cache.py index 3c5edcd5cc5..50324521ac1 100644 --- a/tests/functional/auth/test_jwks_cache.py +++ b/tests/functional/auth/jwt/test_jwks_cache.py @@ -5,7 +5,7 @@ import pytest from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.exceptions import InvalidTokenError, JWKSFetchError +from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError JWKS_URL = "https://idp.example.com/keys" ISSUER = "https://idp.example.com/" diff --git a/tests/functional/auth/test_profiles.py b/tests/functional/auth/jwt/test_profiles.py similarity index 97% rename from tests/functional/auth/test_profiles.py rename to tests/functional/auth/jwt/test_profiles.py index 83a6d74fed6..b16dcd13b5d 100644 --- a/tests/functional/auth/test_profiles.py +++ b/tests/functional/auth/jwt/test_profiles.py @@ -3,7 +3,11 @@ 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 +from aws_lambda_powertools.utilities.auth.jwt.exceptions import ( + InvalidClaimsError, + InvalidSignatureError, + InvalidTokenError, +) def test_cognito_checks_app_client_and_resource_separately(jwks, claims, issue_token): diff --git a/tests/functional/auth/test_testing.py b/tests/functional/auth/jwt/test_testing.py similarity index 87% rename from tests/functional/auth/test_testing.py rename to tests/functional/auth/jwt/test_testing.py index f3f7b10b6f8..fb35e6efddf 100644 --- a/tests/functional/auth/test_testing.py +++ b/tests/functional/auth/jwt/test_testing.py @@ -1,8 +1,8 @@ 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 +from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError +from aws_lambda_powertools.utilities.auth.jwt.testing import mock_claims def test_mock_claims_is_scoped_and_restores_real_verification(jwks): diff --git a/tests/functional/auth/test_token_profile.py b/tests/functional/auth/jwt/test_token_profile.py similarity index 97% rename from tests/functional/auth/test_token_profile.py rename to tests/functional/auth/jwt/test_token_profile.py index f8745f34a0d..09ccb8b6e49 100644 --- a/tests/functional/auth/test_token_profile.py +++ b/tests/functional/auth/jwt/test_token_profile.py @@ -5,7 +5,7 @@ from aws_lambda_powertools.event_handler import APIGatewayHttpResolver from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.exceptions import InvalidClaimsError, InvalidSignatureError +from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidClaimsError, InvalidSignatureError from tests.functional.utils import load_event ARN = "arn:aws:execute-api:us-east-1:123456789012:api123/prod/GET/orders" diff --git a/tests/functional/auth/test_verifier.py b/tests/functional/auth/jwt/test_verifier.py similarity index 99% rename from tests/functional/auth/test_verifier.py rename to tests/functional/auth/jwt/test_verifier.py index 6552f4fab6c..1b7e62f97e2 100644 --- a/tests/functional/auth/test_verifier.py +++ b/tests/functional/auth/jwt/test_verifier.py @@ -5,7 +5,7 @@ from cryptography.hazmat.primitives.asymmetric import ec, ed25519 from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.exceptions import ( +from aws_lambda_powertools.utilities.auth.jwt.exceptions import ( InvalidClaimsError, InvalidSignatureError, InvalidTokenError, diff --git a/tests/integration/auth/conftest.py b/tests/integration/auth/jwt/conftest.py similarity index 100% rename from tests/integration/auth/conftest.py rename to tests/integration/auth/jwt/conftest.py diff --git a/tests/integration/auth/test_https.py b/tests/integration/auth/jwt/test_https.py similarity index 97% rename from tests/integration/auth/test_https.py rename to tests/integration/auth/jwt/test_https.py index 42bb01addcb..36b079c8985 100644 --- a/tests/integration/auth/test_https.py +++ b/tests/integration/auth/jwt/test_https.py @@ -5,7 +5,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.exceptions import JWKSFetchError +from aws_lambda_powertools.utilities.auth.jwt.exceptions import JWKSFetchError def verifier(endpoint, **options): From 7d8936469575787a513a3ecbc803940beaa48437 Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 23 Sep 2026 10:33:40 +1000 Subject: [PATCH 06/15] docs(auth): explain untrusted JWT routing data --- aws_lambda_powertools/utilities/auth/jwt/verifier.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/utilities/auth/jwt/verifier.py b/aws_lambda_powertools/utilities/auth/jwt/verifier.py index cd5a786d170..1b4dd6817e0 100644 --- a/aws_lambda_powertools/utilities/auth/jwt/verifier.py +++ b/aws_lambda_powertools/utilities/auth/jwt/verifier.py @@ -279,6 +279,7 @@ def _header(self, token: str) -> dict[str, Any]: if not isinstance(token, str) or not token: raise InvalidTokenError() try: + # The untrusted header only selects a trusted JWKS key; jwt.decode below verifies the same token. header = jwt.get_unverified_header(token) except (jwt.InvalidTokenError, ValueError, TypeError): raise InvalidTokenError() from None @@ -328,8 +329,8 @@ 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. + # The untrusted issuer only selects a preconfigured verifier. The selected verifier + # validates the same token's signature, issuer, audience, and claims below. payload = jwt.decode(token, options={"verify_signature": False}) issuer = payload.get("iss") except (jwt.PyJWTError, ValueError, TypeError, RecursionError): From 845fce4357612e104475ed2ee8610eb46193f974 Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 23 Sep 2026 10:47:30 +1000 Subject: [PATCH 07/15] docs(auth): improve Lambda usage guidance --- docs/utilities/auth.md | 118 ++++++++++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 25 deletions(-) diff --git a/docs/utilities/auth.md b/docs/utilities/auth.md index 317a4f33e67..f1909e3bdf7 100644 --- a/docs/utilities/auth.md +++ b/docs/utilities/auth.md @@ -1,11 +1,33 @@ --- title: Auth description: JWT access-token verification for Lambda +status: new --- Auth verifies incoming JWT access tokens. Use it inside a Lambda function or a Lambda authorizer. Prefer an API Gateway managed JWT authorizer when it meets your token profile and deployment requirements. +```mermaid +flowchart LR + Request["Bearer token"] --> Integration{"Integration"} + Integration --> Middleware["Event Handler middleware"] + Integration --> Authorizer["Lambda authorizer"] + Integration --> Direct["verify()"] + Middleware --> Verifier["JWTVerifier"] + Authorizer --> Verifier + Direct --> Verifier + Verifier --> Keys{"Fresh signing keys?"} + Keys -->|Yes| Validate["Verify signature and claims"] + Keys -->|No| Provider["Issuer discovery or JWKS endpoint"] + Provider -->|Success| Cache["Replace key cache"] + Cache --> Validate + Provider -->|Unavailable| ServiceFailure["JWKSFetchError, 503, or authorizer 5xx"] + Validate -->|Invalid| Reject["InvalidTokenError, 401, or Deny"] + Validate -->|Valid| Policy["Scopes and authorization policy"] + Policy -->|Denied| Forbidden["ForbiddenError, 403, or Deny"] + Policy -->|Allowed| Allow["Verified claims or Allow"] +``` + ## Key features * Verify asymmetric signatures, exact issuer, resource audience, expiration, and additional required claims. @@ -14,6 +36,27 @@ Use it inside a Lambda function or a Lambda authorizer. Prefer an API Gateway ma * Validate resource-bound Cognito access tokens and combine explicitly trusted issuers. * Adapt verification to the MCP Python SDK without a Powertools dependency on MCP. +## Terminology + +**Access token** is a token issued to authorize calls to a protected resource. An ID token describes authentication to a client application and is not a resource access token. + +**Issuer (`iss`)** identifies the trusted authorization server that created the token. Configure its exact HTTPS value. + +**Resource audience (`aud`)** identifies the API intended to accept the token. Verifying it prevents a token issued for one resource from being reused at another. + +**JSON Web Key Set (JWKS)** contains the public keys used to verify token signatures. Powertools can use a static set, fetch a configured JWKS endpoint, or discover one from the issuer. + +**Key ID (`kid`)** identifies a signing key in the JWKS. It is untrusted token input and only selects a key from the configured or discovered trusted key set. + +## Choosing an integration + +| Integration | Use when | Failure behavior | +| ----------- | -------- | ---------------- | +| API Gateway managed JWT authorizer | Its issuer, audience, scope, and claim features satisfy the API requirements. Powertools Auth is not required. | API Gateway validates the token before invoking Lambda. | +| Event Handler middleware | A Lambda route needs scope checks, custom authorization, or direct control of HTTP responses. | Returns 401, 403, or 503 without running the protected handler. | +| Lambda authorizer | Authorization must run before the backend or be shared by multiple API integrations. | Returns Deny or `isAuthorized=false` for credential and policy failures; JWKS failures surface as an authorizer 5xx. | +| Direct `verify()` | The application owns event parsing and response handling, or the event does not use Event Handler. | Returns verified claims or raises a typed `AuthError`. | + ## Getting started ### Install @@ -27,6 +70,14 @@ Build cryptography dependencies for your Lambda Python version and architecture; The Powertools Layer retains urllib3 from the declared dependency range instead of relying on the runtime's copy. Applications pinning a different AWS SDK must validate that SDK's urllib3 requirements against the Layer or bundle a compatible dependency set. +### Required resources + +Auth requires no additional AWS IAM permissions to verify a token. Remote discovery and JWKS retrieval require DNS resolution and outbound HTTPS connectivity from the Lambda function to the configured identity provider. Static `jwks` performs no network request, but the application owns key rotation. + +!!! warning "Lambda functions connected to a VPC" + A function in private subnets needs a route to its identity provider, such as a NAT gateway for a public endpoint or private network connectivity for an internal endpoint. + Without it, the first verification and later key refreshes fail with `JWKSFetchError`. See [Connecting outbound traffic to the internet](https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc-internet.html){target="_blank"}. + ### 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. @@ -55,18 +106,32 @@ Configure CORS preflight and public routes separately. It always requires `iss`, `aud`, and `exp`. `required_claims` adds requirements without replacing these baseline checks. ```python +from aws_lambda_powertools import Logger from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError +logger = Logger() verifier = JWTVerifier( issuer="https://idp.example.com/", audience="https://orders.example.com", algorithms=["RS256"], required_claims=["sub"], ) + + +def authenticate(token: str) -> dict: + try: + return verifier.verify(token) + except JWKSFetchError as error: + logger.error("Verification keys unavailable", reason=error.reason.value, retryable=error.retryable) + raise # Map to an availability failure, for example HTTP 503. + except InvalidTokenError: + raise # Reject the credential, for example HTTP 401. ``` 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. +`InvalidTokenError` is a credential failure and retrying the same token will not help. `JWKSFetchError` is a retryable infrastructure failure. Handle it separately so an identity-provider outage does not look like an invalid credential. ## Advanced @@ -146,6 +211,9 @@ The callback replaces the error response; it never invokes the protected handler | `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 | +!!! warning "Leave time for Lambda to return an authentication error" + Set `timeout_seconds` lower than the Lambda function timeout, leaving headroom for initialization and application code. A new Lambda function and the verifier both default to three seconds; using both defaults can cause the runtime to terminate the invocation before `JWKSFetchError` reaches your handler. The included SAM example gives the function a ten-second timeout. + 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. @@ -243,6 +311,31 @@ 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. +### Errors and diagnostics + +`AuthError` is the base error. `InvalidTokenError` includes `InvalidClaimsError`, `TokenExpiredError`, and `InvalidSignatureError`. +`JWKSFetchError` is separate from invalid-token errors so applications can distinguish unavailable verification infrastructure. +Every error exposes `reason: AuthFailureReason` and `retryable: bool`. `AuthFailureReason` uses `str, Enum` for Python 3.10 compatibility. +Use `.value` for log fields and metric dimensions; do not parse exception messages. + +| Reason | Retryable | +| ------ | --------- | +| `missing_token` | false | +| `invalid_token` | false | +| `invalid_claims` | false | +| `token_expired` | false | +| `invalid_signature` | false | +| `insufficient_scope` | false | +| `forbidden` | false | +| `jwks_unavailable` | true | + +Retryability identifies failures where retrying after the provider recovers may help; it does not bypass cache backoff or guarantee success. +Reasons and messages are fixed and never contain token data, claims, key IDs, URLs, or provider responses. +Public verification, prefetch, and authorizer operations detach underlying exception causes and contexts. +Log only the fixed diagnostic fields; do not log token dictionaries, request headers, or provider errors. + +Outbound token acquisition, opaque-token introspection, delegated token exchange, interactive grants, SigV4, and native async clients are outside this PR. + ### MCP Python SDK adapter The following adapter targets the `MCPServer` interface in MCP Python SDK 2.2.0 (`mcp==2.2.0`), @@ -330,31 +423,6 @@ Use the targeted SDK's supported tool-error handling for permission failures. Ra API Gateway authorizers in front of an MCP server also require deployment-specific metadata routes and discovery/challenge behavior; an authorizer Deny response alone does not implement MCP authorization. -### Errors and diagnostics - -`AuthError` is the base error. `InvalidTokenError` includes `InvalidClaimsError`, `TokenExpiredError`, and `InvalidSignatureError`. -`JWKSFetchError` is separate from invalid-token errors so applications can distinguish unavailable verification infrastructure. -Every error exposes `reason: AuthFailureReason` and `retryable: bool`. `AuthFailureReason` uses `str, Enum` for Python 3.10 compatibility. -Use `.value` for log fields and metric dimensions; do not parse exception messages. - -| Reason | Retryable | -| ------ | --------- | -| `missing_token` | false | -| `invalid_token` | false | -| `invalid_claims` | false | -| `token_expired` | false | -| `invalid_signature` | false | -| `insufficient_scope` | false | -| `forbidden` | false | -| `jwks_unavailable` | true | - -Retryability identifies failures where retrying after the provider recovers may help; it does not bypass cache backoff or guarantee success. -Reasons and messages are fixed and never contain token data, claims, key IDs, URLs, or provider responses. -Public verification, prefetch, and authorizer operations detach underlying exception causes and contexts. -Log only the fixed diagnostic fields; do not log token dictionaries, request headers, or provider errors. - -Outbound token acquisition, opaque-token introspection, delegated token exchange, interactive grants, SigV4, and native async clients are outside this PR. - ## Testing your code Use `mock_claims` to test route behavior without cryptography or network calls. Supply an Authorization header so the middleware still exercises credential extraction. From 35d930fe935746e6b9b317d053e509f9d7e83d85 Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 23 Sep 2026 10:55:11 +1000 Subject: [PATCH 08/15] docs(auth): align homepage with JWT scope --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 24c77c33cb5..3629a79c118 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,7 +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 | +| [Auth](./utilities/auth.md) | Verify JWT access tokens and protect Lambda routes and API Gateway authorizers | | [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 | From 278fcae228eee7d6d1f569eed9ccaed23bfe26aa Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 23 Sep 2026 11:09:29 +1000 Subject: [PATCH 09/15] docs(auth): simplify JWT usage guide --- docs/utilities/auth.md | 353 ++++++++--------------------------------- 1 file changed, 67 insertions(+), 286 deletions(-) diff --git a/docs/utilities/auth.md b/docs/utilities/auth.md index f1909e3bdf7..7d8b2378e46 100644 --- a/docs/utilities/auth.md +++ b/docs/utilities/auth.md @@ -4,58 +4,24 @@ description: JWT access-token verification for Lambda status: new --- -Auth verifies incoming JWT access tokens. -Use it inside a Lambda function or a Lambda authorizer. Prefer an API Gateway managed JWT authorizer when it meets your token profile and deployment requirements. +Auth verifies JWT access tokens before your Lambda handler processes a request. `JWTVerifier` contains the validation configuration and signing-key cache. It can create an Event Handler middleware or verify a token directly. ```mermaid flowchart LR - Request["Bearer token"] --> Integration{"Integration"} - Integration --> Middleware["Event Handler middleware"] - Integration --> Authorizer["Lambda authorizer"] - Integration --> Direct["verify()"] - Middleware --> Verifier["JWTVerifier"] - Authorizer --> Verifier - Direct --> Verifier - Verifier --> Keys{"Fresh signing keys?"} - Keys -->|Yes| Validate["Verify signature and claims"] - Keys -->|No| Provider["Issuer discovery or JWKS endpoint"] - Provider -->|Success| Cache["Replace key cache"] - Cache --> Validate - Provider -->|Unavailable| ServiceFailure["JWKSFetchError, 503, or authorizer 5xx"] - Validate -->|Invalid| Reject["InvalidTokenError, 401, or Deny"] - Validate -->|Valid| Policy["Scopes and authorization policy"] - Policy -->|Denied| Forbidden["ForbiddenError, 403, or Deny"] - Policy -->|Allowed| Allow["Verified claims or Allow"] + Request["Request with Bearer token"] --> Middleware["require() middleware"] + Middleware --> Verify["verify() token"] + Verify -->|Valid token and scopes| Handler["Route handler"] + Verify -->|Invalid token| Unauthorized["401 Unauthorized"] + Verify -->|Missing scope| Forbidden["403 Forbidden"] + Verify -->|JWKS unavailable| Unavailable["503 Service Unavailable"] ``` ## Key features -* Verify asymmetric signatures, exact issuer, resource audience, expiration, and additional required claims. -* Coordinate discovery and signing-key refresh across threads with bounded key freshness. -* Protect Event Handler routes and create API Gateway IAM or simple authorizer responses. -* Validate resource-bound Cognito access tokens and combine explicitly trusted issuers. -* Adapt verification to the MCP Python SDK without a Powertools dependency on MCP. - -## Terminology - -**Access token** is a token issued to authorize calls to a protected resource. An ID token describes authentication to a client application and is not a resource access token. - -**Issuer (`iss`)** identifies the trusted authorization server that created the token. Configure its exact HTTPS value. - -**Resource audience (`aud`)** identifies the API intended to accept the token. Verifying it prevents a token issued for one resource from being reused at another. - -**JSON Web Key Set (JWKS)** contains the public keys used to verify token signatures. Powertools can use a static set, fetch a configured JWKS endpoint, or discover one from the issuer. - -**Key ID (`kid`)** identifies a signing key in the JWKS. It is untrusted token input and only selects a key from the configured or discovered trusted key set. - -## Choosing an integration - -| Integration | Use when | Failure behavior | -| ----------- | -------- | ---------------- | -| API Gateway managed JWT authorizer | Its issuer, audience, scope, and claim features satisfy the API requirements. Powertools Auth is not required. | API Gateway validates the token before invoking Lambda. | -| Event Handler middleware | A Lambda route needs scope checks, custom authorization, or direct control of HTTP responses. | Returns 401, 403, or 503 without running the protected handler. | -| Lambda authorizer | Authorization must run before the backend or be shared by multiple API integrations. | Returns Deny or `isAuthorized=false` for credential and policy failures; JWKS failures surface as an authorizer 5xx. | -| Direct `verify()` | The application owns event parsing and response handling, or the event does not use Event Handler. | Returns verified claims or raises a typed `AuthError`. | +* Verify JWT signatures, issuer, audience, expiration, and required claims. +* Protect individual Event Handler routes with scopes and custom authorization. +* Reuse signing keys across warm Lambda invocations and refresh them during rotation. +* Build REST API and HTTP API Lambda authorizer responses. ## Getting started @@ -65,85 +31,61 @@ flowchart LR pip install "aws-lambda-powertools[jwt]" ``` -The optional `jwt` extra includes PyJWT, cryptography, and urllib3. It adds no dependencies to the base installation. -Build cryptography dependencies for your Lambda Python version and architecture; see [cross-platform builds](../build_recipes/cross-platform.md). -The Powertools Layer retains urllib3 from the declared dependency range instead of relying on the runtime's copy. -Applications pinning a different AWS SDK must validate that SDK's urllib3 requirements against the Layer or bundle a compatible dependency set. +The `jwt` extra installs PyJWT, cryptography, and urllib3. Build dependencies for the same Python version and architecture as your Lambda function. See [cross-platform builds](../build_recipes/cross-platform.md). ### Required resources -Auth requires no additional AWS IAM permissions to verify a token. Remote discovery and JWKS retrieval require DNS resolution and outbound HTTPS connectivity from the Lambda function to the configured identity provider. Static `jwks` performs no network request, but the application owns key rotation. - -!!! warning "Lambda functions connected to a VPC" - A function in private subnets needs a route to its identity provider, such as a NAT gateway for a public endpoint or private network connectivity for an internal endpoint. - Without it, the first verification and later key refreshes fail with `JWKSFetchError`. See [Connecting outbound traffic to the internet](https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc-internet.html){target="_blank"}. +JWT verification requires no additional IAM permissions. When using issuer discovery or a remote JWKS endpoint, the function needs outbound HTTPS access to the identity provider. A function in private subnets might need a NAT gateway or private connectivity. Static `jwks` does not use the network, but your application is responsible for rotating those keys. ### 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. +Create `JWTVerifier` outside the Lambda handler so warm invocations reuse its signing-key cache. The verifier itself is not middleware. Calling `verifier.require()` creates middleware bound to that verifier and to the requested scopes. ```python title="middleware.py" --8<-- "examples/auth/jwt/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. +Here, `app.get()` registers the middleware only for `GET /orders`. For each matching request, the middleware: -| 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 | +1. Reads the Bearer token from the `Authorization` header. +2. Calls `verifier.verify(token)` to validate the signature and claims. +3. Checks that the token contains `orders:read`. +4. Stores verified claims in `app.context["claims"]` while the route handler runs. -### Verify directly +The route handler does not run when authentication or authorization fails. -`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. +| Failure | Response | +| ------- | -------- | +| Missing or invalid token | 401 | +| Missing required scope or custom authorization denied | 403 | +| Discovery or JWKS endpoint unavailable | 503 | -```python -from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError +Configure public routes and CORS preflight separately. -logger = Logger() -verifier = JWTVerifier( - issuer="https://idp.example.com/", - audience="https://orders.example.com", - algorithms=["RS256"], - required_claims=["sub"], -) +### Verify a token directly +Use `verify()` when you are not using Event Handler middleware or when your application already owns request parsing. It accepts the encoded JWT without the `Bearer` prefix. It does not read headers or create an HTTP response. On success it returns verified claims; on failure it raises a typed exception. + +```python +from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError -def authenticate(token: str) -> dict: - try: - return verifier.verify(token) - except JWKSFetchError as error: - logger.error("Verification keys unavailable", reason=error.reason.value, retryable=error.retryable) - raise # Map to an availability failure, for example HTTP 503. - except InvalidTokenError: - raise # Reject the credential, for example HTTP 401. +try: + claims = verifier.verify(token) +except InvalidTokenError: + # Reject the credential, for example with HTTP 401. + raise +except JWKSFetchError: + # Verification keys are unavailable, for example return HTTP 503. + raise ``` -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. -`InvalidTokenError` is a credential failure and retrying the same token will not help. `JWKSFetchError` is a retryable infrastructure failure. Handle it separately so an identity-provider outage does not look like an invalid credential. +The middleware created by `require()` uses this same method internally and maps these failures to HTTP responses for you. `verify()` always checks `iss`, `aud`, and `exp`; `required_claims` adds more required claims. ## 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. +### Token profile and authorization -Applications must select access tokens for their resource; the generic profile cannot infer a provider's token purpose. -Configure `expected_claims` and/or `expected_headers` when an issuer can mint other token types with the same audience: +The verifier accepts asymmetric algorithms and requires `iss`, `aud`, and `exp`. Configure `expected_claims` or `expected_headers` when your provider uses a claim to distinguish access tokens from other token types: ```python verifier = JWTVerifier( @@ -151,22 +93,12 @@ verifier = JWTVerifier( audience="https://orders.example.com", algorithms=["RS256"], expected_claims={"token_use": "access"}, - expected_headers={"typ": "at+jwt"}, ) ``` -Use the values defined by your provider; not every provider uses both fields. -These mappings require exact, case-sensitive, nonempty string values. Missing or different values raise `InvalidClaimsError`. -They are copied during construction and checked after signature, issuer, audience, and time validation. -The constraints apply to direct verification, middleware, authorizers, and issuer groups and cannot disable any baseline check. -`required_claims` checks presence only. -Local JWT verification does not check individual-token revocation. +Use the values documented by your identity provider. Local 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`: +`require(scopes=[...])` reads scopes from `scope`, `scp`, or `scopes`. All requested scopes must be present. Add an `authorize` callback for application-specific checks: ```python middleware = verifier.require( @@ -175,10 +107,7 @@ middleware = verifier.require( ) ``` -An `on_error` callback receives `AuthErrorContext` with `status_code`, `headers`, `reason`, and `retryable`. -It must return an Event Handler `Response`. Preserve the status and challenge headers when customizing the body. -The reason is an `AuthFailureReason` string enum; `retryable` is true for unavailable JWKS infrastructure and false for credential/policy failures. -The utility does not log failures automatically or add diagnostics to default responses. Applications choose logging, metrics, and sampling: +Use `on_error` to customize the middleware response or emit logs and metrics. The callback receives only stable diagnostic fields, not token data: ```python from aws_lambda_powertools import Logger @@ -201,49 +130,24 @@ def on_error(error: AuthErrorContext) -> Response: middleware = verifier.require(on_error=on_error) ``` -The callback replaces the error response; it never invokes the protected handler. Callback exceptions propagate to the application. - -### Key freshness, rotation, and outages +The callback replaces the default error response and never invokes the protected handler. -| 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 | +### Key freshness and Lambda timeouts -!!! warning "Leave time for Lambda to return an authentication error" - Set `timeout_seconds` lower than the Lambda function timeout, leaving headroom for initialization and application code. A new Lambda function and the verifier both default to three seconds; using both defaults can cause the runtime to terminate the invocation before `JWKSFetchError` reaches your handler. The included SAM example gives the function a ten-second timeout. +| Setting | Default | Purpose | +| ------- | ------- | ------- | +| `timeout_seconds` | 3 seconds | Limits discovery and JWKS requests | +| `jwks_max_age_seconds` | 5 minutes | Limits how long fetched keys remain trusted | +| `unknown_kid_cooldown_seconds` | 5 minutes | Limits repeated refreshes for 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. +The first verification fetches signing keys unless you provide static `jwks`. Warm invocations reuse the cache. A successful refresh replaces the key set so removed keys are no longer trusted. If refresh fails after the cache expires, verification raises `JWKSFetchError` instead of using stale 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. +!!! warning "Leave time for Lambda to handle the error" + Set `timeout_seconds` lower than the Lambda function timeout. If both use the three-second default, Lambda can terminate the invocation before your code receives `JWKSFetchError`. -Construction performs no network I/O. By default the first verification fetches the keys, adding latency to that invocation. -Calling `prefetch()` at module level moves the first fetch into Lambda INIT, but an identity-provider outage can then fail the cold start. -Prefetch is an explicit option, not a default recommendation; choose based on your latency and availability requirements. -Later rotation, expiration, and outages can still cause network I/O. -Static `jwks` is copied when constructing the verifier and performs no discovery or refresh: - -```python -import json - -from aws_lambda_powertools.utilities import parameters - -key_set = parameters.get_parameter("/orders/jwks", max_age=3600) -verifier = JWTVerifier( - issuer="https://idp.internal", - audience="https://orders.internal", - algorithms=["ES256"], - jwks=json.loads(key_set), -) -``` +Calling `prefetch()` during module initialization moves the initial network request into Lambda INIT. This can reduce first-request latency, but an identity-provider outage can then fail the cold start. -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. +Static `jwks` avoids network access. Recreate the verifier or execution environment when the configured keys change. ### Cognito and multiple issuers @@ -259,64 +163,33 @@ 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()`. +`any_of()` selects one of the configured verifiers using the token issuer. It rejects unknown issuers and duplicate issuer configurations. The returned verifier supports the same `verify()`, `prefetch()`, `require()`, and `authorize()` methods. ### Lambda authorizers +Use `authorize()` when API Gateway invokes a dedicated Lambda authorizer: + ```python title="authorizer.py" --8<-- "examples/auth/jwt/src/authorizer/authorizer.py" ``` -The helper accepts raw dictionaries or the corresponding Powertools authorizer Data Classes. - -| Event | `response_format` | Result | -| ----- | ----------------- | ------ | -| REST API TOKEN or REQUEST | `iam` | Serialized IAM policy | -| HTTP API REQUEST payload 1.0 | `iam` | Serialized IAM policy | -| HTTP API REQUEST payload 2.0 | `iam` | Serialized IAM policy | -| HTTP API REQUEST payload 2.0, simple responses enabled | `simple` | Serialized `isAuthorized` response | - -IAM allows require a nonempty string `sub` as principal and cover only the supplied request ARN. -Wildcard, missing, or malformed ARNs raise `ValueError`; the helper cannot construct a request-specific IAM policy without a valid ARN. -Other routes need their own decision. -Invalid tokens and insufficient scopes produce a Deny or `isAuthorized=False`; unavailable signing keys raise `JWKSFetchError`. -For an outage, middleware returns HTTP 503 directly. A Lambda authorizer fails its invocation instead, and API Gateway normally returns a 5xx response. -API callers should treat this as an availability failure rather than repeatedly obtaining new credentials; configure retries and alarms accordingly. - -Pass `on_error` to `authorize()` to record a rejection or unavailable keys, as shown in the example above. -It receives an `AuthError` with the same fixed `reason` and `retryable` attributes exposed by middleware. -Its return value is ignored: invalid credentials still deny access, and `JWKSFetchError` still propagates after the callback. -A callback exception fails the invocation. Successful authorizations do not call it. The default response includes neither diagnostic field. +The helper supports REST API TOKEN and REQUEST events and HTTP API REQUEST payloads. Choose `response_format="iam"` for an IAM policy or `response_format="simple"` for an HTTP API 2.0 simple response. -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. +Invalid tokens and insufficient scopes return Deny or `isAuthorized=false`. If signing keys are unavailable, `JWKSFetchError` fails the authorizer invocation and API Gateway returns a 5xx response. Use `on_error` for logs and metrics; it cannot change the authorization result. -#### Deployment and Gateway caching +`context_claims` copies only explicitly selected scalar claims into authorizer context. Claims are not copied by default. -Disable authorizer-result caching to verify each request. This SAM example sets `ReauthorizeEvery: 0` for both REST and HTTP authorizers; -the underlying API Gateway setting is `AuthorizerResultTtlInSeconds: 0`. -The template is under `examples/auth/jwt/templates/`; its `CodeUri` values are relative to that directory. -Authorizer functions build from `src/authorizer/` with the JWT extra. Backends build independently from `src/backend/` with base Powertools only, -so PyJWT and cryptography are not included in the backend artifacts. -HTTP simple responses also require payload version 2.0 and `EnableSimpleResponses: true`. +The example template disables API Gateway authorizer-result caching so every request is verified: ```yaml title="templates/sam.yaml" --8<-- "examples/auth/jwt/templates/sam.yaml" ``` -If you enable result caching later, a cached decision can outlive the JWT's expiration or a signing key's removal. -The verifier's key-cache settings do not control Gateway's result cache. -HTTP simple responses can apply to multiple routes sharing an identity cache key; include `$context.routeKey` for route-specific decisions. -Route-aware keys still do not recheck an expired token. Cached IAM policies must cover exactly the routes they authorize; this helper deliberately returns one concrete resource. +If you enable Gateway caching, include all request attributes used by authorization in its identity sources. A cached allow can otherwise apply to another route or outlive the token expiration. This cache is independent of the verifier JWKS cache. ### Errors and diagnostics -`AuthError` is the base error. `InvalidTokenError` includes `InvalidClaimsError`, `TokenExpiredError`, and `InvalidSignatureError`. -`JWKSFetchError` is separate from invalid-token errors so applications can distinguish unavailable verification infrastructure. -Every error exposes `reason: AuthFailureReason` and `retryable: bool`. `AuthFailureReason` uses `str, Enum` for Python 3.10 compatibility. -Use `.value` for log fields and metric dimensions; do not parse exception messages. +`AuthError` is the base exception. Invalid credentials raise `InvalidTokenError` or one of its specific subclasses. Problems retrieving signing keys raise `JWKSFetchError`. Every auth exception provides a stable `reason` and a `retryable` flag. | Reason | Retryable | | ------ | --------- | @@ -329,99 +202,7 @@ Use `.value` for log fields and metric dimensions; do not parse exception messag | `forbidden` | false | | `jwks_unavailable` | true | -Retryability identifies failures where retrying after the provider recovers may help; it does not bypass cache backoff or guarantee success. -Reasons and messages are fixed and never contain token data, claims, key IDs, URLs, or provider responses. -Public verification, prefetch, and authorizer operations detach underlying exception causes and contexts. -Log only the fixed diagnostic fields; do not log token dictionaries, request headers, or provider errors. - -Outbound token acquisition, opaque-token introspection, delegated token exchange, interactive grants, SigV4, and native async clients are outside this PR. - -### MCP Python SDK adapter - -The following adapter targets the `MCPServer` interface in MCP Python SDK 2.2.0 (`mcp==2.2.0`), -following the [MCP authorization tutorial](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/authorization). -Install that SDK separately. This example requires the Keycloak access-token claim `typ="Bearer"` and maps `azp`, `sub`, and `scope`. -Adapt the expected purpose and claim mapping to your provider and token configuration. - -```python -import asyncio - -from mcp.server import MCPServer -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.server.auth.settings import AuthSettings -from pydantic import AnyHttpUrl - -from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError - -logger = Logger() -RESOURCE_URL = "https://mcp.example.com" -ISSUER_URL = "https://keycloak.example.com/realms/mcp" -verifier = JWTVerifier( - issuer=ISSUER_URL, - audience=RESOURCE_URL, - algorithms=["RS256"], - required_claims=["azp", "sub", "scope"], - expected_claims={"typ": "Bearer"}, -) - - -class PowertoolsTokenVerifier(TokenVerifier): - async def verify_token(self, token: str) -> AccessToken | None: - try: - claims = await asyncio.to_thread(verifier.verify, token) - except JWKSFetchError as error: - logger.error("Verification keys unavailable", reason=error.reason.value, retryable=error.retryable) - return None - except InvalidTokenError: - return None - if not all(isinstance(claims[name], str) for name in ("azp", "sub", "scope")): - return None - if not claims["azp"] or not claims["sub"]: - return None - return AccessToken( - token=token, - client_id=claims["azp"], - subject=claims["sub"], - scopes=claims["scope"].split(), - expires_at=claims["exp"], - resource=RESOURCE_URL, - ) - - -mcp = MCPServer( - name="orders", - token_verifier=PowertoolsTokenVerifier(), - auth=AuthSettings( - issuer_url=AnyHttpUrl(ISSUER_URL), - resource_server_url=AnyHttpUrl(RESOURCE_URL), - validate_token_resource=True, - required_scopes=["mcp:tools"], - ), -) -``` - -The SDK owns transport, Protected Resource Metadata, and authentication challenges. This adapter maps both invalid tokens and unavailable keys to failed authentication. -The adapter records unavailable keys separately for Lambda-owner alarms and metrics before returning `None`. -A distinct availability response to the API caller requires integration at the SDK transport boundary. -`asyncio.to_thread()` keeps synchronous key fetches off the event loop; cancelling the await does not terminate a running request. - -Tools can enforce permissions using the verified SDK access token: - -```python -from mcp.server.auth.middleware.auth_context import get_access_token - - -def require_scope(scope: str): - caller = get_access_token() - if caller is None or scope not in caller.scopes: - raise PermissionError("Required tool permission is missing") -``` - -Use the targeted SDK's supported tool-error handling for permission failures. Raising `PermissionError` alone does not implement an HTTP challenge or a scope-upgrade flow. -API Gateway authorizers in front of an MCP server also require deployment-specific metadata routes and discovery/challenge behavior; -an authorizer Deny response alone does not implement MCP authorization. +Use `reason.value` for log fields and metric dimensions. Do not parse exception messages or log tokens, claims, or request headers. `retryable=true` means a later attempt might succeed after the identity provider recovers; it does not guarantee that retrying will succeed. ## Testing your code From f48f0d59e2c42ef4066eacf63f6c6687ed8ede9a Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 23 Sep 2026 11:15:46 +1000 Subject: [PATCH 10/15] docs(auth): use complete Lambda examples --- docs/utilities/auth.md | 98 ++++--------------- examples/auth/jwt/src/cognito.py | 21 ++++ examples/auth/jwt/src/custom_authorization.py | 48 +++++++++ examples/auth/jwt/src/direct.py | 39 ++++++++ 4 files changed, 125 insertions(+), 81 deletions(-) create mode 100644 examples/auth/jwt/src/cognito.py create mode 100644 examples/auth/jwt/src/custom_authorization.py create mode 100644 examples/auth/jwt/src/direct.py diff --git a/docs/utilities/auth.md b/docs/utilities/auth.md index 7d8b2378e46..fcf2f96436d 100644 --- a/docs/utilities/auth.md +++ b/docs/utilities/auth.md @@ -66,71 +66,25 @@ Configure public routes and CORS preflight separately. Use `verify()` when you are not using Event Handler middleware or when your application already owns request parsing. It accepts the encoded JWT without the `Bearer` prefix. It does not read headers or create an HTTP response. On success it returns verified claims; on failure it raises a typed exception. -```python -from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError - -try: - claims = verifier.verify(token) -except InvalidTokenError: - # Reject the credential, for example with HTTP 401. - raise -except JWKSFetchError: - # Verification keys are unavailable, for example return HTTP 503. - raise +```python title="direct.py" +--8<-- "examples/auth/jwt/src/direct.py" ``` The middleware created by `require()` uses this same method internally and maps these failures to HTTP responses for you. `verify()` always checks `iss`, `aud`, and `exp`; `required_claims` adds more required claims. ## Advanced -### Token profile and authorization +### Customize route authorization -The verifier accepts asymmetric algorithms and requires `iss`, `aud`, and `exp`. Configure `expected_claims` or `expected_headers` when your provider uses a claim to distinguish access tokens from other token types: +This complete Lambda adds provider-specific token checks, a required scope, a tenant authorization rule, and custom error handling: -```python -verifier = JWTVerifier( - issuer="https://idp.example.com/", - audience="https://orders.example.com", - algorithms=["RS256"], - expected_claims={"token_use": "access"}, -) +```python title="custom_authorization.py" +--8<-- "examples/auth/jwt/src/custom_authorization.py" ``` -Use the values documented by your identity provider. Local verification does not check individual-token revocation. +`expected_claims` must match the access-token profile documented by your identity provider. `authorize` runs only after token verification and scope checks succeed. `on_error` can change the error response and emit logs or metrics, but it never invokes the protected route. -`require(scopes=[...])` reads scopes from `scope`, `scp`, or `scopes`. All requested scopes must be present. Add an `authorize` callback for application-specific checks: - -```python -middleware = verifier.require( - scopes=["orders:read"], - authorize=lambda claims: claims.get("tenant") == "example", -) -``` - -Use `on_error` to customize the middleware response or emit logs and metrics. The callback receives only stable diagnostic fields, not token data: - -```python -from aws_lambda_powertools import Logger -from aws_lambda_powertools.event_handler import Response -from aws_lambda_powertools.utilities.auth import AuthErrorContext - -logger = Logger() - - -def on_error(error: AuthErrorContext) -> Response: - logger.warning("Authorization failed", reason=error.reason.value, retryable=error.retryable) - return Response( - status_code=error.status_code, - content_type="application/json", - body={"message": "Access denied"}, - headers=error.headers, - ) - - -middleware = verifier.require(on_error=on_error) -``` - -The callback replaces the default error response and never invokes the protected handler. +The callback receives stable `reason` and `retryable` fields without token data. Preserve `error.status_code` and `error.headers` unless you intentionally want to change the HTTP contract. ### Key freshness and Lambda timeouts @@ -149,21 +103,17 @@ Calling `prefetch()` during module initialization moves the initial network requ Static `jwks` avoids network access. Recreate the verifier or execution environment when the configured keys change. -### Cognito and multiple issuers +### Cognito -```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) +Use the Cognito profile for resource-bound Cognito access tokens: + +```python title="cognito.py" +--8<-- "examples/auth/jwt/src/cognito.py" ``` -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. +This profile checks RS256, `token_use="access"`, the configured app client ID, and the resource audience. Cognito ID tokens and access tokens without the configured resource audience are rejected. -`any_of()` selects one of the configured verifiers using the token issuer. It rejects unknown issuers and duplicate issuer configurations. The returned verifier supports the same `verify()`, `prefetch()`, `require()`, and `authorize()` methods. +Use `JWTVerifier.any_of()` when the same Lambda trusts access tokens from multiple configured issuers. Unknown issuers are rejected without discovery. ### Lambda authorizers @@ -206,20 +156,6 @@ Use `reason.value` for log fields and metric dimensions. Do not parse exception ## 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.jwt.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 -``` +Use `mock_claims` to replace `verifier.verify()` while testing route behavior without cryptography or network calls. Wrap the call to `app.resolve()` in `mock_claims(verifier, claims)` and include an Authorization header so the middleware still exercises credential extraction. -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. +`mock_claims` restores the verifier when the context manager exits and returns an independent copy of the supplied claims. It bypasses signature and claim validation, so keep separate verification tests for the token profiles your application accepts. diff --git a/examples/auth/jwt/src/cognito.py b/examples/auth/jwt/src/cognito.py new file mode 100644 index 00000000000..e7e63f1a09f --- /dev/null +++ b/examples/auth/jwt/src/cognito.py @@ -0,0 +1,21 @@ +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.cognito( + user_pool_id=os.environ["COGNITO_USER_POOL_ID"], + client_id=os.environ["COGNITO_CLIENT_ID"], + audience=os.environ["RESOURCE_URL"], +) + + +@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/jwt/src/custom_authorization.py b/examples/auth/jwt/src/custom_authorization.py new file mode 100644 index 00000000000..992eb9dea13 --- /dev/null +++ b/examples/auth/jwt/src/custom_authorization.py @@ -0,0 +1,48 @@ +import os + +from aws_lambda_powertools import Logger +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response +from aws_lambda_powertools.utilities.auth import AuthErrorContext, JWTVerifier +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = APIGatewayHttpResolver() +logger = Logger() +verifier = JWTVerifier( + issuer=os.environ["ISSUER_URL"], + audience=os.environ["RESOURCE_URL"], + algorithms=["RS256"], + required_claims=["sub", "tenant"], + expected_claims={"token_use": "access"}, +) + + +def on_error(error: AuthErrorContext) -> Response: + logger.warning("Authorization failed", reason=error.reason.value, retryable=error.retryable) + return Response( + status_code=error.status_code, + content_type="application/json", + body={"message": "Access denied"}, + headers=error.headers, + ) + + +def belongs_to_example_tenant(claims: dict) -> bool: + return claims["tenant"] == "example" + + +@app.get( + "/orders", + middlewares=[ + verifier.require( + scopes=["orders:read"], + authorize=belongs_to_example_tenant, + on_error=on_error, + ), + ], +) +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/jwt/src/direct.py b/examples/auth/jwt/src/direct.py new file mode 100644 index 00000000000..da62ae81181 --- /dev/null +++ b/examples/auth/jwt/src/direct.py @@ -0,0 +1,39 @@ +import os + +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response +from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError +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") +def list_orders(): + authorization = app.current_event.headers.get("authorization", "") + parts = authorization.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + return Response(status_code=401, body={"message": "Unauthorized"}, headers={"WWW-Authenticate": "Bearer"}) + + try: + claims = verifier.verify(parts[1]) + except InvalidTokenError: + return Response( + status_code=401, + body={"message": "Unauthorized"}, + headers={"WWW-Authenticate": 'Bearer error="invalid_token"'}, + ) + except JWKSFetchError: + return Response(status_code=503, body={"message": "Service Unavailable"}) + + return {"subject": claims["sub"], "orders": []} + + +def lambda_handler(event: dict, context: LambdaContext): + return app.resolve(event, context) From 05d7b18198f1bd2bd1c215fd3503718184994c2c Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 23 Sep 2026 12:19:58 +1000 Subject: [PATCH 11/15] feat(auth): prepare JWT verification alpha experience --- .../{auth => auth_alpha}/__init__.py | 8 +-- .../_internal/__init__.py | 0 .../_internal/deadline.py | 2 +- .../{auth => auth_alpha}/_internal/http.py | 2 +- .../_internal/validation.py | 0 .../{auth => auth_alpha}/jwt/__init__.py | 8 ++- .../jwt/_internal/__init__.py | 0 .../jwt/_internal/authorization.py | 4 +- .../jwt/_internal/base.py | 23 ++++++-- .../jwt/_internal/errors.py | 2 +- .../jwt/_internal/jwks.py | 8 +-- .../{auth => auth_alpha}/jwt/exceptions.py | 0 .../jwt/integrations/__init__.py | 0 .../jwt/integrations/api_gateway.py | 8 +-- .../jwt/integrations/event_handler.py | 6 +- .../{auth => auth_alpha}/jwt/testing.py | 2 +- .../{auth => auth_alpha}/jwt/verifier.py | 12 ++-- docs/api_doc/auth.md | 7 --- docs/api_doc/auth_alpha.md | 7 +++ docs/getting-started/install.md | 2 +- docs/index.md | 2 +- docs/utilities/auth.md | 59 +++++++++++++------ .../jwt/src/authorizer/authorizer.py | 4 +- .../jwt/src/authorizer/requirements.txt | 0 .../jwt/src/backend/backend.py | 0 .../jwt/src/backend/requirements.txt | 0 examples/auth_alpha/jwt/src/basic.py | 18 ++++++ .../{auth => auth_alpha}/jwt/src/cognito.py | 2 +- .../jwt/src/custom_authorization.py | 2 +- .../{auth => auth_alpha}/jwt/src/direct.py | 14 ++--- .../jwt/src/middleware.py | 2 +- examples/auth_alpha/jwt/src/prefetch.py | 19 ++++++ .../jwt/templates/sam.yaml | 0 mkdocs.yml | 4 +- .../{auth => auth_alpha}/__init__.py | 0 .../{auth => auth_alpha}/jwt/__init__.py | 0 .../{auth => auth_alpha}/jwt/conftest.py | 2 +- .../jwt/imports/__init__.py | 0 .../jwt/imports/_auth_import_probe.py | 12 ++-- .../jwt/imports/test_imports.py | 0 .../jwt/integrations/__init__.py | 0 .../jwt/integrations/test_authorizer.py | 4 +- .../integrations/test_failure_visibility.py | 4 +- .../jwt/integrations/test_middleware.py | 2 +- .../{auth => auth_alpha}/jwt/test_errors.py | 4 +- .../jwt/test_jwks_cache.py | 4 +- .../{auth => auth_alpha}/jwt/test_profiles.py | 4 +- .../{auth => auth_alpha}/jwt/test_testing.py | 6 +- .../jwt/test_token_profile.py | 4 +- .../{auth => auth_alpha}/jwt/test_verifier.py | 31 +++++++++- .../{auth => auth_alpha}/jwt/conftest.py | 0 .../{auth => auth_alpha}/jwt/test_https.py | 4 +- 52 files changed, 203 insertions(+), 105 deletions(-) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/__init__.py (63%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/_internal/__init__.py (100%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/_internal/deadline.py (88%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/_internal/http.py (96%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/_internal/validation.py (100%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/__init__.py (67%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/_internal/__init__.py (100%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/_internal/authorization.py (94%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/_internal/base.py (80%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/_internal/errors.py (91%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/_internal/jwks.py (93%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/exceptions.py (100%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/integrations/__init__.py (100%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/integrations/api_gateway.py (92%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/integrations/event_handler.py (91%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/testing.py (91%) rename aws_lambda_powertools/utilities/{auth => auth_alpha}/jwt/verifier.py (96%) delete mode 100644 docs/api_doc/auth.md create mode 100644 docs/api_doc/auth_alpha.md rename examples/{auth => auth_alpha}/jwt/src/authorizer/authorizer.py (87%) rename examples/{auth => auth_alpha}/jwt/src/authorizer/requirements.txt (100%) rename examples/{auth => auth_alpha}/jwt/src/backend/backend.py (100%) rename examples/{auth => auth_alpha}/jwt/src/backend/requirements.txt (100%) create mode 100644 examples/auth_alpha/jwt/src/basic.py rename examples/{auth => auth_alpha}/jwt/src/cognito.py (90%) rename examples/{auth => auth_alpha}/jwt/src/custom_authorization.py (93%) rename examples/{auth => auth_alpha}/jwt/src/direct.py (69%) rename examples/{auth => auth_alpha}/jwt/src/middleware.py (91%) create mode 100644 examples/auth_alpha/jwt/src/prefetch.py rename examples/{auth => auth_alpha}/jwt/templates/sam.yaml (100%) rename tests/functional/{auth => auth_alpha}/__init__.py (100%) rename tests/functional/{auth => auth_alpha}/jwt/__init__.py (100%) rename tests/functional/{auth => auth_alpha}/jwt/conftest.py (96%) rename tests/functional/{auth => auth_alpha}/jwt/imports/__init__.py (100%) rename tests/functional/{auth => auth_alpha}/jwt/imports/_auth_import_probe.py (88%) rename tests/functional/{auth => auth_alpha}/jwt/imports/test_imports.py (100%) rename tests/functional/{auth => auth_alpha}/jwt/integrations/__init__.py (100%) rename tests/functional/{auth => auth_alpha}/jwt/integrations/test_authorizer.py (98%) rename tests/functional/{auth => auth_alpha}/jwt/integrations/test_failure_visibility.py (96%) rename tests/functional/{auth => auth_alpha}/jwt/integrations/test_middleware.py (99%) rename tests/functional/{auth => auth_alpha}/jwt/test_errors.py (95%) rename tests/functional/{auth => auth_alpha}/jwt/test_jwks_cache.py (98%) rename tests/functional/{auth => auth_alpha}/jwt/test_profiles.py (97%) rename tests/functional/{auth => auth_alpha}/jwt/test_testing.py (81%) rename tests/functional/{auth => auth_alpha}/jwt/test_token_profile.py (96%) rename tests/functional/{auth => auth_alpha}/jwt/test_verifier.py (88%) rename tests/integration/{auth => auth_alpha}/jwt/conftest.py (100%) rename tests/integration/{auth => auth_alpha}/jwt/test_https.py (94%) diff --git a/aws_lambda_powertools/utilities/auth/__init__.py b/aws_lambda_powertools/utilities/auth_alpha/__init__.py similarity index 63% rename from aws_lambda_powertools/utilities/auth/__init__.py rename to aws_lambda_powertools/utilities/auth_alpha/__init__.py index 1fd05f3cd8f..5809a0dec7e 100644 --- a/aws_lambda_powertools/utilities/auth/__init__.py +++ b/aws_lambda_powertools/utilities/auth_alpha/__init__.py @@ -1,4 +1,4 @@ -"""Authentication and authorization utilities for AWS Lambda.""" +"""Alpha authentication and authorization utilities for AWS Lambda.""" from __future__ import annotations @@ -6,9 +6,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from aws_lambda_powertools.utilities.auth.jwt import AuthErrorContext as AuthErrorContext - from aws_lambda_powertools.utilities.auth.jwt import AuthFailureReason as AuthFailureReason - from aws_lambda_powertools.utilities.auth.jwt import JWTVerifier as JWTVerifier + from aws_lambda_powertools.utilities.auth_alpha.jwt import AuthErrorContext as AuthErrorContext + from aws_lambda_powertools.utilities.auth_alpha.jwt import AuthFailureReason as AuthFailureReason + from aws_lambda_powertools.utilities.auth_alpha.jwt import JWTVerifier as JWTVerifier __all__ = ["AuthErrorContext", "AuthFailureReason", "JWTVerifier"] diff --git a/aws_lambda_powertools/utilities/auth/_internal/__init__.py b/aws_lambda_powertools/utilities/auth_alpha/_internal/__init__.py similarity index 100% rename from aws_lambda_powertools/utilities/auth/_internal/__init__.py rename to aws_lambda_powertools/utilities/auth_alpha/_internal/__init__.py diff --git a/aws_lambda_powertools/utilities/auth/_internal/deadline.py b/aws_lambda_powertools/utilities/auth_alpha/_internal/deadline.py similarity index 88% rename from aws_lambda_powertools/utilities/auth/_internal/deadline.py rename to aws_lambda_powertools/utilities/auth_alpha/_internal/deadline.py index 1db19e6dea7..80422adcd7a 100644 --- a/aws_lambda_powertools/utilities/auth/_internal/deadline.py +++ b/aws_lambda_powertools/utilities/auth_alpha/_internal/deadline.py @@ -2,7 +2,7 @@ import time -from aws_lambda_powertools.utilities.auth._internal.validation import finite_seconds +from aws_lambda_powertools.utilities.auth_alpha._internal.validation import finite_seconds class RequestError(Exception): diff --git a/aws_lambda_powertools/utilities/auth/_internal/http.py b/aws_lambda_powertools/utilities/auth_alpha/_internal/http.py similarity index 96% rename from aws_lambda_powertools/utilities/auth/_internal/http.py rename to aws_lambda_powertools/utilities/auth_alpha/_internal/http.py index 340ff1e43d5..109ebca4836 100644 --- a/aws_lambda_powertools/utilities/auth/_internal/http.py +++ b/aws_lambda_powertools/utilities/auth_alpha/_internal/http.py @@ -6,7 +6,7 @@ import urllib3 from urllib3.connection import HTTPConnection -from aws_lambda_powertools.utilities.auth._internal.deadline import Deadline, RequestError +from aws_lambda_powertools.utilities.auth_alpha._internal.deadline import Deadline, RequestError if TYPE_CHECKING: from collections.abc import Mapping diff --git a/aws_lambda_powertools/utilities/auth/_internal/validation.py b/aws_lambda_powertools/utilities/auth_alpha/_internal/validation.py similarity index 100% rename from aws_lambda_powertools/utilities/auth/_internal/validation.py rename to aws_lambda_powertools/utilities/auth_alpha/_internal/validation.py diff --git a/aws_lambda_powertools/utilities/auth/jwt/__init__.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/__init__.py similarity index 67% rename from aws_lambda_powertools/utilities/auth/jwt/__init__.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/__init__.py index c1005fe7811..c870ae1cf19 100644 --- a/aws_lambda_powertools/utilities/auth/jwt/__init__.py +++ b/aws_lambda_powertools/utilities/auth_alpha/jwt/__init__.py @@ -6,9 +6,11 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthFailureReason as AuthFailureReason - from aws_lambda_powertools.utilities.auth.jwt.integrations.event_handler import AuthErrorContext as AuthErrorContext - from aws_lambda_powertools.utilities.auth.jwt.verifier import JWTVerifier as JWTVerifier + from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import AuthFailureReason as AuthFailureReason + from aws_lambda_powertools.utilities.auth_alpha.jwt.integrations.event_handler import ( + AuthErrorContext as AuthErrorContext, + ) + from aws_lambda_powertools.utilities.auth_alpha.jwt.verifier import JWTVerifier as JWTVerifier __all__ = ["AuthErrorContext", "AuthFailureReason", "JWTVerifier"] diff --git a/aws_lambda_powertools/utilities/auth/jwt/_internal/__init__.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/__init__.py similarity index 100% rename from aws_lambda_powertools/utilities/auth/jwt/_internal/__init__.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/__init__.py diff --git a/aws_lambda_powertools/utilities/auth/jwt/_internal/authorization.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/authorization.py similarity index 94% rename from aws_lambda_powertools/utilities/auth/jwt/_internal/authorization.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/authorization.py index a868a31a0d0..b035991901e 100644 --- a/aws_lambda_powertools/utilities/auth/jwt/_internal/authorization.py +++ b/aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/authorization.py @@ -3,8 +3,8 @@ from collections.abc import Mapping from typing import Any -from aws_lambda_powertools.utilities.auth._internal.validation import string_list -from aws_lambda_powertools.utilities.auth.jwt.exceptions import ( +from aws_lambda_powertools.utilities.auth_alpha._internal.validation import string_list +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import ( AuthError, AuthFailureReason, InvalidClaimsError, diff --git a/aws_lambda_powertools/utilities/auth/jwt/_internal/base.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/base.py similarity index 80% rename from aws_lambda_powertools/utilities/auth/jwt/_internal/base.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/base.py index f9b16567094..140906141dd 100644 --- a/aws_lambda_powertools/utilities/auth/jwt/_internal/base.py +++ b/aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/base.py @@ -3,14 +3,17 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Literal -from aws_lambda_powertools.utilities.auth.jwt._internal.errors import sanitize_errors +from aws_lambda_powertools.utilities.auth_alpha.jwt._internal.errors import sanitize_errors if TYPE_CHECKING: from collections.abc import Callable from aws_lambda_powertools.event_handler import Response - from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthError - from aws_lambda_powertools.utilities.auth.jwt.integrations.event_handler import AuthErrorContext, AuthMiddleware + from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import AuthError + from aws_lambda_powertools.utilities.auth_alpha.jwt.integrations.event_handler import ( + AuthErrorContext, + AuthMiddleware, + ) from aws_lambda_powertools.utilities.data_classes.common import DictWrapper @@ -21,6 +24,16 @@ class Verifier(ABC): def verify(self, token: str) -> dict[str, Any]: """Return verified claims or raise an Auth utility error.""" + def verify_authorization_header(self, value: str | None) -> dict[str, Any]: + """Verify the JWT carried by an HTTP Authorization header. + + The scheme is case-insensitive. Missing headers, malformed values, and + schemes other than Bearer raise ``InvalidTokenError``. + """ + from aws_lambda_powertools.utilities.auth_alpha.jwt._internal.authorization import bearer_token + + return self.verify(bearer_token(value)) + @abstractmethod def prefetch(self) -> None: """Populate remote key caches without accepting a token.""" @@ -59,7 +72,7 @@ def orders(): return {"subject": app.context["claims"]["sub"]} ``` """ - from aws_lambda_powertools.utilities.auth.jwt.integrations.event_handler import AuthMiddleware + from aws_lambda_powertools.utilities.auth_alpha.jwt.integrations.event_handler import AuthMiddleware return AuthMiddleware(self, scopes, authorize, on_error) @@ -104,6 +117,6 @@ def authorize( ) ``` """ - from aws_lambda_powertools.utilities.auth.jwt.integrations.api_gateway import authorize_event + from aws_lambda_powertools.utilities.auth_alpha.jwt.integrations.api_gateway import authorize_event return authorize_event(self, event, scopes, response_format, context_claims, on_error) diff --git a/aws_lambda_powertools/utilities/auth/jwt/_internal/errors.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/errors.py similarity index 91% rename from aws_lambda_powertools/utilities/auth/jwt/_internal/errors.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/errors.py index d33e003d0c4..c99f88e54d2 100644 --- a/aws_lambda_powertools/utilities/auth/jwt/_internal/errors.py +++ b/aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/errors.py @@ -3,7 +3,7 @@ from functools import wraps from typing import TYPE_CHECKING, ParamSpec, TypeVar -from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthError +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import AuthError if TYPE_CHECKING: from collections.abc import Callable diff --git a/aws_lambda_powertools/utilities/auth/jwt/_internal/jwks.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/jwks.py similarity index 93% rename from aws_lambda_powertools/utilities/auth/jwt/_internal/jwks.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/jwks.py index c544f4dc66d..9171c354ac4 100644 --- a/aws_lambda_powertools/utilities/auth/jwt/_internal/jwks.py +++ b/aws_lambda_powertools/utilities/auth_alpha/jwt/_internal/jwks.py @@ -8,9 +8,9 @@ import jwt -from aws_lambda_powertools.utilities.auth._internal.deadline import Deadline, RequestError -from aws_lambda_powertools.utilities.auth._internal.validation import https_url -from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError +from aws_lambda_powertools.utilities.auth_alpha._internal.deadline import Deadline, RequestError +from aws_lambda_powertools.utilities.auth_alpha._internal.validation import https_url +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import InvalidTokenError, JWKSFetchError def copy_key_set(value: dict[str, Any]) -> dict[str, Any]: @@ -46,7 +46,7 @@ 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._internal.http import HTTPClient + from aws_lambda_powertools.utilities.auth_alpha._internal.http import HTTPClient self._issuer = issuer self._uri = uri diff --git a/aws_lambda_powertools/utilities/auth/jwt/exceptions.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/exceptions.py similarity index 100% rename from aws_lambda_powertools/utilities/auth/jwt/exceptions.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/exceptions.py diff --git a/aws_lambda_powertools/utilities/auth/jwt/integrations/__init__.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/integrations/__init__.py similarity index 100% rename from aws_lambda_powertools/utilities/auth/jwt/integrations/__init__.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/integrations/__init__.py diff --git a/aws_lambda_powertools/utilities/auth/jwt/integrations/api_gateway.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/integrations/api_gateway.py similarity index 92% rename from aws_lambda_powertools/utilities/auth/jwt/integrations/api_gateway.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/integrations/api_gateway.py index d2857648f29..6da4682e44b 100644 --- a/aws_lambda_powertools/utilities/auth/jwt/integrations/api_gateway.py +++ b/aws_lambda_powertools/utilities/auth_alpha/jwt/integrations/api_gateway.py @@ -4,22 +4,22 @@ import re from typing import TYPE_CHECKING, Any, Literal -from aws_lambda_powertools.utilities.auth._internal.validation import string_list -from aws_lambda_powertools.utilities.auth.jwt._internal.authorization import ( +from aws_lambda_powertools.utilities.auth_alpha._internal.validation import string_list +from aws_lambda_powertools.utilities.auth_alpha.jwt._internal.authorization import ( ForbiddenError, bearer_token, enforce_scopes, header_token, required_scopes, ) -from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthError, InvalidClaimsError, InvalidTokenError +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import AuthError, InvalidClaimsError, InvalidTokenError from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import APIGatewayAuthorizerResponseV2 from aws_lambda_powertools.utilities.data_classes.common import DictWrapper if TYPE_CHECKING: from collections.abc import Callable - from aws_lambda_powertools.utilities.auth.jwt._internal.base import Verifier + from aws_lambda_powertools.utilities.auth_alpha.jwt._internal.base import Verifier _ARN = re.compile(r"arn:[a-z0-9-]+:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+/[^/]+/[A-Z]+/.*") diff --git a/aws_lambda_powertools/utilities/auth/jwt/integrations/event_handler.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/integrations/event_handler.py similarity index 91% rename from aws_lambda_powertools/utilities/auth/jwt/integrations/event_handler.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/integrations/event_handler.py index a2e79daf53b..3faf72e8e49 100644 --- a/aws_lambda_powertools/utilities/auth/jwt/integrations/event_handler.py +++ b/aws_lambda_powertools/utilities/auth_alpha/jwt/integrations/event_handler.py @@ -5,7 +5,7 @@ from aws_lambda_powertools.event_handler import ApiGatewayResolver, Response from aws_lambda_powertools.event_handler.middlewares import BaseMiddlewareHandler -from aws_lambda_powertools.utilities.auth.jwt._internal.authorization import ( +from aws_lambda_powertools.utilities.auth_alpha.jwt._internal.authorization import ( ForbiddenError, InsufficientScopeError, MissingTokenError, @@ -13,14 +13,14 @@ header_token, required_scopes, ) -from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthError, AuthFailureReason, InvalidTokenError +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import AuthError, AuthFailureReason, InvalidTokenError if TYPE_CHECKING: from collections.abc import Callable from typing import Any from aws_lambda_powertools.event_handler.middlewares import NextMiddleware - from aws_lambda_powertools.utilities.auth.jwt._internal.base import Verifier + from aws_lambda_powertools.utilities.auth_alpha.jwt._internal.base import Verifier @dataclass(frozen=True) diff --git a/aws_lambda_powertools/utilities/auth/jwt/testing.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/testing.py similarity index 91% rename from aws_lambda_powertools/utilities/auth/jwt/testing.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/testing.py index 455fd58eb10..2b70c878e2c 100644 --- a/aws_lambda_powertools/utilities/auth/jwt/testing.py +++ b/aws_lambda_powertools/utilities/auth_alpha/jwt/testing.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from collections.abc import Iterator - from aws_lambda_powertools.utilities.auth.jwt._internal.base import Verifier + from aws_lambda_powertools.utilities.auth_alpha.jwt._internal.base import Verifier @contextmanager diff --git a/aws_lambda_powertools/utilities/auth/jwt/verifier.py b/aws_lambda_powertools/utilities/auth_alpha/jwt/verifier.py similarity index 96% rename from aws_lambda_powertools/utilities/auth/jwt/verifier.py rename to aws_lambda_powertools/utilities/auth_alpha/jwt/verifier.py index 1b4dd6817e0..65b3baf2395 100644 --- a/aws_lambda_powertools/utilities/auth/jwt/verifier.py +++ b/aws_lambda_powertools/utilities/auth_alpha/jwt/verifier.py @@ -7,18 +7,18 @@ import jwt -from aws_lambda_powertools.utilities.auth._internal.deadline import Deadline -from aws_lambda_powertools.utilities.auth._internal.validation import ( +from aws_lambda_powertools.utilities.auth_alpha._internal.deadline import Deadline +from aws_lambda_powertools.utilities.auth_alpha._internal.validation import ( finite_seconds, https_url, is_nonempty_string, string_list, string_mapping, ) -from aws_lambda_powertools.utilities.auth.jwt._internal.base import Verifier -from aws_lambda_powertools.utilities.auth.jwt._internal.errors import sanitize_errors -from aws_lambda_powertools.utilities.auth.jwt._internal.jwks import copy_key_set, shared_cache, signing_key -from aws_lambda_powertools.utilities.auth.jwt.exceptions import ( +from aws_lambda_powertools.utilities.auth_alpha.jwt._internal.base import Verifier +from aws_lambda_powertools.utilities.auth_alpha.jwt._internal.errors import sanitize_errors +from aws_lambda_powertools.utilities.auth_alpha.jwt._internal.jwks import copy_key_set, shared_cache, signing_key +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import ( InvalidClaimsError, InvalidSignatureError, InvalidTokenError, diff --git a/docs/api_doc/auth.md b/docs/api_doc/auth.md deleted file mode 100644 index bc21b5e170b..00000000000 --- a/docs/api_doc/auth.md +++ /dev/null @@ -1,7 +0,0 @@ - -::: aws_lambda_powertools.utilities.auth.jwt.verifier - options: - inherited_members: true -::: aws_lambda_powertools.utilities.auth.AuthErrorContext -::: aws_lambda_powertools.utilities.auth.jwt.exceptions -::: aws_lambda_powertools.utilities.auth.jwt.testing diff --git a/docs/api_doc/auth_alpha.md b/docs/api_doc/auth_alpha.md new file mode 100644 index 00000000000..52409094848 --- /dev/null +++ b/docs/api_doc/auth_alpha.md @@ -0,0 +1,7 @@ + +::: aws_lambda_powertools.utilities.auth_alpha.jwt.verifier + options: + inherited_members: true +::: aws_lambda_powertools.utilities.auth_alpha.AuthErrorContext +::: aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions +::: aws_lambda_powertools.utilities.auth_alpha.jwt.testing diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 1576bfce2d2..e744b9e35e0 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -42,7 +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` | -| [JWT verification](../utilities/auth.md) | `pip install "aws-lambda-powertools[jwt]"` | `PyJWT`, `cryptography`, `urllib3` | +| [JWT verification (alpha)](../utilities/auth.md) | `pip install "aws-lambda-powertools[jwt]"` | `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 3629a79c118..475eda10a74 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,7 +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 and protect Lambda routes and API Gateway authorizers | +| [Auth (alpha)](./utilities/auth.md) | Verify JWT access tokens and use verified claims in Lambda workloads | | [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 index fcf2f96436d..a6b0b8d2523 100644 --- a/docs/utilities/auth.md +++ b/docs/utilities/auth.md @@ -1,26 +1,29 @@ --- -title: Auth +title: Auth (alpha) description: JWT access-token verification for Lambda status: new --- -Auth verifies JWT access tokens before your Lambda handler processes a request. `JWTVerifier` contains the validation configuration and signing-key cache. It can create an Event Handler middleware or verify a token directly. +!!! warning "Alpha / experimental" + This utility ships under the `auth_alpha` namespace while we collect feedback. Its public API may change before GA. Pin your Powertools version before using it in production. + +Auth verifies JWT access tokens in any Lambda workload. Use `verify()` directly, create Event Handler middleware with `require()`, or build a Lambda authorizer response with `authorize()`. ```mermaid flowchart LR - Request["Request with Bearer token"] --> Middleware["require() middleware"] - Middleware --> Verify["verify() token"] - Verify -->|Valid token and scopes| Handler["Route handler"] - Verify -->|Invalid token| Unauthorized["401 Unauthorized"] - Verify -->|Missing scope| Forbidden["403 Forbidden"] - Verify -->|JWKS unavailable| Unavailable["503 Service Unavailable"] + Token["JWT access token"] --> Verify["JWTVerifier.verify()"] + Verify -->|Valid| Claims["Verified claims"] + Claims --> Application["Application logic"] + Verify -->|Invalid| Invalid["InvalidTokenError"] + Verify -->|Keys unavailable| Unavailable["JWKSFetchError"] ``` ## Key features * Verify JWT signatures, issuer, audience, expiration, and required claims. -* Protect individual Event Handler routes with scopes and custom authorization. * Reuse signing keys across warm Lambda invocations and refresh them during rotation. +* Verify tokens directly in any Lambda event flow. +* Protect Event Handler routes with scopes and custom authorization. * Build REST API and HTTP API Lambda authorizer responses. ## Getting started @@ -33,6 +36,18 @@ pip install "aws-lambda-powertools[jwt]" The `jwt` extra installs PyJWT, cryptography, and urllib3. Build dependencies for the same Python version and architecture as your Lambda function. See [cross-platform builds](../build_recipes/cross-platform.md). +### Verify a JWT + +Create the verifier outside the Lambda handler so warm invocations reuse its signing-key cache. Configure the trusted issuer, this workload's audience, and the algorithms accepted from that issuer. + +```python title="basic.py" +--8<-- "examples/auth_alpha/jwt/src/basic.py" +``` + +Replace `token_use` with the access-token marker used by your identity provider. This prevents another JWT type from being accepted only because it has the same audience. + +`verify()` accepts an encoded JWT without the `Bearer` prefix. It returns verified claims or raises `InvalidTokenError`. If discovery or the JWKS endpoint is unavailable, it raises `JWKSFetchError` instead. Your Lambda decides how those failures map to its event source. + ### Required resources JWT verification requires no additional IAM permissions. When using issuer discovery or a remote JWKS endpoint, the function needs outbound HTTPS access to the identity provider. A function in private subnets might need a NAT gateway or private connectivity. Static `jwks` does not use the network, but your application is responsible for rotating those keys. @@ -42,7 +57,7 @@ JWT verification requires no additional IAM permissions. When using issuer disco Create `JWTVerifier` outside the Lambda handler so warm invocations reuse its signing-key cache. The verifier itself is not middleware. Calling `verifier.require()` creates middleware bound to that verifier and to the requested scopes. ```python title="middleware.py" ---8<-- "examples/auth/jwt/src/middleware.py" +--8<-- "examples/auth_alpha/jwt/src/middleware.py" ``` Here, `app.get()` registers the middleware only for `GET /orders`. For each matching request, the middleware: @@ -62,15 +77,15 @@ The route handler does not run when authentication or authorization fails. Configure public routes and CORS preflight separately. -### Verify a token directly +### Verify an Authorization header -Use `verify()` when you are not using Event Handler middleware or when your application already owns request parsing. It accepts the encoded JWT without the `Bearer` prefix. It does not read headers or create an HTTP response. On success it returns verified claims; on failure it raises a typed exception. +When handling HTTP authentication without `require()`, pass the complete `Authorization` header to `verify_authorization_header()`. It validates the Bearer scheme and then calls `verify()` with the extracted JWT. ```python title="direct.py" ---8<-- "examples/auth/jwt/src/direct.py" +--8<-- "examples/auth_alpha/jwt/src/direct.py" ``` -The middleware created by `require()` uses this same method internally and maps these failures to HTTP responses for you. `verify()` always checks `iss`, `aud`, and `exp`; `required_claims` adds more required claims. +Use `verify()` for an encoded JWT and `verify_authorization_header()` for the complete HTTP header. Do not split the header in application code. Both methods require `iss`, `aud`, and `exp`; `required_claims` adds more required claims. ## Advanced @@ -79,7 +94,7 @@ The middleware created by `require()` uses this same method internally and maps This complete Lambda adds provider-specific token checks, a required scope, a tenant authorization rule, and custom error handling: ```python title="custom_authorization.py" ---8<-- "examples/auth/jwt/src/custom_authorization.py" +--8<-- "examples/auth_alpha/jwt/src/custom_authorization.py" ``` `expected_claims` must match the access-token profile documented by your identity provider. `authorize` runs only after token verification and scope checks succeed. `on_error` can change the error response and emit logs or metrics, but it never invokes the protected route. @@ -99,7 +114,13 @@ The first verification fetches signing keys unless you provide static `jwks`. Wa !!! warning "Leave time for Lambda to handle the error" Set `timeout_seconds` lower than the Lambda function timeout. If both use the three-second default, Lambda can terminate the invocation before your code receives `JWKSFetchError`. -Calling `prefetch()` during module initialization moves the initial network request into Lambda INIT. This can reduce first-request latency, but an identity-provider outage can then fail the cold start. +Call `prefetch()` after constructing the verifier to retrieve keys during Lambda INIT: + +```python title="prefetch.py" +--8<-- "examples/auth_alpha/jwt/src/prefetch.py" +``` + +This can reduce first-invocation latency, but an identity-provider outage can then fail the cold start. `prefetch()` is optional; without it, the first `verify()` retrieves the keys. Static `jwks` avoids network access. Recreate the verifier or execution environment when the configured keys change. @@ -108,7 +129,7 @@ Static `jwks` avoids network access. Recreate the verifier or execution environm Use the Cognito profile for resource-bound Cognito access tokens: ```python title="cognito.py" ---8<-- "examples/auth/jwt/src/cognito.py" +--8<-- "examples/auth_alpha/jwt/src/cognito.py" ``` This profile checks RS256, `token_use="access"`, the configured app client ID, and the resource audience. Cognito ID tokens and access tokens without the configured resource audience are rejected. @@ -120,7 +141,7 @@ Use `JWTVerifier.any_of()` when the same Lambda trusts access tokens from multip Use `authorize()` when API Gateway invokes a dedicated Lambda authorizer: ```python title="authorizer.py" ---8<-- "examples/auth/jwt/src/authorizer/authorizer.py" +--8<-- "examples/auth_alpha/jwt/src/authorizer/authorizer.py" ``` The helper supports REST API TOKEN and REQUEST events and HTTP API REQUEST payloads. Choose `response_format="iam"` for an IAM policy or `response_format="simple"` for an HTTP API 2.0 simple response. @@ -132,7 +153,7 @@ Invalid tokens and insufficient scopes return Deny or `isAuthorized=false`. If s The example template disables API Gateway authorizer-result caching so every request is verified: ```yaml title="templates/sam.yaml" ---8<-- "examples/auth/jwt/templates/sam.yaml" +--8<-- "examples/auth_alpha/jwt/templates/sam.yaml" ``` If you enable Gateway caching, include all request attributes used by authorization in its identity sources. A cached allow can otherwise apply to another route or outlive the token expiration. This cache is independent of the verifier JWKS cache. diff --git a/examples/auth/jwt/src/authorizer/authorizer.py b/examples/auth_alpha/jwt/src/authorizer/authorizer.py similarity index 87% rename from examples/auth/jwt/src/authorizer/authorizer.py rename to examples/auth_alpha/jwt/src/authorizer/authorizer.py index b8133db6e12..a94ee913e0b 100644 --- a/examples/auth/jwt/src/authorizer/authorizer.py +++ b/examples/auth_alpha/jwt/src/authorizer/authorizer.py @@ -1,8 +1,8 @@ import os from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import AuthError +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import AuthError from aws_lambda_powertools.utilities.typing import LambdaContext logger = Logger() diff --git a/examples/auth/jwt/src/authorizer/requirements.txt b/examples/auth_alpha/jwt/src/authorizer/requirements.txt similarity index 100% rename from examples/auth/jwt/src/authorizer/requirements.txt rename to examples/auth_alpha/jwt/src/authorizer/requirements.txt diff --git a/examples/auth/jwt/src/backend/backend.py b/examples/auth_alpha/jwt/src/backend/backend.py similarity index 100% rename from examples/auth/jwt/src/backend/backend.py rename to examples/auth_alpha/jwt/src/backend/backend.py diff --git a/examples/auth/jwt/src/backend/requirements.txt b/examples/auth_alpha/jwt/src/backend/requirements.txt similarity index 100% rename from examples/auth/jwt/src/backend/requirements.txt rename to examples/auth_alpha/jwt/src/backend/requirements.txt diff --git a/examples/auth_alpha/jwt/src/basic.py b/examples/auth_alpha/jwt/src/basic.py new file mode 100644 index 00000000000..5f389a986a5 --- /dev/null +++ b/examples/auth_alpha/jwt/src/basic.py @@ -0,0 +1,18 @@ +import os + +from aws_lambda_powertools.utilities.auth_alpha 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"], + # Adapt this constraint to your provider's access-token profile. + expected_claims={"token_use": "access"}, +) + + +def lambda_handler(event: dict, context: LambdaContext): + claims = verifier.verify(event["access_token"]) + return {"subject": claims["sub"]} diff --git a/examples/auth/jwt/src/cognito.py b/examples/auth_alpha/jwt/src/cognito.py similarity index 90% rename from examples/auth/jwt/src/cognito.py rename to examples/auth_alpha/jwt/src/cognito.py index e7e63f1a09f..fd580c3d3ef 100644 --- a/examples/auth/jwt/src/cognito.py +++ b/examples/auth_alpha/jwt/src/cognito.py @@ -1,7 +1,7 @@ import os from aws_lambda_powertools.event_handler import APIGatewayHttpResolver -from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier from aws_lambda_powertools.utilities.typing import LambdaContext app = APIGatewayHttpResolver() diff --git a/examples/auth/jwt/src/custom_authorization.py b/examples/auth_alpha/jwt/src/custom_authorization.py similarity index 93% rename from examples/auth/jwt/src/custom_authorization.py rename to examples/auth_alpha/jwt/src/custom_authorization.py index 992eb9dea13..7120cfe11ae 100644 --- a/examples/auth/jwt/src/custom_authorization.py +++ b/examples/auth_alpha/jwt/src/custom_authorization.py @@ -2,7 +2,7 @@ from aws_lambda_powertools import Logger from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response -from aws_lambda_powertools.utilities.auth import AuthErrorContext, JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha import AuthErrorContext, JWTVerifier from aws_lambda_powertools.utilities.typing import LambdaContext app = APIGatewayHttpResolver() diff --git a/examples/auth/jwt/src/direct.py b/examples/auth_alpha/jwt/src/direct.py similarity index 69% rename from examples/auth/jwt/src/direct.py rename to examples/auth_alpha/jwt/src/direct.py index da62ae81181..7dc443e46b1 100644 --- a/examples/auth/jwt/src/direct.py +++ b/examples/auth_alpha/jwt/src/direct.py @@ -1,8 +1,8 @@ import os from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import InvalidTokenError, JWKSFetchError from aws_lambda_powertools.utilities.typing import LambdaContext app = APIGatewayHttpResolver() @@ -11,18 +11,16 @@ audience=os.environ["RESOURCE_URL"], algorithms=["RS256"], required_claims=["sub"], + # Adapt this constraint to your provider's access-token profile. + expected_claims={"token_use": "access"}, ) @app.get("/orders") def list_orders(): - authorization = app.current_event.headers.get("authorization", "") - parts = authorization.split() - if len(parts) != 2 or parts[0].lower() != "bearer": - return Response(status_code=401, body={"message": "Unauthorized"}, headers={"WWW-Authenticate": "Bearer"}) - + authorization = app.current_event.headers.get("authorization") try: - claims = verifier.verify(parts[1]) + claims = verifier.verify_authorization_header(authorization) except InvalidTokenError: return Response( status_code=401, diff --git a/examples/auth/jwt/src/middleware.py b/examples/auth_alpha/jwt/src/middleware.py similarity index 91% rename from examples/auth/jwt/src/middleware.py rename to examples/auth_alpha/jwt/src/middleware.py index 57b8b9f7eae..f5bc1ac546e 100644 --- a/examples/auth/jwt/src/middleware.py +++ b/examples/auth_alpha/jwt/src/middleware.py @@ -1,7 +1,7 @@ import os from aws_lambda_powertools.event_handler import APIGatewayHttpResolver -from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier from aws_lambda_powertools.utilities.typing import LambdaContext app = APIGatewayHttpResolver() diff --git a/examples/auth_alpha/jwt/src/prefetch.py b/examples/auth_alpha/jwt/src/prefetch.py new file mode 100644 index 00000000000..880bc5e2456 --- /dev/null +++ b/examples/auth_alpha/jwt/src/prefetch.py @@ -0,0 +1,19 @@ +import os + +from aws_lambda_powertools.utilities.auth_alpha 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"], + # Adapt this constraint to your provider's access-token profile. + expected_claims={"token_use": "access"}, +) +verifier.prefetch() + + +def lambda_handler(event: dict, context: LambdaContext): + claims = verifier.verify(event["access_token"]) + return {"subject": claims["sub"]} diff --git a/examples/auth/jwt/templates/sam.yaml b/examples/auth_alpha/jwt/templates/sam.yaml similarity index 100% rename from examples/auth/jwt/templates/sam.yaml rename to examples/auth_alpha/jwt/templates/sam.yaml diff --git a/mkdocs.yml b/mkdocs.yml index 87cee2b4724..b9f02a668b8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,7 +26,7 @@ nav: - core/event_handler/appsync_events.md - core/event_handler/bedrock_agents.md - utilities/parameters.md - - utilities/auth.md + - Auth (alpha): utilities/auth.md - utilities/batch.md - utilities/kafka.md - utilities/typing.md @@ -86,7 +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 + - Auth (alpha): api_doc/auth_alpha.md - Batch Processing: - Base: api_doc/batch/base.md - Decorators: api_doc/batch/decorators.md diff --git a/tests/functional/auth/__init__.py b/tests/functional/auth_alpha/__init__.py similarity index 100% rename from tests/functional/auth/__init__.py rename to tests/functional/auth_alpha/__init__.py diff --git a/tests/functional/auth/jwt/__init__.py b/tests/functional/auth_alpha/jwt/__init__.py similarity index 100% rename from tests/functional/auth/jwt/__init__.py rename to tests/functional/auth_alpha/jwt/__init__.py diff --git a/tests/functional/auth/jwt/conftest.py b/tests/functional/auth_alpha/jwt/conftest.py similarity index 96% rename from tests/functional/auth/jwt/conftest.py rename to tests/functional/auth_alpha/jwt/conftest.py index 63ea3a5089f..69c67d98155 100644 --- a/tests/functional/auth/jwt/conftest.py +++ b/tests/functional/auth_alpha/jwt/conftest.py @@ -9,7 +9,7 @@ import urllib3 from cryptography.hazmat.primitives.asymmetric import rsa -from aws_lambda_powertools.utilities.auth.jwt._internal import jwks as jwks_module +from aws_lambda_powertools.utilities.auth_alpha.jwt._internal import jwks as jwks_module @pytest.fixture(scope="session") diff --git a/tests/functional/auth/jwt/imports/__init__.py b/tests/functional/auth_alpha/jwt/imports/__init__.py similarity index 100% rename from tests/functional/auth/jwt/imports/__init__.py rename to tests/functional/auth_alpha/jwt/imports/__init__.py diff --git a/tests/functional/auth/jwt/imports/_auth_import_probe.py b/tests/functional/auth_alpha/jwt/imports/_auth_import_probe.py similarity index 88% rename from tests/functional/auth/jwt/imports/_auth_import_probe.py rename to tests/functional/auth_alpha/jwt/imports/_auth_import_probe.py index c53a536beb1..b0155a1482f 100644 --- a/tests/functional/auth/jwt/imports/_auth_import_probe.py +++ b/tests/functional/auth_alpha/jwt/imports/_auth_import_probe.py @@ -21,8 +21,8 @@ def find_spec(self, fullname, path=None, target=None): if scenario == "static": sys.meta_path.insert(0, BlockImports("urllib3")) - from aws_lambda_powertools.utilities.auth import JWTVerifier - from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidSignatureError + from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier + from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import InvalidSignatureError fixture = json.load(sys.stdin) verifier = JWTVerifier( @@ -43,7 +43,7 @@ def find_spec(self, fullname, path=None, target=None): raise AssertionError("Invalid signature was accepted") assert "urllib3" not in sys.modules elif scenario == "remote": - from aws_lambda_powertools.utilities.auth import JWTVerifier + from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier assert "urllib3" not in sys.modules verifier = JWTVerifier( @@ -53,8 +53,8 @@ def find_spec(self, fullname, path=None, target=None): ) assert "urllib3" in sys.modules elif scenario == "exports": - auth = importlib.import_module("aws_lambda_powertools.utilities.auth") - jwt_auth = importlib.import_module("aws_lambda_powertools.utilities.auth.jwt") + auth = importlib.import_module("aws_lambda_powertools.utilities.auth_alpha") + jwt_auth = importlib.import_module("aws_lambda_powertools.utilities.auth_alpha.jwt") exports = {"JWTVerifier", "AuthFailureReason", "AuthErrorContext"} assert exports <= set(dir(auth)) @@ -76,7 +76,7 @@ def find_spec(self, fullname, path=None, target=None): assert members["AuthFailureReason"] is jwt_auth.AuthFailureReason assert members["AuthErrorContext"] is jwt_auth.AuthErrorContext elif scenario == "star": - from aws_lambda_powertools.utilities.auth import * # noqa: E402,F403 + from aws_lambda_powertools.utilities.auth_alpha import * # noqa: E402,F403 assert {"JWTVerifier", "AuthFailureReason", "AuthErrorContext"} <= globals().keys() else: diff --git a/tests/functional/auth/jwt/imports/test_imports.py b/tests/functional/auth_alpha/jwt/imports/test_imports.py similarity index 100% rename from tests/functional/auth/jwt/imports/test_imports.py rename to tests/functional/auth_alpha/jwt/imports/test_imports.py diff --git a/tests/functional/auth/jwt/integrations/__init__.py b/tests/functional/auth_alpha/jwt/integrations/__init__.py similarity index 100% rename from tests/functional/auth/jwt/integrations/__init__.py rename to tests/functional/auth_alpha/jwt/integrations/__init__.py diff --git a/tests/functional/auth/jwt/integrations/test_authorizer.py b/tests/functional/auth_alpha/jwt/integrations/test_authorizer.py similarity index 98% rename from tests/functional/auth/jwt/integrations/test_authorizer.py rename to tests/functional/auth_alpha/jwt/integrations/test_authorizer.py index 74aa3a71d41..bb7c1969adf 100644 --- a/tests/functional/auth/jwt/integrations/test_authorizer.py +++ b/tests/functional/auth_alpha/jwt/integrations/test_authorizer.py @@ -2,8 +2,8 @@ import pytest -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import JWKSFetchError +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import JWKSFetchError from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import ( APIGatewayAuthorizerEventV2, APIGatewayAuthorizerRequestEvent, diff --git a/tests/functional/auth/jwt/integrations/test_failure_visibility.py b/tests/functional/auth_alpha/jwt/integrations/test_failure_visibility.py similarity index 96% rename from tests/functional/auth/jwt/integrations/test_failure_visibility.py rename to tests/functional/auth_alpha/jwt/integrations/test_failure_visibility.py index c5e1cc60f1b..0028c236f34 100644 --- a/tests/functional/auth/jwt/integrations/test_failure_visibility.py +++ b/tests/functional/auth_alpha/jwt/integrations/test_failure_visibility.py @@ -5,8 +5,8 @@ import pytest from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response -from aws_lambda_powertools.utilities.auth import AuthErrorContext, AuthFailureReason, JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import JWKSFetchError +from aws_lambda_powertools.utilities.auth_alpha import AuthErrorContext, AuthFailureReason, JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import JWKSFetchError from tests.functional.utils import load_event ARN = "arn:aws:execute-api:us-east-1:123456789012:api123/prod/GET/orders" diff --git a/tests/functional/auth/jwt/integrations/test_middleware.py b/tests/functional/auth_alpha/jwt/integrations/test_middleware.py similarity index 99% rename from tests/functional/auth/jwt/integrations/test_middleware.py rename to tests/functional/auth_alpha/jwt/integrations/test_middleware.py index 6cc0340b227..97639182528 100644 --- a/tests/functional/auth/jwt/integrations/test_middleware.py +++ b/tests/functional/auth_alpha/jwt/integrations/test_middleware.py @@ -10,7 +10,7 @@ LambdaFunctionUrlResolver, Response, ) -from aws_lambda_powertools.utilities.auth import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier from tests.functional.utils import load_event diff --git a/tests/functional/auth/jwt/test_errors.py b/tests/functional/auth_alpha/jwt/test_errors.py similarity index 95% rename from tests/functional/auth/jwt/test_errors.py rename to tests/functional/auth_alpha/jwt/test_errors.py index ec14a610ff0..6923fa36532 100644 --- a/tests/functional/auth/jwt/test_errors.py +++ b/tests/functional/auth_alpha/jwt/test_errors.py @@ -8,8 +8,8 @@ import urllib3 from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import ( +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import ( InvalidClaimsError, InvalidSignatureError, InvalidTokenError, diff --git a/tests/functional/auth/jwt/test_jwks_cache.py b/tests/functional/auth_alpha/jwt/test_jwks_cache.py similarity index 98% rename from tests/functional/auth/jwt/test_jwks_cache.py rename to tests/functional/auth_alpha/jwt/test_jwks_cache.py index 50324521ac1..75853ba66e8 100644 --- a/tests/functional/auth/jwt/test_jwks_cache.py +++ b/tests/functional/auth_alpha/jwt/test_jwks_cache.py @@ -4,8 +4,8 @@ import pytest -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError, JWKSFetchError +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import InvalidTokenError, JWKSFetchError JWKS_URL = "https://idp.example.com/keys" ISSUER = "https://idp.example.com/" diff --git a/tests/functional/auth/jwt/test_profiles.py b/tests/functional/auth_alpha/jwt/test_profiles.py similarity index 97% rename from tests/functional/auth/jwt/test_profiles.py rename to tests/functional/auth_alpha/jwt/test_profiles.py index b16dcd13b5d..714af18463a 100644 --- a/tests/functional/auth/jwt/test_profiles.py +++ b/tests/functional/auth_alpha/jwt/test_profiles.py @@ -2,8 +2,8 @@ import pytest from cryptography.hazmat.primitives.asymmetric import rsa -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import ( +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import ( InvalidClaimsError, InvalidSignatureError, InvalidTokenError, diff --git a/tests/functional/auth/jwt/test_testing.py b/tests/functional/auth_alpha/jwt/test_testing.py similarity index 81% rename from tests/functional/auth/jwt/test_testing.py rename to tests/functional/auth_alpha/jwt/test_testing.py index fb35e6efddf..53e34f442c3 100644 --- a/tests/functional/auth/jwt/test_testing.py +++ b/tests/functional/auth_alpha/jwt/test_testing.py @@ -1,8 +1,8 @@ import pytest -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidTokenError -from aws_lambda_powertools.utilities.auth.jwt.testing import mock_claims +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import InvalidTokenError +from aws_lambda_powertools.utilities.auth_alpha.jwt.testing import mock_claims def test_mock_claims_is_scoped_and_restores_real_verification(jwks): diff --git a/tests/functional/auth/jwt/test_token_profile.py b/tests/functional/auth_alpha/jwt/test_token_profile.py similarity index 96% rename from tests/functional/auth/jwt/test_token_profile.py rename to tests/functional/auth_alpha/jwt/test_token_profile.py index 09ccb8b6e49..51cb6a13c0f 100644 --- a/tests/functional/auth/jwt/test_token_profile.py +++ b/tests/functional/auth_alpha/jwt/test_token_profile.py @@ -4,8 +4,8 @@ import pytest from aws_lambda_powertools.event_handler import APIGatewayHttpResolver -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import InvalidClaimsError, InvalidSignatureError +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import InvalidClaimsError, InvalidSignatureError from tests.functional.utils import load_event ARN = "arn:aws:execute-api:us-east-1:123456789012:api123/prod/GET/orders" diff --git a/tests/functional/auth/jwt/test_verifier.py b/tests/functional/auth_alpha/jwt/test_verifier.py similarity index 88% rename from tests/functional/auth/jwt/test_verifier.py rename to tests/functional/auth_alpha/jwt/test_verifier.py index 1b7e62f97e2..a8700262dd9 100644 --- a/tests/functional/auth/jwt/test_verifier.py +++ b/tests/functional/auth_alpha/jwt/test_verifier.py @@ -4,8 +4,8 @@ import pytest from cryptography.hazmat.primitives.asymmetric import ec, ed25519 -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import ( +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import ( InvalidClaimsError, InvalidSignatureError, InvalidTokenError, @@ -24,6 +24,33 @@ def test_verify_access_token_with_static_keys(jwks, claims, issue_token): assert verifier.verify(issue_token()) == claims +@pytest.mark.parametrize("issuer_group", [False, True]) +@pytest.mark.parametrize("header", ["Bearer {token}", "bearer {token}", " BEARER {token} "]) +def test_verify_authorization_header(jwks, claims, issue_token, issuer_group, header): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + subject = JWTVerifier.any_of(verifier) if issuer_group else verifier + + assert subject.verify_authorization_header(header.format(token=issue_token())) == claims + + +@pytest.mark.parametrize("header", [None, "", "Basic credential", "Bearer", "Bearer one two", 42]) +def test_verify_authorization_header_rejects_missing_or_malformed_values(jwks, header): + verifier = JWTVerifier( + issuer="https://idp.example.com/", + audience="https://api.example.com", + algorithms=["RS256"], + jwks=jwks, + ) + + with pytest.raises(InvalidTokenError): + verifier.verify_authorization_header(header) + + @pytest.mark.parametrize("missing", ["iss", "aud", "exp", "sub"]) def test_required_claims_are_additive(jwks, claims, issue_token, missing): verifier = JWTVerifier( diff --git a/tests/integration/auth/jwt/conftest.py b/tests/integration/auth_alpha/jwt/conftest.py similarity index 100% rename from tests/integration/auth/jwt/conftest.py rename to tests/integration/auth_alpha/jwt/conftest.py diff --git a/tests/integration/auth/jwt/test_https.py b/tests/integration/auth_alpha/jwt/test_https.py similarity index 94% rename from tests/integration/auth/jwt/test_https.py rename to tests/integration/auth_alpha/jwt/test_https.py index 36b079c8985..bece05d6c65 100644 --- a/tests/integration/auth/jwt/test_https.py +++ b/tests/integration/auth_alpha/jwt/test_https.py @@ -4,8 +4,8 @@ import pytest from cryptography.hazmat.primitives.asymmetric import rsa -from aws_lambda_powertools.utilities.auth import JWTVerifier -from aws_lambda_powertools.utilities.auth.jwt.exceptions import JWKSFetchError +from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier +from aws_lambda_powertools.utilities.auth_alpha.jwt.exceptions import JWKSFetchError def verifier(endpoint, **options): From 65a57daf34582c1278cea73aea2acd581090cec3 Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 23 Sep 2026 12:26:40 +1000 Subject: [PATCH 12/15] docs(auth): introduce verifier operations before usage --- docs/utilities/auth.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/utilities/auth.md b/docs/utilities/auth.md index a6b0b8d2523..1cbced412d0 100644 --- a/docs/utilities/auth.md +++ b/docs/utilities/auth.md @@ -36,7 +36,7 @@ pip install "aws-lambda-powertools[jwt]" The `jwt` extra installs PyJWT, cryptography, and urllib3. Build dependencies for the same Python version and architecture as your Lambda function. See [cross-platform builds](../build_recipes/cross-platform.md). -### Verify a JWT +### Create a verifier Create the verifier outside the Lambda handler so warm invocations reuse its signing-key cache. Configure the trusted issuer, this workload's audience, and the algorithms accepted from that issuer. @@ -46,6 +46,18 @@ Create the verifier outside the Lambda handler so warm invocations reuse its sig Replace `token_use` with the access-token marker used by your identity provider. This prevents another JWT type from being accepted only because it has the same audience. +`JWTVerifier` provides the following operations: + +| Method | Use when | +| ------ | -------- | +| `verify(token)` | You already have the encoded JWT | +| `verify_authorization_header(value)` | You have a complete HTTP `Authorization` header | +| `require(...)` | You use Powertools Event Handler and want to protect a route | +| `authorize(event, ...)` | The Lambda function is an API Gateway authorizer | +| `prefetch()` | You want to fetch signing keys during Lambda INIT | + +### Verify a JWT + `verify()` accepts an encoded JWT without the `Bearer` prefix. It returns verified claims or raises `InvalidTokenError`. If discovery or the JWKS endpoint is unavailable, it raises `JWKSFetchError` instead. Your Lambda decides how those failures map to its event source. ### Required resources From 58133636ae54189d3a960d69435bfd2e0723700d Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 23 Sep 2026 12:31:37 +1000 Subject: [PATCH 13/15] docs(auth): add middleware testing example --- docs/utilities/auth.md | 8 +++-- .../auth_alpha/jwt/tests/test_middleware.py | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 examples/auth_alpha/jwt/tests/test_middleware.py diff --git a/docs/utilities/auth.md b/docs/utilities/auth.md index 1cbced412d0..68b34a25ff3 100644 --- a/docs/utilities/auth.md +++ b/docs/utilities/auth.md @@ -189,6 +189,10 @@ Use `reason.value` for log fields and metric dimensions. Do not parse exception ## Testing your code -Use `mock_claims` to replace `verifier.verify()` while testing route behavior without cryptography or network calls. Wrap the call to `app.resolve()` in `mock_claims(verifier, claims)` and include an Authorization header so the middleware still exercises credential extraction. +Use `mock_claims` to test the complete middleware Lambda without cryptography or network calls: -`mock_claims` restores the verifier when the context manager exits and returns an independent copy of the supplied claims. It bypasses signature and claim validation, so keep separate verification tests for the token profiles your application accepts. +```python title="test_middleware.py" +--8<-- "examples/auth_alpha/jwt/tests/test_middleware.py" +``` + +The test still sends an HTTP API event, extracts the Bearer token, and checks the required scope before invoking the route. `mock_claims` replaces only token verification and restores the verifier when the context manager exits. Keep separate verification tests for the token profiles your application accepts. diff --git a/examples/auth_alpha/jwt/tests/test_middleware.py b/examples/auth_alpha/jwt/tests/test_middleware.py new file mode 100644 index 00000000000..d5a4c110068 --- /dev/null +++ b/examples/auth_alpha/jwt/tests/test_middleware.py @@ -0,0 +1,30 @@ +import json + +from aws_lambda_powertools.utilities.auth_alpha.jwt.testing import mock_claims + + +def test_list_orders(monkeypatch): + monkeypatch.setenv("ISSUER_URL", "https://idp.example.com/") + monkeypatch.setenv("RESOURCE_URL", "https://api.example.com") + + from middleware import lambda_handler, verifier + + event = { + "version": "2.0", + "routeKey": "GET /orders", + "rawPath": "/orders", + "rawQueryString": "", + "headers": {"authorization": "Bearer test-token"}, + "requestContext": { + "http": {"method": "GET", "path": "/orders"}, + "stage": "$default", + }, + "isBase64Encoded": False, + } + claims = {"sub": "test-user", "scope": "orders:read"} + + with mock_claims(verifier, claims): + response = lambda_handler(event, {}) + + assert response["statusCode"] == 200 + assert json.loads(response["body"]) == {"subject": "test-user", "orders": []} From c879dcb5c62045e5edda75a3100a76cc601d1782 Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 23 Sep 2026 14:33:16 +1000 Subject: [PATCH 14/15] test(auth): update Nox session for alpha namespace --- noxfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index cfb7e8849b0..4414848d785 100644 --- a/noxfile.py +++ b/noxfile.py @@ -232,6 +232,6 @@ 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", + folders=[f"{PREFIX_TESTS_FUNCTIONAL}/auth_alpha/"], + extras="jwt", ) From cb35cc45bdc21eafe664ac7c1eec33074cf6afb9 Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 23 Sep 2026 16:07:14 +1000 Subject: [PATCH 15/15] test(auth): use concise IDs for malformed JWKS cases --- tests/functional/auth_alpha/jwt/test_jwks_cache.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/functional/auth_alpha/jwt/test_jwks_cache.py b/tests/functional/auth_alpha/jwt/test_jwks_cache.py index 75853ba66e8..7bc9147b5d1 100644 --- a/tests/functional/auth_alpha/jwt/test_jwks_cache.py +++ b/tests/functional/auth_alpha/jwt/test_jwks_cache.py @@ -138,6 +138,7 @@ def test_invalid_discovery_never_falls_back_or_fetches_untrusted_keys(http, issu @pytest.mark.parametrize( "body", [{}, {"keys": None}, {"keys": ["bad-key"]}, [], None, b"not json", b"x" * (1024 * 1024 + 1)], + ids=["missing-keys", "null-keys", "malformed-key", "array", "null", "invalid-json", "oversized"], ) def test_malformed_key_sets_fail_closed(http, issue_token, body): http.serve(JWKS_URL, body)