Coverage for youversion/core/authenticator.py: 100%
38 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 11:31 +0100
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 11:31 +0100
1"""Authentication handler for YouVersion API."""
3import base64
4import os
6import httpx
7import jwt
8from dotenv import load_dotenv
10from ..config import Config
11from .interfaces import IAuthenticator
14class Authenticator(IAuthenticator):
15 """Handles authentication for YouVersion API using OAuth2."""
17 def __init__(self, username: str | None = None, password: str | None = None):
18 """Initialize authenticator with credentials.
20 Args:
21 username: Username for authentication
22 password: Password for authentication
23 """
24 load_dotenv()
26 self.username = username or os.getenv("YOUVERSION_USERNAME")
27 self.password = password or os.getenv("YOUVERSION_PASSWORD")
29 if not self.username or not self.password:
30 raise ValueError(
31 "Username and password must be provided either as arguments "
32 "or as YOUVERSION_USERNAME and YOUVERSION_PASSWORD environment variables"
33 )
34 self.user_id = None
35 self.access_token = None
37 async def authenticate(self, username: str, password: str) -> httpx.AsyncClient:
38 """Authenticate using OAuth2 and return an HTTP client with Bearer token.
40 Args:
41 username: Username for authentication
42 password: Password for authentication
44 Returns:
45 Authenticated httpx.AsyncClient with Bearer token
46 """
47 # Get OAuth2 token
48 token = await self._get_oauth2_token(username, password)
50 # Create HTTP client with Bearer token and default headers
51 headers = {**Config.DEFAULT_HEADERS, "Authorization": f"Bearer {token}"}
52 client = httpx.AsyncClient(headers=headers, timeout=Config.HTTP_TIMEOUT)
54 return client
56 async def _get_oauth2_token(self, username: str, password: str) -> str:
57 """Get OAuth2 access token from YouVersion API.
59 Args:
60 username: Username for authentication
61 password: Password for authentication
63 Returns:
64 OAuth2 access token
66 Raises:
67 httpx.HTTPStatusError: If authentication fails
68 """
69 async with httpx.AsyncClient(timeout=Config.HTTP_TIMEOUT) as client:
70 response = await client.post(
71 Config.AUTH_URL,
72 data={
73 "client_id": base64.b64decode(Config.CLIENT_ID).decode(),
74 "client_secret": base64.b64decode(Config.CLIENT_SECRET).decode(),
75 "grant_type": "password",
76 "username": username,
77 "password": password,
78 },
79 )
80 response.raise_for_status()
81 token_data = response.json()
82 if hasattr(token_data, "__await__"):
83 token_data = await token_data
85 # Decode JWT token to extract user information
86 try:
87 decoded_token = jwt.decode(
88 token_data["access_token"],
89 Config.CLIENT_SECRET,
90 algorithms=["HS256"],
91 )
92 # Try user_id first, then sub (OAuth2 standard)
93 self.user_id = decoded_token.get("user_id") or decoded_token.get("sub")
94 except jwt.DecodeError:
95 # If token cannot be decoded/verified,
96 # try decoding without verification
97 decoded_token = jwt.decode(
98 token_data["access_token"],
99 options={"verify_signature": False},
100 )
101 # Try user_id first, then sub (OAuth2 standard)
102 self.user_id = decoded_token.get("user_id") or decoded_token.get("sub")
103 except jwt.InvalidTokenError:
104 # If decoding fails entirely, continue without user_id
105 self.user_id = None
107 self.access_token = token_data["access_token"]
108 return token_data["access_token"]