fastapi-factory-utilities 0.7.1__py3-none-any.whl → 0.8.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fastapi-factory-utilities might be problematic. Click here for more details.

Files changed (27) hide show
  1. fastapi_factory_utilities/core/exceptions.py +23 -15
  2. fastapi_factory_utilities/core/plugins/aiopika/exchange.py +4 -5
  3. fastapi_factory_utilities/core/plugins/aiopika/listener/abstract.py +4 -4
  4. fastapi_factory_utilities/core/plugins/aiopika/message.py +4 -4
  5. fastapi_factory_utilities/core/plugins/aiopika/queue.py +2 -0
  6. fastapi_factory_utilities/core/plugins/taskiq_plugins/__init__.py +2 -0
  7. fastapi_factory_utilities/core/security/__init__.py +5 -0
  8. fastapi_factory_utilities/core/security/abstracts.py +42 -0
  9. fastapi_factory_utilities/core/security/jwt/__init__.py +45 -0
  10. fastapi_factory_utilities/core/security/jwt/configs.py +32 -0
  11. fastapi_factory_utilities/core/security/jwt/decoders.py +130 -0
  12. fastapi_factory_utilities/core/security/jwt/exceptions.py +23 -0
  13. fastapi_factory_utilities/core/security/jwt/objects.py +107 -0
  14. fastapi_factory_utilities/core/security/jwt/services.py +176 -0
  15. fastapi_factory_utilities/core/security/jwt/stores.py +43 -0
  16. fastapi_factory_utilities/core/security/jwt/types.py +9 -0
  17. fastapi_factory_utilities/core/security/jwt/verifiers.py +46 -0
  18. fastapi_factory_utilities/core/security/kratos.py +43 -43
  19. fastapi_factory_utilities/core/services/hydra/__init__.py +10 -3
  20. fastapi_factory_utilities/core/services/hydra/services.py +112 -34
  21. fastapi_factory_utilities/core/services/status/exceptions.py +1 -1
  22. {fastapi_factory_utilities-0.7.1.dist-info → fastapi_factory_utilities-0.8.3.dist-info}/METADATA +1 -1
  23. {fastapi_factory_utilities-0.7.1.dist-info → fastapi_factory_utilities-0.8.3.dist-info}/RECORD +26 -16
  24. fastapi_factory_utilities/core/security/jwt.py +0 -159
  25. {fastapi_factory_utilities-0.7.1.dist-info → fastapi_factory_utilities-0.8.3.dist-info}/WHEEL +0 -0
  26. {fastapi_factory_utilities-0.7.1.dist-info → fastapi_factory_utilities-0.8.3.dist-info}/entry_points.txt +0 -0
  27. {fastapi_factory_utilities-0.7.1.dist-info → fastapi_factory_utilities-0.8.3.dist-info}/licenses/LICENSE +0 -0
@@ -1,8 +1,7 @@
1
1
  """FastAPI Factory Utilities exceptions."""
2
2
 
3
3
  import logging
4
- from collections.abc import Sequence
5
- from typing import NotRequired, TypedDict, Unpack
4
+ from typing import Any, cast
6
5
 
7
6
  from opentelemetry.trace import Span, get_current_span
8
7
  from opentelemetry.util.types import AttributeValue
@@ -11,42 +10,49 @@ from structlog.stdlib import BoundLogger, get_logger
11
10
  _logger: BoundLogger = get_logger()
12
11
 
13
12
 
14
- class ExceptionParameters(TypedDict):
15
- """Parameters for the exception."""
16
-
17
- message: NotRequired[str]
18
- level: NotRequired[int]
19
-
20
-
21
13
  class FastAPIFactoryUtilitiesError(Exception):
22
14
  """Base exception for the FastAPI Factory Utilities."""
23
15
 
16
+ FILTERED_ATTRIBUTES: tuple[str, ...] = ()
17
+ DEFAULT_LOGGING_LEVEL: int = logging.ERROR
18
+ DEFAULT_MESSAGE: str | None = None
19
+
24
20
  def __init__(
25
21
  self,
26
22
  *args: object,
27
- **kwargs: Unpack[ExceptionParameters],
23
+ **kwargs: Any,
28
24
  ) -> None:
29
25
  """Instantiate the exception.
30
26
 
31
27
  Args:
32
28
  *args: The arguments.
33
- message: The message.
34
- level: The logging level.
35
29
  **kwargs: The keyword arguments.
36
30
 
37
31
  """
32
+ # If Default Message is not set, try to extract it from docstring (first line)
33
+ default_message: str = self.DEFAULT_MESSAGE or "An error occurred"
34
+ if self.DEFAULT_MESSAGE is None and self.__doc__ is not None:
35
+ default_message = self.__doc__.split("\n", maxsplit=1)[0]
38
36
  # Extract the message and the level from the kwargs if they are present
39
- self.message: str | None = kwargs.pop("message", None)
40
- self.level: int = kwargs.pop("level", logging.ERROR)
37
+ self.message: str | None = cast(str | None, kwargs.pop("message", None))
38
+ self.level: int = cast(int, kwargs.pop("level", self.DEFAULT_LOGGING_LEVEL))
41
39
 
42
40
  # If the message is not present, try to extract it from the args
43
41
  if self.message is None and len(args) > 0 and isinstance(args[0], str):
44
42
  self.message = args[0]
43
+ elif self.message is None:
44
+ self.message = default_message
45
45
 
46
46
  # Log the Exception
47
47
  if self.message:
48
48
  _logger.log(level=self.level, event=self.message)
49
49
 
50
+ # Set the kwargs as attributes of the exception
51
+ for key, value in kwargs.items():
52
+ if key in self.FILTERED_ATTRIBUTES:
53
+ continue
54
+ setattr(self, key, value)
55
+
50
56
  try:
51
57
  # Propagate the exception
52
58
  span: Span = get_current_span()
@@ -55,8 +61,10 @@ class FastAPIFactoryUtilitiesError(Exception):
55
61
  if span.is_recording():
56
62
  span.record_exception(self)
57
63
  for key, value in kwargs.items():
64
+ if key in self.FILTERED_ATTRIBUTES:
65
+ continue
58
66
  attribute_value: AttributeValue
59
- if not isinstance(value, (str, bool, int, float, Sequence)):
67
+ if not isinstance(value, (str, bool, int, float)):
60
68
  attribute_value = str(value)
61
69
  else:
62
70
  attribute_value = value
@@ -2,9 +2,7 @@
2
2
 
3
3
  from typing import ClassVar, Self
4
4
 
5
- from aio_pika import Exchange as AiopikaExchange
6
- from aio_pika import ExchangeType
7
- from aio_pika.abc import TimeoutType
5
+ from aio_pika.abc import AbstractExchange, ExchangeType, TimeoutType
8
6
 
9
7
  from .abstract import AbstractAiopikaResource
10
8
  from .exceptions import AiopikaPluginBaseError, AiopikaPluginExchangeNotDeclaredError
@@ -34,11 +32,11 @@ class Exchange(AbstractAiopikaResource):
34
32
  self._internal: bool = internal
35
33
  self._passive: bool = passive
36
34
  self._timeout: TimeoutType = timeout
37
- self._aiopika_exchange: AiopikaExchange | None = None
35
+ self._aiopika_exchange: AbstractExchange | None = None
38
36
  self._is_declared: bool = False
39
37
 
40
38
  @property
41
- def exchange(self) -> AiopikaExchange:
39
+ def exchange(self) -> AbstractExchange:
42
40
  """Get the Aiopika exchange."""
43
41
  if self._aiopika_exchange is None:
44
42
  raise AiopikaPluginExchangeNotDeclaredError(message="Exchange not declared.", exchange=self._name)
@@ -46,6 +44,7 @@ class Exchange(AbstractAiopikaResource):
46
44
 
47
45
  async def _declare(self) -> Self:
48
46
  """Declare the exchange."""
47
+ assert self._channel is not None
49
48
  try:
50
49
  self._aiopika_exchange = await self._channel.declare_exchange( # pyright: ignore
51
50
  name=self._name,
@@ -4,8 +4,7 @@ from abc import abstractmethod
4
4
  from collections.abc import Awaitable, Callable
5
5
  from typing import Any, ClassVar, Generic, Self, TypeVar, cast, get_args
6
6
 
7
- from aio_pika.abc import ConsumerTag, TimeoutType
8
- from aio_pika.message import IncomingMessage
7
+ from aio_pika.abc import AbstractIncomingMessage, ConsumerTag, TimeoutType
9
8
 
10
9
  from ..abstract import AbstractAiopikaResource
11
10
  from ..message import AbstractMessage
@@ -36,12 +35,13 @@ class AbstractListener(AbstractAiopikaResource, Generic[GenericMessage]):
36
35
 
37
36
  async def listen(self) -> None:
38
37
  """Listen for messages."""
38
+ assert self._queue.queue is not None
39
39
  self._consumer_tag = await self._queue.queue.consume( # pyright: ignore
40
- callback=cast(Callable[[IncomingMessage], Awaitable[Any]], self._on_message), # pyright: ignore
40
+ callback=cast(Callable[[AbstractIncomingMessage], Awaitable[Any]], self._on_message), # pyright: ignore
41
41
  exclusive=True,
42
42
  )
43
43
 
44
- async def _on_message(self, incoming_message: IncomingMessage) -> None:
44
+ async def _on_message(self, incoming_message: AbstractIncomingMessage) -> None:
45
45
  """On message."""
46
46
  message: GenericMessage = self._message_type.model_validate_json(incoming_message.body)
47
47
  message.set_incoming_message(incoming_message=incoming_message)
@@ -3,8 +3,8 @@
3
3
  from enum import StrEnum, auto
4
4
  from typing import ClassVar, Generic, TypeVar
5
5
 
6
- from aio_pika.abc import DeliveryMode, HeadersType
7
- from aio_pika.message import IncomingMessage, Message
6
+ from aio_pika.abc import AbstractIncomingMessage, DeliveryMode, HeadersType
7
+ from aio_pika.message import Message
8
8
  from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
9
9
 
10
10
  GenericMessageData = TypeVar("GenericMessageData", bound=BaseModel)
@@ -35,7 +35,7 @@ class AbstractMessage(BaseModel, Generic[GenericMessageData]):
35
35
  sender: SenderModel = Field(description="The sender of the message.")
36
36
  data: GenericMessageData = Field(description="The data of the message.")
37
37
 
38
- _incoming_message: IncomingMessage | None = PrivateAttr()
38
+ _incoming_message: AbstractIncomingMessage | None = PrivateAttr()
39
39
  _headers: HeadersType = PrivateAttr(default_factory=dict)
40
40
 
41
41
  def get_headers(self) -> HeadersType:
@@ -46,7 +46,7 @@ class AbstractMessage(BaseModel, Generic[GenericMessageData]):
46
46
  """Set the headers of the message."""
47
47
  self._headers = headers
48
48
 
49
- def set_incoming_message(self, incoming_message: IncomingMessage) -> None:
49
+ def set_incoming_message(self, incoming_message: AbstractIncomingMessage) -> None:
50
50
  """Set the incoming message."""
51
51
  self._incoming_message = incoming_message
52
52
  self.set_headers(headers=incoming_message.headers)
@@ -49,6 +49,7 @@ class Queue(AbstractAiopikaResource):
49
49
 
50
50
  async def _declare(self) -> Self:
51
51
  """Declare the queue."""
52
+ assert self._channel is not None
52
53
  try:
53
54
  self._queue = await self._channel.declare_queue( # pyright: ignore
54
55
  name=self._name,
@@ -65,6 +66,7 @@ class Queue(AbstractAiopikaResource):
65
66
 
66
67
  async def _bind(self) -> Self:
67
68
  """Bind the queue to the exchange."""
69
+ assert self._queue is not None
68
70
  try:
69
71
  await self._queue.bind( # pyright: ignore
70
72
  exchange=self._exchange.exchange,
@@ -2,12 +2,14 @@
2
2
 
3
3
  from importlib.util import find_spec
4
4
 
5
+ from .configs import RedisCredentialsConfig
5
6
  from .depends import depends_scheduler_component
6
7
  from .exceptions import TaskiqPluginBaseError
7
8
  from .plugin import TaskiqPlugin
8
9
  from .schedulers import SchedulerComponent
9
10
 
10
11
  __all__: list[str] = [ # pylint: disable=invalid-name
12
+ "RedisCredentialsConfig",
11
13
  "SchedulerComponent",
12
14
  "TaskiqPlugin",
13
15
  "TaskiqPluginBaseError",
@@ -0,0 +1,5 @@
1
+ """Security module."""
2
+
3
+ from .abstracts import AuthenticationAbstract
4
+
5
+ __all__: list[str] = ["AuthenticationAbstract"]
@@ -0,0 +1,42 @@
1
+ """Provides the security authentication abstract classes."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from fastapi import Request
6
+
7
+
8
+ class AuthenticationAbstract(ABC):
9
+ """Authentication abstract class."""
10
+
11
+ def __init__(self, raise_exception: bool = True) -> None:
12
+ """Initialize the authentication abstract class.
13
+
14
+ Args:
15
+ raise_exception (bool): Whether to raise an exception or return None.
16
+ """
17
+ self._raise_exception: bool = raise_exception
18
+ self._errors: list[Exception] = []
19
+
20
+ def has_errors(self) -> bool:
21
+ """Check if the authentication has errors.
22
+
23
+ Returns:
24
+ bool: True if the authentication has errors, False otherwise.
25
+ """
26
+ return len(self._errors) > 0
27
+
28
+ def raise_exception(self, exception: Exception) -> None:
29
+ """Raise the exception if the authentication has errors.
30
+
31
+ Args:
32
+ exception (Exception): The exception to raise.
33
+ """
34
+ if self._raise_exception:
35
+ raise exception
36
+ else:
37
+ self._errors.append(exception)
38
+
39
+ @abstractmethod
40
+ async def authenticate(self, request: Request) -> None:
41
+ """Authenticate the request."""
42
+ raise NotImplementedError()
@@ -0,0 +1,45 @@
1
+ """Provides security-related functions for the API."""
2
+
3
+ from .configs import JWTBearerAuthenticationConfig
4
+ from .decoders import (
5
+ JWTBearerTokenDecoder,
6
+ JWTBearerTokenDecoderAbstract,
7
+ )
8
+ from .exceptions import (
9
+ InvalidJWTError,
10
+ InvalidJWTPayploadError,
11
+ JWTAuthenticationError,
12
+ MissingJWTCredentialsError,
13
+ NotVerifiedJWTError,
14
+ )
15
+ from .objects import JWTPayload
16
+ from .services import (
17
+ JWTAuthenticationService,
18
+ JWTAuthenticationServiceAbstract,
19
+ )
20
+ from .stores import JWKStoreAbstract, JWKStoreMemory
21
+ from .types import JWTToken, OAuth2Audience, OAuth2Issuer, OAuth2Scope, OAuth2Subject
22
+ from .verifiers import JWTNoneVerifier, JWTVerifierAbstract
23
+
24
+ __all__: list[str] = [
25
+ "InvalidJWTError",
26
+ "InvalidJWTPayploadError",
27
+ "JWKStoreAbstract",
28
+ "JWKStoreMemory",
29
+ "JWTAuthenticationError",
30
+ "JWTAuthenticationService",
31
+ "JWTAuthenticationServiceAbstract",
32
+ "JWTBearerAuthenticationConfig",
33
+ "JWTBearerTokenDecoder",
34
+ "JWTBearerTokenDecoderAbstract",
35
+ "JWTNoneVerifier",
36
+ "JWTPayload",
37
+ "JWTToken",
38
+ "JWTVerifierAbstract",
39
+ "MissingJWTCredentialsError",
40
+ "NotVerifiedJWTError",
41
+ "OAuth2Audience",
42
+ "OAuth2Issuer",
43
+ "OAuth2Scope",
44
+ "OAuth2Subject",
45
+ ]
@@ -0,0 +1,32 @@
1
+ """Provides the configurations for the JWT bearer token."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from jwt.algorithms import get_default_algorithms, requires_cryptography
6
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
7
+
8
+
9
+ class JWTBearerAuthenticationConfig(BaseModel):
10
+ """JWT bearer token authentication configuration."""
11
+
12
+ model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True, extra="forbid")
13
+
14
+ authorized_algorithms: list[str] = Field(
15
+ default_factory=lambda: list(get_default_algorithms().keys()), description="The authorized algorithms."
16
+ )
17
+
18
+ audience: str = Field(description="The audience to be included in the JWT token.")
19
+ authorized_audiences: list[str] | None = Field(default=None, description="The authorized audiences.")
20
+ authorized_issuers: list[str] | None = Field(default=None, description="The authorized issuers.")
21
+
22
+ @field_validator("authorized_algorithms")
23
+ @classmethod
24
+ def validate_authorized_algorithms(cls, v: list[str]) -> list[str]:
25
+ """Validate the authorized algorithms."""
26
+ invalid_algorithms: list[str] = []
27
+ for algorithm in v:
28
+ if algorithm not in requires_cryptography:
29
+ invalid_algorithms.append(algorithm)
30
+ if invalid_algorithms:
31
+ raise ValueError(f"Invalid algorithms: {invalid_algorithms}")
32
+ return v
@@ -0,0 +1,130 @@
1
+ """Provides the JWT bearer token decoders.
2
+
3
+ Can be implemented to support different JWT bearer token formats or additional claims.
4
+ https://www.iana.org/assignments/jwt/jwt.xhtml#claims
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any, Generic, TypeVar
9
+
10
+ from jwt import InvalidTokenError, decode, get_unverified_header
11
+ from jwt.api_jwk import PyJWK
12
+ from pydantic import ValidationError
13
+
14
+ from .configs import JWTBearerAuthenticationConfig
15
+ from .exceptions import InvalidJWTError, InvalidJWTPayploadError
16
+ from .objects import JWTPayload
17
+ from .stores import JWKStoreAbstract
18
+ from .types import JWTToken, OAuth2Subject
19
+
20
+ JWTBearerPayloadGeneric = TypeVar("JWTBearerPayloadGeneric", bound=JWTPayload)
21
+
22
+
23
+ async def decode_jwt_token_payload(
24
+ jwt_token: JWTToken,
25
+ public_key: PyJWK,
26
+ jwt_bearer_authentication_config: JWTBearerAuthenticationConfig,
27
+ subject: OAuth2Subject | None = None,
28
+ ) -> dict[str, Any]:
29
+ """Decode the JWT bearer token payload.
30
+
31
+ Args:
32
+ jwt_token (JWTToken): The JWT bearer token.
33
+ public_key (PyJWK): The public key.
34
+ jwt_bearer_authentication_config (JWTBearerAuthenticationConfig): The JWT bearer authentication configuration.
35
+ subject (OAuth2Subject | None): The subject.
36
+
37
+ Returns:
38
+ dict[str, Any]: The decoded JWT bearer token payload.
39
+
40
+ Raises:
41
+ JWTBearerTokenDecoderError: If the JWT bearer token is invalid.
42
+ """
43
+ # Additional kwargs for the decode function
44
+ kwargs: dict[str, Any] = {}
45
+ if jwt_bearer_authentication_config.authorized_issuers:
46
+ kwargs["issuer"] = jwt_bearer_authentication_config.authorized_issuers
47
+ if jwt_bearer_authentication_config.authorized_audiences:
48
+ kwargs["audience"] = jwt_bearer_authentication_config.authorized_audiences
49
+ if subject:
50
+ kwargs["subject"] = subject
51
+ # Decode the JWT bearer token payload
52
+ try:
53
+ return decode(
54
+ jwt=jwt_token,
55
+ key=public_key,
56
+ algorithms=jwt_bearer_authentication_config.authorized_algorithms,
57
+ options={"verify_signature": True},
58
+ **kwargs,
59
+ )
60
+ except InvalidTokenError as e:
61
+ raise InvalidJWTError("Failed to decode the JWT bearer token payload") from e
62
+
63
+
64
+ class JWTBearerTokenDecoderAbstract(ABC, Generic[JWTBearerPayloadGeneric]):
65
+ """JWT bearer token decoder."""
66
+
67
+ def get_kid_from_jwt_unsafe_header(self, jwt_token: JWTToken) -> str:
68
+ """Get the kid from the JWT header.
69
+
70
+ Args:
71
+ jwt_token (JWTToken): The JWT bearer token.
72
+
73
+ Returns:
74
+ str: The kid.
75
+ """
76
+ try:
77
+ jwt_unsafe_headers: dict[str, Any] = get_unverified_header(jwt_token)
78
+ return jwt_unsafe_headers["kid"]
79
+ except (KeyError, InvalidTokenError) as e:
80
+ raise InvalidJWTError("Failed to get the kid from the JWT header") from e
81
+
82
+ @abstractmethod
83
+ async def decode_payload(self, jwt_token: JWTToken) -> JWTBearerPayloadGeneric:
84
+ """Decode the JWT bearer token payload.
85
+
86
+ Args:
87
+ jwt_token (JWTToken): The JWT bearer token.
88
+
89
+ Returns:
90
+ JWTBearerPayloadGeneric: The decoded JWT bearer token payload.
91
+
92
+ Raises:
93
+ InvalidJWTError: If the JWT bearer token is invalid.
94
+ InvalidJWTPayploadError: If the JWT bearer token payload is invalid.
95
+ """
96
+ raise NotImplementedError()
97
+
98
+
99
+ class JWTBearerTokenDecoder(JWTBearerTokenDecoderAbstract[JWTPayload]):
100
+ """JWT bearer token classic decoder."""
101
+
102
+ def __init__(
103
+ self, jwt_bearer_authentication_config: JWTBearerAuthenticationConfig, jwks_store: JWKStoreAbstract
104
+ ) -> None:
105
+ """Initialize the JWT bearer token classic decoder.
106
+
107
+ Args:
108
+ jwt_bearer_authentication_config (JWTBearerAuthenticationConfig): The JWT bearer authentication
109
+ configuration.
110
+ jwks_store (JWKStoreAbstract): The JWKS store.
111
+ """
112
+ self._jwt_bearer_authentication_config: JWTBearerAuthenticationConfig = jwt_bearer_authentication_config
113
+ self._jwks_store: JWKStoreAbstract = jwks_store
114
+
115
+ async def decode_payload(self, jwt_token: JWTToken) -> JWTPayload:
116
+ """Decode the JWT bearer token."""
117
+ # Get the kid from the JWT header
118
+ kid: str = self.get_kid_from_jwt_unsafe_header(jwt_token=jwt_token)
119
+ # Get the JWK from the JWKS store
120
+ jwk: PyJWK = await self._jwks_store.get_jwk(kid=kid)
121
+ # Decode the JWT bearer token payload
122
+ jwt_decoded: dict[str, Any] = await decode_jwt_token_payload(
123
+ jwt_token=jwt_token,
124
+ public_key=jwk,
125
+ jwt_bearer_authentication_config=self._jwt_bearer_authentication_config,
126
+ )
127
+ try:
128
+ return JWTPayload.model_validate(jwt_decoded)
129
+ except ValidationError as e:
130
+ raise InvalidJWTPayploadError("Failed to validate the JWT bearer token payload") from e
@@ -0,0 +1,23 @@
1
+ """Provides the exceptions for the JWT authentication."""
2
+
3
+ from fastapi_factory_utilities.core.exceptions import FastAPIFactoryUtilitiesError
4
+
5
+
6
+ class JWTAuthenticationError(FastAPIFactoryUtilitiesError):
7
+ """JWT authentication error."""
8
+
9
+
10
+ class MissingJWTCredentialsError(JWTAuthenticationError):
11
+ """Missing JWT authentication credentials error."""
12
+
13
+
14
+ class InvalidJWTError(JWTAuthenticationError):
15
+ """Invalid JWT authentication credentials error."""
16
+
17
+
18
+ class InvalidJWTPayploadError(JWTAuthenticationError):
19
+ """Invalid JWT payload error."""
20
+
21
+
22
+ class NotVerifiedJWTError(JWTAuthenticationError):
23
+ """Not verified JWT error."""
@@ -0,0 +1,107 @@
1
+ """Provides the JWT bearer token objects."""
2
+
3
+ import datetime
4
+ from typing import Annotated, Any, ClassVar
5
+
6
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
7
+
8
+ from .types import OAuth2Audience, OAuth2Issuer, OAuth2Scope, OAuth2Subject
9
+
10
+
11
+ def validate_string_list_field(value: Any) -> list[str]:
12
+ """Validate a string list field.
13
+
14
+ Accepts either a space-separated string or a list of strings.
15
+ Converts all values to lowercase strings.
16
+
17
+ Args:
18
+ value: Either a string (space-separated) or a list of strings.
19
+
20
+ Returns:
21
+ A list of lowercase strings.
22
+
23
+ Raises:
24
+ ValueError: If the value is not a string or list, or if the resulting list is empty.
25
+ """
26
+ cleaned_value: list[str]
27
+ if isinstance(value, str):
28
+ cleaned_value = value.split(sep=" ")
29
+ elif isinstance(value, list):
30
+ cleaned_value = [str(item) for item in value if item is not None]
31
+ else:
32
+ raise ValueError(f"Invalid value type: expected str or list, got {type(value).__name__}")
33
+ cleaned_value = [item.lower() for item in cleaned_value if item.strip()]
34
+ if len(cleaned_value) == 0:
35
+ raise ValueError("Invalid value: empty list after processing")
36
+ return cleaned_value
37
+
38
+
39
+ def validate_timestamp_field(value: Any) -> datetime.datetime:
40
+ """Validate a timestamp field.
41
+
42
+ Accepts either a Unix timestamp (int or string) or a datetime object.
43
+ Converts timestamps to UTC datetime objects.
44
+
45
+ Args:
46
+ value: Either a Unix timestamp (int or string) or a datetime object.
47
+
48
+ Returns:
49
+ A datetime object in UTC timezone.
50
+
51
+ Raises:
52
+ ValueError: If the value cannot be converted to a datetime.
53
+ """
54
+ if isinstance(value, datetime.datetime):
55
+ return value
56
+ if isinstance(value, str):
57
+ try:
58
+ value = int(value)
59
+ except ValueError as e:
60
+ raise ValueError(f"Invalid timestamp string: {value}") from e
61
+ if isinstance(value, int):
62
+ try:
63
+ return datetime.datetime.fromtimestamp(value, tz=datetime.UTC)
64
+ except (ValueError, OSError) as e:
65
+ raise ValueError(f"Invalid timestamp value: {value}") from e
66
+ raise ValueError(f"Invalid value type: expected int, str, or datetime, got {type(value).__name__}")
67
+
68
+
69
+ class JWTPayload(BaseModel):
70
+ """JWT bearer token payload.
71
+
72
+ Represents a decoded JWT bearer token with OAuth2 claims.
73
+ All fields are required and validated according to OAuth2/JWT standards.
74
+
75
+ Attributes:
76
+ scope: List of OAuth2 scopes granted by the token.
77
+ aud: List of audiences (intended recipients) of the token.
78
+ iss: The issuer of the JWT token.
79
+ exp: The expiration date/time of the JWT token (UTC).
80
+ iat: The issued at date/time of the JWT token (UTC).
81
+ nbf: The not before date/time of the JWT token (UTC).
82
+ sub: The subject (user identifier) of the JWT token.
83
+ """
84
+
85
+ model_config: ClassVar[ConfigDict] = ConfigDict(
86
+ arbitrary_types_allowed=True,
87
+ extra="ignore",
88
+ frozen=True,
89
+ )
90
+
91
+ scope: Annotated[list[OAuth2Scope], BeforeValidator(validate_string_list_field)] = Field(
92
+ description="The scope of the JWT token."
93
+ )
94
+ aud: Annotated[list[OAuth2Audience], BeforeValidator(validate_string_list_field)] = Field(
95
+ description="The audiences of the JWT token."
96
+ )
97
+ iss: OAuth2Issuer = Field(description="The issuer of the JWT token.")
98
+ exp: Annotated[datetime.datetime, BeforeValidator(validate_timestamp_field)] = Field(
99
+ description="The expiration date of the JWT token."
100
+ )
101
+ iat: Annotated[datetime.datetime, BeforeValidator(validate_timestamp_field)] = Field(
102
+ description="The issued at date of the JWT token."
103
+ )
104
+ nbf: Annotated[datetime.datetime, BeforeValidator(validate_timestamp_field)] = Field(
105
+ description="The not before date of the JWT token."
106
+ )
107
+ sub: OAuth2Subject = Field(description="The subject of the JWT token.")