Spaces:
Running
Running
| """ | |
| JWT access tokens and the authenticated-user dependency. | |
| This module is the **only** place a request's identity may come from. Routers | |
| take `user_id: str = Depends(current_user)`; no endpoint accepts a caller-supplied | |
| user id, so identity cannot be spoofed by editing a request body. | |
| Kept in `api/` because it is a pure HTTP concern — `app/` stays reusable by the | |
| Streamlit application, which has its own session handling. | |
| """ | |
| from __future__ import annotations | |
| from datetime import datetime, timedelta, timezone | |
| import jwt | |
| from fastapi import Depends, HTTPException, status | |
| from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer | |
| from api.settings import get_api_settings | |
| _ALGORITHM = "HS256" | |
| _TOKEN_TYPE = "access" | |
| # auto_error=False so we can raise our own 401 with a WWW-Authenticate header | |
| # instead of FastAPI's bare 403 for a missing Authorization header. | |
| _bearer = HTTPBearer(auto_error=False) | |
| def _unauthorized(detail: str) -> HTTPException: | |
| return HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail=detail, | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| def issue_access_token(username: str) -> tuple[str, int]: | |
| """ | |
| Mint a short-lived access token. | |
| Returns: | |
| (token, expires_in_seconds) — the client uses the second value to | |
| refresh proactively rather than waiting for a 401. | |
| """ | |
| cfg = get_api_settings() | |
| ttl = timedelta(minutes=cfg.access_token_ttl_minutes) | |
| now = datetime.now(timezone.utc) | |
| payload = { | |
| "sub": username, | |
| "type": _TOKEN_TYPE, | |
| "iat": int(now.timestamp()), | |
| "exp": int((now + ttl).timestamp()), | |
| } | |
| token = jwt.encode(payload, cfg.jwt_secret, algorithm=_ALGORITHM) | |
| return token, int(ttl.total_seconds()) | |
| def decode_access_token(token: str) -> str: | |
| """Verify an access token and return its subject. Raises 401 when invalid.""" | |
| cfg = get_api_settings() | |
| try: | |
| payload = jwt.decode(token, cfg.jwt_secret, algorithms=[_ALGORITHM]) | |
| except jwt.ExpiredSignatureError: | |
| raise _unauthorized("Access token expired") from None | |
| except jwt.PyJWTError: | |
| raise _unauthorized("Invalid access token") from None | |
| # Reject a refresh token presented where an access token is required. | |
| if payload.get("type") != _TOKEN_TYPE: | |
| raise _unauthorized("Wrong token type") | |
| subject = payload.get("sub") | |
| if not subject: | |
| raise _unauthorized("Malformed token") | |
| return str(subject) | |
| def current_user( | |
| credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), | |
| ) -> str: | |
| """ | |
| FastAPI dependency resolving the authenticated user id. | |
| This is the single source of identity for every protected route. | |
| """ | |
| if credentials is None or not credentials.credentials: | |
| raise _unauthorized("Not authenticated") | |
| return decode_access_token(credentials.credentials) | |