From 4ed6ece8cbde05b45bfbe3a49be62ad9841dfdce Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 23 Oct 2025 17:03:04 -0700 Subject: [PATCH 1/3] Fix 401 authentication errors with automatic token refresh - Add automatic token refresh on 401 Unauthorized responses in API client - Preserve AWS credentials when refreshing tokens (required for MQTT) - Save refreshed tokens to cache after successful API calls - Add retry logic to prevent infinite retry loops This fix addresses the issue where cached tokens were rejected by the server with 401 errors but could still be refreshed. Previously, users would need to manually delete cached tokens or re-enter credentials. Now the system automatically refreshes tokens and retries the request. Fixes the 'API request failed: 401' error when using cached tokens. --- src/nwp500/api_client.py | 29 +++++++++++++++++++++++++++++ src/nwp500/auth.py | 24 ++++++++++++++++++++++++ src/nwp500/cli/__main__.py | 4 ++++ 3 files changed, 57 insertions(+) diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index abb5fa2..efba20f 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -114,6 +114,7 @@ async def _make_request( endpoint: str, json_data: Optional[dict[str, Any]] = None, params: Optional[dict[str, Any]] = None, + retry_on_auth_failure: bool = True, ) -> dict[str, Any]: """ Make an authenticated API request. @@ -123,6 +124,7 @@ async def _make_request( endpoint: API endpoint path json_data: JSON body data params: Query parameters + retry_on_auth_failure: Whether to retry once on 401 errors Returns: Response data dictionary @@ -158,6 +160,33 @@ async def _make_request( msg = response_data.get("msg", "") if code != 200 or not response.ok: + # If we get a 401 and haven't retried yet, try refreshing + # token + if code == 401 and retry_on_auth_failure: + _logger.warning( + "Received 401 Unauthorized. " + "Attempting to refresh token..." + ) + try: + # Try to refresh the token + if self._auth_client.current_tokens: + await self._auth_client.refresh_token( + self._auth_client.current_tokens.refresh_token + ) + # Retry the request once with new token + return await self._make_request( + method, + endpoint, + json_data, + params, + retry_on_auth_failure=False, + ) + except Exception as refresh_error: + _logger.error( + f"Token refresh failed: {refresh_error}" + ) + # Fall through to raise original error + _logger.error(f"API error: {code} - {msg}") raise APIError( f"API request failed: {msg}", diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index ebb72fc..9febb09 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -400,6 +400,30 @@ async def refresh_token(self, refresh_token: str) -> AuthTokens: data = response_data.get("data", {}) new_tokens = AuthTokens.from_dict(data) + # Preserve AWS credentials from old tokens if not in refresh + # response + if self._auth_response and self._auth_response.tokens: + old_tokens = self._auth_response.tokens + if ( + not new_tokens.access_key_id + and old_tokens.access_key_id + ): + new_tokens.access_key_id = old_tokens.access_key_id + if not new_tokens.secret_key and old_tokens.secret_key: + new_tokens.secret_key = old_tokens.secret_key + if ( + not new_tokens.session_token + and old_tokens.session_token + ): + new_tokens.session_token = old_tokens.session_token + if ( + not new_tokens.authorization_expires_in + and old_tokens.authorization_expires_in + ): + new_tokens.authorization_expires_in = ( + old_tokens.authorization_expires_in + ) + # Update stored auth response if we have one if self._auth_response: self._auth_response.tokens = new_tokens diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index 394ea70..4fe9e8d 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -130,6 +130,10 @@ async def async_main(args: argparse.Namespace) -> int: _logger.info("Fetching device information...") device = await api_client.get_first_device() + # Save tokens if they were refreshed during API call + if auth_client.current_tokens and auth_client.user_email: + save_tokens(auth_client.current_tokens, auth_client.user_email) + if not device: _logger.error("No devices found for this account.") return 1 From 3846d8086850faf86f410a50e1e362a28edeaa28 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 23 Oct 2025 18:13:05 -0700 Subject: [PATCH 2/3] Use specific exception types in token refresh error handling - Catch only TokenRefreshError and AuthenticationError instead of Exception - Prevents masking unexpected errors during token refresh - Addresses code review feedback --- src/nwp500/api_client.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index efba20f..f0c89b9 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -9,7 +9,11 @@ import aiohttp -from .auth import AuthenticationError, NavienAuthClient +from .auth import ( + AuthenticationError, + NavienAuthClient, + TokenRefreshError, +) from .config import API_BASE_URL from .models import Device, FirmwareInfo, TOUInfo @@ -181,7 +185,10 @@ async def _make_request( params, retry_on_auth_failure=False, ) - except Exception as refresh_error: + except ( + TokenRefreshError, + AuthenticationError, + ) as refresh_error: _logger.error( f"Token refresh failed: {refresh_error}" ) From f87ab65cde813fd88098526517c592582303c7d0 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 23 Oct 2025 18:18:34 -0700 Subject: [PATCH 3/3] Add validation for refresh_token before attempting refresh - Check that refresh_token exists and is not None/empty - Use local variable to avoid repeated attribute access - Log error when refresh_token is not available - Prevents passing None to refresh_token() method Addresses code review feedback --- src/nwp500/api_client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index f0c89b9..ac90246 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -173,9 +173,10 @@ async def _make_request( ) try: # Try to refresh the token - if self._auth_client.current_tokens: + tokens = self._auth_client.current_tokens + if tokens and tokens.refresh_token: await self._auth_client.refresh_token( - self._auth_client.current_tokens.refresh_token + tokens.refresh_token ) # Retry the request once with new token return await self._make_request( @@ -185,6 +186,11 @@ async def _make_request( params, retry_on_auth_failure=False, ) + else: + _logger.error( + "Cannot refresh token: " + "refresh_token not available" + ) except ( TokenRefreshError, AuthenticationError,