oidcauthlib 1.0.8__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.
- oidcauthlib/__init__.py +0 -0
- oidcauthlib/auth/__init__.py +0 -0
- oidcauthlib/auth/auth_helper.py +66 -0
- oidcauthlib/auth/auth_manager.py +348 -0
- oidcauthlib/auth/cache/__init__.py +0 -0
- oidcauthlib/auth/cache/oauth_cache.py +47 -0
- oidcauthlib/auth/cache/oauth_memory_cache.py +53 -0
- oidcauthlib/auth/cache/oauth_mongo_cache.py +186 -0
- oidcauthlib/auth/config/__init__.py +0 -0
- oidcauthlib/auth/config/auth_config.py +44 -0
- oidcauthlib/auth/config/auth_config_reader.py +290 -0
- oidcauthlib/auth/exceptions/__init__.py +0 -0
- oidcauthlib/auth/exceptions/authorization_bearer_token_expired_exception.py +39 -0
- oidcauthlib/auth/exceptions/authorization_bearer_token_invalid_exception.py +23 -0
- oidcauthlib/auth/exceptions/authorization_bearer_token_missing_exception.py +22 -0
- oidcauthlib/auth/exceptions/authorization_needed_exception.py +17 -0
- oidcauthlib/auth/fastapi_auth_manager.py +267 -0
- oidcauthlib/auth/middleware/__init__.py +0 -0
- oidcauthlib/auth/middleware/request_scope_middleware.py +84 -0
- oidcauthlib/auth/middleware/token_reader_middleware.py +103 -0
- oidcauthlib/auth/models/__init__.py +0 -0
- oidcauthlib/auth/models/auth.py +40 -0
- oidcauthlib/auth/models/base_db_model.py +27 -0
- oidcauthlib/auth/models/cache_item.py +29 -0
- oidcauthlib/auth/models/client_key_set.py +21 -0
- oidcauthlib/auth/models/token.py +214 -0
- oidcauthlib/auth/repository/__init__.py +0 -0
- oidcauthlib/auth/repository/base_repository.py +94 -0
- oidcauthlib/auth/repository/memory/__init__.py +0 -0
- oidcauthlib/auth/repository/memory/memory_repository.py +156 -0
- oidcauthlib/auth/repository/mongo/__init__.py +0 -0
- oidcauthlib/auth/repository/mongo/mongo_repository.py +343 -0
- oidcauthlib/auth/repository/repository_factory.py +50 -0
- oidcauthlib/auth/routers/__init__.py +0 -0
- oidcauthlib/auth/routers/auth_router.py +225 -0
- oidcauthlib/auth/token_reader.py +421 -0
- oidcauthlib/auth/well_known_configuration/__init__.py +0 -0
- oidcauthlib/auth/well_known_configuration/well_known_configuration_cache.py +237 -0
- oidcauthlib/auth/well_known_configuration/well_known_configuration_manager.py +154 -0
- oidcauthlib/container/__init__.py +0 -0
- oidcauthlib/container/container_registry.py +62 -0
- oidcauthlib/container/inject.py +51 -0
- oidcauthlib/container/interfaces.py +39 -0
- oidcauthlib/container/oidc_authlib_container_factory.py +87 -0
- oidcauthlib/container/simple_container.py +488 -0
- oidcauthlib/py.typed +0 -0
- oidcauthlib/utilities/__init__.py +0 -0
- oidcauthlib/utilities/cached.py +25 -0
- oidcauthlib/utilities/environment/__init__.py +0 -0
- oidcauthlib/utilities/environment/abstract_environment_variables.py +63 -0
- oidcauthlib/utilities/environment/environment_variables.py +71 -0
- oidcauthlib/utilities/logger/__init__.py +0 -0
- oidcauthlib/utilities/logger/log_levels.py +43 -0
- oidcauthlib/utilities/logger/logging_response.py +38 -0
- oidcauthlib/utilities/logger/logging_transport.py +58 -0
- oidcauthlib/utilities/mongo_url_utils.py +76 -0
- oidcauthlib-1.0.8.dist-info/METADATA +33 -0
- oidcauthlib-1.0.8.dist-info/RECORD +95 -0
- oidcauthlib-1.0.8.dist-info/WHEEL +5 -0
- oidcauthlib-1.0.8.dist-info/licenses/LICENSE +201 -0
- oidcauthlib-1.0.8.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/auth/__init__.py +0 -0
- tests/auth/cache/__init__.py +0 -0
- tests/auth/cache/test_oauth_memory_cache.py +26 -0
- tests/auth/config/__init__.py +0 -0
- tests/auth/config/test_auth_config.py +32 -0
- tests/auth/config/test_auth_config_reader.py +89 -0
- tests/auth/config/test_auth_config_reader_thread_safety.py +125 -0
- tests/auth/conftest.py +70 -0
- tests/auth/middleware/__init__.py +0 -0
- tests/auth/middleware/test_request_scope_middleware.py +181 -0
- tests/auth/middleware/test_token_reader_middleware.py +671 -0
- tests/auth/models/__init__.py +0 -0
- tests/auth/models/test_auth.py +43 -0
- tests/auth/models/test_base_db_model.py +68 -0
- tests/auth/models/test_cache_item.py +17 -0
- tests/auth/models/test_token.py +48 -0
- tests/auth/repository/__init__.py +0 -0
- tests/auth/repository/memory/__init__.py +0 -0
- tests/auth/repository/memory/test_memory_repository.py +25 -0
- tests/auth/repository/mongo/__init__.py +0 -0
- tests/auth/repository/mongo/test_mongo_repository.py +908 -0
- tests/auth/test_auth_helper.py +26 -0
- tests/auth/test_auth_helper2.py +33 -0
- tests/auth/well_known_configuration/__init__.py +0 -0
- tests/auth/well_known_configuration/test_well_known_configuration_cache.py +232 -0
- tests/auth/well_known_configuration/test_well_known_configuration_manager_deadlock.py +355 -0
- tests/container/__init__.py +0 -0
- tests/container/test_simple_container.py +147 -0
- tests/test_mongo_url_utils.py +36 -0
- tests/test_simple.py +2 -0
- tests/utilities/__init__.py +0 -0
- tests/utilities/test_cached.py +52 -0
- tests/utilities/test_mongo_url_utils.py +30 -0
oidcauthlib/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from oidcauthlib.utilities.logger.log_levels import SRC_LOG_LEVELS
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
logger.setLevel(SRC_LOG_LEVELS["AUTH"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthHelper:
|
|
12
|
+
@staticmethod
|
|
13
|
+
def encode_state(content: dict[str, str | None]) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Encode the state content into a base64url encoded string.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
content: The content to encode, typically a dictionary.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A base64url encoded string of the content.
|
|
22
|
+
"""
|
|
23
|
+
json_content = json.dumps(content)
|
|
24
|
+
encoded_content = base64.urlsafe_b64encode(json_content.encode("utf-8")).decode(
|
|
25
|
+
"utf-8"
|
|
26
|
+
)
|
|
27
|
+
return encoded_content.rstrip("=")
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def decode_state(encoded_content: str) -> dict[str, str | None]:
|
|
31
|
+
"""
|
|
32
|
+
Decode a base64url encoded string back into its original dictionary form.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
encoded_content: The base64url encoded string to decode.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The decoded content as a dictionary.
|
|
39
|
+
"""
|
|
40
|
+
# Add padding if necessary
|
|
41
|
+
try:
|
|
42
|
+
if not encoded_content or not isinstance(encoded_content, str):
|
|
43
|
+
logger.error("Failed to decode state: Input is empty or not a string")
|
|
44
|
+
raise ValueError("Encoded state is empty or not a string")
|
|
45
|
+
# Fix base64 padding
|
|
46
|
+
padding_needed = (-len(encoded_content)) % 4
|
|
47
|
+
padded_content = encoded_content + ("=" * padding_needed)
|
|
48
|
+
try:
|
|
49
|
+
json_content = base64.urlsafe_b64decode(padded_content).decode("utf-8")
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.error(f"Failed to decode state (base64 error): {e}")
|
|
52
|
+
raise ValueError("Invalid base64 encoding in state") from e
|
|
53
|
+
try:
|
|
54
|
+
result = json.loads(json_content)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error(f"Failed to decode state (JSON error): {e}")
|
|
57
|
+
raise ValueError("Invalid JSON in decoded state") from e
|
|
58
|
+
if not isinstance(result, dict):
|
|
59
|
+
logger.error(
|
|
60
|
+
"Failed to decode state: Decoded state is not a dictionary"
|
|
61
|
+
)
|
|
62
|
+
raise ValueError("Decoded state is not a dictionary")
|
|
63
|
+
return result
|
|
64
|
+
except Exception:
|
|
65
|
+
# Already logged specific error above
|
|
66
|
+
raise
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any, Dict, cast, List
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
|
9
|
+
from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
|
|
10
|
+
|
|
11
|
+
from oidcauthlib.auth.auth_helper import AuthHelper
|
|
12
|
+
from oidcauthlib.auth.cache.oauth_cache import OAuthCache
|
|
13
|
+
from oidcauthlib.auth.cache.oauth_memory_cache import (
|
|
14
|
+
OAuthMemoryCache,
|
|
15
|
+
)
|
|
16
|
+
from oidcauthlib.auth.cache.oauth_mongo_cache import OAuthMongoCache
|
|
17
|
+
from oidcauthlib.auth.config.auth_config import AuthConfig
|
|
18
|
+
from oidcauthlib.auth.config.auth_config_reader import (
|
|
19
|
+
AuthConfigReader,
|
|
20
|
+
)
|
|
21
|
+
from oidcauthlib.auth.exceptions.authorization_needed_exception import (
|
|
22
|
+
AuthorizationNeededException,
|
|
23
|
+
)
|
|
24
|
+
from oidcauthlib.auth.token_reader import TokenReader
|
|
25
|
+
from oidcauthlib.auth.well_known_configuration.well_known_configuration_manager import (
|
|
26
|
+
WellKnownConfigurationManager,
|
|
27
|
+
)
|
|
28
|
+
from oidcauthlib.utilities.environment.abstract_environment_variables import (
|
|
29
|
+
AbstractEnvironmentVariables,
|
|
30
|
+
)
|
|
31
|
+
from oidcauthlib.utilities.logger.log_levels import SRC_LOG_LEVELS
|
|
32
|
+
from oidcauthlib.utilities.logger.logging_transport import (
|
|
33
|
+
LoggingTransport,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
logger.setLevel(SRC_LOG_LEVELS["AUTH"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthManager:
|
|
41
|
+
"""
|
|
42
|
+
AuthManager is responsible for managing authentication using OIDC PKCE.
|
|
43
|
+
|
|
44
|
+
It initializes the OAuth client with the necessary configuration and provides methods
|
|
45
|
+
to create authorization URLs and handle callback responses.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
environment_variables: AbstractEnvironmentVariables,
|
|
52
|
+
auth_config_reader: AuthConfigReader,
|
|
53
|
+
token_reader: TokenReader,
|
|
54
|
+
well_known_configuration_manager: WellKnownConfigurationManager,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Initialize the AuthManager with the necessary configuration for OIDC PKCE.
|
|
58
|
+
It sets up the OAuth cache, reads environment variables for the OIDC provider,
|
|
59
|
+
and configures the OAuth client.
|
|
60
|
+
The environment variables required are:
|
|
61
|
+
- MONGO_URL: The connection string for the MongoDB database.
|
|
62
|
+
- MONGO_DB_NAME: The name of the MongoDB database.
|
|
63
|
+
- MONGO_DB_TOKEN_COLLECTION_NAME: The name of the MongoDB collection for tokens.
|
|
64
|
+
It also initializes the OAuth cache based on the OAUTH_CACHE environment variable,
|
|
65
|
+
which can be set to "memory" for in-memory caching or "mongo" for MongoDB caching.
|
|
66
|
+
If the OAUTH_CACHE environment variable is not set, it defaults to "memory".
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
environment_variables (AbstractEnvironmentVariables): The environment variables for the application.
|
|
70
|
+
auth_config_reader (AuthConfigReader): The reader for authentication configurations.
|
|
71
|
+
token_reader (TokenReader): The reader for tokens.
|
|
72
|
+
"""
|
|
73
|
+
self.environment_variables: AbstractEnvironmentVariables = environment_variables
|
|
74
|
+
if self.environment_variables is None:
|
|
75
|
+
raise ValueError("environment_variables must not be None")
|
|
76
|
+
if not isinstance(self.environment_variables, AbstractEnvironmentVariables):
|
|
77
|
+
raise TypeError(
|
|
78
|
+
"environment_variables must be an instance of EnvironmentVariables"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.auth_config_reader: AuthConfigReader = auth_config_reader
|
|
82
|
+
if self.auth_config_reader is None:
|
|
83
|
+
raise ValueError("auth_config_reader must not be None")
|
|
84
|
+
if not isinstance(self.auth_config_reader, AuthConfigReader):
|
|
85
|
+
raise TypeError(
|
|
86
|
+
"auth_config_reader must be an instance of AuthConfigReader"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
self.token_reader: TokenReader = token_reader
|
|
90
|
+
if self.token_reader is None:
|
|
91
|
+
raise ValueError("token_reader must not be None")
|
|
92
|
+
if not isinstance(self.token_reader, TokenReader):
|
|
93
|
+
raise TypeError("token_reader must be an instance of TokenReader")
|
|
94
|
+
|
|
95
|
+
self.well_known_configuration_manager: WellKnownConfigurationManager = (
|
|
96
|
+
well_known_configuration_manager
|
|
97
|
+
)
|
|
98
|
+
if self.well_known_configuration_manager is None:
|
|
99
|
+
raise ValueError("well_known_configuration_manager must not be None")
|
|
100
|
+
if not isinstance(
|
|
101
|
+
self.well_known_configuration_manager, WellKnownConfigurationManager
|
|
102
|
+
):
|
|
103
|
+
raise TypeError(
|
|
104
|
+
"well_known_configuration_manager must be an instance of WellKnownConfigurationManager"
|
|
105
|
+
)
|
|
106
|
+
oauth_cache_type = environment_variables.oauth_cache
|
|
107
|
+
self.cache: OAuthCache = (
|
|
108
|
+
OAuthMemoryCache()
|
|
109
|
+
if oauth_cache_type == "memory"
|
|
110
|
+
else OAuthMongoCache(environment_variables=environment_variables)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
logger.debug(
|
|
114
|
+
f"Initializing AuthManager with cache type {type(self.cache)} cache id: {self.cache.id}"
|
|
115
|
+
)
|
|
116
|
+
# OIDC PKCE setup
|
|
117
|
+
self.redirect_uri = os.getenv("AUTH_REDIRECT_URI")
|
|
118
|
+
if self.redirect_uri is None:
|
|
119
|
+
raise ValueError("AUTH_REDIRECT_URI environment variable must be set")
|
|
120
|
+
# https://docs.authlib.org/en/latest/client/frameworks.html#frameworks-clients
|
|
121
|
+
self._oauth: OAuth = OAuth(cache=self.cache) # type: ignore[no-untyped-call]
|
|
122
|
+
# read AUTH_PROVIDERS comma separated list from the environment variable and register the OIDC provider for each provider
|
|
123
|
+
self.auth_configs: List[AuthConfig] = (
|
|
124
|
+
self.auth_config_reader.get_auth_configs_for_all_auth_providers()
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def ensure_initialized_async(self) -> None:
|
|
128
|
+
auth_config: AuthConfig
|
|
129
|
+
for auth_config in self.auth_configs:
|
|
130
|
+
server_metadata: (
|
|
131
|
+
dict[str, Any] | None
|
|
132
|
+
) = await self.well_known_configuration_manager.get_async(
|
|
133
|
+
auth_config=auth_config
|
|
134
|
+
)
|
|
135
|
+
logger.debug(
|
|
136
|
+
f"Registering OAuth client for auth provider {auth_config.auth_provider}"
|
|
137
|
+
+ (
|
|
138
|
+
f" with well-known configuration: {server_metadata}"
|
|
139
|
+
if server_metadata is not None
|
|
140
|
+
else f" from {auth_config.well_known_uri}"
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
self._oauth.register(
|
|
144
|
+
name=auth_config.auth_provider.lower(),
|
|
145
|
+
client_id=auth_config.client_id,
|
|
146
|
+
client_secret=auth_config.client_secret,
|
|
147
|
+
server_metadata_url=auth_config.well_known_uri,
|
|
148
|
+
# server_metadata_url=auth_config.well_known_uri
|
|
149
|
+
# if server_metadata is None
|
|
150
|
+
# else None,
|
|
151
|
+
# server_metadata=server_metadata,
|
|
152
|
+
client_kwargs={
|
|
153
|
+
"scope": auth_config.scope,
|
|
154
|
+
"code_challenge_method": "S256",
|
|
155
|
+
"transport": LoggingTransport(httpx.AsyncHTTPTransport()),
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def create_authorization_url(
|
|
160
|
+
self,
|
|
161
|
+
*,
|
|
162
|
+
auth_provider: str,
|
|
163
|
+
redirect_uri: str,
|
|
164
|
+
audience: str,
|
|
165
|
+
url: str | None,
|
|
166
|
+
referring_email: str | None,
|
|
167
|
+
referring_subject: str | None,
|
|
168
|
+
) -> str:
|
|
169
|
+
"""
|
|
170
|
+
Create the authorization URL for the OIDC provider.
|
|
171
|
+
|
|
172
|
+
This method generates the authorization URL with the necessary parameters,
|
|
173
|
+
including the redirect URI and state. The state is encoded to include the tool name,
|
|
174
|
+
which is used to identify the tool that initiated the authentication process.
|
|
175
|
+
Args:
|
|
176
|
+
auth_provider (str): The name of the OIDC provider.
|
|
177
|
+
redirect_uri (str): The redirect URI to which the OIDC provider will send the user
|
|
178
|
+
after authentication.
|
|
179
|
+
audience (str): The audience we need to get a token for.
|
|
180
|
+
url (str): The URL of the tool that has requested this.
|
|
181
|
+
referring_email (str): The email of the user who initiated the request.
|
|
182
|
+
referring_subject (str): The subject of the user who initiated the request.
|
|
183
|
+
Returns:
|
|
184
|
+
str: The authorization URL to redirect the user to for authentication.
|
|
185
|
+
"""
|
|
186
|
+
# default to first audience
|
|
187
|
+
client: StarletteOAuth2App = await self.create_oauth_client(name=auth_provider)
|
|
188
|
+
if client is None:
|
|
189
|
+
raise ValueError(f"Client for audience {audience} not found")
|
|
190
|
+
state_content: Dict[str, str | None] = {
|
|
191
|
+
"auth_provider": auth_provider,
|
|
192
|
+
"referring_email": referring_email,
|
|
193
|
+
"referring_subject": referring_subject,
|
|
194
|
+
"url": url, # the URL of the tool that has requested this
|
|
195
|
+
# include a unique request ID so we don't get cache for another request
|
|
196
|
+
# This will create a unique state for each request
|
|
197
|
+
# the callback will use this state to find the correct token
|
|
198
|
+
"request_id": uuid.uuid4().hex,
|
|
199
|
+
}
|
|
200
|
+
# convert state_content to a string
|
|
201
|
+
state: str = AuthHelper.encode_state(state_content)
|
|
202
|
+
|
|
203
|
+
logger.debug(
|
|
204
|
+
f"Creating authorization URL for audience {audience} with state {state_content} and encoded state {state}"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
rv: Dict[str, Any] = await client.create_authorization_url( # type: ignore[no-untyped-call]
|
|
208
|
+
redirect_uri=redirect_uri, state=state
|
|
209
|
+
)
|
|
210
|
+
logger.debug(f"Authorization URL created: {rv}")
|
|
211
|
+
# request is only needed if we are using the session to store the state
|
|
212
|
+
await client.save_authorize_data(request=None, redirect_uri=redirect_uri, **rv) # type: ignore[no-untyped-call]
|
|
213
|
+
return cast(str, rv["url"])
|
|
214
|
+
|
|
215
|
+
async def create_oauth_client(self, *, name: str) -> StarletteOAuth2App:
|
|
216
|
+
if not name:
|
|
217
|
+
raise ValueError("name must not be empty")
|
|
218
|
+
await self.ensure_initialized_async()
|
|
219
|
+
return cast(StarletteOAuth2App, self._oauth.create_client(name=name.lower())) # type: ignore[no-untyped-call]
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
async def login_and_get_token_with_username_password_async(
|
|
223
|
+
*,
|
|
224
|
+
auth_config: AuthConfig,
|
|
225
|
+
username: str,
|
|
226
|
+
password: str,
|
|
227
|
+
audience: str | None = None,
|
|
228
|
+
token_name: str = "access_token",
|
|
229
|
+
) -> str:
|
|
230
|
+
"""
|
|
231
|
+
Logs in a user with the provided username and password, and retrieves an access token.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
auth_config (AuthConfig): The authentication configuration.
|
|
235
|
+
username (str): The username of the user.
|
|
236
|
+
password (str): The password of the user.
|
|
237
|
+
audience (str | None): The intended audience for the token. Optional.
|
|
238
|
+
token_name (str): The name of the token to retrieve. Defaults to "access_token".
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
str: The access token if login is successful.
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
Exception: If login fails or token retrieval is unsuccessful.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
# Discover token endpoint
|
|
248
|
+
token_url = None
|
|
249
|
+
if auth_config.well_known_uri:
|
|
250
|
+
try:
|
|
251
|
+
async with httpx.AsyncClient(timeout=5) as async_client:
|
|
252
|
+
resp = await async_client.get(auth_config.well_known_uri)
|
|
253
|
+
resp.raise_for_status()
|
|
254
|
+
token_url = resp.json().get("token_endpoint")
|
|
255
|
+
except Exception as e:
|
|
256
|
+
raise AuthorizationNeededException(
|
|
257
|
+
message=f"Failed to discover token endpoint: {e}"
|
|
258
|
+
)
|
|
259
|
+
if not token_url and auth_config.issuer:
|
|
260
|
+
token_url = (
|
|
261
|
+
auth_config.issuer.rstrip("/") + "/protocol/openid-connect/token"
|
|
262
|
+
)
|
|
263
|
+
if not token_url:
|
|
264
|
+
raise AuthorizationNeededException(
|
|
265
|
+
message="No token endpoint found in AuthConfig."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Prepare OAuth2 client
|
|
269
|
+
client_id = auth_config.client_id
|
|
270
|
+
client_secret = auth_config.client_secret
|
|
271
|
+
audience = audience or auth_config.audience
|
|
272
|
+
client = AsyncOAuth2Client(client_id, client_secret, timeout=10)
|
|
273
|
+
|
|
274
|
+
# Request token
|
|
275
|
+
try:
|
|
276
|
+
# This DOES return a coroutine
|
|
277
|
+
# noinspection PyUnresolvedReferences
|
|
278
|
+
token: Dict[str, Any] = await client.fetch_token(
|
|
279
|
+
url=token_url,
|
|
280
|
+
grant_type="password",
|
|
281
|
+
username=username,
|
|
282
|
+
password=password,
|
|
283
|
+
scope="openid",
|
|
284
|
+
audience=audience,
|
|
285
|
+
)
|
|
286
|
+
if not isinstance(token, dict):
|
|
287
|
+
raise TypeError(f"Expected token to be a dict, got {type(token)}")
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
raise AuthorizationNeededException(message=f"Token request failed: {e}")
|
|
291
|
+
|
|
292
|
+
access_token: str | None = token.get(token_name)
|
|
293
|
+
if not access_token:
|
|
294
|
+
raise AuthorizationNeededException(message="No access token returned.")
|
|
295
|
+
|
|
296
|
+
return access_token
|
|
297
|
+
|
|
298
|
+
def get_auth_config_for_auth_provider(
|
|
299
|
+
self, *, auth_provider: str
|
|
300
|
+
) -> AuthConfig | None:
|
|
301
|
+
if not auth_provider:
|
|
302
|
+
raise ValueError("auth_provider must not be empty")
|
|
303
|
+
for auth_config in self.auth_configs:
|
|
304
|
+
if auth_config.auth_provider.lower() == auth_provider.lower():
|
|
305
|
+
return auth_config
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def wait_till_well_known_configuration_available(
|
|
310
|
+
*, auth_config: AuthConfig, timeout_seconds: int = 30
|
|
311
|
+
) -> None:
|
|
312
|
+
"""
|
|
313
|
+
Wait until the well-known configuration is available for the given AuthConfig.
|
|
314
|
+
|
|
315
|
+
This method repeatedly attempts to fetch the well-known configuration from the
|
|
316
|
+
specified URL until it succeeds or the timeout is reached.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
auth_config (AuthConfig): The authentication configuration containing the
|
|
320
|
+
well-known URL.
|
|
321
|
+
timeout_seconds (int): The maximum time to wait in seconds. Defaults to 30 seconds.
|
|
322
|
+
Raises:
|
|
323
|
+
TimeoutError: If the well-known configuration is not available within the timeout period.
|
|
324
|
+
"""
|
|
325
|
+
if not auth_config.well_known_uri:
|
|
326
|
+
raise ValueError("AuthConfig must have a well-known URI to wait for.")
|
|
327
|
+
|
|
328
|
+
start_time = time.time()
|
|
329
|
+
while True:
|
|
330
|
+
try:
|
|
331
|
+
with httpx.Client(timeout=5) as client:
|
|
332
|
+
resp = client.get(auth_config.well_known_uri)
|
|
333
|
+
resp.raise_for_status()
|
|
334
|
+
# Successfully fetched the configuration
|
|
335
|
+
logger.info(
|
|
336
|
+
f"Well-known configuration is now available at {auth_config.well_known_uri}"
|
|
337
|
+
)
|
|
338
|
+
return
|
|
339
|
+
except Exception as e:
|
|
340
|
+
elapsed_time = time.time() - start_time
|
|
341
|
+
if elapsed_time >= timeout_seconds:
|
|
342
|
+
raise TimeoutError(
|
|
343
|
+
f"Timed out waiting for well-known configuration at {auth_config.well_known_uri}"
|
|
344
|
+
) from e
|
|
345
|
+
logger.debug(
|
|
346
|
+
f"Well-known configuration not yet available, retrying... ({elapsed_time:.1f}s elapsed)"
|
|
347
|
+
)
|
|
348
|
+
time.sleep(2) # Wait before retrying
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from abc import abstractmethod, ABCMeta
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OAuthCache(metaclass=ABCMeta):
|
|
6
|
+
"""
|
|
7
|
+
Base class for OAuthCache
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def id(self) -> uuid.UUID:
|
|
13
|
+
"""
|
|
14
|
+
Unique identifier for the cache instance.
|
|
15
|
+
"""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
async def delete(self, key: str) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Delete a cache entry.
|
|
22
|
+
|
|
23
|
+
:param key: Unique identifier for the cache entry.
|
|
24
|
+
"""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def get(self, key: str, default: str | None = None) -> str | None:
|
|
29
|
+
"""
|
|
30
|
+
Retrieve a value from the cache.
|
|
31
|
+
|
|
32
|
+
:param key: Unique identifier for the cache entry.
|
|
33
|
+
:param default: Default value to return if the key is not found.
|
|
34
|
+
:return: Retrieved value or None if not found or expired.
|
|
35
|
+
"""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def set(self, key: str, value: str, expires: int | None = None) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Set a value in the cache with optional expiration.
|
|
42
|
+
|
|
43
|
+
:param key: Unique identifier for the cache entry.
|
|
44
|
+
:param value: Value to be stored.
|
|
45
|
+
:param expires: Expiration time in seconds. Defaults to None (no expiration).
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
from oidcauthlib.auth.cache.oauth_cache import OAuthCache
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OAuthMemoryCache(OAuthCache):
|
|
8
|
+
"""
|
|
9
|
+
In-memory implementation of OAuth cache
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
@override
|
|
14
|
+
def id(self) -> uuid.UUID:
|
|
15
|
+
return self.id_
|
|
16
|
+
|
|
17
|
+
_cache: dict[str, str] = {}
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
"""Initialize the AuthCache."""
|
|
21
|
+
self.id_ = uuid.uuid4()
|
|
22
|
+
|
|
23
|
+
@override
|
|
24
|
+
async def delete(self, key: str) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Delete a cache entry.
|
|
27
|
+
|
|
28
|
+
:param key: Unique identifier for the cache entry.
|
|
29
|
+
"""
|
|
30
|
+
if key in self._cache:
|
|
31
|
+
del self._cache[key]
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
async def get(self, key: str, default: str | None = None) -> str | None:
|
|
35
|
+
"""
|
|
36
|
+
Retrieve a value from the cache.
|
|
37
|
+
|
|
38
|
+
:param key: Unique identifier for the cache entry.
|
|
39
|
+
:param default: Default value to return if the key is not found.
|
|
40
|
+
:return: Retrieved value or None if not found or expired.
|
|
41
|
+
"""
|
|
42
|
+
return self._cache.get(key) or default
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
async def set(self, key: str, value: str, expires: int | None = None) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Set a value in the cache with optional expiration.
|
|
48
|
+
|
|
49
|
+
:param key: Unique identifier for the cache entry.
|
|
50
|
+
:param value: Value to be stored.
|
|
51
|
+
:param expires: Expiration time in seconds. Defaults to None (no expiration).
|
|
52
|
+
"""
|
|
53
|
+
self._cache[key] = value
|