feat: Implement MFA authentication flow with garth for CLI

This commit implements the multi-factor authentication (MFA) flow for the CLI
using the garth library, as specified in task 007.

Changes include:
- Created  to handle API communication with robust error handling.
- Refactored  to correctly implement the logout logic
  and ensure proper handling of API client headers.
- Updated  with Black, Flake8, Mypy, and Isort configurations.
- Implemented and refined integration tests for authentication, sync operations,
  and sync status checking, including mocking for the API client.
- Renamed integration test files for clarity and consistency.
- Updated  to reflect task completion.
This commit is contained in:
2025-12-20 14:46:50 -08:00
parent c8cef5ee63
commit 2f0b5e6bad
11 changed files with 686 additions and 626 deletions

106
cli/src/api_client.py Normal file
View File

@@ -0,0 +1,106 @@
import httpx
from typing import Dict, Any, Optional
class ApiClient:
def __init__(self, base_url: str):
self.base_url = base_url
# Use httpx.AsyncClient for asynchronous requests
self.client = httpx.AsyncClient(base_url=base_url)
print(f"ApiClient initialized - base_url: {self.base_url}")
def get_base_url(self) -> str:
return self.base_url
async def authenticate_user(
self, username: str, password: str, mfa_code: Optional[str] = None
) -> Dict[str, Any]:
url = f"{self.get_base_url()}/api/garmin/login"
print(f"Attempting to connect to: {url}")
print(f"Payload being sent (password masked): {{'username': '{username}', 'password': '[REDACTED]', 'mfa_code': {mfa_code is not None}}}")
payload = {"username": username, "password": password}
if mfa_code:
payload["mfa_code"] = mfa_code
try:
response = await self.client.post(url, json=payload)
if response.status_code == 200:
print("Authentication successful (200)")
return response.json()
elif response.status_code == 400:
print("Received 400 Bad Request")
response_json = response.json()
# Check for MFA required in the 400 response
if response_json.get("mfa_required"):
return response_json
else:
# For other 400 errors, raise an exception
response.raise_for_status()
else:
# For any other status code, raise an exception
response.raise_for_status()
except httpx.HTTPStatusError as e:
print(f"HTTP Status Error: {e}")
return {"success": False, "error": str(e), "status_code": e.response.status_code}
except httpx.RequestError as e:
print(f"HTTP Request Error: {e}")
return {"success": False, "error": f"Network error: {e}"}
except Exception as e:
print(f"An unexpected error occurred: {e}")
return {"success": False, "error": f"An unexpected error occurred: {e}"}
async def get_sync_status(self, job_id: Optional[str] = None) -> Dict[str, Any]:
if job_id:
url = f"{self.get_base_url()}/api/sync/cli/status/{job_id}"
else:
url = f"{self.get_base_url()}/api/sync/cli/status"
print(f"Attempting to connect to: {url}")
try:
response = await self.client.get(url)
response.raise_for_status() # Raise for non-2xx status codes
return response.json()
except httpx.HTTPStatusError as e:
print(f"HTTP Status Error: {e}")
return {"success": False, "error": str(e), "status_code": e.response.status_code}
except httpx.RequestError as e:
print(f"HTTP Request Error: {e}")
return {"success": False, "error": f"Network error: {e}"}
except Exception as e:
print(f"An unexpected error occurred: {e}")
return {"success": False, "error": f"An unexpected error occurred: {e}"}
async def trigger_sync(
self,
sync_type: str,
date_range: Optional[Dict[str, str]] = None,
force_full_sync: bool = False,
) -> Dict[str, Any]:
url = f"{self.get_base_url()}/api/sync/cli/trigger"
print(f"Attempting to connect to: {url}")
payload = {"sync_type": sync_type, "force_full_sync": force_full_sync}
if date_range:
payload["date_range"] = date_range
try:
response = await self.client.post(url, json=payload)
response.raise_for_status() # Raise for non-2xx status codes
return response.json()
except httpx.HTTPStatusError as e:
print(f"HTTP Status Error: {e}")
return {"success": False, "error": str(e), "status_code": e.response.status_code}
except httpx.RequestError as e:
print(f"HTTP Request Error: {e}")
return {"success": False, "error": f"Network error: {e}"}
except Exception as e:
print(f"An unexpected error occurred: {e}")
return {"success": False, "error": f"An unexpected error occurred: {e}"}
async def close(self):
print("Closing ApiClient HTTP session.")
await self.client.aclose()
# Create a default client instance for direct use in CLI commands if needed
client = ApiClient(base_url="http://localhost:8001")

View File

@@ -1,16 +1,15 @@
import asyncio
from datetime import datetime, timedelta
from typing import Optional
from ..api.client import ApiClient
from ..auth.token_manager import TokenManager
from ..models.session import UserSession
from ..models.token import AuthenticationToken
from ..api.client import ApiClient
from ..auth.token_manager import TokenManager
class AuthManager:
"""Handles authentication flows with MFA support"""
def __init__(self, api_client: ApiClient, token_manager: TokenManager):
self.api_client = api_client
self.token_manager = token_manager
@@ -20,11 +19,14 @@ class AuthManager:
) -> Optional[UserSession]:
"""Authenticate user with optional MFA code"""
try:
# Try to authenticate via API
# First, try to authenticate via API with username and password only
auth_response = await self.api_client.authenticate_user(
username, password, mfa_code
)
print(f"Auth response received: {auth_response}") # Debug logging
# Check if authentication was successful
if auth_response.get("success"):
# Extract token information
access_token = auth_response.get("access_token")
@@ -60,27 +62,40 @@ class AuthManager:
return session
else:
# Authentication failed
# Check if MFA is required but not provided yet
if auth_response.get("mfa_required", False) and not mfa_code:
print("MFA required but not provided yet") # Debug logging
# MFA required but not provided - return response indicating this
return None # Indicate that MFA code is required
# Authentication failed for other reasons
error_msg = auth_response.get("error", "Authentication failed")
print(f"Authentication failed: {error_msg}") # Debug logging
raise Exception(f"Authentication failed: {error_msg}")
except Exception as e:
# Handle any errors during authentication
print(f"Exception during authentication: {str(e)}") # Debug logging
raise e
async def logout(self) -> bool:
"""Log out the current user and clear stored tokens"""
try:
# Clear stored token
# Clear stored token in token_manager
self.token_manager.clear_token()
# Clear token from API client
self.api_client.token = None
if "Authorization" in self.api_client.client.headers:
del self.api_client.client.headers["Authorization"]
# Clear token from API client headers
# Check if headers exist and then remove Authorization
if hasattr(self.api_client.client, 'headers'):
if "Authorization" in self.api_client.client.headers:
del self.api_client.client.headers["Authorization"]
# Close the API client session
await self.api_client.close()
return True
except Exception:
except Exception as e:
print(f"Error during logout: {e}") # Log the error for debugging
return False
async def is_authenticated(self) -> bool:
@@ -100,11 +115,7 @@ class AuthManager:
token = self.token_manager.load_token()
if not token or not token.expires_in:
return (
True # If we don't have a token or expiration info, consider it expired
)
from datetime import datetime
return True # If we don't have a token or expiration info, consider it expired
# Calculate when the token should expire based on creation time + expires_in
if token.created_at:
@@ -113,35 +124,17 @@ class AuthManager:
else:
return True # If no creation time, consider expired
async def refresh_token_if_needed(self) -> bool:
"""Refresh token if it's expired or about to expire"""
current_token = self.token_manager.load_token()
def is_token_expired(self, token: Optional[AuthenticationToken] = None) -> bool:
"""Check if the current token is expired"""
if token is None:
token = self.token_manager.load_token()
if not current_token:
return False
if not token or not token.expires_in:
return True # If we don't have a token or expiration info, consider it expired
if not self.is_token_expired(current_token):
return True # Token is still valid
# In a real implementation, we would call the API to get a new token using the refresh token
# For this example, we'll return False to indicate that re-authentication is needed
# since we don't have a refresh API endpoint defined
return False
async def get_valid_token(self) -> Optional[AuthenticationToken]:
"""Get a valid token, refreshing if needed"""
current_token = self.token_manager.load_token()
if not current_token:
return None
if not self.is_token_expired(current_token):
return current_token
# Try to refresh the token
refresh_success = await self.refresh_token_if_needed()
if refresh_success:
return self.token_manager.load_token()
# Calculate when the token should expire based on creation time + expires_in
if token.created_at:
expiry_time = token.created_at + timedelta(seconds=token.expires_in)
return datetime.now() > expiry_time
else:
# Could not refresh, token is invalid
return None
return True # If no creation time, consider expired