feat(stalker): authenticate resolved portal sessions

This commit is contained in:
4gray committed 2026-07-27 10:21:58 +02:00
1 parent c12ad52019
commit ffefe48992
2 files changed
+867

No files matched your search

@@ -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<StalkerNormalizedProfileResult, { kind: 'failure' }>
): 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<StalkerNormalizedProfileResult, { kind: 'failure' }>,
responder: (
request: StalkerHttpRequest
) => StalkerHttpRequestOutcome | Promise<StalkerHttpRequestOutcome>
) {
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<string, string> }).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<string, string> }).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(
'<html><title>Cloudflare challenge</title></html>',
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');
});
});
@@ -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<StalkerHttpRequestOutcome>;
}
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<Record<string, unknown>>;
#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<StalkerAuthOutcome> {
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<StalkerAuthOutcome> {
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<Record<string, string | number>>
): Promise<StalkerAuthenticatedRequestOutcome> {
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<Record<string, string>> {
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<Record<string, unknown>>
): 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 extends StalkerAuthOutcome>(
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<Uint8Array>): {
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<Record<string, unknown>>
): 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<Record<string, unknown>> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Readonly<Record<string, unknown>>)
: 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,
};
}