Skip to content

Commit e85c25f

Browse files
committed
v1.0.2: JWT Authentication Pro compatibility fix
## What's Fixed - JWT Authentication Pro compatibility - Full support for JWT Auth Pro plugin - Token expiry detection - Now parses JWT exp claim directly (supports 180+ day tokens) - HTTP 202 handling - Properly handles 'Accepted' responses from JWT Auth Pro - Rate limiting support - Handles HTTP 429 with proper Retry-After delays - Retry logic - Automatic retries with exponential backoff for failures - Token refresh - Better handling of refresh tokens with JWT Auth Pro format - Reduced logging - Routine polling uses debug level (quiet in production) - Error recovery - Clears stale tokens after 3 failures for fresh auth ## Root Cause The integration was defaulting to 1-hour token expiry when JWT Auth Pro doesn't return expires_in in the response. This caused unnecessary token refresh attempts every 55 minutes, leading to intermittent disconnections. The fix parses the actual JWT token to extract the real expiry time.
1 parent b853f55 commit e85c25f

File tree

5 files changed

+521
-147
lines changed

5 files changed

+521
-147
lines changed

README.md

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,24 @@ Install this integration once, and **every device automatically has PRO features
4444

4545
### HACS (Recommended)
4646

47+
**🎉 Now available directly in HACS!**
48+
49+
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=WJDDesigns&repository=ultra-card-pro-cloud&category=integration)
50+
4751
1. Open **HACS** in Home Assistant
4852
2. Click **Integrations**
49-
3. Click the **** menu → **Custom repositories**
50-
4. Add repository: `https://github.com/WJDDesigns/ultra-card-pro-cloud`
51-
5. Category: **Integration**
52-
6. Click **Add**
53-
7. Find **"Ultra Card Pro Cloud"** and click **Download**
54-
8. **Restart Home Assistant**
53+
3. Search for **"Ultra Card Pro Cloud"**
54+
4. Click **Download**
55+
5. **Restart Home Assistant**
56+
57+
_Or add manually:_
58+
59+
1. Click the **** menu → **Custom repositories**
60+
2. Add repository: `https://github.com/WJDDesigns/ultra-card-pro-cloud`
61+
3. Category: **Integration**
62+
4. Click **Add**
63+
5. Find **"Ultra Card Pro Cloud"** and click **Download**
64+
6. **Restart Home Assistant**
5565

5666
### Manual Installation
5767

@@ -212,6 +222,17 @@ npm run deploy
212222

213223
## 📝 Changelog
214224

225+
### Version 1.0.2
226+
227+
- **JWT Authentication Pro compatibility** - Full support for JWT Auth Pro plugin with token refresh mechanism
228+
- **Fixed token expiry detection** - Now correctly parses JWT token expiry from the token itself (supports 180+ day tokens)
229+
- **Fixed 202 status handling** - Properly handles HTTP 202 "Accepted" responses from JWT Auth Pro
230+
- **Added rate limiting support** - Handles HTTP 429 responses with proper retry-after delays
231+
- **Added retry logic** - Automatic retries with exponential backoff for transient failures
232+
- **Improved token refresh** - Better handling of refresh tokens with JWT Auth Pro format
233+
- **Reduced logging noise** - Routine polling now uses debug level (quiet logs in production)
234+
- **Better error recovery** - Clears stale tokens after 3 consecutive failures to force fresh authentication
235+
215236
### Version 1.0.1
216237

217238
- **Reduced console logging** - Removed excess debug logging for cleaner output

custom_components/ultra_card_pro_cloud/config_flow.py

Lines changed: 108 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import annotations
33

44
import logging
5+
import asyncio
56
from typing import Any
67

78
import aiohttp
@@ -24,6 +25,11 @@
2425

2526
_LOGGER = logging.getLogger(__name__)
2627

28+
# Constants for retry logic
29+
MAX_RETRIES = 3
30+
RETRY_DELAY = 2 # seconds
31+
RATE_LIMIT_DELAY = 5 # seconds
32+
2733
STEP_USER_DATA_SCHEMA = vol.Schema(
2834
{
2935
vol.Required(CONF_USERNAME): str,
@@ -38,41 +44,100 @@ async def validate_auth(
3844
"""Validate the user credentials."""
3945
session = async_get_clientsession(hass)
4046
url = f"{API_BASE_URL}{JWT_ENDPOINT}/token"
41-
42-
try:
43-
async with session.post(
44-
url,
45-
json={"username": username, "password": password},
46-
timeout=aiohttp.ClientTimeout(total=10),
47-
) as response:
48-
if response.status == 401 or response.status == 403:
49-
raise InvalidAuth
50-
51-
if response.status != 200:
52-
_LOGGER.error("Authentication failed with status: %s", response.status)
53-
raise CannotConnect
54-
55-
data = await response.json()
56-
57-
# Validate response structure
58-
if not data.get("token"):
59-
raise InvalidAuth
60-
61-
return {
62-
"user_id": data.get("user_id"),
63-
"username": data.get("user_nicename", username),
64-
"email": data.get("user_email"),
65-
"display_name": data.get("user_display_name", username),
66-
}
67-
68-
except aiohttp.ClientError as err:
69-
_LOGGER.error("Connection error: %s", err)
70-
raise CannotConnect from err
71-
except InvalidAuth:
72-
raise
73-
except Exception as err:
74-
_LOGGER.exception("Unexpected error during authentication: %s", err)
75-
raise CannotConnect from err
47+
48+
_LOGGER.debug("🔐 Validating auth for user: %s", username)
49+
50+
for attempt in range(MAX_RETRIES):
51+
try:
52+
async with session.post(
53+
url,
54+
json={"username": username, "password": password},
55+
timeout=aiohttp.ClientTimeout(total=15),
56+
) as response:
57+
response_text = await response.text()
58+
_LOGGER.debug("📥 Auth response status: %s", response.status)
59+
_LOGGER.debug("📥 Auth response (first 500 chars): %s", response_text[:500])
60+
61+
# Handle rate limiting (JWT Auth Pro feature)
62+
if response.status == 429:
63+
retry_after = int(response.headers.get("Retry-After", RATE_LIMIT_DELAY))
64+
_LOGGER.warning("⏳ Rate limited, waiting %s seconds before retry (attempt %d/%d)",
65+
retry_after, attempt + 1, MAX_RETRIES)
66+
await asyncio.sleep(retry_after)
67+
continue
68+
69+
# Handle auth failures
70+
if response.status == 401 or response.status == 403:
71+
_LOGGER.error("❌ Authentication failed: Invalid credentials (status %s)", response.status)
72+
raise InvalidAuth
73+
74+
# JWT Auth Pro may return 202 for async operations
75+
if response.status == 202:
76+
_LOGGER.debug("⏳ Got 202 Accepted, retrying (attempt %d/%d)",
77+
attempt + 1, MAX_RETRIES)
78+
await asyncio.sleep(2)
79+
continue
80+
81+
# Accept any 2xx status as success
82+
if not (200 <= response.status < 300):
83+
_LOGGER.error("❌ Authentication failed with status: %s - %s",
84+
response.status, response_text[:200])
85+
if attempt < MAX_RETRIES - 1:
86+
_LOGGER.info("🔄 Retrying in %s seconds (attempt %d/%d)",
87+
RETRY_DELAY * (attempt + 1), attempt + 1, MAX_RETRIES)
88+
await asyncio.sleep(RETRY_DELAY * (attempt + 1))
89+
continue
90+
raise CannotConnect
91+
92+
# Parse the response
93+
try:
94+
import json
95+
data = json.loads(response_text)
96+
except json.JSONDecodeError as e:
97+
_LOGGER.error("❌ Failed to parse auth response: %s", e)
98+
raise CannotConnect from e
99+
100+
# JWT Auth Pro response format - check for token in various locations
101+
token = (
102+
data.get("token") or
103+
data.get("data", {}).get("token") or
104+
data.get("jwt_token") or
105+
data.get("access_token")
106+
)
107+
108+
if not token:
109+
_LOGGER.error("❌ No token in response. Keys: %s", list(data.keys()))
110+
raise InvalidAuth
111+
112+
_LOGGER.debug("✅ Credentials validated for user: %s", username)
113+
114+
return {
115+
"user_id": data.get("user_id") or data.get("data", {}).get("user_id"),
116+
"username": data.get("user_nicename") or data.get("data", {}).get("user_nicename") or username,
117+
"email": data.get("user_email") or data.get("data", {}).get("user_email"),
118+
"display_name": data.get("user_display_name") or data.get("data", {}).get("user_display_name") or username,
119+
}
120+
121+
except aiohttp.ClientError as err:
122+
_LOGGER.error("❌ Connection error (attempt %d/%d): %s", attempt + 1, MAX_RETRIES, err)
123+
if attempt < MAX_RETRIES - 1:
124+
await asyncio.sleep(RETRY_DELAY * (attempt + 1))
125+
continue
126+
raise CannotConnect from err
127+
except InvalidAuth:
128+
raise
129+
except CannotConnect:
130+
raise
131+
except Exception as err:
132+
_LOGGER.exception("❌ Unexpected error during authentication: %s", err)
133+
if attempt < MAX_RETRIES - 1:
134+
await asyncio.sleep(RETRY_DELAY * (attempt + 1))
135+
continue
136+
raise CannotConnect from err
137+
138+
# If we get here, all retries failed
139+
_LOGGER.error("❌ Authentication failed after %d attempts", MAX_RETRIES)
140+
raise CannotConnect
76141

77142

78143
class UltraCardProCloudConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@@ -98,17 +163,20 @@ async def async_step_user(
98163
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
99164
self._abort_if_unique_id_configured()
100165

166+
_LOGGER.debug("✅ Creating config entry for user: %s", info['username'])
101167
return self.async_create_entry(
102168
title=f"Ultra Card Pro ({info['username']})",
103169
data=user_input,
104170
)
105171

106172
except InvalidAuth:
173+
_LOGGER.warning("⚠️ Invalid credentials provided")
107174
errors["base"] = ERROR_INVALID_AUTH
108175
except CannotConnect:
176+
_LOGGER.warning("⚠️ Cannot connect to ultracard.io")
109177
errors["base"] = ERROR_CANNOT_CONNECT
110178
except Exception: # pylint: disable=broad-except
111-
_LOGGER.exception("Unexpected exception")
179+
_LOGGER.exception("Unexpected exception during config flow")
112180
errors["base"] = ERROR_UNKNOWN
113181

114182
return self.async_show_form(
@@ -144,14 +212,17 @@ async def async_step_reauth_confirm(
144212
if entry:
145213
self.hass.config_entries.async_update_entry(entry, data=user_input)
146214
await self.hass.config_entries.async_reload(entry.entry_id)
215+
_LOGGER.debug("✅ Re-authentication successful")
147216
return self.async_abort(reason="reauth_successful")
148217

149218
except InvalidAuth:
219+
_LOGGER.warning("⚠️ Invalid credentials during re-auth")
150220
errors["base"] = ERROR_INVALID_AUTH
151221
except CannotConnect:
222+
_LOGGER.warning("⚠️ Cannot connect to ultracard.io during re-auth")
152223
errors["base"] = ERROR_CANNOT_CONNECT
153224
except Exception: # pylint: disable=broad-except
154-
_LOGGER.exception("Unexpected exception")
225+
_LOGGER.exception("Unexpected exception during re-auth")
155226
errors["base"] = ERROR_UNKNOWN
156227

157228
return self.async_show_form(
@@ -167,4 +238,3 @@ class CannotConnect(Exception):
167238

168239
class InvalidAuth(Exception):
169240
"""Error to indicate there is invalid auth."""
170-

0 commit comments

Comments
 (0)