mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat(stalker): authenticate resolved portal sessions
This commit is contained in:
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user