c2casgiutils 0.5.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,26 @@
1
+ import importlib.metadata
2
+ import logging
3
+
4
+ from fastapi import FastAPI
5
+
6
+ from c2casgiutils import auth, broadcast, health_checks, tools
7
+
8
+ _LOGGER = logging.getLogger(__name__)
9
+
10
+ __version__ = importlib.metadata.version("c2casgiutils")
11
+
12
+ app = FastAPI(
13
+ title="C2C ASGI Utils",
14
+ description="Provide some tools for a Fast API application",
15
+ version=__version__,
16
+ )
17
+
18
+ app.include_router(tools.router, prefix="", tags=["c2c_tools"])
19
+ app.include_router(health_checks.router, prefix="/health", tags=["c2c_health_checks"])
20
+ app.include_router(auth.router, prefix="/auth", tags=["c2c_auth"])
21
+
22
+
23
+ async def startup(main_app: FastAPI) -> None:
24
+ """Initialize application on startup."""
25
+ await broadcast.startup(main_app)
26
+ await tools.startup(main_app)
c2casgiutils/auth.py ADDED
@@ -0,0 +1,622 @@
1
+ import datetime
2
+ import logging
3
+ import secrets
4
+ import urllib.parse
5
+ from enum import Enum
6
+ from typing import Annotated, Any, TypedDict, cast
7
+
8
+ import aiohttp
9
+ import jwt
10
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
11
+ from fastapi.responses import RedirectResponse
12
+ from fastapi.security import APIKeyHeader, APIKeyQuery
13
+ from pydantic import BaseModel
14
+
15
+ from c2casgiutils.config import settings
16
+
17
+ _LOG = logging.getLogger(__name__)
18
+
19
+ # Security schemes
20
+ api_key_query = APIKeyQuery(name="secret", auto_error=False)
21
+ api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
22
+
23
+
24
+ class AuthConfig(TypedDict, total=False):
25
+ """Configuration of the authentication."""
26
+
27
+ # The repository to check access to (<organization>/<repository>).
28
+ github_repository: str | None
29
+ # The type of access to check (admin|push|pull).
30
+ github_access_type: str | None
31
+
32
+
33
+ ADMIN_AUTH_CONFIG = AuthConfig(
34
+ github_repository=settings.auth.github.repository,
35
+ github_access_type="admin",
36
+ )
37
+
38
+
39
+ class UserInfo(BaseModel):
40
+ """Details about the user authenticated with GitHub."""
41
+
42
+ login: str = ""
43
+ display_name: str = ""
44
+ url: str = ""
45
+ token: str = ""
46
+
47
+
48
+ class AuthInfo(BaseModel):
49
+ """Details about the authentication status and user information."""
50
+
51
+ is_logged_in: bool
52
+ user: UserInfo
53
+
54
+
55
+ async def _is_auth_secret(
56
+ request: Request,
57
+ response: Response,
58
+ query_secret: Annotated[str | None, Depends(api_key_query)] = None,
59
+ header_secret: Annotated[str | None, Depends(api_key_header)] = None,
60
+ ) -> bool:
61
+ if not settings.auth.secret:
62
+ return False
63
+
64
+ expected = settings.auth.secret
65
+ secret = query_secret or header_secret
66
+ if secret is None:
67
+ try:
68
+ secret_payload = _get_jwt_cookie(request, settings.auth.cookie)
69
+ if secret_payload is not None:
70
+ secret = secret_payload.get("secret")
71
+ except jwt.ExpiredSignatureError:
72
+ _LOG.warning("Expired JWT cookie")
73
+ except jwt.InvalidTokenError as jwt_exception:
74
+ _LOG.warning("Invalid JWT cookie", exc_info=jwt_exception)
75
+
76
+ if secret is not None:
77
+ if secret == "": # nosec
78
+ # Logout
79
+ response.delete_cookie(key=settings.auth.cookie)
80
+ return False
81
+ if secret != expected:
82
+ return False
83
+ # Login or refresh the cookie
84
+ _set_jwt_cookie(
85
+ request,
86
+ response,
87
+ payload={
88
+ "secret": secret,
89
+ },
90
+ )
91
+ # Since this could be used from outside c2cwsgiutils views, we cannot set the path to c2c
92
+ return True
93
+ return False
94
+
95
+
96
+ async def _is_auth_user_github(request: Request) -> AuthInfo:
97
+ if settings.auth.test.username is not None:
98
+ # For testing purposes, we can return a fake user
99
+ return AuthInfo(
100
+ is_logged_in=True,
101
+ user=UserInfo(
102
+ login="test",
103
+ display_name=settings.auth.test.username,
104
+ url="https://example.com",
105
+ token="", # nosec
106
+ ),
107
+ )
108
+ try:
109
+ user_payload = _get_jwt_cookie(
110
+ request,
111
+ settings.auth.cookie,
112
+ )
113
+ except jwt.ExpiredSignatureError as jwt_exception:
114
+ raise HTTPException(401, "Expired session") from jwt_exception
115
+ except jwt.InvalidTokenError as jwt_exception:
116
+ raise HTTPException(401, "Invalid session") from jwt_exception
117
+ return AuthInfo(is_logged_in=user_payload is not None, user=UserInfo(**(user_payload or {})))
118
+
119
+
120
+ async def get_auth(request: Request, response: Response) -> AuthInfo:
121
+ """
122
+ Check if the client is authenticated.
123
+
124
+ Returns: boolean to indicated if the user is authenticated, and a dictionary with user details.
125
+ """
126
+ auth_type_ = auth_type()
127
+ if auth_type_ == AuthenticationType.TEST:
128
+ # For testing purposes, we can return a fake user
129
+ assert settings.auth.test.username is not None, "Test username must be set in settings"
130
+ return AuthInfo(
131
+ is_logged_in=True,
132
+ user=UserInfo(
133
+ login="test",
134
+ display_name=settings.auth.test.username,
135
+ url="https://example.com",
136
+ token="", # nosec
137
+ ),
138
+ )
139
+ if auth_type_ == AuthenticationType.NONE:
140
+ return AuthInfo(is_logged_in=False, user=UserInfo())
141
+ if auth_type_ == AuthenticationType.SECRET:
142
+ return AuthInfo(is_logged_in=await _is_auth_secret(request, response), user=UserInfo())
143
+ if auth_type_ == AuthenticationType.GITHUB:
144
+ return await _is_auth_user_github(request)
145
+
146
+ return AuthInfo(is_logged_in=False, user=UserInfo())
147
+
148
+
149
+ async def auth_required(auth_info: Annotated[AuthInfo, Depends(get_auth)]) -> None:
150
+ """
151
+ Check if the client is authenticated and raise an exception if not.
152
+
153
+ Usage:
154
+ @app.get("/protected")
155
+ async def protected_route(_: Annotated[bool, Depends(auth_required)]):
156
+ return {"message": "You are authenticated"}
157
+ """
158
+ if not auth_info.is_logged_in:
159
+ raise HTTPException(
160
+ status_code=status.HTTP_403_FORBIDDEN,
161
+ detail="Missing or invalid secret (parameter, X-API-Key header or cookie)",
162
+ )
163
+
164
+
165
+ class AuthenticationType(Enum):
166
+ """The type of authentication."""
167
+
168
+ # No Authentication configured
169
+ NONE = 0
170
+ # Authentication with a shared secret
171
+ SECRET = 1
172
+ # Authentication on GitHub and by having an access on a repository
173
+ GITHUB = 2
174
+ # Authentication used for testing purposes
175
+ TEST = 3
176
+
177
+
178
+ def auth_type() -> AuthenticationType:
179
+ """Get the authentication type."""
180
+ if settings.auth.secret is not None:
181
+ return AuthenticationType.SECRET
182
+
183
+ if settings.auth.test.username is not None:
184
+ return AuthenticationType.TEST
185
+
186
+ has_client_id = settings.auth.github.client_id is not None
187
+ has_client_secret = settings.auth.github.client_secret is not None
188
+ has_repo = settings.auth.github.repository is not None
189
+
190
+ if has_client_id and has_client_secret and has_repo:
191
+ return AuthenticationType.GITHUB
192
+
193
+ return AuthenticationType.NONE
194
+
195
+
196
+ async def check_access(
197
+ auth_info: Annotated[AuthInfo, Depends(get_auth)],
198
+ auth_config: AuthConfig,
199
+ ) -> bool:
200
+ """
201
+ Check if the user has access to the resource.
202
+
203
+ If the authentication type is not GitHub, this function is equivalent to is_auth.
204
+ """
205
+ if not auth_info.is_logged_in:
206
+ return False
207
+
208
+ if await check_admin_access(auth_info):
209
+ return True
210
+
211
+ return await check_access_config(auth_info, auth_config)
212
+
213
+
214
+ async def check_admin_access(auth_info: Annotated[AuthInfo, Depends(get_auth)]) -> bool:
215
+ """Check if the user has admin access to the resource."""
216
+ if not auth_info.is_logged_in:
217
+ return False
218
+
219
+ if auth_type() != AuthenticationType.GITHUB:
220
+ return True
221
+
222
+ return await check_access_config(auth_info, ADMIN_AUTH_CONFIG)
223
+
224
+
225
+ async def check_access_config(
226
+ auth_info: Annotated[AuthInfo, Depends(get_auth)],
227
+ auth_config: AuthConfig,
228
+ ) -> bool:
229
+ """Check if the user has access to the resource."""
230
+ if not auth_info.is_logged_in:
231
+ return False
232
+
233
+ repo_url = settings.auth.github.repo_url
234
+ token = auth_info.user.token
235
+ headers = {
236
+ "Authorization": f"Bearer {token}",
237
+ "Accept": "application/json",
238
+ }
239
+
240
+ async with (
241
+ aiohttp.ClientSession() as session,
242
+ session.get(
243
+ f"{repo_url}/{auth_config.get('github_repository')}",
244
+ headers=headers,
245
+ ) as response,
246
+ ):
247
+ repository = await response.json()
248
+ return not (
249
+ "permissions" not in repository
250
+ or repository["permissions"][auth_config.get("github_access_type")] is not True
251
+ )
252
+
253
+
254
+ async def require_access(
255
+ auth_info: Annotated[AuthInfo, Depends(get_auth)],
256
+ auth_config: AuthConfig,
257
+ ) -> None:
258
+ """
259
+ FastAPI dependency that requires GitHub repository access.
260
+
261
+ Usage:
262
+ @app.get("/protected")
263
+ async def protected_route(_: Annotated[bool, Depends(
264
+ lambda auth: require_access(auth_info, {"github_repository": "org/repo", "github_access_type": "admin"})
265
+ )]):
266
+ return {"message": "You have access"}
267
+ """
268
+ if not await check_access(auth_info, auth_config):
269
+ raise HTTPException(
270
+ status_code=status.HTTP_403_FORBIDDEN,
271
+ detail="You don't have access to this resource",
272
+ )
273
+
274
+
275
+ async def require_admin_access(
276
+ auth_info: Annotated[AuthInfo, Depends(get_auth)],
277
+ ) -> None:
278
+ """FastAPI dependency that requires admin access.
279
+
280
+ Usage:
281
+ @app.get("/admin_protected")
282
+ async def admin_protected_route(_: Annotated[bool, Depends(require_admin_access)]):
283
+ return {"message": "You have admin access"}
284
+ """
285
+ if not await check_admin_access(auth_info):
286
+ raise HTTPException(
287
+ status_code=status.HTTP_403_FORBIDDEN,
288
+ detail="You don't have admin access to this resource",
289
+ )
290
+
291
+
292
+ def is_enabled() -> bool:
293
+ """Is the authentication enabled."""
294
+ return auth_type() is not None
295
+
296
+
297
+ # Helper functions for FastAPI dependency injections
298
+
299
+
300
+ def _set_jwt_cookie(
301
+ request: Request,
302
+ response: Response,
303
+ payload: dict[str, Any],
304
+ cookie_name: str = settings.auth.cookie,
305
+ expiration: int = settings.auth.cookie_age,
306
+ path: str | None = None,
307
+ ) -> None:
308
+ """
309
+ Set a JWT cookie in the response.
310
+
311
+ Arguments
312
+ ---------
313
+ response: The response object to set the cookie on.
314
+ payload: The payload to encode in the JWT.
315
+ cookie_name: The name of the cookie to set.
316
+ expiration: The expiration time in seconds for the cookie and the token.
317
+ """
318
+ if path is None:
319
+ if settings.auth.jwt.cookie.path is not None:
320
+ path = settings.auth.jwt.cookie.path
321
+ else:
322
+ path = request.url_for("c2c_index").path
323
+
324
+ jwt_payload = {
325
+ **payload,
326
+ "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=expiration),
327
+ "iat": datetime.datetime.now(datetime.timezone.utc),
328
+ }
329
+ response.set_cookie(
330
+ key=cookie_name,
331
+ value=jwt.encode(jwt_payload, settings.auth.jwt.secret, algorithm=settings.auth.jwt.algorithm),
332
+ max_age=expiration,
333
+ httponly=True,
334
+ secure=settings.auth.jwt.cookie.secure,
335
+ samesite=settings.auth.jwt.cookie.same_site,
336
+ path=path,
337
+ )
338
+
339
+
340
+ def _get_jwt_cookie(request: Request, cookie_name: str) -> dict[str, Any] | None:
341
+ """
342
+ Get the JWT cookie from the request.
343
+
344
+ Arguments
345
+ ---------
346
+ request: The request object containing cookies.
347
+ cookie_name: The name of the cookie to retrieve.
348
+
349
+ Returns
350
+ -------
351
+ The decoded JWT payload if the cookie exists and is valid, otherwise None.
352
+ """
353
+ if cookie_name not in request.cookies:
354
+ return None
355
+ return cast(
356
+ "dict[str, Any]",
357
+ jwt.decode(
358
+ request.cookies[cookie_name],
359
+ settings.auth.jwt.secret,
360
+ algorithms=[settings.auth.jwt.algorithm],
361
+ options={"require": ["exp", "iat"]}, # Force presence of timestamps
362
+ ),
363
+ )
364
+
365
+
366
+ async def _github_login(request: Request, response: Response) -> RedirectResponse:
367
+ """Get the view that start the authentication on GitHub."""
368
+ params = dict(request.query_params)
369
+ callback_url = str(request.url_for("c2c_github_callback"))
370
+ if "came_from" in params:
371
+ callback_url = f"{callback_url}?came_from={params['came_from']}"
372
+
373
+ proxy_url = settings.auth.github.proxy_url
374
+ if proxy_url is not None:
375
+ url = (
376
+ proxy_url
377
+ + ("&" if "?" in proxy_url else "?")
378
+ + urllib.parse.urlencode({"came_from": callback_url})
379
+ )
380
+ else:
381
+ url = callback_url
382
+
383
+ # Generate state for CSRF protection
384
+ state = secrets.token_urlsafe(32)
385
+
386
+ # Build authorization URL manually
387
+ auth_url = settings.auth.github.authorize_url
388
+ client_id = settings.auth.github.client_id
389
+ scope = settings.auth.github.scope
390
+
391
+ if client_id is None:
392
+ raise HTTPException(
393
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
394
+ detail="GitHub client ID is not configured",
395
+ )
396
+ if scope is None:
397
+ raise HTTPException(
398
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
399
+ detail="GitHub scope is not configured",
400
+ )
401
+
402
+ params = {
403
+ "client_id": client_id,
404
+ "redirect_uri": url,
405
+ "scope": scope,
406
+ "state": state,
407
+ "response_type": "code",
408
+ }
409
+ authorization_url = f"{auth_url}?{urllib.parse.urlencode(params)}"
410
+
411
+ # State is used to prevent CSRF.
412
+ _set_jwt_cookie(
413
+ request,
414
+ response,
415
+ payload={
416
+ "oauth_state": state,
417
+ },
418
+ cookie_name=settings.auth.github.state_cookie,
419
+ expiration=settings.auth.github.state_cookie_age,
420
+ path=request.url_for("c2c_github_callback").path,
421
+ )
422
+
423
+ redirect_response = RedirectResponse(authorization_url)
424
+ for value in response.headers.getlist("Set-Cookie"):
425
+ redirect_response.headers.append("Set-Cookie", value)
426
+ return redirect_response
427
+
428
+
429
+ class _ErrorResponse(BaseModel):
430
+ """Error response model for GitHub login callback."""
431
+
432
+ error: str
433
+
434
+
435
+ async def _github_login_callback(
436
+ request: Request,
437
+ response: Response,
438
+ ) -> _ErrorResponse | RedirectResponse:
439
+ """
440
+ Do the post login operation authentication on GitHub.
441
+
442
+ This will use the oauth token to get the user details from GitHub.
443
+ And ask the GitHub rest API the information related to the configured repository
444
+ to know which kind of access the user have.
445
+ """
446
+ try:
447
+ state_payload = _get_jwt_cookie(
448
+ request,
449
+ settings.auth.github.state_cookie,
450
+ )
451
+ stored_state = state_payload.get("oauth_state") if state_payload else None
452
+ except jwt.ExpiredSignatureError as jwt_exception:
453
+ response.status_code = status.HTTP_401_UNAUTHORIZED
454
+ return _ErrorResponse(error=f"Expired JWT cookie: {jwt_exception}")
455
+ except jwt.InvalidTokenError as jwt_exception:
456
+ response.status_code = status.HTTP_400_BAD_REQUEST
457
+ _LOG.warning("Invalid JWT cookie", exc_info=jwt_exception)
458
+ return _ErrorResponse(error=f"Invalid JWT cookie: {jwt_exception}")
459
+
460
+ received_state = request.query_params.get("state")
461
+ code = request.query_params.get("code")
462
+ response.delete_cookie(
463
+ key=settings.auth.github.state_cookie,
464
+ )
465
+
466
+ # Verify state parameter to prevent CSRF attacks
467
+ if stored_state != received_state:
468
+ response.status_code = status.HTTP_400_BAD_REQUEST
469
+ return _ErrorResponse(error="Invalid state parameter")
470
+
471
+ if request.query_params.get("error"):
472
+ response.status_code = status.HTTP_400_BAD_REQUEST
473
+ return _ErrorResponse(error=request.query_params.get("error", "Missing error"))
474
+
475
+ callback_url = str(request.url_for("c2c_github_callback"))
476
+ proxy_url = settings.auth.github.proxy_url
477
+ if proxy_url is not None:
478
+ url = (
479
+ proxy_url
480
+ + ("&" if "?" in proxy_url else "?")
481
+ + urllib.parse.urlencode({"came_from": callback_url})
482
+ )
483
+ else:
484
+ url = callback_url
485
+
486
+ # Exchange code for token
487
+ token_url = settings.auth.github.token_url
488
+ client_id = settings.auth.github.client_id
489
+ client_secret = settings.auth.github.client_secret
490
+
491
+ # Prepare token exchange
492
+ token_data = {
493
+ "client_id": client_id,
494
+ "client_secret": client_secret,
495
+ "code": code,
496
+ "redirect_uri": url,
497
+ "state": received_state,
498
+ }
499
+ headers = {"Accept": "application/json"}
500
+
501
+ # Get token
502
+ async with aiohttp.ClientSession() as session:
503
+ async with session.post(token_url, data=token_data, headers=headers) as response_token:
504
+ if response_token.status != 200:
505
+ response.status_code = status.HTTP_400_BAD_REQUEST
506
+ return _ErrorResponse(error=f"Failed to obtain token: {await response_token.text()}")
507
+ token = await response_token.json()
508
+
509
+ # Get user info
510
+ user_url = settings.auth.github.user_url
511
+ headers = {
512
+ "Authorization": f"Bearer {token}",
513
+ "Accept": "application/json",
514
+ }
515
+
516
+ async with session.get(user_url, headers=headers) as response_user:
517
+ if response_user.status != 200:
518
+ response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
519
+ return _ErrorResponse(error=f"Failed to get user info: {await response_user.text()}")
520
+ user = await response_user.json()
521
+
522
+ user_information = UserInfo(
523
+ login=user["login"],
524
+ display_name=user["name"],
525
+ url=user["html_url"],
526
+ token=token,
527
+ )
528
+ _set_jwt_cookie(request, response, payload=user_information.model_dump())
529
+
530
+ # Redirect to success page or front page
531
+ redirect_after_login = request.query_params.get("came_from", str(request.url_for("c2c_index")))
532
+ redirect_response = RedirectResponse(redirect_after_login)
533
+ for value in response.headers.getlist("Set-Cookie"):
534
+ redirect_response.headers.append("Set-Cookie", value)
535
+ return redirect_response
536
+
537
+
538
+ async def _github_logout(request: Request, response: Response) -> RedirectResponse:
539
+ """Logout the user."""
540
+ response.delete_cookie(key=settings.auth.cookie)
541
+
542
+ redirect_url = request.query_params.get("came_from", str(request.url_for("c2c_index")))
543
+ return RedirectResponse(redirect_url)
544
+
545
+
546
+ router = APIRouter()
547
+
548
+ _auth_type = auth_type()
549
+ if _auth_type == AuthenticationType.SECRET:
550
+ _LOG.warning(
551
+ "It is recommended to use OAuth2 with GitHub login instead of the `C2C_SECRET` because it "
552
+ "protects from brute force attacks and the access grant is personal and can be revoked.",
553
+ )
554
+
555
+
556
+ if _auth_type == AuthenticationType.SECRET:
557
+
558
+ @router.get("/login")
559
+ async def login(
560
+ request: Request,
561
+ response: Response,
562
+ secret: str | None = None,
563
+ api_key: Annotated[str | None, Depends(api_key_header)] = None,
564
+ ) -> dict[str, str]:
565
+ """Login with a secret."""
566
+ if secret is None and api_key is None:
567
+ raise HTTPException(
568
+ status_code=status.HTTP_400_BAD_REQUEST,
569
+ detail="Missing secret or X-API-Key header",
570
+ )
571
+
572
+ actual_secret = secret or api_key
573
+ expected = settings.auth.secret
574
+ if not expected or actual_secret != expected:
575
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid secret")
576
+
577
+ # Set cookie
578
+ _set_jwt_cookie(
579
+ request,
580
+ response,
581
+ payload={
582
+ "secret": actual_secret,
583
+ },
584
+ )
585
+ return {"status": "success", "message": "Authentication successful"}
586
+
587
+ @router.get("/logout")
588
+ async def c2c_logout(response: Response) -> dict[str, str]:
589
+ """Logout by clearing the authentication cookie."""
590
+ response.delete_cookie(key=settings.auth.cookie)
591
+ return {"status": "success", "message": "Logged out successfully"}
592
+
593
+
594
+ if _auth_type in (AuthenticationType.SECRET, AuthenticationType.GITHUB):
595
+
596
+ @router.get("/status")
597
+ async def c2c_auth_status(auth_info: Annotated[AuthInfo, Depends(get_auth)]) -> AuthInfo:
598
+ """Get the authentication status."""
599
+ return auth_info
600
+
601
+
602
+ if _auth_type == AuthenticationType.GITHUB:
603
+ if not settings.auth.github.client_secret:
604
+ _LOG.warning(
605
+ "You are using GitHub authentication but the `AUTH_GITHUB_CLIENT_SECRET` is not set. "
606
+ "This will work, but for security reasons, it is recommended to set this value.",
607
+ )
608
+
609
+ @router.get("/github/login")
610
+ async def c2c_github_login(request: Request, response: Response) -> RedirectResponse:
611
+ """Initialize GitHub OAuth login flow."""
612
+ return await _github_login(request, response)
613
+
614
+ @router.get("/github/callback", response_model=_ErrorResponse)
615
+ async def c2c_github_callback(request: Request, response: Response) -> _ErrorResponse | RedirectResponse:
616
+ """Handle GitHub OAuth callback."""
617
+ return await _github_login_callback(request, response)
618
+
619
+ @router.get("/github/logout")
620
+ async def c2c_github_logout(request: Request, response: Response) -> RedirectResponse:
621
+ """Logout from GitHub authentication."""
622
+ return await _github_logout(request, response)