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