From ffefe489923f29032a9be20d41ea4ddf6af5918b Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 10:21:58 +0200 Subject: [PATCH] feat(stalker): authenticate resolved portal sessions --- .../stalker-auth-session.spec.ts | 314 ++++++++++ .../stalker-session/stalker-auth-session.ts | 553 ++++++++++++++++++ 2 files changed, 867 insertions(+) create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts new file mode 100644 index 000000000..93bc4c1c2 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts @@ -0,0 +1,314 @@ +import { + createStalkerIdentityProfile, + type StalkerNormalizedProfileResult, +} from '@iptvnator/portal/stalker/protocol'; +import { StalkerCookieJar } from './stalker-cookie-jar'; +import type { StalkerEndpointFullSessionOutcome } from './stalker-endpoint-resolver'; +import { + STALKER_HTTP_OUTCOME_KINDS, + type StalkerHttpRequest, + type StalkerHttpRequestOutcome, +} from './stalker-http-session'; +import { + StalkerAuthSession, + type StalkerAuthSessionDependencies, +} from './stalker-auth-session'; + +const transport = { + maxResponseBytes: 1024 * 1024, + timeoutMs: 5000, +}; + +function success( + value: unknown, + status = 200, + contentType = 'application/json' +): StalkerHttpRequestOutcome { + return { + kind: STALKER_HTTP_OUTCOME_KINDS.Success, + result: { + body: new TextEncoder().encode(JSON.stringify(value)), + contentType, + finalUrl: 'https://portal.test/server/load.php', + status, + }, + }; +} + +function resolved( + profile: Exclude +): StalkerEndpointFullSessionOutcome { + return { + cookieJar: new StalkerCookieJar({ + mac: '00:1A:79:AA:BB:CC', + stb_lang: 'en', + timezone: 'UTC', + }), + endpoint: 'https://portal.test/server/load.php', + handshakeRandom: 'random-one', + identity: createStalkerIdentityProfile({ + handshakeRandom: 'random-one', + macAddress: '00:1A:79:AA:BB:CC', + referer: 'https://portal.test/c/', + }), + kind: 'full-session', + landingUrl: 'https://portal.test/c/', + profile, + profileEnvelope: { js: profile.profile }, + token: 'token-secret', + }; +} + +function harness( + profile: Exclude, + responder: ( + request: StalkerHttpRequest + ) => StalkerHttpRequestOutcome | Promise +) { + const calls: StalkerHttpRequest[] = []; + const dependencies: StalkerAuthSessionDependencies = { + createHttpSession: () => ({ + request: async (request) => { + calls.push(request); + return responder(request); + }, + }), + }; + return { + auth: new StalkerAuthSession( + resolved(profile), + transport, + dependencies + ), + calls, + }; +} + +describe('StalkerAuthSession', () => { + it('reuses a ready first profile without repeating handshake or profile', async () => { + const readyProfile = { + kind: 'ready', + profile: { + id: 'profile-1', + login: 'account-one', + watchdog_timeout: '45', + }, + status: 0, + } as const; + const { auth, calls } = harness(readyProfile, () => { + throw new Error('No request expected'); + }); + + const outcome = await auth.start(); + + expect(outcome).toEqual({ + accountSummary: { + name: 'account-one', + }, + kind: 'ready', + watchdogIntervalSeconds: 45, + }); + expect(auth.getPrincipalKey()).toBe('account-one'); + expect(calls).toHaveLength(0); + }); + + it('runs do_auth only after status 2, then sends the second profile with step 1', async () => { + const { auth, calls } = harness( + { + kind: 'credentials-required', + profile: { status: 2 }, + status: 2, + }, + (request) => { + if (request.params?.['action'] === 'do_auth') { + return success({ js: true }); + } + if (request.params?.['action'] === 'get_profile') { + return success({ + js: { + login: 'confirmed-user', + status: 0, + }, + }); + } + throw new Error('Unexpected request'); + } + ); + + expect(await auth.start()).toEqual({ + attemptNumber: 1, + kind: 'credentials-required', + }); + const outcome = await auth.submitCredentials({ + username: 'confirmed-user', + password: 'confirmed-password', + }); + + expect(outcome).toEqual({ + accountSummary: { + name: 'confirmed-user', + status: '0', + }, + kind: 'ready', + }); + expect(auth.getPrincipalKey()).toBe('confirmed-user'); + expect(calls.map((call) => call.params?.['action'])).toEqual([ + 'do_auth', + 'get_profile', + ]); + expect(calls[0].params).toMatchObject({ + login: 'confirmed-user', + password: 'confirmed-password', + }); + expect(calls[1].params?.['auth_second_step']).toBe(1); + expect( + (calls[0] as { headers?: Record }).headers + ?.Authorization + ).toBe('Bearer token-secret'); + }); + + it('marks rejected saved credentials and accepts a fresh bounded submission', async () => { + let doAuthCount = 0; + const { auth } = harness( + { + kind: 'credentials-required', + profile: { status: 2 }, + status: 2, + }, + (request) => { + if (request.params?.['action'] === 'do_auth') { + doAuthCount += 1; + return success({ js: doAuthCount > 1 }); + } + return success({ js: { login: 'fresh-user', status: 0 } }); + } + ); + + expect( + await auth.start({ + username: 'saved-user', + password: 'saved-password', + }) + ).toEqual({ + attemptNumber: 2, + kind: 'credentials-required', + savedCredentialsRejected: true, + }); + expect( + await auth.submitCredentials({ + username: 'fresh-user', + password: 'fresh-password', + }) + ).toMatchObject({ kind: 'ready' }); + expect(auth.getPrincipalKey()).toBe('fresh-user'); + }); + + it('enforces the three-submission limit without rotating token or identity', async () => { + const { auth, calls } = harness( + { + kind: 'credentials-required', + profile: { status: 2 }, + status: 2, + }, + () => success({ js: false }) + ); + await auth.start(); + + await expect( + auth.submitCredentials({ username: 'one', password: 'bad' }) + ).resolves.toMatchObject({ + attemptNumber: 2, + kind: 'credentials-required', + }); + await expect( + auth.submitCredentials({ username: 'two', password: 'bad' }) + ).resolves.toMatchObject({ + attemptNumber: 3, + kind: 'credentials-required', + }); + await expect( + auth.submitCredentials({ username: 'three', password: 'bad' }) + ).resolves.toEqual({ + kind: 'failure', + reason: 'credentials-attempt-limit', + retryable: false, + stage: 'do-auth', + }); + expect(calls).toHaveLength(3); + expect( + calls.every( + (call) => + (call as { headers?: Record }).headers + ?.Authorization === 'Bearer token-secret' + ) + ).toBe(true); + }); + + it.each([ + { + expected: { + kind: 'failure', + reason: 'rate-limited', + retryable: true, + stage: 'do-auth', + }, + response: success({ error: 'slow down' }, 429), + }, + { + expected: { + kind: 'failure', + reason: 'portal-protection-blocked', + retryable: true, + stage: 'do-auth', + }, + response: success( + 'Cloudflare challenge', + 403, + 'text/html' + ), + }, + ])( + 'keeps do_auth transport/protection failures distinct from bad credentials', + async ({ response, expected }) => { + const { auth } = harness( + { + kind: 'credentials-required', + profile: { status: 2 }, + status: 2, + }, + () => response + ); + await auth.start(); + + await expect( + auth.submitCredentials({ + username: 'user', + password: 'password', + }) + ).resolves.toEqual(expected); + } + ); + + it('keeps token and accepted credentials in non-enumerable class state', async () => { + const { auth } = harness( + { + kind: 'credentials-required', + profile: { status: 2 }, + status: 2, + }, + (request) => + request.params?.['action'] === 'do_auth' + ? success({ js: true }) + : success({ js: { login: 'private-user', status: 0 } }) + ); + await auth.start(); + const outcome = await auth.submitCredentials({ + username: 'private-user', + password: 'private-password', + }); + + expect(JSON.stringify(auth)).toBe('{}'); + expect(JSON.stringify(outcome)).not.toContain('token-secret'); + expect(JSON.stringify(outcome)).not.toContain('private-password'); + }); +}); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts new file mode 100644 index 000000000..b94495559 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts @@ -0,0 +1,553 @@ +import { + STALKER_FAILURE_REASONS, + classifyStalkerDoAuth, + classifyStalkerProfile, + classifyStalkerResponseFailure, + parseStalkerResponseEnvelope, + type StalkerIdentityProfile, +} from '@iptvnator/portal/stalker/protocol'; +import type { + StalkerSessionAccountSummary, + StalkerSessionFailureReason, + StalkerSessionStage, +} from '@iptvnator/shared/interfaces'; +import type { StalkerEndpointFullSessionOutcome } from './stalker-endpoint-resolver'; +import { + STALKER_HTTP_OUTCOME_KINDS, + STALKER_HTTP_REQUEST_MODES, + StalkerHttpSession, + type StalkerHttpRequest, + type StalkerHttpRequestOutcome, +} from './stalker-http-session'; +import type { + StalkerTransportConfig, + StalkerTransportResult, +} from './stalker-session.types'; + +const USERNAME_MAX_BYTES = 512; +const PASSWORD_MAX_BYTES = 2048; + +interface StalkerHttpSessionLike { + request(request: StalkerHttpRequest): Promise; +} + +export interface StalkerAuthSessionDependencies { + createHttpSession?: ( + resolved: StalkerEndpointFullSessionOutcome, + transport: StalkerTransportConfig + ) => StalkerHttpSessionLike; +} + +export interface StalkerAuthCredentials { + password: string; + username: string; +} + +export interface StalkerAuthReadyOutcome { + accountSummary?: StalkerSessionAccountSummary; + kind: 'ready'; + watchdogIntervalSeconds?: number; +} + +export interface StalkerAuthCredentialsRequiredOutcome { + attemptNumber: number; + kind: 'credentials-required'; + savedCredentialsRejected?: boolean; +} + +export interface StalkerAuthFailureOutcome { + kind: 'failure'; + reason: StalkerSessionFailureReason; + retryable: boolean; + retryAfterSeconds?: number; + stage: StalkerSessionStage; +} + +export interface StalkerAuthOriginApprovalOutcome { + finalOrigin: string; + kind: 'origin-approval-required'; + sourceOrigin: string; + targetUrl: string; +} + +export type StalkerAuthOutcome = + | StalkerAuthReadyOutcome + | StalkerAuthCredentialsRequiredOutcome + | StalkerAuthFailureOutcome + | StalkerAuthOriginApprovalOutcome; + +export type StalkerAuthenticatedRequestOutcome = + | { kind: 'success'; value: unknown } + | { kind: 'token-rejected' } + | StalkerAuthFailureOutcome + | StalkerAuthOriginApprovalOutcome; + +type AuthState = 'new' | 'awaiting-credentials' | 'ready' | 'failed'; + +export class StalkerAuthSession { + readonly #endpoint: string; + readonly #http: StalkerHttpSessionLike; + readonly #identity: StalkerIdentityProfile; + readonly #token: string; + #acceptedCredentials: StalkerAuthCredentials | undefined; + #credentialAttempts = 0; + #lastOutcome: StalkerAuthOutcome | undefined; + #principalKey = 'mac-only'; + #profile: Readonly>; + #state: AuthState = 'new'; + + constructor( + resolved: StalkerEndpointFullSessionOutcome, + transport: StalkerTransportConfig, + dependencies: StalkerAuthSessionDependencies = {} + ) { + this.#endpoint = resolved.endpoint; + this.#identity = resolved.identity; + this.#profile = resolved.profile.profile; + this.#token = resolved.token; + this.#http = + dependencies.createHttpSession?.(resolved, transport) ?? + new StalkerHttpSession(resolved.cookieJar, transport); + if (resolved.profile.kind === 'ready') { + this.#principalKey = + readNonEmptyString(resolved.profile.profile['login']) ?? + 'mac-only'; + } + } + + async start( + savedCredentials?: StalkerAuthCredentials + ): Promise { + if (this.#state !== 'new') { + return ( + this.#lastOutcome ?? + authFailure( + STALKER_FAILURE_REASONS.IncompatibleResponse, + 'profile-first', + false + ) + ); + } + + const initialProfile = classifyStalkerProfile({ + js: this.#profile, + }); + if (initialProfile.kind === 'ready') { + return this.markReady(initialProfile.profile); + } + if (initialProfile.kind === 'blocked') { + return this.markFailed( + authFailure( + STALKER_FAILURE_REASONS.AccountOrDeviceBlocked, + 'profile-first', + false + ) + ); + } + if (initialProfile.kind === 'failure') { + return this.markFailed( + authFailure(initialProfile.reason, 'profile-first', false) + ); + } + + this.#state = 'awaiting-credentials'; + if (savedCredentials !== undefined) { + return this.submitCredentials(savedCredentials, true); + } + return this.setOutcome({ + attemptNumber: 1, + kind: 'credentials-required', + }); + } + + async submitCredentials( + credentials: StalkerAuthCredentials, + savedCredentials = false + ): Promise { + if (this.#state !== 'awaiting-credentials') { + return authFailure( + STALKER_FAILURE_REASONS.IncompatibleResponse, + 'do-auth', + false + ); + } + const normalized = normalizeCredentials(credentials); + if (normalized === null) { + return authFailure( + STALKER_FAILURE_REASONS.InvalidIdentityInput, + 'do-auth', + false + ); + } + if (this.#credentialAttempts >= 3) { + return this.markFailed( + authFailure( + STALKER_FAILURE_REASONS.CredentialsAttemptLimit, + 'do-auth', + false + ) + ); + } + + this.#credentialAttempts += 1; + const doAuth = await this.#http.request({ + headers: this.authenticatedHeaders(), + mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing, + params: { + action: 'do_auth', + JsHttpRequest: '1-xml', + login: normalized.username, + password: normalized.password, + type: 'stb', + }, + url: this.#endpoint, + }); + const normalizedDoAuth = normalizeResponse(doAuth, 'do-auth'); + if (normalizedDoAuth.kind !== 'success') { + return this.markFailed(normalizedDoAuth); + } + + const doAuthClassification = classifyStalkerDoAuth( + normalizedDoAuth.value + ); + if (doAuthClassification.kind === 'credentials-rejected') { + return this.handleCredentialRejection(savedCredentials); + } + if (doAuthClassification.kind === 'failure') { + return this.markFailed( + authFailure(doAuthClassification.reason, 'do-auth', false) + ); + } + + const secondProfile = await this.#http.request({ + headers: this.authenticatedHeaders(), + mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing, + params: { + ...this.#identity.profileParameters, + action: 'get_profile', + auth_second_step: 1, + JsHttpRequest: '1-xml', + metrics: JSON.stringify(this.#identity.metrics), + not_valid_token: 0, + type: 'stb', + }, + url: this.#endpoint, + }); + const normalizedSecondProfile = normalizeResponse( + secondProfile, + 'profile-second' + ); + if (normalizedSecondProfile.kind !== 'success') { + return this.markFailed(normalizedSecondProfile); + } + const classifiedProfile = classifyStalkerProfile( + normalizedSecondProfile.value + ); + if (classifiedProfile.kind === 'blocked') { + return this.markFailed( + authFailure( + STALKER_FAILURE_REASONS.AccountOrDeviceBlocked, + 'profile-second', + false + ) + ); + } + if (classifiedProfile.kind === 'failure') { + return this.markFailed( + authFailure(classifiedProfile.reason, 'profile-second', false) + ); + } + if (classifiedProfile.kind === 'credentials-required') { + return this.handleCredentialRejection(savedCredentials); + } + + this.#acceptedCredentials = normalized; + this.#principalKey = + readNonEmptyString(classifiedProfile.profile['login']) ?? + normalized.username; + return this.markReady(classifiedProfile.profile); + } + + getPrincipalKey(): string { + return this.#principalKey; + } + + getEndpoint(): string { + return this.#endpoint; + } + + hasAcceptedCredentials(): boolean { + return this.#acceptedCredentials !== undefined; + } + + async request( + parameters: Readonly> + ): Promise { + if (this.#state !== 'ready') { + return authFailure( + STALKER_FAILURE_REASONS.IncompatibleResponse, + 'ready', + false + ); + } + const outcome = await this.#http.request({ + headers: this.authenticatedHeaders(), + mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing, + params: parameters, + url: this.#endpoint, + }); + return normalizeResponse(outcome, 'ready', true); + } + + private authenticatedHeaders(): Readonly> { + return { + ...this.#identity.headers, + Authorization: `Bearer ${this.#token}`, + }; + } + + private handleCredentialRejection( + savedCredentials: boolean + ): StalkerAuthOutcome { + if (this.#credentialAttempts >= 3) { + return this.markFailed( + authFailure( + STALKER_FAILURE_REASONS.CredentialsAttemptLimit, + 'do-auth', + false + ) + ); + } + this.#state = 'awaiting-credentials'; + return this.setOutcome({ + attemptNumber: this.#credentialAttempts + 1, + kind: 'credentials-required', + ...(savedCredentials ? { savedCredentialsRejected: true } : {}), + }); + } + + private markReady( + profile: Readonly> + ): StalkerAuthReadyOutcome { + this.#profile = profile; + this.#state = 'ready'; + return this.setOutcome(buildReadyOutcome(profile)); + } + + private markFailed( + outcome: + | StalkerAuthFailureOutcome + | StalkerAuthOriginApprovalOutcome + | { kind: 'token-rejected' } + ): StalkerAuthOutcome { + const failure = + outcome.kind === 'token-rejected' + ? authFailure( + STALKER_FAILURE_REASONS.AuthRefreshExhausted, + 'refreshing', + false + ) + : outcome; + this.#state = 'failed'; + return this.setOutcome(failure); + } + + private setOutcome( + outcome: Outcome + ): Outcome { + this.#lastOutcome = outcome; + return outcome; + } +} + +function normalizeResponse( + outcome: StalkerHttpRequestOutcome, + stage: StalkerSessionStage, + allowTokenRejection = false +): StalkerAuthenticatedRequestOutcome { + if (outcome.kind === STALKER_HTTP_OUTCOME_KINDS.Failure) { + return authFailure(outcome.reason, stage, outcome.retryable); + } + if (outcome.kind === STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired) { + return { + finalOrigin: outcome.finalOrigin, + kind: 'origin-approval-required', + sourceOrigin: outcome.sourceOrigin, + targetUrl: outcome.targetUrl, + }; + } + + const parsed = parseResult(outcome.result); + const classifiedFailure = classifyStalkerResponseFailure({ + httpStatus: outcome.result.status, + rawBody: parsed.rawBody, + value: parsed.value, + }); + if (classifiedFailure.kind === 'failure') { + return authFailure( + classifiedFailure.reason, + stage, + isRetryableFailure(classifiedFailure.reason), + outcome.result.retryAfterSeconds + ); + } + if (classifiedFailure.kind === 'token-rejected') { + return allowTokenRejection + ? { kind: 'token-rejected' } + : authFailure( + STALKER_FAILURE_REASONS.IncompatibleResponse, + stage, + false + ); + } + if (outcome.result.status !== 200 || parsed.value === undefined) { + return authFailure( + STALKER_FAILURE_REASONS.IncompatibleResponse, + stage, + false + ); + } + return { kind: 'success', value: parsed.value }; +} + +function parseResult(result: StalkerTransportResult): { + rawBody?: string; + value?: unknown; +} { + let rawBody: string; + try { + rawBody = new TextDecoder('utf-8', { fatal: true }).decode(result.body); + } catch { + return {}; + } + const parsed = parseStalkerResponseEnvelope({ + body: rawBody, + contentType: result.contentType, + maxBodyBytes: result.body.byteLength, + }); + return parsed.kind === 'parsed' + ? { rawBody, value: parsed.value } + : { rawBody }; +} + +function normalizeCredentials( + value: StalkerAuthCredentials +): StalkerAuthCredentials | null { + if ( + typeof value?.username !== 'string' || + typeof value?.password !== 'string' + ) { + return null; + } + const username = value.username.trim(); + const password = value.password; + if ( + username.length === 0 || + password.length === 0 || + Buffer.byteLength(username, 'utf8') > USERNAME_MAX_BYTES || + Buffer.byteLength(password, 'utf8') > PASSWORD_MAX_BYTES + ) { + return null; + } + return { password, username }; +} + +function buildReadyOutcome( + profile: Readonly> +): StalkerAuthReadyOutcome { + const accountInfo = asRecord(profile['account_info']); + const accountSummary = compactAccountSummary({ + accountBalance: + readNonEmptyString(accountInfo?.['account_balance']) ?? + readNonEmptyString(profile['account_balance']), + expiresAt: + readNonEmptyString(accountInfo?.['expire_date']) ?? + readNonEmptyString(profile['expire_date']), + name: + readNonEmptyString(accountInfo?.['login']) ?? + readNonEmptyString(profile['login']) ?? + readNonEmptyString(profile['name']), + status: + readStringLike(accountInfo?.['status']) ?? + readStringLike(profile['status']), + tariffPlan: + readNonEmptyString(accountInfo?.['tariff_plan_name']) ?? + readNonEmptyString(profile['tariff_plan_name']), + }); + const watchdogIntervalSeconds = readPositiveFiniteNumber( + profile['watchdog_timeout'] + ); + return { + ...(accountSummary === undefined ? {} : { accountSummary }), + kind: 'ready', + ...(watchdogIntervalSeconds === undefined + ? {} + : { watchdogIntervalSeconds }), + }; +} + +function compactAccountSummary( + value: StalkerSessionAccountSummary +): StalkerSessionAccountSummary | undefined { + const compact = Object.fromEntries( + Object.entries(value).filter(([, field]) => field !== undefined) + ) as StalkerSessionAccountSummary; + return Object.keys(compact).length === 0 ? undefined : compact; +} + +function readPositiveFiniteNumber(value: unknown): number | undefined { + const number = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : Number.NaN; + return Number.isFinite(number) && number > 0 ? number : undefined; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function readStringLike(value: unknown): string | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + return readNonEmptyString(value); +} + +function asRecord( + value: unknown +): Readonly> | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Readonly>) + : undefined; +} + +function isRetryableFailure(reason: StalkerSessionFailureReason): boolean { + return ( + reason === STALKER_FAILURE_REASONS.DnsFailure || + reason === STALKER_FAILURE_REASONS.NetworkUnreachable || + reason === STALKER_FAILURE_REASONS.RequestTimeout || + reason === STALKER_FAILURE_REASONS.RateLimited || + reason === STALKER_FAILURE_REASONS.PortalUnavailable || + reason === STALKER_FAILURE_REASONS.PortalProtectionBlocked + ); +} + +function authFailure( + reason: StalkerSessionFailureReason, + stage: StalkerSessionStage, + retryable: boolean, + retryAfterSeconds?: number +): StalkerAuthFailureOutcome { + return { + kind: 'failure', + reason, + retryable, + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), + stage, + }; +}