"""Authentication utilities for the medication-adherence layer. Self-contained and additive: the existing chat endpoints never import this, so they stay fully public and unchanged. """ from datetime import datetime, timedelta from typing import Optional from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt import bcrypt from sqlalchemy.orm import Session import config from database import get_db, User # tokenUrl is only used by Swagger's "Authorize" button. oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login", auto_error=False) def _to_bytes(password: str) -> bytes: # bcrypt only considers the first 72 bytes; truncate explicitly to avoid errors. return password.encode("utf-8")[:72] def hash_password(password: str) -> str: return bcrypt.hashpw(_to_bytes(password), bcrypt.gensalt()).decode("utf-8") def verify_password(plain: str, hashed: str) -> bool: try: return bcrypt.checkpw(_to_bytes(plain), hashed.encode("utf-8")) except Exception: return False def create_access_token(user_id: int) -> str: expire = datetime.utcnow() + timedelta(minutes=config.JWT_EXPIRE_MIN) payload = {"sub": str(user_id), "exp": expire} return jwt.encode(payload, config.JWT_SECRET, algorithm=config.JWT_ALG) def decode_token(token: str) -> Optional[int]: try: payload = jwt.decode(token, config.JWT_SECRET, algorithms=[config.JWT_ALG]) sub = payload.get("sub") return int(sub) if sub is not None else None except (JWTError, ValueError): return None _credentials_exc = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"}, ) def get_current_user( token: Optional[str] = Depends(oauth2_scheme), db: Session = Depends(get_db), ) -> User: """FastAPI dependency: resolves the Bearer token to a User or 401s.""" if not token: raise _credentials_exc user_id = decode_token(token) if user_id is None: raise _credentials_exc user = db.query(User).filter(User.id == user_id).first() if user is None: raise _credentials_exc return user def get_optional_user( token: Optional[str] = Depends(oauth2_scheme), db: Session = Depends(get_db), ) -> Optional[User]: """Like get_current_user but returns None instead of raising. Used by the /v2 conversation endpoints so chat works logged-in or out. """ if not token: return None user_id = decode_token(token) if user_id is None: return None return db.query(User).filter(User.id == user_id).first()