From 4abc65c775c071e30240a1cb668d346f8ec39246 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 18:57:03 +0200 Subject: [PATCH] fix(stalker): bind the playback token to the endpoint the headers claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collection playback route resolved the session token from the raw playlist row while building the headers from the post-repair endpoint, so a completed portal repair could send a token negotiated for one host to another — and key the session cache differently from every other consumer, forcing a redundant handshake. The token now comes from the same coordinates the headers carry. A foreign-host stream skips the token entirely: the header builder gives it the credential-free profile anyway, so obtaining one only stalled playback behind a handshake against a portal that may be slow or offline. Co-Authored-By: Claude Fable 5 --- .../stream-resolver.service.spec.ts | 70 ++++++++++++++++--- .../lib/collection/stream-resolver.service.ts | 30 +++++--- 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index c5300f2f1..1f0c91f68 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -4,7 +4,10 @@ import { XtreamApiService, XtreamUrlService, } from '@iptvnator/portal/xtream/data-access'; -import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; +import { + StalkerPortalRepairService, + StalkerSessionService, +} from '@iptvnator/portal/stalker/data-access'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { DataService, @@ -47,7 +50,6 @@ describe('StreamResolverService', () => { getCachedToken: jest.fn(() => null), ensureToken: jest.fn().mockResolvedValue({ token: null }), makeAuthenticatedRequest: jest.fn(), - ensureToken: jest.fn().mockResolvedValue({ token: null }), }; epgBridge = { getChannelPrograms: jest.fn(), @@ -823,6 +825,50 @@ describe('StreamResolverService', () => { expect(playback.origin).toBe('https://stalker.example.com'); }); + it('binds the playback token to the endpoint the headers claim', async () => { + // A completed repair moved the endpoint. The headers are built from + // that moved endpoint, so the session must be negotiated for it too — + // authenticating against the pre-repair row would send a token minted + // for one host to another (and key the session cache differently from + // every other consumer, forcing a redundant handshake). + const stored = { + _id: 'stalker-1', + portalUrl: 'https://old.example.com/stalker_portal/server/load.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + } satisfies Partial; + const repaired = + 'https://new.example.com/stalker_portal/server/load.php'; + playlistsService.getPlaylistById.mockReturnValue(of(stored)); + jest.spyOn( + TestBed.inject(StalkerPortalRepairService), + 'applyOverride' + ).mockImplementation( + (playlist: any) => ({ ...playlist, portalUrl: repaired }) as any + ); + stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN88' }); + stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ + js: { cmd: 'ffmpeg https://new.example.com:8080/live/88.ts' }, + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::88', + name: 'Moved Portal Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '88', + stalkerCmd: 'ffrt3 http://old.example.com/media/88.mpg', + } satisfies UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ portalUrl: repaired }) + ); + expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN88'); + expect(playback.origin).toBe('https://new.example.com'); + }); + it('authenticates a cold session for a direct-URL radio favorite', async () => { // A direct-URL radio favorite skips create_link entirely, so on a // cold session nothing has authenticated and the in-memory cache is @@ -861,9 +907,11 @@ describe('StreamResolverService', () => { expect(playback.headers?.['Authorization']).toBe('Bearer PERSISTED77'); }); - it('still plays when cold-session authentication fails', async () => { - // Many portals do not gate the stream itself — a failed handshake - // must not block playback, only omit the Bearer header. + it('falls back to create_link when the cold session cannot be established', async () => { + // A portal-owned static stream with no usable session would be served + // knowing it will 401, so playback is not blocked — it takes the + // `create_link` route instead, which mints a URL carrying its own + // token and is the only path that can trigger the lazy portal repair. playlistsService.getPlaylistById.mockReturnValue( of({ _id: 'stalker-1', @@ -877,6 +925,9 @@ describe('StreamResolverService', () => { stalkerSession.ensureToken.mockRejectedValue( new Error('handshake refused') ); + stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ + js: { cmd: 'ffmpeg https://stalker.example.com/radio/99-tmp.mp3' }, + }); const playback = await service.resolvePlayback({ uid: 'stalker::stalker-1::99', @@ -891,8 +942,9 @@ describe('StreamResolverService', () => { } satisfies UnifiedCollectionItem); expect(playback.streamUrl).toBe( - 'https://stalker.example.com/radio/99.mp3' + 'https://stalker.example.com/radio/99-tmp.mp3' ); + // No session, so no Bearer — the minted URL carries its own token. expect(playback.headers?.['Authorization']).toBeUndefined(); }); @@ -1131,10 +1183,12 @@ describe('StreamResolverService', () => { _id: 'stalker-1', portalUrl: 'https://new.example.com/portal.php', macAddress: 'AA:BB:CC:00:00:99', - isFullStalkerPortal: false, + isFullStalkerPortal: true, } satisfies Partial) ); - stalkerSession.getCachedToken.mockReturnValue('TOKEN-NEW'); + // A full portal, so the Bearer assertion below is meaningful: only a + // portal with a session has a token to attach at all. + stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN-NEW' }); const playback = await service.resolvePlayback({ uid: 'stalker::stalker-1::93', diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index bd9b070ce..c779fe1dc 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -523,19 +523,34 @@ export class StreamResolverService { macAddress: resolved.macAddress, portalUrl: resolved.portalUrl, } as Playlist; - const token = await this.resolveStalkerPlaybackToken( - item.playlistId, - playlist + const crossOriginStream = isCrossOriginStalkerStream( + headerPlaylist, + resolved.streamUrl ); + // Classified before authenticating, for the same reason the static + // branch classifies first: the header builder gives a foreign host the + // credential-free profile, so a token obtained here would be discarded + // — after stalling playback behind a handshake against a portal that + // may be slow or offline while the CDN is perfectly reachable. + // + // When it IS needed, the token is resolved from `headerPlaylist` + // rather than the row it came from: those are the exact coordinates + // the headers claim, so the bearer token and the MAC cookie cannot end + // up bound to a different endpoint than the one they are sent to. A + // repair override that moved the endpoint is the live case — it + // reaches `resolved.portalUrl` but not the raw row, and every other + // session consumer (`ensureStalkerSession`, `executeStalkerRequest`) + // already authenticates against the override, so this also stops the + // resolver from keying the session cache differently and re-shaking. + const token = + playlist && !crossOriginStream + ? await this.resolveStalkerPlaybackToken(headerPlaylist) + : null; const headers = buildStalkerExternalPlaybackHeaders( headerPlaylist, token, resolved.streamUrl ); - const crossOriginStream = isCrossOriginStalkerStream( - headerPlaylist, - resolved.streamUrl - ); const portalOrigin = getStalkerPortalOrigin(headerPlaylist); return { @@ -565,7 +580,6 @@ export class StreamResolverService { * stay non-fatal: many portals do not gate the stream itself. */ private async resolveStalkerPlaybackToken( - playlistId: string, playlist: Playlist | undefined ): Promise { // The shared mode contract, not the raw flag: a legacy row with an