fix(stalker): record credentials in the imported session fingerprint

The import composed the fingerprint from endpoint and identity only, while the
runtime computes it from the playlist — which carries the credentials too. So
the first `ensureToken()` after importing a login-required portal mismatched
the fingerprint the import had just written, refused the token and repeated
the whole handshake/profile/do_auth flow, silently defeating reuse for exactly
the portals this work exists for.

Also refreshes the mock-server instructions the change invalidated: the README
still described the client `do_auth` path as dormant and HTTP-only, and two
comments still claimed the app sends `auth_second_step=1` on its first profile
request — it now sends 0 first and 1 only on the retry after do_auth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-03 18:42:11 +02:00
1 parent 226ffbb9e6
commit c248f9a615
5 files changed
+56 -14

No files matched your search

+1 -1
View File
@@ -82,7 +82,7 @@ actually get wrong:
| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow |
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work |
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app drives this end to end: enter a username and password in the Stalker import dialog and it runs `do_auth`, then retries `get_profile` with `auth_second_step=1`. Covered by `stalker.e2e.ts` (`@stalker full portal authentication`) |
| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials |
| `00:1A:79:00:00:0A` | **static-channel-cmd** | ITV rows carry a directly playable `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'` — a client honouring the flags must play them without calling `create_link` |
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
@@ -11,10 +11,9 @@ import { handleHandshake } from './handshake.handler';
/**
* Handler-level coverage for the authentication actions.
*
* The e2e suite drives the app against this server, but the login-required
* flow cannot be reached from the client yet (its `do_auth` path is dormant
* and sends empty credentials), so the scenario is pinned down here rather
* than only asserted in prose.
* The e2e suite drives the app through this flow end to end; these handler
* tests pin the SERVER half of the contract independently, so a client-side
* regression and a mock-side one cannot mask each other.
*
* Handlers are called directly instead of through the dispatcher: the
* dispatcher pulls in every content handler and therefore the faker-based
@@ -82,8 +81,8 @@ describe('stalker mock authentication handlers', () => {
})['token'] as string;
expect(token).toMatch(/^[0-9A-F]{32}$/);
// The app sends auth_second_step=1 on its very first profile request,
// so that parameter alone must not satisfy the scenario.
// A client could mislabel its first profile as the second auth step;
// that parameter alone must not satisfy the scenario.
expect(
invoke(
handleGetProfile,
@@ -33,10 +33,11 @@ export function handleGetProfile(
return;
}
// Gate on the actual do_auth completion, not on auth_second_step: the app
// sends auth_second_step=1 on its very first get_profile, so a parameter
// check would let the login-required flow be bypassed without ever
// exercising status 2 -> do_auth -> profile retry.
// Gate on the actual do_auth completion, not on auth_second_step. The
// client now sends `auth_second_step=0` first and `1` only on the retry
// after do_auth, so a parameter check would happen to work — but it would
// also silently pass for any client that mislabels its first profile,
// which is exactly the protocol mistake this scenario exists to catch.
if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) {
res.json({
js: {
@@ -3,9 +3,11 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
stalkerSessionFingerprint,
StalkerPortalDiscoveryService,
StalkerPortalError,
} from '@iptvnator/portal/stalker/data-access';
import { Playlist } from '@iptvnator/shared/interfaces';
import { StalkerPortalImportComponent } from './stalker-portal-import.component';
describe('StalkerPortalImportComponent identity handling', () => {
@@ -193,6 +195,41 @@ describe('StalkerPortalImportComponent identity handling', () => {
expect(playlist.stalkerSessionIdentity).toEqual(expect.any(String));
});
it('records credentials in the imported session fingerprint', async () => {
// Otherwise the first runtime ensureToken() computes a fingerprint
// WITH the credentials, mismatches the imported one, and discards the
// session the import just established — silently defeating reuse for
// exactly the login portals this flow exists for.
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
});
component.form.patchValue({
_id: 'playlist-login-fp',
title: 'Login Portal',
macAddress: '00:1A:79:00:00:08',
portalUrl: 'https://portal.example.com/stalker_portal/c',
username: 'user',
password: 'secret',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
const playlist = store.dispatch.mock.calls[0][0].playlist;
expect(playlist.stalkerSessionIdentity).toBe(
stalkerSessionFingerprint({
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:08',
username: 'user',
password: 'secret',
} as Playlist)
);
});
it('records the effective cadence when the portal advertises none', async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
@@ -245,14 +245,19 @@ export class StalkerPortalImportComponent {
portalUrl,
isFullStalkerPortal,
stalkerToken,
// The identity this token was negotiated for. Reuse is
// refused when it no longer matches, so an edited MAC cannot
// inherit the previous session.
// What this token was negotiated for: endpoint, identity AND
// credentials. Reuse is refused when any of them no longer
// matches — and the credentials must be included here, or the
// first runtime `ensureToken()` would compute a fingerprint
// WITH them, mismatch this one, and throw away the session
// the import just established.
...(stalkerToken
? {
stalkerSessionIdentity: stalkerSessionFingerprint({
portalUrl,
macAddress: formValue.macAddress ?? '',
username: formValue.username ?? '',
password: formValue.password ?? '',
...this.toPlaylistIdentityFields(stalkerIdentity),
} as Playlist),
}