belgie 0.1.0a4__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.
belgie/__init__.py ADDED
@@ -0,0 +1,97 @@
1
+ """Belgie - Modern authentication and analytics for FastAPI."""
2
+
3
+ from importlib import import_module
4
+ from typing import TYPE_CHECKING
5
+
6
+ from belgie.auth import (
7
+ Auth,
8
+ AuthClient,
9
+ AuthenticationError,
10
+ AuthorizationError,
11
+ AuthSettings,
12
+ BelgieError,
13
+ ConfigurationError,
14
+ CookieSettings,
15
+ DBConnection,
16
+ GoogleOAuthProvider,
17
+ GoogleProviderSettings,
18
+ GoogleUserInfo,
19
+ HookContext,
20
+ HookEvent,
21
+ HookRunner,
22
+ Hooks,
23
+ InvalidStateError,
24
+ OAuthError,
25
+ OAuthProviderProtocol,
26
+ Providers,
27
+ SessionExpiredError,
28
+ SessionManager,
29
+ SessionSettings,
30
+ URLSettings,
31
+ generate_session_id,
32
+ generate_state_token,
33
+ parse_scopes,
34
+ validate_scopes,
35
+ )
36
+
37
+ if TYPE_CHECKING:
38
+ from belgie_alchemy import AlchemyAdapter as AlchemyAdapter
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ _ALCHEMY_IMPORT_ERROR = "AlchemyAdapter requires the 'alchemy' extra. Install with: uv add belgie[alchemy]"
43
+
44
+
45
+ def __getattr__(name: str) -> object:
46
+ if name != "AlchemyAdapter":
47
+ msg = f"module 'belgie' has no attribute {name!r}"
48
+ raise AttributeError(msg)
49
+
50
+ try:
51
+ module = import_module("belgie_alchemy")
52
+ except ModuleNotFoundError as exc:
53
+ raise ImportError(_ALCHEMY_IMPORT_ERROR) from exc
54
+
55
+ return module.AlchemyAdapter
56
+
57
+
58
+ __all__ = [ # noqa: RUF022
59
+ # Version
60
+ "__version__",
61
+ # Core
62
+ "Auth",
63
+ "AuthClient",
64
+ "AuthSettings",
65
+ "Hooks",
66
+ "HookContext",
67
+ "HookEvent",
68
+ "HookRunner",
69
+ # Adapters
70
+ "AlchemyAdapter",
71
+ "DBConnection",
72
+ # Session
73
+ "SessionManager",
74
+ # Providers
75
+ "GoogleOAuthProvider",
76
+ "GoogleProviderSettings",
77
+ "GoogleUserInfo",
78
+ "OAuthProviderProtocol",
79
+ "Providers",
80
+ # Settings
81
+ "SessionSettings",
82
+ "CookieSettings",
83
+ "URLSettings",
84
+ # Exceptions
85
+ "BelgieError",
86
+ "AuthenticationError",
87
+ "AuthorizationError",
88
+ "SessionExpiredError",
89
+ "InvalidStateError",
90
+ "OAuthError",
91
+ "ConfigurationError",
92
+ # Utils
93
+ "generate_session_id",
94
+ "generate_state_token",
95
+ "parse_scopes",
96
+ "validate_scopes",
97
+ ]
belgie/alchemy.py ADDED
@@ -0,0 +1,24 @@
1
+ """Alchemy re-exports for belgie consumers."""
2
+
3
+ _ALCHEMY_IMPORT_ERROR = "belgie.alchemy requires the 'alchemy' extra. Install with: uv add belgie[alchemy]"
4
+
5
+ try:
6
+ from belgie_alchemy import ( # type: ignore[import-not-found]
7
+ AlchemyAdapter,
8
+ Base,
9
+ DatabaseSettings,
10
+ DateTimeUTC,
11
+ PrimaryKeyMixin,
12
+ TimestampMixin,
13
+ )
14
+ except ModuleNotFoundError as exc:
15
+ raise ImportError(_ALCHEMY_IMPORT_ERROR) from exc
16
+
17
+ __all__ = [
18
+ "AlchemyAdapter",
19
+ "Base",
20
+ "DatabaseSettings",
21
+ "DateTimeUTC",
22
+ "PrimaryKeyMixin",
23
+ "TimestampMixin",
24
+ ]
@@ -0,0 +1,65 @@
1
+ """Belgie Auth - Authentication components."""
2
+
3
+ from belgie_proto import DBConnection
4
+
5
+ from belgie.auth.core.auth import Auth
6
+ from belgie.auth.core.client import AuthClient
7
+ from belgie.auth.core.exceptions import (
8
+ AuthenticationError,
9
+ AuthorizationError,
10
+ BelgieError,
11
+ ConfigurationError,
12
+ InvalidStateError,
13
+ OAuthError,
14
+ SessionExpiredError,
15
+ )
16
+ from belgie.auth.core.hooks import HookContext, HookEvent, HookRunner, Hooks
17
+ from belgie.auth.core.settings import (
18
+ AuthSettings,
19
+ CookieSettings,
20
+ SessionSettings,
21
+ URLSettings,
22
+ )
23
+ from belgie.auth.providers import OAuthProviderProtocol, Providers
24
+ from belgie.auth.providers.google import GoogleOAuthProvider, GoogleProviderSettings, GoogleUserInfo
25
+ from belgie.auth.session.manager import SessionManager
26
+ from belgie.auth.utils.crypto import generate_session_id, generate_state_token
27
+ from belgie.auth.utils.scopes import parse_scopes, validate_scopes
28
+
29
+ __all__ = [ # noqa: RUF022
30
+ # Core
31
+ "Auth",
32
+ "AuthClient",
33
+ "AuthSettings",
34
+ "Hooks",
35
+ "HookContext",
36
+ "HookEvent",
37
+ "HookRunner",
38
+ # Adapters
39
+ "DBConnection",
40
+ # Session
41
+ "SessionManager",
42
+ # Providers
43
+ "GoogleOAuthProvider",
44
+ "GoogleProviderSettings",
45
+ "GoogleUserInfo",
46
+ "OAuthProviderProtocol",
47
+ "Providers",
48
+ # Settings
49
+ "SessionSettings",
50
+ "CookieSettings",
51
+ "URLSettings",
52
+ # Exceptions
53
+ "BelgieError",
54
+ "AuthenticationError",
55
+ "AuthorizationError",
56
+ "SessionExpiredError",
57
+ "InvalidStateError",
58
+ "OAuthError",
59
+ "ConfigurationError",
60
+ # Utils
61
+ "generate_session_id",
62
+ "generate_state_token",
63
+ "parse_scopes",
64
+ "validate_scopes",
65
+ ]
File without changes
@@ -0,0 +1,368 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator, Callable # noqa: TC003
4
+ from functools import cached_property
5
+ from typing import Protocol, cast
6
+ from uuid import UUID
7
+
8
+ from belgie_proto import (
9
+ AccountProtocol,
10
+ AdapterProtocol,
11
+ DBConnection,
12
+ OAuthStateProtocol,
13
+ SessionProtocol,
14
+ UserProtocol,
15
+ )
16
+ from fastapi import APIRouter, Depends, Request, status
17
+ from fastapi.responses import RedirectResponse
18
+ from fastapi.security import SecurityScopes # noqa: TC002
19
+
20
+ from belgie.auth.core.client import AuthClient
21
+ from belgie.auth.core.hooks import HookRunner, Hooks
22
+ from belgie.auth.core.settings import AuthSettings # noqa: TC001
23
+ from belgie.auth.providers.protocols import OAuthProviderProtocol, Providers # noqa: TC001
24
+ from belgie.auth.session.manager import SessionManager
25
+
26
+
27
+ class DBDependencyProvider(Protocol):
28
+ """Protocol for database dependency providers.
29
+
30
+ This allows Auth to work with any dependency injection system
31
+ without coupling to a specific implementation.
32
+ """
33
+
34
+ @property
35
+ def dependency(self) -> Callable[[], DBConnection | AsyncGenerator[DBConnection, None]]:
36
+ """FastAPI dependency that provides database connections."""
37
+ ...
38
+
39
+
40
+ class _AuthCallable:
41
+ """Descriptor that makes Auth instances callable with instance-specific dependencies.
42
+
43
+ This allows Depends(auth) to work seamlessly - each Auth instance gets its own
44
+ callable that has the Auth instance's database dependency baked into the signature.
45
+ """
46
+
47
+ def __get__(self, obj: Auth | None, objtype: type | None = None) -> object:
48
+ """Return instance-specific callable when accessed through an instance."""
49
+ if obj is None:
50
+ # Accessed through class, return descriptor itself
51
+ return self
52
+
53
+ # Return a callable with this instance's db.dependency
54
+ if obj.db is None:
55
+ msg = "Auth.db must be configured with a dependency"
56
+ raise RuntimeError(msg)
57
+ dependency = obj.db.dependency
58
+
59
+ def __call__( # noqa: N807
60
+ db: DBConnection = Depends(dependency), # noqa: B008
61
+ ) -> AuthClient:
62
+ return AuthClient(
63
+ db=db,
64
+ adapter=obj.adapter,
65
+ session_manager=obj.session_manager,
66
+ cookie_name=obj.settings.cookie.name,
67
+ hook_runner=obj.hook_runner,
68
+ )
69
+
70
+ return __call__
71
+
72
+
73
+ class Auth[UserT: UserProtocol, AccountT: AccountProtocol, SessionT: SessionProtocol, OAuthStateT: OAuthStateProtocol]:
74
+ """Main authentication orchestrator for Belgie.
75
+
76
+ The Auth class provides a complete OAuth 2.0 authentication solution with session management,
77
+ user creation, and FastAPI integration. It automatically loads OAuth providers from environment
78
+ variables and creates router endpoints for authentication.
79
+
80
+ Type Parameters:
81
+ UserT: User model type implementing UserProtocol
82
+ AccountT: Account model type implementing AccountProtocol
83
+ SessionT: Session model type implementing SessionProtocol
84
+ OAuthStateT: OAuth state model type implementing OAuthStateProtocol
85
+
86
+ Attributes:
87
+ settings: Authentication configuration settings
88
+ adapter: Database adapter for persistence operations
89
+ session_manager: Session manager instance for session operations
90
+ providers: Dictionary of registered OAuth providers keyed by provider_id
91
+ router: FastAPI router with authentication endpoints (cached property)
92
+
93
+ Example:
94
+ >>> from belgie import Auth, AuthSettings, AlchemyAdapter
95
+ >>> from belgie.auth.providers.google import GoogleOAuthProvider, GoogleProviderSettings
96
+ >>> from myapp.models import User, Account, Session, OAuthState
97
+ >>>
98
+ >>> settings = AuthSettings(
99
+ ... secret="your-secret-key",
100
+ ... base_url="http://localhost:8000",
101
+ ... )
102
+ >>>
103
+ >>> adapter = AlchemyAdapter(
104
+ ... user=User,
105
+ ... account=Account,
106
+ ... session=Session,
107
+ ... oauth_state=OAuthState,
108
+ ... )
109
+ >>> db = DatabaseSettings(dialect={"type": "sqlite", "database": ":memory:"})
110
+ >>>
111
+ >>> # Explicitly pass provider settings
112
+ >>> providers: Providers = {
113
+ ... "google": GoogleProviderSettings(
114
+ ... client_id="your-client-id",
115
+ ... client_secret="your-client-secret",
116
+ ... redirect_uri="http://localhost:8000/auth/provider/google/callback",
117
+ ... ),
118
+ ... }
119
+ >>> auth = Auth(settings=settings, adapter=adapter, providers=providers, db=db)
120
+ >>> app.include_router(auth.router)
121
+ """
122
+
123
+ # Use descriptor to make each instance callable with its own dependency
124
+ __call__: Callable[..., AuthClient] = cast("Callable[..., AuthClient]", _AuthCallable())
125
+
126
+ def __init__(
127
+ self,
128
+ settings: AuthSettings,
129
+ adapter: AdapterProtocol[UserT, AccountT, SessionT, OAuthStateT],
130
+ db: DBDependencyProvider,
131
+ providers: Providers | None = None,
132
+ hooks: Hooks | None = None,
133
+ ) -> None:
134
+ """Initialize the Auth instance.
135
+
136
+ Args:
137
+ settings: Authentication configuration including session, cookie, and URL settings
138
+ adapter: Database adapter for user, account, session, and OAuth state persistence
139
+ db: Database dependency provider
140
+ providers: Dictionary of provider settings. Each setting is callable and returns its provider.
141
+ If None, no providers are registered.
142
+
143
+ Raises:
144
+ """
145
+ self.settings = settings
146
+ self.adapter = adapter
147
+ self.db = db
148
+
149
+ self.session_manager = SessionManager(
150
+ adapter=adapter,
151
+ max_age=settings.session.max_age,
152
+ update_age=settings.session.update_age,
153
+ )
154
+
155
+ self.hook_runner = HookRunner(hooks or Hooks())
156
+
157
+ # Instantiate providers by calling the settings
158
+ self.providers: dict[str, OAuthProviderProtocol] = (
159
+ {provider_id: provider_settings() for provider_id, provider_settings in providers.items()} # ty: ignore[call-non-callable]
160
+ if providers
161
+ else {}
162
+ )
163
+
164
+ @cached_property
165
+ def router(self) -> APIRouter:
166
+ """FastAPI router with all provider routes (cached).
167
+
168
+ Creates a router with the following structure:
169
+ - /auth/provider/{provider_id}/signin - Provider signin endpoints
170
+ - /auth/provider/{provider_id}/callback - Provider callback endpoints
171
+ - /auth/signout - Global signout endpoint
172
+
173
+ Returns:
174
+ APIRouter with all authentication endpoints
175
+ """
176
+ main_router = APIRouter(prefix="/auth", tags=["auth"])
177
+ provider_router = APIRouter(prefix="/provider")
178
+
179
+ if self.db is None:
180
+ msg = "Auth.db must be configured with a dependency"
181
+ raise RuntimeError(msg)
182
+ dependency = self.db.dependency
183
+
184
+ # Include all registered provider routers
185
+ for provider in self.providers.values():
186
+ # Provider's router has prefix /{provider_id}
187
+ # Combined with provider_router prefix: /auth/provider/{provider_id}/...
188
+ provider_specific_router = provider.get_router(
189
+ self.adapter,
190
+ self.settings.cookie,
191
+ session_max_age=self.settings.session.max_age,
192
+ signin_redirect=self.settings.urls.signin_redirect,
193
+ signout_redirect=self.settings.urls.signout_redirect,
194
+ hook_runner=self.hook_runner,
195
+ db_dependency=dependency,
196
+ )
197
+ provider_router.include_router(provider_specific_router)
198
+
199
+ # Add signout endpoint to main router (not provider-specific)
200
+ async def _get_db(db: DBConnection = Depends(dependency)) -> DBConnection: # noqa: B008
201
+ return db
202
+
203
+ @main_router.post("/signout")
204
+ async def signout(
205
+ request: Request,
206
+ db: DBConnection = Depends(_get_db), # noqa: B008, FAST002
207
+ ) -> RedirectResponse:
208
+ session_id_str = request.cookies.get(self.settings.cookie.name)
209
+
210
+ if session_id_str:
211
+ try:
212
+ session_id = UUID(session_id_str)
213
+ await self.sign_out(db, session_id)
214
+ except ValueError:
215
+ pass
216
+
217
+ response = RedirectResponse(
218
+ url=self.settings.urls.signout_redirect,
219
+ status_code=status.HTTP_302_FOUND,
220
+ )
221
+
222
+ response.delete_cookie(
223
+ key=self.settings.cookie.name,
224
+ domain=self.settings.cookie.domain,
225
+ )
226
+
227
+ return response
228
+
229
+ # Include provider router in main router
230
+ main_router.include_router(provider_router)
231
+ return main_router
232
+
233
+ async def get_user_from_session(
234
+ self,
235
+ db: DBConnection,
236
+ session_id: UUID,
237
+ ) -> UserT | None:
238
+ """Retrieve user from a session ID.
239
+
240
+ This method maintains backward compatibility by delegating to AuthClient internally.
241
+
242
+ Args:
243
+ db: Database connection
244
+ session_id: UUID of the session
245
+
246
+ Returns:
247
+ User object if session is valid and user exists, None otherwise
248
+
249
+ Example:
250
+ >>> user = await auth.get_user_from_session(db, session_id)
251
+ >>> if user:
252
+ ... print(f"Found user: {user.email}")
253
+ """
254
+ client = self.__call__(db)
255
+ return await client.get_user_from_session(session_id)
256
+
257
+ async def sign_out(
258
+ self,
259
+ db: DBConnection,
260
+ session_id: UUID,
261
+ ) -> bool:
262
+ """Sign out a user by deleting their session.
263
+
264
+ This method maintains backward compatibility by delegating to AuthClient internally.
265
+
266
+ Args:
267
+ db: Database connection
268
+ session_id: UUID of the session to delete
269
+
270
+ Returns:
271
+ True if session was deleted, False if session didn't exist
272
+
273
+ Example:
274
+ >>> success = await auth.sign_out(db, session_id)
275
+ >>> if success:
276
+ ... print("User signed out successfully")
277
+ """
278
+ client = self.__call__(db)
279
+ return await client.sign_out(session_id)
280
+
281
+ async def _get_session_from_cookie(
282
+ self,
283
+ request: Request,
284
+ db: DBConnection,
285
+ ) -> SessionT | None:
286
+ """Extract and validate session from request cookies.
287
+
288
+ This method delegates to AuthClient for consistency.
289
+
290
+ Args:
291
+ request: FastAPI Request object
292
+ db: Database connection
293
+
294
+ Returns:
295
+ Session if valid, None otherwise
296
+ """
297
+ client = self.__call__(db)
298
+ return await client._get_session_from_cookie(request) # noqa: SLF001
299
+
300
+ async def user(
301
+ self,
302
+ security_scopes: SecurityScopes,
303
+ request: Request,
304
+ db: DBConnection,
305
+ ) -> UserT:
306
+ """FastAPI dependency for retrieving the authenticated user.
307
+
308
+ Extracts the session from cookies, validates it, and returns the authenticated user.
309
+ Optionally validates user-level scopes if specified.
310
+
311
+ This method maintains backward compatibility by delegating to AuthClient internally.
312
+
313
+ Args:
314
+ security_scopes: FastAPI SecurityScopes for scope validation
315
+ request: FastAPI Request object containing cookies
316
+ db: Database connection
317
+
318
+ Returns:
319
+ Authenticated user object
320
+
321
+ Raises:
322
+ HTTPException: 401 if not authenticated or session invalid
323
+ HTTPException: 403 if required scopes are not granted
324
+
325
+ Example:
326
+ >>> from fastapi import Depends, Security
327
+ >>>
328
+ >>> @app.get("/protected")
329
+ >>> async def protected_route(user: User = Depends(auth.user)):
330
+ ... return {"email": user.email}
331
+ >>>
332
+ >>> @app.get("/resource")
333
+ >>> async def resource_route(user: User = Security(auth.user, scopes=[Scope.READ])):
334
+ ... return {"data": "..."}
335
+ """
336
+ client = self.__call__(db)
337
+ return await client.get_user(security_scopes, request)
338
+
339
+ async def session(
340
+ self,
341
+ request: Request,
342
+ db: DBConnection,
343
+ ) -> SessionT:
344
+ """FastAPI dependency for retrieving the current session.
345
+
346
+ Extracts and validates the session from cookies.
347
+
348
+ This method maintains backward compatibility by delegating to AuthClient internally.
349
+
350
+ Args:
351
+ request: FastAPI Request object containing cookies
352
+ db: Database connection
353
+
354
+ Returns:
355
+ Active session object
356
+
357
+ Raises:
358
+ HTTPException: 401 if not authenticated or session invalid/expired
359
+
360
+ Example:
361
+ >>> from fastapi import Depends
362
+ >>>
363
+ >>> @app.get("/session-info")
364
+ >>> async def session_info(session: Session = Depends(auth.session)):
365
+ ... return {"session_id": str(session.id), "expires_at": session.expires_at.isoformat()}
366
+ """
367
+ client = self.__call__(db)
368
+ return await client.get_session(request)