AuthManager.ts•10.1 kB
/**
* AuthManager - WhaTap Authentication Manager
*
* Handles the complete WhaTap authentication flow:
* 1. Obtain CSRF token from login page
* 2. Perform web login (get cookies)
* 3. Obtain mobile API token
*/
import axios, { AxiosInstance } from 'axios';
import * as cheerio from 'cheerio';
import { wrapper } from 'axios-cookiejar-support';
import { CookieJar } from 'tough-cookie';
import type {
LoginCredentials,
Session,
WebLoginResponse,
MobileLoginResponse,
} from '../types';
import { AuthenticationError, SessionError } from '../types';
import { SessionStore } from './SessionStore';
/**
* AuthManager handles WhaTap authentication and session management
*/
export class AuthManager {
private session: Session | null = null;
private sessionStore: SessionStore;
private axios: AxiosInstance;
private cookieJar: CookieJar;
constructor(sessionStore: SessionStore) {
this.sessionStore = sessionStore;
// Create axios instance with cookie jar support (like mobile app)
this.cookieJar = new CookieJar();
this.axios = wrapper(axios.create({
jar: this.cookieJar,
timeout: 30000,
headers: {
'User-Agent': 'WhatapMxqlCLI/1.0.0',
},
withCredentials: true,
// Follow redirects (cookie jar will manage cookies across redirects)
maxRedirects: 5,
validateStatus: (status) => status < 400 || status === 302,
}));
}
/**
* Login to WhaTap service
*
* Performs 3-step authentication:
* 1. Get CSRF token
* 2. Web login (cookies)
* 3. Get mobile API token
*
* @param credentials - Login credentials
* @returns Session data
* @throws {AuthenticationError} If authentication fails
*/
async login(credentials: LoginCredentials): Promise<Session> {
const serviceUrl = credentials.serviceUrl || 'https://service.whatap.io';
try {
// Step 1: Get CSRF token
const csrf = await this.getCsrfToken(serviceUrl);
// Step 2: Web login
const webLoginResult = await this.webLogin(
serviceUrl,
credentials.email,
credentials.password,
csrf
);
// Step 3: Get mobile API token
const mobileLoginResult = await this.getMobileToken(
serviceUrl,
credentials.email,
credentials.password,
webLoginResult
);
// Create session
this.session = {
email: credentials.email,
accountId: mobileLoginResult.accountId,
cookies: {
wa: webLoginResult.wa,
jsessionid: webLoginResult.jsessionid,
},
apiToken: mobileLoginResult.apiToken,
serviceUrl,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
};
// Save session
await this.sessionStore.save(this.session);
return this.session;
} catch (error) {
if (error instanceof AuthenticationError) {
throw error;
}
throw new AuthenticationError(
`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Load existing session from storage
*
* @returns Session data or null if not found/expired
*/
async loadSession(): Promise<Session | null> {
try {
this.session = await this.sessionStore.load();
return this.session;
} catch (error) {
throw new SessionError(
`Failed to load session: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Logout and clear session
*/
async logout(): Promise<void> {
this.session = null;
await this.sessionStore.clear();
}
/**
* Check if authenticated (session exists and not expired)
*/
isAuthenticated(): boolean {
if (!this.session) {
return false;
}
return new Date(this.session.expiresAt) > new Date();
}
/**
* Get current session
*
* @throws {AuthenticationError} If not authenticated
*/
getSession(): Session {
if (!this.session) {
throw new AuthenticationError('Not authenticated');
}
return this.session;
}
/**
* Get cookie header string for API requests
*
* @returns Cookie header value
* @throws {AuthenticationError} If not authenticated
*/
getCookieHeader(): string {
if (!this.session) {
throw new AuthenticationError('Not authenticated');
}
// Use WHATAP (not wa) for cookie header
return `WHATAP=${this.session.cookies.wa}; JSESSIONID=${this.session.cookies.jsessionid}`;
}
/**
* Step 1: Get CSRF token from login page
*
* @param serviceUrl - WhaTap service URL
* @returns CSRF token
* @throws {AuthenticationError} If failed to get CSRF token
*/
private async getCsrfToken(serviceUrl: string): Promise<string> {
try {
const response = await this.axios.get(`${serviceUrl}/account/login`, {
params: { lang: 'en' },
});
// Parse HTML and extract CSRF token
const $ = cheerio.load(response.data);
const csrf = $('#_csrf').attr('value');
if (!csrf) {
throw new AuthenticationError('CSRF token not found in login page');
}
return csrf;
} catch (error) {
if (error instanceof AuthenticationError) {
throw error;
}
throw new AuthenticationError(
`Failed to get CSRF token: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Step 2: Perform web login
*
* @param serviceUrl - WhaTap service URL
* @param email - User email
* @param password - User password
* @param csrf - CSRF token
* @returns Web login response with cookies
* @throws {AuthenticationError} If login fails
*/
private async webLogin(
serviceUrl: string,
email: string,
password: string,
csrf: string
): Promise<WebLoginResponse> {
try {
const response = await this.axios.post(
`${serviceUrl}/account/login`,
new URLSearchParams({
email,
password,
_csrf: csrf,
rememberMe: 'on',
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Referer: `${serviceUrl}/account/login`,
},
}
);
// Check for error messages in response
const responseText = typeof response.data === 'string' ? response.data : '';
// Debug: check if login was successful
if (responseText.includes('login') && responseText.includes('email')) {
// Still on login page - authentication failed
throw new AuthenticationError('Authentication failed - invalid credentials or login page returned');
}
if (responseText.includes('locked')) {
throw new AuthenticationError('Account is locked', 'ACCOUNT_LOCKED');
}
// Extract cookies from cookie jar (not from response headers)
const allCookies = await this.cookieJar.getCookies(serviceUrl);
let wa = '';
let jsessionid = '';
let mfaEnabled = false;
allCookies.forEach((cookie) => {
// WhaTap uses 'WHATAP' cookie (not 'wa')
if (cookie.key === 'WHATAP' || cookie.key === 'wa') {
wa = cookie.value;
} else if (cookie.key === 'JSESSIONID') {
jsessionid = cookie.value;
}
});
if (!wa || !jsessionid) {
throw new AuthenticationError('Required cookies not found (WHATAP/wa or JSESSIONID)');
}
// Check for MFA redirect
if (response.status === 302) {
const location = response.headers['location'];
if (location && location.includes('mfa')) {
mfaEnabled = true;
}
}
if (mfaEnabled) {
throw new AuthenticationError(
'MFA/OTP is enabled. Please disable it or implement OTP support.',
'MFA_REQUIRED'
);
}
return { wa, jsessionid, mfaEnabled };
} catch (error) {
if (error instanceof AuthenticationError) {
throw error;
}
throw new AuthenticationError(
`Web login failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Step 3: Get mobile API token
*
* @param serviceUrl - WhaTap service URL
* @param email - User email
* @param password - User password
* @param cookies - Cookies from web login
* @returns Mobile login response with API token
* @throws {AuthenticationError} If failed to get API token
*/
private async getMobileToken(
serviceUrl: string,
email: string,
password: string,
cookies: WebLoginResponse
): Promise<MobileLoginResponse> {
try {
// Prepare mobile login request (exactly like mobile app)
const mobileLoginData = {
email,
password,
appVersion: '1.0.0',
deviceInfo: 'CLI',
deviceModel: 'CLI',
deviceType: 'CLI',
fcmToken: 'CLI',
mobileDeviceToken: '', // NOT USED but required by API
osVersion: process.platform,
};
const response = await this.axios.post(
`${serviceUrl}/mobile/api/login`,
mobileLoginData,
{
headers: {
'Content-Type': 'application/json',
// Cookie jar will automatically include cookies from webLogin
},
}
);
if (response.status !== 200) {
throw new AuthenticationError(
`Mobile login failed with status ${response.status}`
);
}
const data = response.data;
if (!data.accountId || !data.apiToken) {
throw new AuthenticationError('API token not received from mobile login');
}
return {
accountId: data.accountId,
apiToken: data.apiToken,
};
} catch (error) {
if (error instanceof AuthenticationError) {
throw error;
}
throw new AuthenticationError(
`Failed to get mobile API token: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
}