""" App user management logic. """ import asyncio import fastapi from cachetools import LRUCache, TTLCache from fastapi import Depends, HTTPException, WebSocket, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from .. import logger from ..connectors.db import Client from ..constants import Auth0Users from ..environment_vars import AUTH0_SETTINGS from ..security import auth0 from ..typings import UserID logger = logger.new_logger(__name__) auth0_client = auth0.Auth0(settings=AUTH0_SETTINGS) async def register_user(db: Client, subject: auth0.types.Auth0Subject) -> UserID: """Register a new user in the app's DB using Auth0 data. Args: db: The database client instance. subject: The user's subject, as provided by Auth0. Example: "auth0|5f3e3e3e3e3e3e3e3e3e3e3e" """ auth0_subject_data = await auth0_client.get_user(subject) fields = { field_name: auth0_subject_data[field_name] for field_name in ( Auth0Users.NICKNAME, Auth0Users.NAME, Auth0Users.EMAIL, ) } created_user_id = await db.Users.create(subject, fields) return created_user_id # Cache for user ID lookups by subject. Key: Auth0 subject, Value: User ID. subjects_user_id = TTLCache(maxsize=256, ttl=3600) async def get_subject_user_id( db: Client, subject: auth0.types.Auth0Subject ) -> UserID | None: """Get the user's ID from the app's DB looking it up by subject. For performance, the result is cached for 1 hour. Args: db: The database client instance. subject: The user's subject, as provided by Auth0. Example: "auth0|5f3e3e3e3e3e3e3e3e3e3e3e" Returns: The user's ID, or None if a user for the given subject does not exist. """ user_id = subjects_user_id.get(subject) if user_id is None: # Cache miss logic, will hit the DB user_data = await db.Users.get(subject) user_id = user_data.get(Auth0Users.ID) if user_data else None subjects_user_id[subject] = user_id return user_id async def verify_auth0( token: HTTPAuthorizationCredentials | None = Depends(HTTPBearer()), ): """THIS IS A FASTAPI DEPENDENCY FUNCTION, WRAPPING THE ACTUAL VERIFICATION FUNCTION. DO NOT CALL THIS FUNCTION DIRECTLY. THIS DEPENDENCY IS FOR HTTP REQUESTS AND WILL NOT WORK WITH WEBSOCKETS. """ return await _verify_auth0(token) async def verify_auth0_websocket(websocket: WebSocket) -> tuple[dict, dict] | None: """Verify the token provided in the WebSocket query parameters. The token must be provided in the query parameters as a JSON object with the key "token". Example: {"token": "your_token_here"} Args: websocket: The WebSocket connection instance. Returns: A tuple containing the JSON object from the query parameters and the user's credentials if the token is valid. None if the token is invalid or not provided, and the connection is closed. """ await websocket.accept() json = await websocket.receive_json() token = json.get("token") async def ws_close(): await websocket.close(code=fastapi.status.WS_1008_POLICY_VIOLATION) if not token: await ws_close() return None try: token = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) except ValueError: await ws_close() return None try: credentials = await _verify_auth0(token) return json, credentials except HTTPException: await ws_close() return None _ = None # Merely for keeping a reference to fire and forget the user creation task. async def _verify_auth0(token: HTTPAuthorizationCredentials) -> dict: """Verify the token using Auth0. If the token is valid, return the user's credentials. Should the user not exist in the app's DB, create it. Args: token: The token to verify. It can be an instance of HTTPAuthorizationCredentials or a string. """ if not token: logger.error("No Auth0 token provided.") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) credentials = auth0_client.verify(token.credentials) subject = credentials.get("sub") if not subject: logger.error("No subject found in the token.") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) # Check whether the user exists in the app's DB and create it if not. # Even though this check is performed every time an Auth0 token is verified, # the target function is cached to avoid unnecessary DB queries. # Fire and forget the user creation task so that the frontend does not # have to wait for it. _ = asyncio.create_task(_create_user_if_not_exists(credentials["sub"])) return credentials _seen_subjects = LRUCache(maxsize=1024) _user_creation_lock = asyncio.Lock() def _user_already_created(subject: str) -> bool: return isinstance(_seen_subjects.get(subject), int) async def _create_user_if_not_exists(subject: str) -> UserID | None: """Create a new user in the app's DB if it does not exist. This is necessary for automatic user registration when a new user logs in for the first time, right after the token is verified. CACHE WARNING: This function is cached to remember the result for the same subject, so that there are no unnecessary DB queries. Args: subject: The user's subject, as provided by Auth0. Example: "auth0|5f3e3e3e3e3e3e3e3e3e3e3e" Returns: The user's ID. None if the user already existed. """ if _user_already_created(subject): return None db = Client() # Use a lock preventing conflicts in the edge case of multiple # requests for the same user being processed simultaneously. async with _user_creation_lock: if _user_already_created(subject): # Check again after acquiring the lock. This may help if the # lock has a queue and the user was seen while waiting. return None registered_user_data = await db.Users.get(subject) if registered_user_data is None: # User does not exist, create it. logger.info( "New user detected (subject {}). Proceeding with " "registration into app db...", subject, ) assigned_user_id = await register_user(db, subject) _seen_subjects[subject] = assigned_user_id return assigned_user_id return None