byoi 0.1.0a1__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.
byoi/dependencies.py ADDED
@@ -0,0 +1,144 @@
1
+ """FastAPI dependencies for the BYOI library.
2
+
3
+ Provides FastAPI dependency injection utilities for accessing BYOI services.
4
+ """
5
+
6
+ from typing import Annotated, Callable
7
+
8
+ from fastapi import Depends, Request
9
+
10
+ from byoi.providers import ProviderManager
11
+ from byoi.service import AuthService
12
+
13
+ __all__ = (
14
+ "get_provider_manager",
15
+ "get_auth_service",
16
+ "ProviderManagerDep",
17
+ "AuthServiceDep",
18
+ )
19
+
20
+
21
+ def get_provider_manager(request: Request) -> ProviderManager:
22
+ """FastAPI dependency to get the ProviderManager.
23
+
24
+ The ProviderManager must be stored in the application state
25
+ using the key "byoi_provider_manager".
26
+
27
+ Args:
28
+ request: The FastAPI request.
29
+
30
+ Returns:
31
+ The ProviderManager instance.
32
+
33
+ Raises:
34
+ RuntimeError: If BYOI is not configured in the application.
35
+
36
+ Example:
37
+ ```python
38
+ @app.get("/providers")
39
+ async def list_providers(
40
+ provider_manager: ProviderManagerDep,
41
+ ):
42
+ return provider_manager.list_providers()
43
+ ```
44
+ """
45
+ provider_manager = getattr(request.app.state, "byoi_provider_manager", None)
46
+ if provider_manager is None:
47
+ raise RuntimeError(
48
+ "BYOI is not configured. Call BYOI.setup() during application startup."
49
+ )
50
+ return provider_manager
51
+
52
+
53
+ def get_auth_service(request: Request) -> AuthService:
54
+ """FastAPI dependency to get the AuthService.
55
+
56
+ The AuthService must be stored in the application state
57
+ using the key "byoi_auth_service".
58
+
59
+ Args:
60
+ request: The FastAPI request.
61
+
62
+ Returns:
63
+ The AuthService instance.
64
+
65
+ Raises:
66
+ RuntimeError: If BYOI is not configured in the application.
67
+
68
+ Example:
69
+ ```python
70
+ @app.post("/auth/login")
71
+ async def login(
72
+ request: TokenExchangeRequest,
73
+ auth_service: AuthServiceDep,
74
+ ):
75
+ return await auth_service.authenticate(request)
76
+ ```
77
+ """
78
+ auth_service = getattr(request.app.state, "byoi_auth_service", None)
79
+ if auth_service is None:
80
+ raise RuntimeError(
81
+ "BYOI is not configured. Call BYOI.setup() during application startup."
82
+ )
83
+ return auth_service
84
+
85
+
86
+ # Type aliases for dependency injection
87
+ ProviderManagerDep = Annotated[ProviderManager, Depends(get_provider_manager)]
88
+ AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
89
+
90
+
91
+ def create_provider_manager_dependency(
92
+ provider_manager: ProviderManager,
93
+ ) -> Callable[[], ProviderManager]:
94
+ """Create a custom dependency that returns a specific ProviderManager.
95
+
96
+ Useful for testing or when not using the BYOI class for setup.
97
+
98
+ Args:
99
+ provider_manager: The ProviderManager to return.
100
+
101
+ Returns:
102
+ A dependency function.
103
+
104
+ Example:
105
+ ```python
106
+ provider_manager = ProviderManager(cache=my_cache)
107
+ app.dependency_overrides[get_provider_manager] = (
108
+ create_provider_manager_dependency(provider_manager)
109
+ )
110
+ ```
111
+ """
112
+
113
+ def dependency() -> ProviderManager:
114
+ return provider_manager
115
+
116
+ return dependency
117
+
118
+
119
+ def create_auth_service_dependency(
120
+ auth_service: AuthService,
121
+ ) -> Callable[[], AuthService]:
122
+ """Create a custom dependency that returns a specific AuthService.
123
+
124
+ Useful for testing or when not using the BYOI class for setup.
125
+
126
+ Args:
127
+ auth_service: The AuthService to return.
128
+
129
+ Returns:
130
+ A dependency function.
131
+
132
+ Example:
133
+ ```python
134
+ auth_service = AuthService(...)
135
+ app.dependency_overrides[get_auth_service] = (
136
+ create_auth_service_dependency(auth_service)
137
+ )
138
+ ```
139
+ """
140
+
141
+ def dependency() -> AuthService:
142
+ return auth_service
143
+
144
+ return dependency
byoi/errors.py ADDED
@@ -0,0 +1,360 @@
1
+ __all__ = (
2
+ "BYOIError",
3
+ "ConfigurationError",
4
+ "ProviderError",
5
+ "ProviderNotFoundError",
6
+ "ProviderDiscoveryError",
7
+ "AuthenticationError",
8
+ "InvalidStateError",
9
+ "StateExpiredError",
10
+ "InvalidCodeError",
11
+ "TokenExchangeError",
12
+ "TokenRefreshError",
13
+ "TokenValidationError",
14
+ "InvalidNonceError",
15
+ "InvalidIssuerError",
16
+ "InvalidAudienceError",
17
+ "TokenExpiredError",
18
+ "JWKSFetchError",
19
+ "IdentityError",
20
+ "IdentityAlreadyLinkedError",
21
+ "IdentityNotFoundError",
22
+ "CannotUnlinkLastIdentityError",
23
+ "UserNotFoundError",
24
+ "CacheError",
25
+ )
26
+
27
+ from typing import Any
28
+
29
+
30
+ class BYOIError(Exception):
31
+ """Base class for all BYOI exceptions.
32
+
33
+ All BYOI errors have a message and a unique error code that can be used
34
+ for programmatic error handling.
35
+
36
+ Example:
37
+ ```python
38
+ from fastapi import HTTPException
39
+ from byoi import BYOIError, InvalidStateError
40
+
41
+ try:
42
+ result = await auth_service.exchange_code(request)
43
+ except InvalidStateError as e:
44
+ raise HTTPException(status_code=400, detail=e.to_dict())
45
+ ```
46
+ """
47
+
48
+ def __init__(self, message: str, code: str = "byoi_error") -> None:
49
+ self.message = message
50
+ self.code = code
51
+ super().__init__(message)
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ """Convert the error to a dictionary suitable for API responses.
55
+
56
+ Returns:
57
+ A dictionary with 'error', 'code', and 'message' keys.
58
+ """
59
+ return {
60
+ "error": self.__class__.__name__,
61
+ "code": self.code,
62
+ "message": self.message,
63
+ }
64
+
65
+ def __repr__(self) -> str:
66
+ return f"{self.__class__.__name__}(message={self.message!r}, code={self.code!r})"
67
+
68
+
69
+ # =============================================================================
70
+ # Configuration Errors
71
+ # =============================================================================
72
+
73
+
74
+ class ConfigurationError(BYOIError):
75
+ """Error in BYOI configuration."""
76
+
77
+ def __init__(self, message: str) -> None:
78
+ super().__init__(message, code="configuration_error")
79
+
80
+
81
+ # =============================================================================
82
+ # Provider Errors
83
+ # =============================================================================
84
+
85
+
86
+ class ProviderError(BYOIError):
87
+ """Base class for provider-related errors."""
88
+
89
+ def __init__(self, message: str, code: str = "provider_error") -> None:
90
+ super().__init__(message, code=code)
91
+
92
+
93
+ class ProviderNotFoundError(ProviderError):
94
+ """The requested provider is not configured."""
95
+
96
+ def __init__(self, provider_name: str) -> None:
97
+ self.provider_name = provider_name
98
+ super().__init__(
99
+ f"Provider '{provider_name}' is not configured",
100
+ code="provider_not_found",
101
+ )
102
+
103
+
104
+ class ProviderDiscoveryError(ProviderError):
105
+ """Failed to discover provider endpoints."""
106
+
107
+ def __init__(self, provider_name: str, reason: str) -> None:
108
+ self.provider_name = provider_name
109
+ self.reason = reason
110
+ super().__init__(
111
+ f"Failed to discover endpoints for provider '{provider_name}': {reason}",
112
+ code="provider_discovery_error",
113
+ )
114
+
115
+
116
+ # =============================================================================
117
+ # Authentication Errors
118
+ # =============================================================================
119
+
120
+
121
+ class AuthenticationError(BYOIError):
122
+ """Base class for authentication errors."""
123
+
124
+ def __init__(self, message: str, code: str = "authentication_error") -> None:
125
+ super().__init__(message, code=code)
126
+
127
+
128
+ class InvalidStateError(AuthenticationError):
129
+ """The OAuth state parameter is invalid or not found."""
130
+
131
+ def __init__(self, state: str | None = None) -> None:
132
+ self.state = state
133
+ super().__init__(
134
+ "Invalid or unknown OAuth state parameter",
135
+ code="invalid_state",
136
+ )
137
+
138
+ def __repr__(self) -> str:
139
+ return f"{self.__class__.__name__}(state={self.state!r})"
140
+
141
+
142
+ class StateExpiredError(AuthenticationError):
143
+ """The OAuth state has expired."""
144
+
145
+ def __init__(self, state: str) -> None:
146
+ self.state = state
147
+ super().__init__(
148
+ "OAuth state has expired",
149
+ code="state_expired",
150
+ )
151
+
152
+ def __repr__(self) -> str:
153
+ return f"{self.__class__.__name__}(state={self.state!r})"
154
+
155
+
156
+ class InvalidCodeError(AuthenticationError):
157
+ """The authorization code is invalid."""
158
+
159
+ def __init__(self, reason: str | None = None) -> None:
160
+ self.reason = reason
161
+ message = "Invalid authorization code"
162
+ if reason:
163
+ message = f"{message}: {reason}"
164
+ super().__init__(message, code="invalid_code")
165
+
166
+
167
+ class TokenExchangeError(AuthenticationError):
168
+ """Failed to exchange authorization code for tokens."""
169
+
170
+ def __init__(self, reason: str) -> None:
171
+ self.reason = reason
172
+ super().__init__(
173
+ f"Token exchange failed: {reason}",
174
+ code="token_exchange_error",
175
+ )
176
+
177
+ def __repr__(self) -> str:
178
+ return f"{self.__class__.__name__}(reason={self.reason!r})"
179
+
180
+
181
+ class TokenRefreshError(AuthenticationError):
182
+ """Failed to refresh access token."""
183
+
184
+ def __init__(self, reason: str) -> None:
185
+ self.reason = reason
186
+ super().__init__(
187
+ f"Token refresh failed: {reason}",
188
+ code="token_refresh_error",
189
+ )
190
+
191
+ def __repr__(self) -> str:
192
+ return f"{self.__class__.__name__}(reason={self.reason!r})"
193
+
194
+
195
+ # =============================================================================
196
+ # Token Validation Errors
197
+ # =============================================================================
198
+
199
+
200
+ class TokenValidationError(AuthenticationError):
201
+ """Base class for token validation errors."""
202
+
203
+ def __init__(self, message: str, code: str = "token_validation_error") -> None:
204
+ super().__init__(message, code=code)
205
+
206
+
207
+ class InvalidNonceError(TokenValidationError):
208
+ """The nonce in the ID token doesn't match the expected value."""
209
+
210
+ def __init__(self) -> None:
211
+ super().__init__(
212
+ "ID token nonce does not match expected value",
213
+ code="invalid_nonce",
214
+ )
215
+
216
+
217
+ class InvalidIssuerError(TokenValidationError):
218
+ """The issuer in the ID token doesn't match the expected value."""
219
+
220
+ def __init__(self, expected: str, actual: str) -> None:
221
+ self.expected = expected
222
+ self.actual = actual
223
+ super().__init__(
224
+ f"ID token issuer mismatch: expected '{expected}', got '{actual}'",
225
+ code="invalid_issuer",
226
+ )
227
+
228
+ def __repr__(self) -> str:
229
+ return f"{self.__class__.__name__}(expected={self.expected!r}, actual={self.actual!r})"
230
+
231
+
232
+ class InvalidAudienceError(TokenValidationError):
233
+ """The audience in the ID token doesn't match the expected value."""
234
+
235
+ def __init__(self, expected: str, actual: str | list[str]) -> None:
236
+ self.expected = expected
237
+ self.actual = actual
238
+ super().__init__(
239
+ f"ID token audience mismatch: expected '{expected}', got '{actual}'",
240
+ code="invalid_audience",
241
+ )
242
+
243
+ def __repr__(self) -> str:
244
+ return f"{self.__class__.__name__}(expected={self.expected!r}, actual={self.actual!r})"
245
+
246
+
247
+ class TokenExpiredError(TokenValidationError):
248
+ """The token has expired."""
249
+
250
+ def __init__(self) -> None:
251
+ super().__init__(
252
+ "Token has expired",
253
+ code="token_expired",
254
+ )
255
+
256
+
257
+ class JWKSFetchError(TokenValidationError):
258
+ """Failed to fetch JWKS from the provider."""
259
+
260
+ def __init__(self, provider_name: str, reason: str) -> None:
261
+ self.provider_name = provider_name
262
+ self.reason = reason
263
+ super().__init__(
264
+ f"Failed to fetch JWKS for provider '{provider_name}': {reason}",
265
+ code="jwks_fetch_error",
266
+ )
267
+
268
+ def __repr__(self) -> str:
269
+ return f"{self.__class__.__name__}(provider_name={self.provider_name!r}, reason={self.reason!r})"
270
+
271
+
272
+ # =============================================================================
273
+ # Identity Errors
274
+ # =============================================================================
275
+
276
+
277
+ class IdentityError(BYOIError):
278
+ """Base class for identity-related errors."""
279
+
280
+ def __init__(self, message: str, code: str = "identity_error") -> None:
281
+ super().__init__(message, code=code)
282
+
283
+
284
+ class IdentityAlreadyLinkedError(IdentityError):
285
+ """The identity is already linked to a user."""
286
+
287
+ def __init__(
288
+ self,
289
+ provider_name: str,
290
+ provider_subject: str,
291
+ existing_user_id: str | None = None,
292
+ ) -> None:
293
+ self.provider_name = provider_name
294
+ self.provider_subject = provider_subject
295
+ self.existing_user_id = existing_user_id
296
+ super().__init__(
297
+ f"Identity from provider '{provider_name}' is already linked to a user",
298
+ code="identity_already_linked",
299
+ )
300
+
301
+ def __repr__(self) -> str:
302
+ return (
303
+ f"{self.__class__.__name__}(provider_name={self.provider_name!r}, "
304
+ f"provider_subject={self.provider_subject!r}, existing_user_id={self.existing_user_id!r})"
305
+ )
306
+
307
+
308
+ class IdentityNotFoundError(IdentityError):
309
+ """The identity was not found."""
310
+
311
+ def __init__(self, identity_id: str) -> None:
312
+ self.identity_id = identity_id
313
+ super().__init__(
314
+ f"Identity '{identity_id}' not found",
315
+ code="identity_not_found",
316
+ )
317
+
318
+ def __repr__(self) -> str:
319
+ return f"{self.__class__.__name__}(identity_id={self.identity_id!r})"
320
+
321
+
322
+ class CannotUnlinkLastIdentityError(IdentityError):
323
+ """Cannot unlink the user's only identity."""
324
+
325
+ def __init__(self, user_id: str) -> None:
326
+ self.user_id = user_id
327
+ super().__init__(
328
+ f"Cannot unlink the only identity for user '{user_id}'",
329
+ code="cannot_unlink_last_identity",
330
+ )
331
+
332
+ def __repr__(self) -> str:
333
+ return f"{self.__class__.__name__}(user_id={self.user_id!r})"
334
+
335
+
336
+ class UserNotFoundError(IdentityError):
337
+ """The user was not found."""
338
+
339
+ def __init__(self, user_id: str) -> None:
340
+ self.user_id = user_id
341
+ super().__init__(
342
+ f"User '{user_id}' not found",
343
+ code="user_not_found",
344
+ )
345
+
346
+ def __repr__(self) -> str:
347
+ return f"{self.__class__.__name__}(user_id={self.user_id!r})"
348
+
349
+
350
+ # =============================================================================
351
+ # Cache Errors
352
+ # =============================================================================
353
+
354
+
355
+ class CacheError(BYOIError):
356
+ """Error in cache operations."""
357
+
358
+ def __init__(self, message: str) -> None:
359
+ super().__init__(message, code="cache_error")
360
+