From 7bc14d369345605b14a193f24942619c91fcd8f8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 08:16:49 +0200 Subject: [PATCH] fix(stalker): retire only the token that actually failed auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request dispatched with the previous token can see its authorization failure arrive after a profile refresh has already cached a fresh one. The retry path deleted the cache blindly, killing the fresh token and kicking off another handshake that in turn invalidated tokens of newer requests — cascading retries on strict portals. makeAuthenticatedRequest() now retires the cached token only while it still equals the token that failed; a late failure of a stale token leaves the refreshed token in place and the retry reuses it. Co-Authored-By: Claude Opus 5 --- .../src/lib/stalker-session.service.spec.ts | 59 +++++++++++++++++++ .../src/lib/stalker-session.service.ts | 24 ++++++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index ef618e1e1..9da8369c3 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -283,6 +283,65 @@ describe('StalkerSessionService identity payloads', () => { expect(service.getCachedToken(playlist._id)).toBe('fresh-token'); }); + it('keeps a freshly refreshed token when a stale request fails auth late', async () => { + const playlist = { + _id: 'playlist-5', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist; + + // The request went out with the previous token; meanwhile a + // profile refresh has already cached a fresh one. + jest.spyOn(service, 'ensureToken') + .mockResolvedValueOnce({ token: 'stale-token' }) + .mockResolvedValueOnce({ token: 'fresh-token' }); + service.setCachedToken(playlist._id, 'fresh-token'); + + dataService.sendIpcEvent + .mockResolvedValueOnce({ js: 'Authorization failed. 75' }) + .mockResolvedValueOnce({ js: { data: [] } }); + + await service.makeAuthenticatedRequest(playlist, { + type: 'itv', + action: 'get_ordered_list', + }); + + // The late failure of the stale token must not delete the fresh + // one — the retry reuses it instead of forcing a new handshake. + expect(service.getCachedToken(playlist._id)).toBe('fresh-token'); + expect(dataService.sendIpcEvent).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ token: 'fresh-token' }) + ); + }); + + it('still retires the cached token when it is the one that failed', async () => { + const playlist = { + _id: 'playlist-6', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist; + + jest.spyOn(service, 'ensureToken') + .mockResolvedValueOnce({ token: 'dead-token' }) + .mockResolvedValueOnce({ token: 'new-token' }); + service.setCachedToken(playlist._id, 'dead-token'); + + dataService.sendIpcEvent + .mockResolvedValueOnce({ js: 'Authorization failed. 75' }) + .mockResolvedValueOnce({ js: { data: [] } }); + + await service.makeAuthenticatedRequest(playlist, { + type: 'itv', + action: 'get_ordered_list', + }); + + // Existing behavior preserved: the failed token itself is gone. + expect(service.getCachedToken(playlist._id)).toBeNull(); + }); + it('refreshes the account profile even when a pending authentication fails', async () => { const playlist = { _id: 'playlist-2', diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 4bc756f9e..bca8ef00b 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -606,6 +606,22 @@ export class StalkerSessionService { } } + /** + * Clears the cached token only while it is still the one that just + * failed. A request dispatched with the previous token can see its + * authorization failure arrive after a profile refresh has already + * cached a fresh token — deleting blindly would kill that fresh token + * and trigger a cascade of competing handshakes on strict portals. + */ + private retireFailedToken( + playlistId: string, + failedToken: string | null + ): void { + if (failedToken && this.getCachedToken(playlistId) === failedToken) { + this.clearCachedToken(playlistId); + } + } + /** * Checks if a response or error indicates an authorization failure */ @@ -667,8 +683,8 @@ export class StalkerSessionService { // Check for authorization failure in response if (this.isAuthorizationError(response)) { if (retryOnAuthFailure && playlist.isFullStalkerPortal) { - // Clear cached token to force re-authentication - this.clearCachedToken(playlist._id); + // Retire the failed token so the retry re-authenticates + this.retireFailedToken(playlist._id, token); // Retry once with fresh authentication return this.makeAuthenticatedRequest( playlist, @@ -688,8 +704,8 @@ export class StalkerSessionService { retryOnAuthFailure && playlist.isFullStalkerPortal ) { - // Clear cached token and retry with new handshake - this.clearCachedToken(playlist._id); + // Retire the failed token and retry with new handshake + this.retireFailedToken(playlist._id, token); return this.makeAuthenticatedRequest( playlist, params,