fastapi-factory-utilities 0.3.6__py3-none-any.whl → 0.9.1__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 -29
- 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 +14 -29
- fastapi_factory_utilities/core/plugins/odm_plugin/configs.py +1 -1
- fastapi_factory_utilities/core/plugins/odm_plugin/depends.py +4 -3
- 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 +153 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/repositories.py +17 -15
- 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 +43 -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 +43 -43
- 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.6.dist-info → fastapi_factory_utilities-0.9.1.dist-info}/METADATA +21 -15
- fastapi_factory_utilities-0.9.1.dist-info/RECORD +111 -0
- {fastapi_factory_utilities-0.3.6.dist-info → fastapi_factory_utilities-0.9.1.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.6.dist-info/RECORD +0 -78
- {fastapi_factory_utilities-0.3.6.dist-info → fastapi_factory_utilities-0.9.1.dist-info}/entry_points.txt +0 -0
- {fastapi_factory_utilities-0.3.6.dist-info → fastapi_factory_utilities-0.9.1.dist-info/licenses}/LICENSE +0 -0
|
@@ -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
|
|
@@ -1,41 +1,37 @@
|
|
|
1
1
|
"""Provide Kratos Session and Identity classes."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from typing import Annotated
|
|
3
|
+
from http import HTTPStatus
|
|
5
4
|
|
|
6
|
-
from fastapi import
|
|
5
|
+
from fastapi import HTTPException, Request
|
|
7
6
|
|
|
7
|
+
from fastapi_factory_utilities.core.security.abstracts import AuthenticationAbstract
|
|
8
8
|
from fastapi_factory_utilities.core.services.kratos import (
|
|
9
9
|
KratosOperationError,
|
|
10
10
|
KratosService,
|
|
11
11
|
KratosSessionInvalidError,
|
|
12
12
|
KratosSessionObject,
|
|
13
|
-
depends_kratos_service,
|
|
14
13
|
)
|
|
15
14
|
|
|
16
15
|
|
|
17
|
-
class
|
|
18
|
-
"""Kratos Session Authentication Errors."""
|
|
19
|
-
|
|
20
|
-
MISSING_CREDENTIALS = "Missing Credentials"
|
|
21
|
-
INVALID_CREDENTIALS = "Invalid Credentials"
|
|
22
|
-
INTERNAL_SERVER_ERROR = "Internal Server Error"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class KratosSessionAuthentication:
|
|
16
|
+
class KratosSessionAuthenticationService(AuthenticationAbstract):
|
|
26
17
|
"""Kratos Session class."""
|
|
27
18
|
|
|
28
19
|
DEFAULT_COOKIE_NAME: str = "ory_kratos_session"
|
|
29
20
|
|
|
30
|
-
def __init__(
|
|
21
|
+
def __init__(
|
|
22
|
+
self, kratos_service: KratosService, cookie_name: str = DEFAULT_COOKIE_NAME, raise_exception: bool = True
|
|
23
|
+
) -> None:
|
|
31
24
|
"""Initialize the KratosSessionAuthentication class.
|
|
32
25
|
|
|
33
26
|
Args:
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
kratos_service (KratosService): Kratos service object.
|
|
28
|
+
cookie_name (str): Name of the cookie to extract the session.
|
|
29
|
+
raise_exception (bool): Whether to raise an exception or return None.
|
|
36
30
|
"""
|
|
31
|
+
self._kratos_service: KratosService = kratos_service
|
|
37
32
|
self._cookie_name: str = cookie_name
|
|
38
|
-
self.
|
|
33
|
+
self._session: KratosSessionObject
|
|
34
|
+
super().__init__(raise_exception=raise_exception)
|
|
39
35
|
|
|
40
36
|
def _extract_cookie(self, request: Request) -> str | None:
|
|
41
37
|
"""Extract the cookie from the request.
|
|
@@ -51,9 +47,16 @@ class KratosSessionAuthentication:
|
|
|
51
47
|
"""
|
|
52
48
|
return request.cookies.get(self._cookie_name, None)
|
|
53
49
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
@property
|
|
51
|
+
def session(self) -> KratosSessionObject:
|
|
52
|
+
"""Get the Kratos session.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
KratosSessionObject: Kratos session object.
|
|
56
|
+
"""
|
|
57
|
+
return self._session
|
|
58
|
+
|
|
59
|
+
async def authenticate(self, request: Request) -> None:
|
|
57
60
|
"""Extract the Kratos session from the request.
|
|
58
61
|
|
|
59
62
|
Args:
|
|
@@ -61,38 +64,35 @@ class KratosSessionAuthentication:
|
|
|
61
64
|
kratos_service (KratosService): Kratos service object.
|
|
62
65
|
|
|
63
66
|
Returns:
|
|
64
|
-
|
|
67
|
+
None: If the authentication is successful or not raise_exception is False.
|
|
65
68
|
|
|
66
69
|
Raises:
|
|
67
70
|
HTTPException: If the session is invalid and raise_exception is True.
|
|
68
71
|
"""
|
|
69
72
|
cookie: str | None = self._extract_cookie(request)
|
|
70
73
|
if not cookie:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
status_code=
|
|
74
|
-
detail=
|
|
74
|
+
return self.raise_exception(
|
|
75
|
+
HTTPException(
|
|
76
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
77
|
+
detail="Missing Credentials",
|
|
75
78
|
)
|
|
76
|
-
|
|
77
|
-
return KratosSessionAuthenticationErrors.MISSING_CREDENTIALS
|
|
79
|
+
)
|
|
78
80
|
|
|
79
81
|
try:
|
|
80
|
-
|
|
81
|
-
except KratosSessionInvalidError
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
status_code=
|
|
82
|
+
self._session = await self._kratos_service.whoami(cookie_value=cookie)
|
|
83
|
+
except KratosSessionInvalidError:
|
|
84
|
+
return self.raise_exception(
|
|
85
|
+
HTTPException(
|
|
86
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
85
87
|
detail="Invalid Credentials",
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
status_code=500,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
except KratosOperationError:
|
|
91
|
+
return self.raise_exception(
|
|
92
|
+
HTTPException(
|
|
93
|
+
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
93
94
|
detail="Internal Server Error",
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
return KratosSessionAuthenticationErrors.INTERNAL_SERVER_ERROR
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
97
|
|
|
98
|
-
return
|
|
98
|
+
return
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Hydra service module."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import HydraOperationError, HydraTokenInvalidError
|
|
4
|
+
from .objects import HydraTokenIntrospectObject
|
|
5
|
+
from .services import (
|
|
6
|
+
HydraIntrospectService,
|
|
7
|
+
HydraOAuth2ClientCredentialsService,
|
|
8
|
+
depends_hydra_introspect_service,
|
|
9
|
+
depends_hydra_oauth2_client_credentials_service,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__: list[str] = [
|
|
13
|
+
"HydraIntrospectService",
|
|
14
|
+
"HydraOAuth2ClientCredentialsService",
|
|
15
|
+
"HydraOperationError",
|
|
16
|
+
"HydraTokenIntrospectObject",
|
|
17
|
+
"HydraTokenInvalidError",
|
|
18
|
+
"depends_hydra_introspect_service",
|
|
19
|
+
"depends_hydra_oauth2_client_credentials_service",
|
|
20
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Python exceptions for the Hydra service."""
|
|
2
|
+
|
|
3
|
+
from fastapi_factory_utilities.core.exceptions import FastAPIFactoryUtilitiesError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HydraError(FastAPIFactoryUtilitiesError):
|
|
7
|
+
"""Base class for all exceptions raised by the Hydra service."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HydraOperationError(HydraError):
|
|
11
|
+
"""Exception raised when a Hydra operation fails."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HydraTokenInvalidError(HydraError):
|
|
15
|
+
"""Exception raised when a Hydra token is invalid."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Provides the objects for the Hydra service."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HydraTokenIntrospectObject(BaseModel):
|
|
9
|
+
"""Represents the object returned by the Hydra token introspection."""
|
|
10
|
+
|
|
11
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
|
|
12
|
+
|
|
13
|
+
active: bool
|
|
14
|
+
aud: list[str]
|
|
15
|
+
client_id: str
|
|
16
|
+
exp: int
|
|
17
|
+
ext: dict[str, str] | None = None
|
|
18
|
+
iat: int
|
|
19
|
+
iss: str
|
|
20
|
+
nbf: int
|
|
21
|
+
obfuscated_subject: str | None = None
|
|
22
|
+
scope: str
|
|
23
|
+
sub: str
|
|
24
|
+
token_type: str
|
|
25
|
+
token_use: str
|
|
26
|
+
username: str | None = None
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Provides a service to interact with the Hydra service."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
from typing import Annotated, Any, Generic, TypeVar, get_args
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
import jwt
|
|
10
|
+
from fastapi import Depends
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from fastapi_factory_utilities.core.app import (
|
|
14
|
+
DependencyConfig,
|
|
15
|
+
HttpServiceDependencyConfig,
|
|
16
|
+
depends_dependency_config,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .exceptions import HydraOperationError
|
|
20
|
+
from .objects import HydraTokenIntrospectObject
|
|
21
|
+
|
|
22
|
+
HydraIntrospectObjectGeneric = TypeVar("HydraIntrospectObjectGeneric", bound=HydraTokenIntrospectObject)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HydraIntrospectGenericService(Generic[HydraIntrospectObjectGeneric]):
|
|
26
|
+
"""Service to interact with the Hydra introspect service with a generic introspect object."""
|
|
27
|
+
|
|
28
|
+
INTROSPECT_ENDPOINT: str = "/admin/oauth2/introspect"
|
|
29
|
+
WELLKNOWN_JWKS_ENDPOINT: str = "/.well-known/jwks.json"
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
hydra_admin_http_config: HttpServiceDependencyConfig,
|
|
34
|
+
hydra_public_http_config: HttpServiceDependencyConfig,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Instanciate the Hydra introspect service.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
hydra_admin_http_config (HttpServiceDependencyConfig): The Hydra admin HTTP configuration.
|
|
40
|
+
hydra_public_http_config (HttpServiceDependencyConfig): The Hydra public HTTP configuration.
|
|
41
|
+
"""
|
|
42
|
+
self._hydra_admin_http_config: HttpServiceDependencyConfig = hydra_admin_http_config
|
|
43
|
+
self._hydra_public_http_config: HttpServiceDependencyConfig = hydra_public_http_config
|
|
44
|
+
# Retrieve the concrete introspect object class
|
|
45
|
+
generic_args: tuple[Any, ...] = get_args(self.__orig_bases__[0]) # type: ignore
|
|
46
|
+
self._concreate_introspect_object_class: type[HydraIntrospectObjectGeneric] = generic_args[0]
|
|
47
|
+
|
|
48
|
+
async def introspect(self, token: str) -> HydraIntrospectObjectGeneric:
|
|
49
|
+
"""Introspects a token using the Hydra introspect service.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
token (str): The token to introspect.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
async with aiohttp.ClientSession(
|
|
56
|
+
base_url=str(self._hydra_admin_http_config.url),
|
|
57
|
+
) as session:
|
|
58
|
+
async with session.post(
|
|
59
|
+
url=self.INTROSPECT_ENDPOINT,
|
|
60
|
+
data={"token": token},
|
|
61
|
+
) as response:
|
|
62
|
+
response.raise_for_status()
|
|
63
|
+
instrospect: HydraIntrospectObjectGeneric = self._concreate_introspect_object_class.model_validate(
|
|
64
|
+
await response.json()
|
|
65
|
+
)
|
|
66
|
+
except aiohttp.ClientResponseError as error:
|
|
67
|
+
raise HydraOperationError("Failed to introspect the token", status_code=error.status) from error
|
|
68
|
+
except json.JSONDecodeError as error:
|
|
69
|
+
raise HydraOperationError("Failed to decode the introspect response") from error
|
|
70
|
+
except ValidationError as error:
|
|
71
|
+
raise HydraOperationError("Failed to validate the introspect response") from error
|
|
72
|
+
|
|
73
|
+
return instrospect
|
|
74
|
+
|
|
75
|
+
async def get_wellknown_jwks(self) -> jwt.PyJWKSet:
|
|
76
|
+
"""Get the JWKS from the Hydra service."""
|
|
77
|
+
try:
|
|
78
|
+
async with aiohttp.ClientSession(
|
|
79
|
+
base_url=str(self._hydra_public_http_config.url),
|
|
80
|
+
) as session:
|
|
81
|
+
async with session.get(
|
|
82
|
+
url=self.WELLKNOWN_JWKS_ENDPOINT,
|
|
83
|
+
) as response:
|
|
84
|
+
response.raise_for_status()
|
|
85
|
+
jwks_data: dict[str, Any] = await response.json()
|
|
86
|
+
jwks: jwt.PyJWKSet = jwt.PyJWKSet.from_dict(jwks_data)
|
|
87
|
+
return jwks
|
|
88
|
+
except aiohttp.ClientResponseError as error:
|
|
89
|
+
raise HydraOperationError(
|
|
90
|
+
"Failed to get the JWKS from the Hydra service", status_code=error.status
|
|
91
|
+
) from error
|
|
92
|
+
except json.JSONDecodeError as error:
|
|
93
|
+
raise HydraOperationError("Failed to decode the JWKS from the Hydra service") from error
|
|
94
|
+
except ValidationError as error:
|
|
95
|
+
raise HydraOperationError("Failed to validate the JWKS from the Hydra service") from error
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class HydraIntrospectService(HydraIntrospectGenericService[HydraTokenIntrospectObject]):
|
|
99
|
+
"""Service to interact with the Hydra introspect service with the default HydraTokenIntrospectObject."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class HydraOAuth2ClientCredentialsService:
|
|
103
|
+
"""Service to interact with the Hydra service."""
|
|
104
|
+
|
|
105
|
+
INTROSPECT_ENDPOINT: str = "/admin/oauth2/introspect"
|
|
106
|
+
CLIENT_CREDENTIALS_ENDPOINT: str = "/oauth2/token"
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
hydra_public_http_config: HttpServiceDependencyConfig,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Instanciate the Hydra service.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
hydra_admin_http_config (HttpServiceDependencyConfig): The Hydra admin HTTP configuration.
|
|
116
|
+
hydra_public_http_config (HttpServiceDependencyConfig): The Hydra public HTTP configuration.
|
|
117
|
+
"""
|
|
118
|
+
self._hydra_public_http_config: HttpServiceDependencyConfig = hydra_public_http_config
|
|
119
|
+
|
|
120
|
+
async def oauth2_client_credentials(self, client_id: str, client_secret: str, scope: str) -> str:
|
|
121
|
+
"""Get the OAuth2 client credentials.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
client_id (str): The client ID.
|
|
125
|
+
client_secret (str): The client secret.
|
|
126
|
+
scope (str): The scope.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
str: The access token.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
HydraOperationError: If the client credentials request fails.
|
|
133
|
+
"""
|
|
134
|
+
# Create base64 encoded Basic Auth header
|
|
135
|
+
auth_string = f"{client_id}:{client_secret}"
|
|
136
|
+
auth_bytes = auth_string.encode("utf-8")
|
|
137
|
+
auth_b64 = b64encode(auth_bytes).decode("utf-8")
|
|
138
|
+
|
|
139
|
+
async with aiohttp.ClientSession(
|
|
140
|
+
base_url=str(self._hydra_public_http_config.url),
|
|
141
|
+
) as session:
|
|
142
|
+
async with session.post(
|
|
143
|
+
url=self.CLIENT_CREDENTIALS_ENDPOINT,
|
|
144
|
+
headers={"Authorization": f"Basic {auth_b64}"},
|
|
145
|
+
data={"grant_type": "client_credentials", "scope": scope},
|
|
146
|
+
) as response:
|
|
147
|
+
response_data = await response.json()
|
|
148
|
+
if response.status != HTTPStatus.OK:
|
|
149
|
+
raise HydraOperationError(f"Failed to get client credentials: {response_data}")
|
|
150
|
+
|
|
151
|
+
return response_data["access_token"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def depends_hydra_oauth2_client_credentials_service(
|
|
155
|
+
dependency_config: Annotated[DependencyConfig, Depends(depends_dependency_config)],
|
|
156
|
+
) -> HydraOAuth2ClientCredentialsService:
|
|
157
|
+
"""Dependency injection for the Hydra OAuth2 client credentials service.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
dependency_config (DependencyConfig): The dependency configuration.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
HydraOAuth2ClientCredentialsService: The Hydra OAuth2 client credentials service instance.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
HydraOperationError: If the Hydra public dependency is not configured.
|
|
167
|
+
"""
|
|
168
|
+
if dependency_config.hydra_public is None:
|
|
169
|
+
raise HydraOperationError(message="Hydra public dependency not configured")
|
|
170
|
+
|
|
171
|
+
return HydraOAuth2ClientCredentialsService(
|
|
172
|
+
hydra_public_http_config=dependency_config.hydra_public,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def depends_hydra_introspect_service(
|
|
177
|
+
dependency_config: Annotated[DependencyConfig, Depends(depends_dependency_config)],
|
|
178
|
+
) -> HydraIntrospectService:
|
|
179
|
+
"""Dependency injection for the Hydra introspect service.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
dependency_config (DependencyConfig): The dependency configuration.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
HydraIntrospectService: The Hydra introspect service instance.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
HydraOperationError: If the Hydra admin dependency is not configured.
|
|
189
|
+
"""
|
|
190
|
+
if getattr(dependency_config, "hydra_admin", None) is None:
|
|
191
|
+
raise HydraOperationError(message="Hydra admin dependency not configured")
|
|
192
|
+
assert dependency_config.hydra_admin is not None
|
|
193
|
+
if getattr(dependency_config, "hydra_public", None) is None:
|
|
194
|
+
raise HydraOperationError(message="Hydra public dependency not configured")
|
|
195
|
+
assert dependency_config.hydra_public is not None
|
|
196
|
+
|
|
197
|
+
return HydraIntrospectService(
|
|
198
|
+
hydra_admin_http_config=dependency_config.hydra_admin,
|
|
199
|
+
hydra_public_http_config=dependency_config.hydra_public,
|
|
200
|
+
)
|