Download src/web/auth.py from Cripple-Lee/Personal-Assistant-Server: direct link, hf CLI and curl.
- Browser
- Download file 5.23 kB
-
https://huggingface.co/spaces/Cripple-Lee/Personal-Assistant-Server/resolve/main/src/web/auth.py
- Command line
-
hf download hf://spaces/Cripple-Lee/Personal-Assistant-Server/src/web/auth.py
-
curl -L -o auth.py https://huggingface.co/spaces/Cripple-Lee/Personal-Assistant-Server/resolve/main/src/web/auth.py
5.23 kB
| """Authentication for the Aegra / LangGraph server via ``langgraph_sdk.Auth``. | |
| The ``@auth.authenticate`` handler verifies the HS256 JWT issued by the | |
| ``/auth/login`` route (see ``web/login.py``) and returns the authenticated | |
| user's data. Aegra injects that dict into | |
| ``config["configurable"]["langgraph_auth_user"]`` before graph execution, so | |
| nodes and tools can read the user's identity, role, etc. The Hugging Face | |
| token used to build agents comes from the server-wide ``HF_TOKEN`` env var | |
| (see ``web/login.py``), not from the user record. | |
| Wire it up in ``langgraph.json``:: | |
| "auth": { "path": "./src/web/auth.py:auth" } | |
| Requires the ``JWT_SECRET`` environment variable. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from typing import Any | |
| import jwt | |
| from langgraph_sdk import Auth | |
| auth = Auth() | |
| JWT_SECRET = os.environ.get("JWT_SECRET") | |
| if not JWT_SECRET: | |
| raise RuntimeError("JWT_SECRET environment variable is required") | |
| JWT_ALGORITHM = "HS256" | |
| # Per-user monthly token allowance (prompt + completion tokens). A run that | |
| # would start after the user has reached this limit is rejected with 403. | |
| # Usage resets on the first day of every month (see web/db.py). | |
| MONTHLY_TOKEN_LIMIT = int(os.environ.get("MONTHLY_TOKEN_LIMIT", "100000")) | |
| def create_access_token(user: dict[str, Any]) -> str: | |
| """Sign a stateless access token for the given user row.""" | |
| payload = { | |
| "sub": str(user["id"]), | |
| "username": user["username"], | |
| "display_name": user.get("display_name") or user["username"], | |
| "role": user.get("role", "user"), | |
| } | |
| return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) | |
| async def authenticate(headers: dict) -> dict: | |
| """Verify the bearer JWT and return the authenticated user's data. | |
| Everything returned here is available server-side as | |
| ``config["configurable"]["langgraph_auth_user"]`` in graph nodes/tools and | |
| as ``ctx.user`` in authorization handlers. Raising any exception denies | |
| the request with 401. | |
| """ | |
| authorization = headers.get("Authorization") or headers.get("authorization") or "" | |
| token = authorization.replace("Bearer ", "").strip() | |
| if not token: | |
| # Clean 401 (no traceback) for requests without a bearer token. | |
| raise Auth.exceptions.HTTPException(status_code=401, detail="Missing Authorization header") | |
| try: | |
| payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) | |
| except jwt.PyJWTError as exc: | |
| raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid or expired token") from exc | |
| # Look up the user so token data can't go stale (role changes, | |
| # deactivated accounts). | |
| from web import db # local import to avoid a cycle at module load time | |
| user = await db.get_user_by_id(payload["sub"]) | |
| if not user or not user.get("is_active", True): | |
| raise Auth.exceptions.HTTPException(status_code=401, detail="Account not found or deactivated") | |
| return { | |
| "identity": str(user["id"]), | |
| "display_name": user.get("display_name") or user["username"], | |
| "permissions": [user.get("role", "user")], | |
| "is_authenticated": True, | |
| # Custom fields — preserved on ctx.user and in langgraph_auth_user. | |
| "username": user["username"], | |
| "role": user.get("role", "user"), | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Authorization handlers (additive: they only restrict, never grant) | |
| # --------------------------------------------------------------------------- | |
| async def stamp_thread_owner(ctx, value): | |
| """Tag every new thread with the owner's identity as metadata.""" | |
| metadata = value.setdefault("metadata", {}) | |
| metadata["owner_id"] = ctx.user.identity | |
| return value | |
| async def filter_threads_by_owner(ctx, value): | |
| """Users only see their own threads in search/list results.""" | |
| return {"metadata": {"owner_id": ctx.user.identity}} | |
| async def filter_thread_reads_by_owner(ctx, value): | |
| """Apply the same owner filter to by-id reads, state, and history.""" | |
| return {"metadata": {"owner_id": ctx.user.identity}} | |
| async def enforce_monthly_token_limit(ctx, value): | |
| """Reject run creation once the user's monthly token limit is reached. | |
| Runs are created on a thread (``POST /threads/{id}/runs``), so this | |
| handler fires for every new run — including streamed ones. Raising | |
| ``HTTPException`` here aborts the request before any model tokens are | |
| spent; the client surfaces the detail message to the user. | |
| """ | |
| from web import db # local import to avoid a cycle at module load time | |
| used = await db.get_token_usage(ctx.user.identity) | |
| if used >= MONTHLY_TOKEN_LIMIT: | |
| raise Auth.exceptions.HTTPException( | |
| status_code=403, | |
| detail=( | |
| f"Monthly token limit reached ({used}/{MONTHLY_TOKEN_LIMIT} " | |
| "tokens used). Your allowance refreshes on the first day of " | |
| "next month." | |
| ), | |
| ) | |
| # Accept the request (no filter needed for run creation). | |
| return None | |