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.
- c2casgiutils/__init__.py +26 -0
- c2casgiutils/auth.py +622 -0
- c2casgiutils/broadcast/__init__.py +170 -0
- c2casgiutils/broadcast/interface.py +28 -0
- c2casgiutils/broadcast/local.py +44 -0
- c2casgiutils/broadcast/redis.py +198 -0
- c2casgiutils/broadcast/utils.py +10 -0
- c2casgiutils/config.py +251 -0
- c2casgiutils/headers.py +268 -0
- c2casgiutils/health_checks.py +346 -0
- c2casgiutils/py.typed +0 -0
- c2casgiutils/redis_utils.py +77 -0
- c2casgiutils/scripts/genversion.py +62 -0
- c2casgiutils/tools/__init__.py +91 -0
- c2casgiutils/tools/headers.py +85 -0
- c2casgiutils/tools/logging_.py +146 -0
- c2casgiutils/tools/static/favicon-16x16.png +0 -0
- c2casgiutils/tools/static/favicon-32x32.png +0 -0
- c2casgiutils/tools/static/index.css +18 -0
- c2casgiutils/tools/static/index.js +234 -0
- c2casgiutils/tools/templates/index.html +109 -0
- c2casgiutils-0.5.0.dist-info/METADATA +466 -0
- c2casgiutils-0.5.0.dist-info/RECORD +25 -0
- c2casgiutils-0.5.0.dist-info/WHEEL +4 -0
- c2casgiutils-0.5.0.dist-info/entry_points.txt +3 -0
c2casgiutils/__init__.py
ADDED
|
@@ -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)
|