mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 04:12:36 +00:00
The ASGI message events route now accepts the same scopes as its Flask sibling (conversations:read or chat:run) through a shared constant. A token request that fails routing gets Flask's 404/405 instead of a 403. Creating a token retires an expired token that still held the name. allowed_ids uses is_pat instead of a bare literal. PAT_ENABLED is documented as the master switch it is: turning it off stops every existing token from authenticating. The docs explain that sources are matched by name (oldest wins) and point CI flows at sources upload --replace.
89 lines
3.8 KiB
Python
89 lines
3.8 KiB
Python
"""Request authentication for the native-async (Starlette) routes.
|
|
|
|
Mirrors ``authenticate_request`` and the log-context hooks in
|
|
``docsgpt/app.py`` so a route moved off Flask keeps the same gates: the same
|
|
JWT decoder and 401 payloads, and the OIDC revocation check.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from contextvars import Token
|
|
from typing import Optional, Sequence, Tuple, Union
|
|
|
|
import anyio
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse
|
|
|
|
from docsgpt.api.oidc.denylist import is_denied as oidc_session_denied
|
|
from docsgpt.api.pat.tokens import is_pat
|
|
from docsgpt.auth import handle_auth
|
|
from docsgpt.core import log_context
|
|
from docsgpt.core.settings import settings
|
|
|
|
|
|
async def authenticate(
|
|
request: Request, *, pat_scope: Union[str, Sequence[str], None] = None
|
|
) -> Tuple[Optional[dict], Optional[JSONResponse]]:
|
|
"""Decode the caller's JWT the way Flask's ``authenticate_request`` does.
|
|
|
|
Args:
|
|
request: The incoming Starlette request.
|
|
pat_scope: Scope (or any-of scopes) a personal access token needs for this route. Left
|
|
unset, the route rejects PATs outright (deny by default, matching
|
|
the Flask rule table in ``docsgpt/api/pat/rules.py``). A token with
|
|
a resource filter is always rejected.
|
|
|
|
Returns:
|
|
tuple: ``(claims, None)`` for an authenticated caller, ``(None, None)``
|
|
when no token was sent (the route decides whether that is allowed), or
|
|
``(None, response)`` carrying the 401 to return.
|
|
"""
|
|
# A personal access token resolves against Postgres; keep that sync read off the event loop.
|
|
decoded = await anyio.to_thread.run_sync(handle_auth, request)
|
|
if not decoded:
|
|
return None, None
|
|
if "error" in decoded:
|
|
return None, JSONResponse(decoded, status_code=401)
|
|
if is_pat(decoded):
|
|
# A PAT lookup already excludes revoked tokens and deactivated users,
|
|
# so the session denylist below does not apply to it.
|
|
accepted = (pat_scope,) if isinstance(pat_scope, str) else tuple(pat_scope or ())
|
|
if not set(accepted).intersection(decoded.get("scopes") or []):
|
|
return None, JSONResponse(
|
|
{"success": False, "message": "Token lacks the required scope", "error": "insufficient_scope"},
|
|
status_code=403,
|
|
)
|
|
if decoded.get("resource_filter"):
|
|
# These routes sit outside the Flask rule table and cannot tie what
|
|
# they serve to an allowlist, so a restricted token is kept out.
|
|
return None, JSONResponse(
|
|
{
|
|
"success": False,
|
|
"message": "This endpoint is not available to a resource-restricted token",
|
|
"error": "resource_not_allowed",
|
|
},
|
|
status_code=403,
|
|
)
|
|
return decoded, None
|
|
# The denylist is a sync Redis read; keep it off the event loop.
|
|
if settings.AUTH_TYPE == "oidc" and await anyio.to_thread.run_sync(oidc_session_denied, decoded):
|
|
return None, JSONResponse(
|
|
{"message": "Authentication error: session revoked", "error": "token_revoked"},
|
|
status_code=401,
|
|
)
|
|
return decoded, None
|
|
|
|
|
|
def bind_log_context(endpoint: str, user_id: Optional[str] = None) -> Token:
|
|
"""Stamp this request's log records with a fresh activity id, the endpoint and user.
|
|
|
|
Each request runs in its own task, so the binding ends with the request.
|
|
"""
|
|
return log_context.bind(activity_id=str(uuid.uuid4()), endpoint=endpoint, user_id=user_id)
|
|
|
|
|
|
def json_error(message: str, status_code: int) -> JSONResponse:
|
|
"""Return the ``{"success": false, "message": ...}`` body the Flask routes use."""
|
|
return JSONResponse({"success": False, "message": message}, status_code=status_code)
|