"""Password hashing and JWT token utilities.""" from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any import bcrypt from jose import JWTError, jwt from app.core.config import settings from app.core.exceptions import AuthenticationError # bcrypt operates on at most 72 bytes; longer inputs must be truncated # explicitly (bcrypt 5.x raises rather than silently truncating). _BCRYPT_MAX_BYTES = 72 def _to_bcrypt_bytes(password: str) -> bytes: return password.encode("utf-8")[:_BCRYPT_MAX_BYTES] def hash_password(plain_password: str) -> str: hashed = bcrypt.hashpw(_to_bcrypt_bytes(plain_password), bcrypt.gensalt()) return hashed.decode("utf-8") def verify_password(plain_password: str, hashed_password: str) -> bool: try: return bcrypt.checkpw(_to_bcrypt_bytes(plain_password), hashed_password.encode("utf-8")) except (ValueError, TypeError): return False def create_access_token( subject: str, expires_delta: timedelta | None = None, extra_claims: dict[str, Any] | None = None, ) -> str: expire = datetime.now(UTC) + ( expires_delta or timedelta(minutes=settings.access_token_expire_minutes) ) to_encode: dict[str, Any] = {"sub": subject, "exp": expire, "type": "access"} if extra_claims: to_encode.update(extra_claims) return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) def decode_access_token(token: str) -> str: """Return the subject (user id) from a valid access token or raise.""" try: payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]) except JWTError as exc: raise AuthenticationError("Invalid or expired token") from exc subject = payload.get("sub") if not subject: raise AuthenticationError("Invalid token payload") return str(subject)