fastapi-factory-utilities 0.3.3__py3-none-any.whl → 0.8.2__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.
- fastapi_factory_utilities/core/api/__init__.py +1 -1
- fastapi_factory_utilities/core/api/v1/sys/health.py +1 -1
- fastapi_factory_utilities/core/app/__init__.py +4 -4
- fastapi_factory_utilities/core/app/application.py +22 -26
- fastapi_factory_utilities/core/app/builder.py +19 -35
- fastapi_factory_utilities/core/app/config.py +2 -0
- fastapi_factory_utilities/core/app/fastapi_builder.py +3 -2
- fastapi_factory_utilities/core/exceptions.py +64 -28
- fastapi_factory_utilities/core/plugins/__init__.py +2 -31
- fastapi_factory_utilities/core/plugins/abstracts.py +40 -0
- fastapi_factory_utilities/core/plugins/aiopika/__init__.py +25 -0
- fastapi_factory_utilities/core/plugins/aiopika/abstract.py +48 -0
- fastapi_factory_utilities/core/plugins/aiopika/configs.py +85 -0
- fastapi_factory_utilities/core/plugins/aiopika/depends.py +20 -0
- fastapi_factory_utilities/core/plugins/aiopika/exceptions.py +29 -0
- fastapi_factory_utilities/core/plugins/aiopika/exchange.py +69 -0
- fastapi_factory_utilities/core/plugins/aiopika/listener/__init__.py +7 -0
- fastapi_factory_utilities/core/plugins/aiopika/listener/abstract.py +72 -0
- fastapi_factory_utilities/core/plugins/aiopika/message.py +86 -0
- fastapi_factory_utilities/core/plugins/aiopika/plugins.py +84 -0
- fastapi_factory_utilities/core/plugins/aiopika/publisher/__init__.py +7 -0
- fastapi_factory_utilities/core/plugins/aiopika/publisher/abstract.py +66 -0
- fastapi_factory_utilities/core/plugins/aiopika/queue.py +88 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/__init__.py +14 -157
- fastapi_factory_utilities/core/plugins/odm_plugin/builder.py +4 -3
- fastapi_factory_utilities/core/plugins/odm_plugin/configs.py +1 -1
- fastapi_factory_utilities/core/plugins/odm_plugin/documents.py +1 -1
- fastapi_factory_utilities/core/plugins/odm_plugin/helpers.py +16 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/plugins.py +155 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/repositories.py +12 -23
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/__init__.py +8 -115
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/instruments/__init__.py +85 -0
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/plugins.py +137 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/__init__.py +31 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/configs.py +12 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/depends.py +51 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/exceptions.py +13 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/plugin.py +41 -0
- fastapi_factory_utilities/core/plugins/taskiq_plugins/schedulers.py +187 -0
- fastapi_factory_utilities/core/protocols.py +1 -54
- fastapi_factory_utilities/core/security/__init__.py +5 -0
- fastapi_factory_utilities/core/security/abstracts.py +42 -0
- fastapi_factory_utilities/core/security/jwt/__init__.py +41 -0
- fastapi_factory_utilities/core/security/jwt/configs.py +32 -0
- fastapi_factory_utilities/core/security/jwt/decoders.py +130 -0
- fastapi_factory_utilities/core/security/jwt/exceptions.py +23 -0
- fastapi_factory_utilities/core/security/jwt/objects.py +107 -0
- fastapi_factory_utilities/core/security/jwt/services.py +176 -0
- fastapi_factory_utilities/core/security/jwt/stores.py +43 -0
- fastapi_factory_utilities/core/security/jwt/types.py +9 -0
- fastapi_factory_utilities/core/security/jwt/verifiers.py +46 -0
- fastapi_factory_utilities/core/security/kratos.py +53 -33
- fastapi_factory_utilities/core/services/hydra/__init__.py +20 -0
- fastapi_factory_utilities/core/services/hydra/exceptions.py +15 -0
- fastapi_factory_utilities/core/services/hydra/objects.py +26 -0
- fastapi_factory_utilities/core/services/hydra/services.py +200 -0
- fastapi_factory_utilities/core/services/status/__init__.py +2 -2
- fastapi_factory_utilities/core/services/status/exceptions.py +1 -1
- fastapi_factory_utilities/core/utils/status.py +2 -1
- fastapi_factory_utilities/core/utils/yaml_reader.py +1 -1
- fastapi_factory_utilities/example/app.py +15 -5
- fastapi_factory_utilities/example/entities/books/__init__.py +1 -1
- fastapi_factory_utilities/example/models/books/__init__.py +1 -1
- {fastapi_factory_utilities-0.3.3.dist-info → fastapi_factory_utilities-0.8.2.dist-info}/METADATA +21 -15
- fastapi_factory_utilities-0.8.2.dist-info/RECORD +111 -0
- {fastapi_factory_utilities-0.3.3.dist-info → fastapi_factory_utilities-0.8.2.dist-info}/WHEEL +1 -1
- fastapi_factory_utilities/core/app/plugin_manager/__init__.py +0 -15
- fastapi_factory_utilities/core/app/plugin_manager/exceptions.py +0 -33
- fastapi_factory_utilities/core/app/plugin_manager/plugin_manager.py +0 -190
- fastapi_factory_utilities/core/plugins/example/__init__.py +0 -31
- fastapi_factory_utilities/core/plugins/httpx_plugin/__init__.py +0 -31
- fastapi_factory_utilities/core/security/jwt.py +0 -158
- fastapi_factory_utilities-0.3.3.dist-info/RECORD +0 -78
- {fastapi_factory_utilities-0.3.3.dist-info → fastapi_factory_utilities-0.8.2.dist-info}/entry_points.txt +0 -0
- {fastapi_factory_utilities-0.3.3.dist-info → fastapi_factory_utilities-0.8.2.dist-info/licenses}/LICENSE +0 -0
|
@@ -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,41 @@
|
|
|
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 OAuth2Scope
|
|
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
|
+
"JWTVerifierAbstract",
|
|
38
|
+
"MissingJWTCredentialsError",
|
|
39
|
+
"NotVerifiedJWTError",
|
|
40
|
+
"OAuth2Scope",
|
|
41
|
+
]
|
|
@@ -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.")
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Provides the JWT bearer authentication service."""
|
|
2
|
+
|
|
3
|
+
from http import HTTPStatus
|
|
4
|
+
from typing import Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
from fastapi import HTTPException, Request
|
|
7
|
+
|
|
8
|
+
from fastapi_factory_utilities.core.security.abstracts import AuthenticationAbstract
|
|
9
|
+
|
|
10
|
+
from .configs import JWTBearerAuthenticationConfig
|
|
11
|
+
from .decoders import JWTBearerTokenDecoder, JWTBearerTokenDecoderAbstract
|
|
12
|
+
from .exceptions import InvalidJWTError, InvalidJWTPayploadError, MissingJWTCredentialsError, NotVerifiedJWTError
|
|
13
|
+
from .objects import JWTPayload
|
|
14
|
+
from .stores import JWKStoreAbstract
|
|
15
|
+
from .types import JWTToken
|
|
16
|
+
from .verifiers import JWTNoneVerifier, JWTVerifierAbstract
|
|
17
|
+
|
|
18
|
+
JWTBearerPayloadGeneric = TypeVar("JWTBearerPayloadGeneric", bound=JWTPayload)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JWTAuthenticationServiceAbstract(AuthenticationAbstract, Generic[JWTBearerPayloadGeneric]):
|
|
22
|
+
"""JWT authentication service.
|
|
23
|
+
|
|
24
|
+
This service is the orchestrator for the JWT bearer authentication.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
jwt_bearer_authentication_config: JWTBearerAuthenticationConfig,
|
|
30
|
+
jwt_verifier: JWTVerifierAbstract[JWTBearerPayloadGeneric],
|
|
31
|
+
jwt_decoder: JWTBearerTokenDecoderAbstract[JWTBearerPayloadGeneric],
|
|
32
|
+
raise_exception: bool = True,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Initialize the JWT bearer authentication service.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
jwt_bearer_authentication_config (JWTBearerAuthenticationConfig): The JWT bearer authentication
|
|
38
|
+
configuration.
|
|
39
|
+
jwt_verifier (JWTVerifierAbstract): The JWT bearer token verifier.
|
|
40
|
+
jwt_decoder (JWTBearerTokenDecoderAbstract[JWTBearerPayloadGeneric]): The JWT bearer token decoder.
|
|
41
|
+
raise_exception (bool, optional): Whether to raise an exception or return None. Defaults to True.
|
|
42
|
+
"""
|
|
43
|
+
# Configuration and Behavior
|
|
44
|
+
self._jwt_bearer_authentication_config: JWTBearerAuthenticationConfig = jwt_bearer_authentication_config
|
|
45
|
+
self._jwt_verifier: JWTVerifierAbstract[JWTBearerPayloadGeneric] = jwt_verifier
|
|
46
|
+
self._jwt_decoder: JWTBearerTokenDecoderAbstract[JWTBearerPayloadGeneric] = jwt_decoder
|
|
47
|
+
# Runtime variables
|
|
48
|
+
self._jwt: JWTToken | None = None
|
|
49
|
+
self._jwt_payload: JWTBearerPayloadGeneric | None = None
|
|
50
|
+
super().__init__(raise_exception=raise_exception)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def verifier(self) -> JWTVerifierAbstract[JWTBearerPayloadGeneric]:
|
|
54
|
+
"""Get the JWT bearer token verifier.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
JWTVerifierAbstract[JWTBearerPayloadGeneric]: The JWT bearer token verifier.
|
|
58
|
+
"""
|
|
59
|
+
return self._jwt_verifier
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def decoder(self) -> JWTBearerTokenDecoderAbstract[JWTBearerPayloadGeneric]:
|
|
63
|
+
"""Get the JWT bearer token decoder.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
JWTBearerTokenDecoderAbstract[JWTBearerPayloadGeneric]: The JWT bearer token decoder.
|
|
67
|
+
"""
|
|
68
|
+
return self._jwt_decoder
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def extract_authorization_header_from_request(cls, request: Request) -> str:
|
|
72
|
+
"""Extract the authorization header from the request.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
request (Request): The request object.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
str: The authorization header.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
MissingJWTCredentialsError: If the authorization header is missing.
|
|
82
|
+
"""
|
|
83
|
+
authorization_header: str | None = request.headers.get("Authorization", None)
|
|
84
|
+
if not authorization_header:
|
|
85
|
+
raise MissingJWTCredentialsError(message="Missing Credentials")
|
|
86
|
+
return authorization_header
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def extract_bearer_token_from_authorization_header(cls, authorization_header: str) -> JWTToken:
|
|
90
|
+
"""Extract the bearer token from the authorization header.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
authorization_header (str): The authorization header.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
JWTToken: The bearer token.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
InvalidJWTError: If the authorization header is invalid.
|
|
100
|
+
"""
|
|
101
|
+
if not authorization_header.startswith("Bearer "):
|
|
102
|
+
raise InvalidJWTError(message="Invalid Credentials")
|
|
103
|
+
return JWTToken(authorization_header.split(sep=" ")[1])
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def payload(self) -> JWTBearerPayloadGeneric | None:
|
|
107
|
+
"""Get the JWT bearer payload.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
JWTBearerPayloadGeneric | None: The JWT bearer payload, or None if not authenticated yet.
|
|
111
|
+
"""
|
|
112
|
+
return self._jwt_payload
|
|
113
|
+
|
|
114
|
+
async def authenticate(self, request: Request) -> None:
|
|
115
|
+
"""Authenticate the JWT bearer token.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
request (Request): The request object.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
None: If the authentication is successful or not raise_exception is False.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
MissingJWTCredentialsError: If the authorization header is missing.
|
|
125
|
+
InvalidJWTError: If the authorization header is invalid.
|
|
126
|
+
InvalidJWTPayploadError: If the JWT bearer token payload is invalid.
|
|
127
|
+
NotVerifiedJWTError: If the JWT bearer token is not verified.
|
|
128
|
+
"""
|
|
129
|
+
authorization_header: str
|
|
130
|
+
try:
|
|
131
|
+
authorization_header = self.extract_authorization_header_from_request(request=request)
|
|
132
|
+
self._jwt = self.extract_bearer_token_from_authorization_header(authorization_header=authorization_header)
|
|
133
|
+
except (MissingJWTCredentialsError, InvalidJWTError) as e:
|
|
134
|
+
return self.raise_exception(HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)))
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
self._jwt_payload = await self._jwt_decoder.decode_payload(jwt_token=self._jwt)
|
|
138
|
+
except (InvalidJWTError, InvalidJWTPayploadError) as e:
|
|
139
|
+
return self.raise_exception(HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e)))
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
await self._jwt_verifier.verify(jwt_token=self._jwt, jwt_payload=self._jwt_payload)
|
|
143
|
+
except NotVerifiedJWTError as e:
|
|
144
|
+
return self.raise_exception(HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e)))
|
|
145
|
+
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class JWTAuthenticationService(JWTAuthenticationServiceAbstract[JWTPayload]):
|
|
150
|
+
"""JWT bearer authentication service."""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
jwt_bearer_authentication_config: JWTBearerAuthenticationConfig,
|
|
155
|
+
jwks_store: JWKStoreAbstract,
|
|
156
|
+
raise_exception: bool = True,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Initialize the JWT bearer authentication service.
|
|
159
|
+
|
|
160
|
+
Don't enforce the public_key from configuration, for the developper to
|
|
161
|
+
provide it through the dependency injection freely from any source.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
jwt_bearer_authentication_config (JWTBearerAuthenticationConfig): The JWT bearer authentication
|
|
165
|
+
configuration.
|
|
166
|
+
jwks_store (JWKStoreAbstract): The JWKS store.
|
|
167
|
+
raise_exception (bool, optional): Whether to raise an exception or return None. Defaults to True.
|
|
168
|
+
"""
|
|
169
|
+
super().__init__(
|
|
170
|
+
jwt_bearer_authentication_config=jwt_bearer_authentication_config,
|
|
171
|
+
jwt_verifier=JWTNoneVerifier(),
|
|
172
|
+
jwt_decoder=JWTBearerTokenDecoder(
|
|
173
|
+
jwt_bearer_authentication_config=jwt_bearer_authentication_config, jwks_store=jwks_store
|
|
174
|
+
),
|
|
175
|
+
raise_exception=raise_exception,
|
|
176
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Provides the JWK stores."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from asyncio import Lock
|
|
5
|
+
|
|
6
|
+
from jwt.api_jwk import PyJWK, PyJWKSet
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JWKStoreAbstract(ABC):
|
|
10
|
+
"""JWK store abstract class."""
|
|
11
|
+
|
|
12
|
+
async def get_jwk(self, kid: str) -> PyJWK:
|
|
13
|
+
"""Get the JWK from the store."""
|
|
14
|
+
return (await self.get_jwks())[kid]
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def get_jwks(self) -> PyJWKSet:
|
|
18
|
+
"""Get the JWKS from the store."""
|
|
19
|
+
raise NotImplementedError()
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
async def store_jwks(self, jwks: PyJWKSet) -> None:
|
|
23
|
+
"""Store the JWKS in the store."""
|
|
24
|
+
raise NotImplementedError()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class JWKStoreMemory(JWKStoreAbstract):
|
|
28
|
+
"""JWK store in memory. Concurrent safe."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
"""Initialize the JWK store in memory."""
|
|
32
|
+
self._jwks: PyJWKSet = PyJWKSet([])
|
|
33
|
+
self._lock: Lock = Lock()
|
|
34
|
+
|
|
35
|
+
async def get_jwks(self) -> PyJWKSet:
|
|
36
|
+
"""Get the JWKS from the store."""
|
|
37
|
+
async with self._lock:
|
|
38
|
+
return self._jwks
|
|
39
|
+
|
|
40
|
+
async def store_jwks(self, jwks: PyJWKSet) -> None:
|
|
41
|
+
"""Store the JWKS in the store."""
|
|
42
|
+
async with self._lock:
|
|
43
|
+
self._jwks = jwks
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Provides the JWT bearer token types."""
|
|
2
|
+
|
|
3
|
+
from typing import NewType
|
|
4
|
+
|
|
5
|
+
JWTToken = NewType("JWTToken", str)
|
|
6
|
+
OAuth2Scope = NewType("OAuth2Scope", str)
|
|
7
|
+
OAuth2Audience = NewType("OAuth2Audience", str)
|
|
8
|
+
OAuth2Issuer = NewType("OAuth2Issuer", str)
|
|
9
|
+
OAuth2Subject = NewType("OAuth2Subject", str)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Provides the JWT bearer token validator."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
from .objects import JWTPayload
|
|
7
|
+
from .types import JWTToken
|
|
8
|
+
|
|
9
|
+
JWTBearerPayloadGeneric = TypeVar("JWTBearerPayloadGeneric", bound=JWTPayload)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JWTVerifierAbstract(ABC, Generic[JWTBearerPayloadGeneric]):
|
|
13
|
+
"""JWT verifier."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def verify(
|
|
17
|
+
self,
|
|
18
|
+
jwt_token: JWTToken,
|
|
19
|
+
jwt_payload: JWTBearerPayloadGeneric,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Verify the JWT bearer token.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
jwt_token (JWTToken): The JWT bearer token.
|
|
25
|
+
jwt_payload (JWTBearerPayloadGeneric): The JWT bearer payload.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
NotVerifiedJWTError: If the JWT bearer token is not verified.
|
|
29
|
+
"""
|
|
30
|
+
raise NotImplementedError()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class JWTNoneVerifier(JWTVerifierAbstract[JWTPayload]):
|
|
34
|
+
"""JWT none verifier."""
|
|
35
|
+
|
|
36
|
+
async def verify(self, jwt_token: JWTToken, jwt_payload: JWTPayload) -> None:
|
|
37
|
+
"""Verify the JWT bearer token.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
jwt_token (JWTToken): The JWT bearer token.
|
|
41
|
+
jwt_payload (JWTBearerPayload): The JWT bearer payload.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
NotVerifiedJWTError: If the JWT bearer token is not verified.
|
|
45
|
+
"""
|
|
46
|
+
return
|