Skip to content

Commit 6e4d55b

Browse files
committed
Refactor: OAuth2 token refresh — instance-var caching + heartbeat-driven refresh
- OAuth2Handler.__init__ reads _access_token, _refresh_token, _expires_at from AddonSettings once at construction; no per-call settings I/O - _store_tokens() updates both settings and instance vars atomically - _do_token_refresh(): unconditional internal refresh (single _ so subclass can override); base uses refresh_token grant - refresh_access_token() -> bool: no-op when > 300s from expiry (_REFRESH_MARGIN), calls _do_token_refresh() otherwise; returns True/False instead of raising - get_valid_token(): pure getter returning self._access_token — no side-effects; callers that need a fresh token must call refresh_access_token() first - active_authentication(): calls refresh_access_token() before get_valid_token() - _clear_token_settings(): clears both settings and instance vars NLZIETOAuth2Handler: - __init__ caches _id_token from settings - _do_token_refresh() override: silent re-auth via id_token (web flow) or refresh_token grant (device flow); calls super()._do_token_refresh() for latter - 401 fallback in list_profiles() calls _do_token_refresh() directly (force-refresh, bypasses expiry check) - _store_tokens() and _clear_token_settings() maintain _id_token in sync chn_nlziet: - __set_auth_headers(): refresh_access_token() then get_valid_token() - create_iptv_streams() + create_iptv_epg(): refresh token on every heartbeat cycle so long IPTV Manager sessions (4+ hours) never hit stale tokens Tests (all 55 pass, 18 skipped = live-credential tests): - Updated test_06, test_16: set _expires_at on instance (not just settings), call refresh_access_token() explicitly - Updated test_refresh_no_tokens: assertFalse() instead of assertRaises(ValueError) - New test_refresh_access_token_noop_when_valid: monkey-patch verifies _do_token_refresh is NOT called when token is fresh - New test_get_valid_token_is_pure_getter: verifies no refresh side-effect - New test_instance_vars_loaded_from_settings_at_init: verifies all three instance vars populated from settings at construction Co-authored-by: Claude Sonnet 4.6 Signed-off-by: Oliver Signed-off-by: Olliver Schinagl <oliver@schinagl.nl>
1 parent e6751b2 commit 6e4d55b

File tree

4 files changed

+138
-40
lines changed

4 files changed

+138
-40
lines changed

channels/channel.nlziet/nlziet/chn_nlziet.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ def __validate_token(self) -> bool:
270270
def __set_auth_headers(self):
271271
"""Set the Bearer token and required app headers for API requests."""
272272

273+
self.__handler.refresh_access_token()
273274
token = self.__handler.get_valid_token()
274275
if token:
275276
self.httpHeaders["Authorization"] = "Bearer {}".format(token)
@@ -1420,6 +1421,9 @@ def create_iptv_streams(self, parameter_parser):
14201421
Logger.warning("NLZIET IPTV: Not authenticated, returning empty streams")
14211422
return []
14221423

1424+
if self.__handler.refresh_access_token():
1425+
self.httpHeaders["Authorization"] = "Bearer {}".format(self.__handler.get_valid_token())
1426+
14231427
Logger.debug("NLZIET IPTV: create_iptv_streams called")
14241428
live_data = UriHandler.open(API_V9_EPG_LIVE, additional_headers=self.httpHeaders)
14251429
if not live_data:
@@ -1494,6 +1498,9 @@ def create_iptv_epg(self, parameter_parser):
14941498
Logger.warning("NLZIET IPTV: Not authenticated, returning empty EPG")
14951499
return {}
14961500

1501+
if self.__handler.refresh_access_token():
1502+
self.httpHeaders["Authorization"] = "Bearer {}".format(self.__handler.get_valid_token())
1503+
14971504
# Load appconfig (cached); abort if the server has blocked the app.
14981505
appconfig = self.__load_appconfig()
14991506
if self.__check_app_blocked(appconfig):

resources/lib/authentication/nlzietoauth2handler.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ def __init__(self, use_device_flow: Optional[bool] = None):
7070
self._use_device_flow = use_device_flow
7171
client_id = self.TV_CLIENT_ID if use_device_flow else self.WEB_CLIENT_ID
7272
super(NLZIETOAuth2Handler, self).__init__(realm="nlziet", client_id=client_id)
73+
# id_token is NLZIET-specific; used for web-flow silent re-authentication.
74+
self._id_token = AddonSettings.get_setting(f"{self.prefix}id_token", store=LOCAL) or ""
7375

7476
@property
7577
def base_auth_url(self) -> str: return self.BASE_AUTH_URL
@@ -177,7 +179,8 @@ def _store_tokens(self, tokens: dict):
177179
"""Override to also store id_token for NLZiet silent auth."""
178180
super()._store_tokens(tokens)
179181
if "id_token" in tokens:
180-
AddonSettings.set_setting(f"{self.prefix}id_token", tokens["id_token"], store=LOCAL)
182+
self._id_token = tokens["id_token"]
183+
AddonSettings.set_setting(f"{self.prefix}id_token", self._id_token, store=LOCAL)
181184
Logger.debug(f"OAuth2: Stored id_token for {self.realm} silent auth")
182185

183186
def _exchange_code_with_verifier(self, auth_code: str, code_verifier: str):
@@ -191,16 +194,14 @@ def _exchange_code_with_verifier(self, auth_code: str, code_verifier: str):
191194
}
192195
self._request_tokens(data)
193196

194-
def refresh_access_token(self):
195-
"""Refresh access token using refresh_token (device flow) or silent re-auth (web flow)."""
196-
refresh_token = AddonSettings.get_setting(f"{self.prefix}refresh_token", store=LOCAL)
197-
if refresh_token:
197+
def _do_token_refresh(self):
198+
"""Unconditional token refresh: uses refresh_token (device flow) or silent re-auth (web flow)."""
199+
if self._refresh_token:
198200
Logger.debug(f"OAuth2: Refreshing access token using refresh_token for {self.realm}")
199-
super().refresh_access_token()
201+
super()._do_token_refresh()
200202
return
201203

202-
id_token = AddonSettings.get_setting(f"{self.prefix}id_token", store=LOCAL)
203-
if not id_token:
204+
if not self._id_token:
204205
raise ValueError("No refresh_token or id_token available for authentication.")
205206

206207
Logger.debug(f"OAuth2: Attempting silent re-authentication for {self.realm}")
@@ -219,7 +220,7 @@ def refresh_access_token(self):
219220
"code_challenge_method": "S256",
220221
"response_mode": "query",
221222
"prompt": "none",
222-
"id_token_hint": id_token
223+
"id_token_hint": self._id_token
223224
}
224225

225226
auth_url = f"{self.base_auth_url}?{urlencode(auth_params)}"
@@ -245,6 +246,8 @@ def refresh_access_token(self):
245246

246247
except Exception as e:
247248
Logger.warning(f"OAuth2: Silent re-authentication failed for {self.realm}: {e}")
249+
self._id_token = ""
250+
self._access_token = ""
248251
AddonSettings.set_setting(f"{self.prefix}id_token", "", store=LOCAL)
249252
AddonSettings.set_setting(f"{self.prefix}access_token", "", store=LOCAL)
250253
raise
@@ -282,8 +285,8 @@ def list_profiles(self) -> Optional[list]:
282285
if status.error and status.code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
283286
Logger.warning("NLZIET: Profile API returned %s, attempting token refresh", status.code)
284287
try:
285-
self.refresh_access_token()
286-
access_token = AddonSettings.get_setting(f"{self.prefix}access_token", store=LOCAL)
288+
self._do_token_refresh()
289+
access_token = self.get_valid_token()
287290
if not access_token:
288291
Logger.warning("NLZIET: Token refresh did not yield an access token")
289292
return None
@@ -299,9 +302,7 @@ def list_profiles(self) -> Optional[list]:
299302
return None
300303
except Exception as e:
301304
Logger.warning(f"NLZIET: Token refresh failed: {e}")
302-
AddonSettings.set_setting(f"{self.prefix}access_token", "", store=LOCAL)
303-
AddonSettings.set_setting(f"{self.prefix}refresh_token", "", store=LOCAL)
304-
AddonSettings.set_setting(f"{self.prefix}id_token", "", store=LOCAL)
305+
self._clear_token_settings()
305306
return None
306307
elif not response:
307308
Logger.error("NLZIET: Empty response from profile API")
@@ -745,6 +746,7 @@ def get_user_info(self) -> Optional[dict]:
745746

746747
def _clear_token_settings(self) -> None:
747748
super()._clear_token_settings()
749+
self._id_token = ""
748750
AddonSettings.set_setting(f"{self.prefix}id_token", "", store=LOCAL)
749751

750752
def _extract_csrf_token(self, content: str) -> Optional[str]:

resources/lib/authentication/oauth2handler.py

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,21 @@ def decode(token, **_kwargs):
4444
class OAuth2Handler(AuthenticationHandler, ABC):
4545
"""Generic OAuth2 PKCE Handler for Retrospect."""
4646

47+
# How many seconds before token expiry we proactively refresh.
48+
# 300s (5 minutes) gives ample time to retry on transient network errors.
49+
_REFRESH_MARGIN = 300
50+
4751
def __init__(self, realm: str, client_id: str):
4852
super(OAuth2Handler, self).__init__(realm, device_id=None)
4953
self.client_id_val = client_id
5054
# Use client-specific prefix to prevent storage collisions between different OAuth clients
5155
# e.g., "nlziet_oauth2_triple-web_" vs "nlziet_oauth2_triple-android-tv_"
5256
self.prefix = f"{realm}_oauth2_{client_id}_"
57+
# Read token state once at construction; updated in-place by _store_tokens.
58+
# Settings is the cross-invocation persistence layer (channel is re-created per action).
59+
self._access_token = AddonSettings.get_setting(f"{self.prefix}access_token", store=LOCAL) or ""
60+
self._refresh_token = AddonSettings.get_setting(f"{self.prefix}refresh_token", store=LOCAL) or ""
61+
self._expires_at = int(AddonSettings.get_setting(f"{self.prefix}expires_at", store=LOCAL) or 0)
5362

5463
@property
5564
@abstractmethod
@@ -105,13 +114,16 @@ def _request_tokens(self, data: dict):
105114
raise
106115

107116
def _store_tokens(self, tokens: dict):
108-
"""Persist token fields to addon settings."""
109-
AddonSettings.set_setting(f"{self.prefix}access_token", tokens["access_token"], store=LOCAL)
117+
"""Persist token fields to addon settings and update cached instance state."""
118+
self._access_token = tokens["access_token"]
119+
AddonSettings.set_setting(f"{self.prefix}access_token", self._access_token, store=LOCAL)
110120
if "refresh_token" in tokens:
111-
AddonSettings.set_setting(f"{self.prefix}refresh_token", tokens["refresh_token"], store=LOCAL)
121+
self._refresh_token = tokens["refresh_token"]
122+
AddonSettings.set_setting(f"{self.prefix}refresh_token", self._refresh_token, store=LOCAL)
112123

113124
expires_in = tokens.get("expires_in", 3600)
114-
AddonSettings.set_setting(f"{self.prefix}expires_at", str(int(time.time()) + expires_in), store=LOCAL)
125+
self._expires_at = int(time.time()) + expires_in
126+
AddonSettings.set_setting(f"{self.prefix}expires_at", str(self._expires_at), store=LOCAL)
115127

116128
def exchange_code(self, code: str, verifier: str) -> bool:
117129
"""Exchange authorization code for tokens.
@@ -135,34 +147,50 @@ def exchange_code(self, code: str, verifier: str) -> bool:
135147
Logger.error(f"OAuth2: exchange_code failed for {self.realm}: {e}")
136148
return False
137149

138-
def refresh_access_token(self):
139-
"""Refresh the access token using the refresh token."""
140-
refresh_token = AddonSettings.get_setting(f"{self.prefix}refresh_token", store=LOCAL)
141-
if not refresh_token:
150+
def _do_token_refresh(self):
151+
"""Unconditional access token refresh using the stored refresh token.
152+
153+
Subclasses override this to implement flow-specific refresh logic
154+
(e.g. device flow vs. silent re-authentication).
155+
"""
156+
if not self._refresh_token:
142157
raise ValueError("No refresh token available.")
143158

144159
Logger.debug(f"OAuth2: Refreshing access token for {self.realm}")
145160
data = {
146161
"grant_type": "refresh_token",
147162
"client_id": self.client_id_val,
148-
"refresh_token": refresh_token
163+
"refresh_token": self._refresh_token
149164
}
150165
self._request_tokens(data)
151166

152-
def get_valid_token(self) -> Optional[str]:
153-
"""Get a valid access token, refreshing if necessary."""
154-
expires_at = int(AddonSettings.get_setting(f"{self.prefix}expires_at", store=LOCAL) or 0)
167+
def refresh_access_token(self) -> bool:
168+
"""Refresh the access token if within _REFRESH_MARGIN seconds of expiry.
169+
170+
Intended to be called on every heartbeat cycle. The method is a no-op
171+
when the token is still comfortably valid, so calling it frequently is
172+
cheap (one in-memory comparison).
173+
174+
:return: True if the token is valid after the call, False on failure.
175+
"""
176+
if time.time() < (self._expires_at - self._REFRESH_MARGIN):
177+
Logger.trace(f"OAuth2: Token still valid for {self.realm} "
178+
f"({self._expires_at - time.time():.0f}s remaining)")
179+
return True
180+
try:
181+
self._do_token_refresh()
182+
return True
183+
except Exception as e:
184+
Logger.warning(f"OAuth2: Token refresh failed for {self.realm}: {e}")
185+
return False
155186

156-
if time.time() > (expires_at - 60):
157-
try:
158-
self.refresh_access_token()
159-
except Exception as e:
160-
Logger.warning(f"OAuth2: Token refresh failed for {self.realm}: {e}")
161-
return None
162-
else:
163-
Logger.trace(f"OAuth2: Using cached token for {self.realm}")
187+
def get_valid_token(self) -> Optional[str]:
188+
"""Return the cached access token.
164189
165-
return AddonSettings.get_setting(f"{self.prefix}access_token", store=LOCAL)
190+
Pure getter — no refresh side-effect. Call refresh_access_token() first
191+
whenever a fresh token is required.
192+
"""
193+
return self._access_token or None
166194

167195
def _extract_username_from_token(self, token: str) -> Optional[str]:
168196
"""Extract username from JWT token."""
@@ -180,6 +208,7 @@ def _extract_username_from_token(self, token: str) -> Optional[str]:
180208

181209
def active_authentication(self) -> AuthenticationResult:
182210
"""Check for active authentication session."""
211+
self.refresh_access_token()
183212
token = self.get_valid_token()
184213
if token:
185214
username = self._extract_username_from_token(token)
@@ -195,6 +224,9 @@ def log_on(self, username: str, password: str) -> AuthenticationResult:
195224
return AuthenticationResult(None, error="OAuth2 requires browser login.")
196225

197226
def _clear_token_settings(self) -> None:
227+
self._access_token = ""
228+
self._refresh_token = ""
229+
self._expires_at = 0
198230
AddonSettings.set_setting(f"{self.prefix}access_token", "", store=LOCAL)
199231
AddonSettings.set_setting(f"{self.prefix}refresh_token", "", store=LOCAL)
200232
AddonSettings.set_setting(f"{self.prefix}expires_at", "", store=LOCAL)

tests/authentication/test_nlziethandler.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,13 @@ def test_06_token_refresh(self):
216216
if not result.logged_on:
217217
self.skipTest("No active session - test_03 must run first")
218218

219-
# Force token expiry
219+
# Force token expiry on both settings and cached instance state
220220
expiry_key = f"{self.handler.prefix}expires_at"
221221
AddonSettings.set_setting(expiry_key, str(int(time.time()) - 100), store=LOCAL)
222+
self.handler._expires_at = int(time.time()) - 100
222223

223-
# get_valid_token should trigger silent re-authentication
224+
# refresh_access_token() performs silent re-authentication when needed
225+
self.assertTrue(self.handler.refresh_access_token(), "Silent re-authentication failed")
224226
token = self.handler.get_valid_token()
225227
self.assertIsNotNone(token, "Silent re-authentication failed")
226228

@@ -612,7 +614,9 @@ def _run_device_flow_refresh_token(self):
612614
expiry_key = f"{device_handler.prefix}expires_at"
613615
old_expiry = int(AddonSettings.get_setting(expiry_key, store=LOCAL) or 0)
614616
AddonSettings.set_setting(expiry_key, str(int(time.time()) - 10), store=LOCAL)
617+
device_handler._expires_at = int(time.time()) - 10
615618

619+
self.assertTrue(device_handler.refresh_access_token(), "Refresh token grant failed")
616620
token = device_handler.get_valid_token()
617621
self.assertIsNotNone(token, "Refresh token grant failed")
618622

@@ -755,11 +759,64 @@ def test_20_appconfig_status(self):
755759
pass # Overridden to skip in mocked test class
756760

757761
def test_refresh_no_tokens_raises_value_error(self):
758-
"""refresh_access_token raises ValueError when no refresh or id token is stored."""
762+
"""refresh_access_token returns False when no refresh or id token is stored."""
759763
AddonSettings.set_setting(f"{self.handler.prefix}refresh_token", "", store=LOCAL)
760764
AddonSettings.set_setting(f"{self.handler.prefix}id_token", "", store=LOCAL)
761-
with self.assertRaises(ValueError):
762-
self.handler.refresh_access_token()
765+
self.handler._refresh_token = ""
766+
self.handler._id_token = ""
767+
self.handler._expires_at = 0 # force expiry so the margin check doesn't short-circuit
768+
self.assertFalse(self.handler.refresh_access_token())
769+
770+
def test_refresh_access_token_noop_when_valid(self):
771+
"""refresh_access_token() skips the network when the token is still fresh."""
772+
from tests.authentication.nlziet_mocks import MOCK_ACCESS_TOKEN
773+
# Seed a valid token with expiry far in the future.
774+
self.handler._access_token = MOCK_ACCESS_TOKEN
775+
self.handler._id_token = ""
776+
self.handler._refresh_token = ""
777+
self.handler._expires_at = int(time.time()) + 7200 # 2 hours from now
778+
779+
# Track whether _do_token_refresh is invoked.
780+
refresh_called = []
781+
original = self.handler._do_token_refresh
782+
self.handler._do_token_refresh = lambda: refresh_called.append(True) or original()
783+
try:
784+
result = self.handler.refresh_access_token()
785+
finally:
786+
self.handler._do_token_refresh = original
787+
788+
self.assertTrue(result, "Expected True for still-valid token")
789+
self.assertEqual(refresh_called, [], "_do_token_refresh must NOT be called when token is fresh")
790+
791+
def test_get_valid_token_is_pure_getter(self):
792+
"""get_valid_token() returns the cached access token without any side-effect."""
793+
from tests.authentication.nlziet_mocks import MOCK_ACCESS_TOKEN
794+
self.handler._access_token = MOCK_ACCESS_TOKEN
795+
self.handler._expires_at = 0 # expired — but get_valid_token must NOT refresh
796+
797+
refresh_called = []
798+
original = self.handler._do_token_refresh
799+
self.handler._do_token_refresh = lambda: refresh_called.append(True) or original()
800+
try:
801+
token = self.handler.get_valid_token()
802+
finally:
803+
self.handler._do_token_refresh = original
804+
805+
self.assertEqual(token, MOCK_ACCESS_TOKEN, "Must return the cached token verbatim")
806+
self.assertEqual(refresh_called, [], "_do_token_refresh must NOT be called by get_valid_token()")
807+
808+
def test_instance_vars_loaded_from_settings_at_init(self):
809+
"""Token state read from settings once at construction, not on every call."""
810+
from tests.authentication.nlziet_mocks import MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN
811+
future_expiry = int(time.time()) + 3600
812+
AddonSettings.set_setting(f"{self.handler.prefix}access_token", MOCK_ACCESS_TOKEN, store=LOCAL)
813+
AddonSettings.set_setting(f"{self.handler.prefix}refresh_token", MOCK_REFRESH_TOKEN, store=LOCAL)
814+
AddonSettings.set_setting(f"{self.handler.prefix}expires_at", str(future_expiry), store=LOCAL)
815+
816+
fresh = NLZIETOAuth2Handler(use_device_flow=False)
817+
self.assertEqual(fresh._access_token, MOCK_ACCESS_TOKEN)
818+
self.assertEqual(fresh._refresh_token, MOCK_REFRESH_TOKEN)
819+
self.assertEqual(fresh._expires_at, future_expiry)
763820

764821
def test_set_profile_exchanges_token(self):
765822
"""set_profile performs a token exchange and stores a profile-scoped token."""

0 commit comments

Comments
 (0)