'use strict';
const nodeFs = require('fs');
/* Require Optional Dependencies */
var fs = nodeFs;
try {
fs = require('fs-extra');
} catch {
// Native PostgreSQL mode only needs the Node.js fs implementation.
}
try {
var unzipper = require('unzipper');
} catch {
// Validated by the legacy RemoteAuth constructor below.
}
try {
var archiver = require('archiver');
} catch {
// Validated by the legacy RemoteAuth constructor below.
}
const path = require('path');
const { Events } = require('./../util/Constants');
const { BrowserSessionBridge } = require('./../session/BrowserSessionBridge');
const { SESSION_STORE_KIND } = require('./../session/PostgresSessionStore');
const BaseAuthStrategy = require('./BaseAuthStrategy');
const comparableJid = (jid) => {
const normalized = String(jid || '')
.trim()
.toLowerCase();
if (!normalized) return '';
const separator = normalized.lastIndexOf('@');
const rawUser =
separator === -1 ? normalized : normalized.slice(0, separator);
const rawServer =
separator === -1 ? 's.whatsapp.net' : normalized.slice(separator + 1);
const server = rawServer === 'c.us' ? 's.whatsapp.net' : rawServer;
return `${rawUser.split(':')[0]}@${server}`;
};
/**
* Remote-based authentication
* @param {object} options - options
* @param {object} options.store - Remote database store instance
* @param {string} options.clientId - Client id to distinguish instances if you are using multiple, otherwise keep null if you are using only one instance
* @param {string} options.dataPath - Change the default path for saving session files, default is: "./.wwebjs_auth/"
* @param {number} options.backupSyncIntervalMs - Sets the time interval for periodic session backups. Accepts values starting from 60000ms {1 minute}
* @param {number} options.initialSyncDelayMs - Delay before the first checkpoint for a newly paired session
* @param {function} options.identityResolver - Trusted resolver for the companion fingerprint; required for verified native PostgreSQL mode
* @param {boolean} options.requireFingerprintVerification - Fail readiness unless the trusted fingerprint matches. Defaults to true in production.
* @param {number} options.rmMaxRetries - Sets the maximum number of retries for removing the session directory
*/
class RemoteAuth extends BaseAuthStrategy {
constructor({
clientId,
dataPath,
store,
backupSyncIntervalMs,
initialSyncDelayMs = 60000,
identityResolver,
requireFingerprintVerification = process.env.NODE_ENV === 'production',
rmMaxRetries,
} = {}) {
const nativePostgres = store?.kind === SESSION_STORE_KIND;
if (!nativePostgres && (!fs.emptyDir || !unzipper || !archiver))
throw new Error(
'Optional Dependencies [fs-extra, unzipper, archiver] are required to use RemoteAuth. Make sure to run npm install correctly and remove the --no-optional flag',
);
super();
const idRegex = /^[-_\w]+$/i;
if (clientId && !idRegex.test(clientId)) {
throw new Error(
'Invalid clientId. Only alphanumeric characters, underscores and hyphens are allowed.',
);
}
if (!backupSyncIntervalMs || backupSyncIntervalMs < 60000) {
throw new Error(
'Invalid backupSyncIntervalMs. Accepts values starting from 60000ms {1 minute}.',
);
}
if (!Number.isFinite(initialSyncDelayMs) || initialSyncDelayMs < 0) {
throw new Error('Invalid initialSyncDelayMs.');
}
if (!store) throw new Error('Remote database store is required.');
if (
identityResolver !== undefined &&
typeof identityResolver !== 'function'
) {
throw new Error('wwebjs_companion_identity_resolver_invalid');
}
if (
nativePostgres &&
requireFingerprintVerification &&
typeof identityResolver !== 'function'
) {
throw new Error('wwebjs_companion_identity_resolver_required');
}
this.store = store;
this.nativePostgres = nativePostgres;
this.clientId = clientId;
this.backupSyncIntervalMs = backupSyncIntervalMs;
this.initialSyncDelayMs = initialSyncDelayMs;
this.identityResolver = identityResolver;
this.requireFingerprintVerification = requireFingerprintVerification;
this.dataPath = path.resolve(dataPath || './.wwebjs_auth/');
try {
fs.accessSync(process.cwd(), fs.constants.W_OK);
this.zipDir = process.cwd();
} catch {
this.zipDir = this.dataPath;
}
this.tempDir = `${this.dataPath}/wwebjs_temp_session_${this.clientId}`;
this.requiredDirs = [
'Default',
'IndexedDB',
'Local Storage',
]; /* => Required Files & Dirs in WWebJS to restore session */
this.rmMaxRetries = rmMaxRetries ?? 4;
this.authReady = false;
this.shutdownRequested = false;
this.checkpointTail = Promise.resolve();
this.periodicCheckpoint = undefined;
this.profileDirty = true;
this.criticalCheckpointTimer = undefined;
this.providerHandoffKey = undefined;
this.preparedProviderHandoff = undefined;
}
validateClientOptions(options) {
if (!this.nativePostgres) return;
if (options.webVersion !== this.store.webVersion) {
throw new Error('wwebjs_web_version_incompatible');
}
if (
options.webVersionCache?.type === 'none' ||
options.webVersionCache?.strict !== true
) {
throw new Error('wwebjs_strict_web_cache_required');
}
}
async beforeBrowserInitialized() {
this.shutdownRequested = false;
this.projectionHydrated = false;
this.shutdownProjection = undefined;
await this.browserBridge?.close();
this.browserBridge = undefined;
const puppeteerOpts = this.client.options.puppeteer;
const sessionDirName = this.clientId
? `RemoteAuth-${this.clientId}`
: 'RemoteAuth';
const dirPath = path.join(this.dataPath, sessionDirName);
if (
puppeteerOpts.userDataDir &&
puppeteerOpts.userDataDir !== dirPath
) {
throw new Error(
'RemoteAuth is not compatible with a user-supplied userDataDir.',
);
}
this.userDataDir = dirPath;
this.sessionName = sessionDirName;
if (this.nativePostgres) {
await this.store.open({
onLeaseLost: (error) => this.handleLeaseLoss(error),
});
try {
await this.extractNativeSession();
} catch (error) {
await this.store.releaseLease().catch(() => {});
throw error;
}
} else {
await this.extractRemoteSession();
}
this.client.options.puppeteer = {
...puppeteerOpts,
userDataDir: dirPath,
};
}
async afterBrowserInitialized() {
if (!this.nativePostgres) return;
this.browserBridge = new BrowserSessionBridge({
page: this.client.pupPage,
expectedVersion: this.store.webVersion,
logger: this.store.logger,
onCriticalMutation: () => this.scheduleCriticalCheckpoint(),
});
try {
await this.browserBridge.install();
} catch (error) {
const browser = this.client.pupBrowser;
if (browser?.isConnected?.()) {
if (browser.process()) await browser.close();
else browser.disconnect();
}
await this.store.releaseLease().catch(() => {});
throw error;
}
}
async beforeClientInjected() {
if (!this.nativePostgres || this.projectionHydrated) return;
try {
await this.client.pupPage.waitForFunction(
'window.Debug?.VERSION != undefined',
{ timeout: this.client.options.authTimeoutMs || 30000 },
);
await this.browserBridge.assertCompatible();
const projection = await this.store.consumePendingProjection();
if (!projection) return;
await this.browserBridge.importProjection(projection);
this.projectionHydrated = true;
this.store.logger.log('handoff.browser_reload_started');
await this.client.pupPage.reload({
waitUntil: 'load',
timeout: 0,
});
await this.client.pupPage.waitForFunction(
'window.Debug?.VERSION != undefined',
{ timeout: this.client.options.authTimeoutMs || 30000 },
);
await this.browserBridge.assertCompatible();
this.store.logger.log('handoff.browser_reload_completed');
} catch (error) {
if (this.store.isHandoffRevision()) {
await this.store
.rollback(error.code || 'handoff_browser_hydration_failed')
.catch(() => {});
}
await this.browserBridge.close();
const browser = this.client.pupBrowser;
if (browser?.isConnected?.()) {
if (browser.process()) await browser.close();
else browser.disconnect();
}
await this.store.releaseLease();
throw error;
}
}
async onInitializationFailure(error) {
if (!this.nativePostgres) return;
this.stopBackupSync();
await this.browserBridge?.close().catch(() => {});
if (this.store.isHandoffRevision()) {
await this.store
.rollback(error.code || 'handoff_client_initialization_failed')
.catch(() => {});
}
await this.store.releaseLease().catch(() => {});
this.store.logger.log(
'client.initialization_failed',
{ error },
{ force: true },
);
}
async logout() {
this.shutdownRequested = true;
this.stopBackupSync();
await this.checkpointTail.catch(() => {});
this.authReady = false;
if (this.nativePostgres) {
await this.browserBridge?.close();
await this.store.delete();
// A server-side/mobile logout navigates the still-open page back
// to pairing and Client immediately initializes this strategy
// again. Keep the pool reusable in that path; an explicit
// Client.logout() has already closed Chromium and can close it.
if (this.client.pupBrowser?.isConnected?.()) {
await this.store.releaseLease();
} else {
await this.store.close();
}
} else {
await this.deleteRemoteSession();
}
await this.removeLocalSession();
}
async destroy() {
await this.shutdown();
}
async disconnect() {
this.shutdownRequested = true;
this.stopBackupSync();
// Client calls destroy() immediately after this hook. The final
// checkpoint belongs to shutdown(); saving here as well would rotate
// the active/previous revisions twice with the same Chromium profile.
await this.checkpointTail;
}
async beforeBrowserDestroyed() {
if (!this.nativePostgres || !this.authReady) return;
this.stopBackupSync();
await this.checkpointTail;
if (!this.browserBridge) return;
await this.browserBridge.flush();
this.shutdownProjection = await this.browserBridge.exportProjection();
this.lastProjection = this.shutdownProjection;
await this.browserBridge.close();
this.store.logger.log('checkpoint.shutdown_projection_captured', {
record_count: this.shutdownProjection.records.length,
size_bytes: this.shutdownProjection.size_bytes,
});
}
/**
* Persists a final checkpoint without deleting the remote session, then
* removes the ephemeral Chromium profile.
*/
async shutdown() {
this.shutdownRequested = true;
this.stopBackupSync();
await this.checkpointTail;
if (this.authReady) {
if (this.nativePostgres) {
if (
!this.shutdownProjection &&
this.client.pupBrowser?.isConnected?.()
) {
await this.beforeBrowserDestroyed();
}
await this.store.checkpointProfile({
profilePath: this.userDataDir,
projection: this.shutdownProjection || this.lastProjection,
source:
this.store.revisionStatus === 'staging'
? this.store.revisionSource || 'pairing'
: 'checkpoint',
});
} else {
await this.checkpoint({ force: true, reason: 'shutdown' });
}
}
this.authReady = false;
await this.browserBridge?.close();
if (this.nativePostgres) await this.store.close();
await this.removeLocalSession();
}
async onAuthenticationNeeded() {
if (this.nativePostgres && this.store.isHandoffRevision()) {
await this.store.rollback('handoff_requested_qr');
return {
failed: true,
restart: false,
failureEventPayload: 'wwebjs_handoff_requested_qr',
};
}
return {
failed: false,
restart: false,
failureEventPayload: undefined,
};
}
async afterAuthReady() {
const sessionExists = this.nativePostgres
? this.store.revisionStatus === 'active' ||
this.store.isHandoffRevision()
: await this.store.sessionExists({
session: this.sessionName,
});
this.authReady = false;
if (!sessionExists && this.initialSyncDelayMs > 0) {
await this.delay(this.initialSyncDelayMs);
}
this.assertNotShuttingDown();
// READY must only be published after at least one durable checkpoint.
if (this.nativePostgres) {
try {
const resolvedIdentity = this.identityResolver
? await this.identityResolver(this.client)
: {};
const trustedFingerprint =
resolvedIdentity?.companionFingerprint ||
resolvedIdentity?.deviceFingerprint;
if (
this.requireFingerprintVerification &&
!trustedFingerprint
) {
throw new Error(
'wwebjs_companion_fingerprint_not_verified',
);
}
const browserJid =
this.client.info?.wid?._serialized ||
this.client.info?.wid?.user;
if (
resolvedIdentity?.jid &&
comparableJid(resolvedIdentity.jid) !==
comparableJid(browserJid)
) {
throw new Error('wwebjs_identity_resolver_jid_mismatch');
}
const validation = await this.store.validateReadyIdentity({
jid: browserJid,
companionFingerprint: trustedFingerprint,
});
if (
this.requireFingerprintVerification &&
(!trustedFingerprint ||
(!validation.fingerprintVerified &&
!validation.fingerprintRegistered))
) {
throw new Error(
'wwebjs_companion_fingerprint_not_verified',
);
}
} catch (error) {
this.authReady = false;
if (this.store.isHandoffRevision()) {
await this.store.rollback(
error.code || 'handoff_identity_validation_failed',
);
}
this.store.logger.log(
'session.identity_validation_failed',
{ error },
{ force: true },
);
await this.store.releaseLease();
const browser = this.client.pupBrowser;
if (browser?.isConnected?.()) {
if (browser.process()) await browser.close();
else browser.disconnect();
}
throw error;
}
}
let persisted;
try {
persisted = await this.checkpoint({
emit: true,
force: true,
reason: 'ready',
});
} catch (error) {
this.authReady = false;
if (this.nativePostgres) {
if (this.store.isHandoffRevision()) {
await this.store
.rollback(error.code || 'handoff_checkpoint_failed')
.catch(() => {});
}
await this.store.releaseLease().catch(() => {});
const browser = this.client.pupBrowser;
if (browser?.isConnected?.()) {
if (browser.process()) await browser.close();
else browser.disconnect();
}
}
throw error;
}
if (!persisted) {
throw new Error(
'Unable to persist the initial RemoteAuth checkpoint.',
);
}
this.assertNotShuttingDown();
this.authReady = true;
if (
this.nativePostgres &&
(this.profileDirty || this.browserBridge?.dirty)
) {
this.scheduleCriticalCheckpoint();
}
this.backupSync = setInterval(() => {
void this.checkpoint({
periodic: true,
reason: 'periodic',
}).catch(() => {});
}, this.backupSyncIntervalMs);
}
/**
* Queues a serialized durable checkpoint. Periodic calls are coalesced so
* a slow store can never build an unbounded interval backlog.
*/
checkpoint({
emit = false,
force = false,
periodic = false,
reason = 'manual',
} = {}) {
if (!force && !this.authReady) return Promise.resolve(false);
if (
this.nativePostgres &&
periodic &&
!this.profileDirty &&
!this.browserBridge?.dirty
) {
return Promise.resolve(false);
}
if (periodic && this.periodicCheckpoint) {
return this.periodicCheckpoint;
}
const queued = this.checkpointTail.then(() =>
this.performCheckpoint({ emit, force, reason }),
);
this.checkpointTail = queued.catch(() => {});
if (periodic) {
this.periodicCheckpoint = queued;
void queued
.finally(() => {
if (this.periodicCheckpoint === queued) {
this.periodicCheckpoint = undefined;
}
})
.catch(() => {});
}
return queued;
}
async storeRemoteSession(options = {}) {
return this.checkpoint({ emit: options.emit, force: true });
}
async performCheckpoint({ emit, force, reason }) {
const pathExists = await this.isValidPath(this.userDataDir);
if (!pathExists) return false;
if (this.nativePostgres) {
if (!this.browserBridge) {
throw new Error('wwebjs_browser_bridge_unavailable');
}
this.store.logger.log('checkpoint.started', { reason });
const journal = await this.browserBridge.flush();
if (!force && !this.profileDirty && !journal?.dirty) return false;
const projection = await this.browserBridge.exportProjection();
this.lastProjection = projection;
const source =
this.store.revisionStatus === 'staging'
? this.store.revisionSource || 'pairing'
: 'checkpoint';
await this.store.checkpointProfile({
profilePath: this.userDataDir,
projection,
source,
});
const postCheckpointJournal = await this.browserBridge.flush();
if (postCheckpointJournal?.dirty) {
this.profileDirty = true;
if (postCheckpointJournal.critical) {
this.scheduleCriticalCheckpoint();
}
} else {
await this.browserBridge.markClean();
this.profileDirty = false;
}
if (emit) this.client.emit(Events.REMOTE_SESSION_SAVED);
return true;
}
let compressedSessionPath;
try {
compressedSessionPath = await this.compressSession();
await this.store.save({
session: this.sessionName,
path: compressedSessionPath,
});
if (emit) this.client.emit(Events.REMOTE_SESSION_SAVED);
return true;
} finally {
const paths = [
this.tempDir,
...(compressedSessionPath ? [compressedSessionPath] : []),
];
await Promise.allSettled(
paths.map((p) =>
fs.promises.rm(p, {
recursive: true,
force: true,
maxRetries: this.rmMaxRetries,
}),
),
);
}
}
async extractNativeSession() {
await this.removeLocalSession();
try {
const restored = await this.store.restoreProfile({
profilePath: this.userDataDir,
selector: 'active',
});
if (!restored) {
if (this.store.revisionStatus === 'active') {
throw new Error('wwebjs_active_profile_artifact_missing');
}
await fs.promises.mkdir(this.userDataDir, {
recursive: true,
});
}
} catch (activeRevisionError) {
await this.removeLocalSession();
try {
await this.store.restoreProfile({
profilePath: this.userDataDir,
selector: 'previous',
});
this.store.logger.log(
'artifact.active_restore_failed_previous_restored',
{ error: activeRevisionError },
{ force: true },
);
} catch (previousRevisionError) {
throw new AggregateError(
[activeRevisionError, previousRevisionError],
'Unable to restore active or previous PostgreSQL session revision.',
);
}
}
}
scheduleCriticalCheckpoint() {
this.profileDirty = true;
if (!this.authReady || this.shutdownRequested) return;
if (this.criticalCheckpointTimer) return;
this.criticalCheckpointTimer = setTimeout(() => {
this.criticalCheckpointTimer = undefined;
void this.checkpoint({
force: true,
reason: 'critical_mutation',
}).catch((error) => {
this.store.logger.log(
'checkpoint.critical_failed',
{ error },
{ force: true },
);
});
}, 100);
}
async handleLeaseLoss(error) {
if (this.shutdownRequested) return;
this.shutdownRequested = true;
this.authReady = false;
this.stopBackupSync();
await this.browserBridge?.close();
this.client.emit(Events.DISCONNECTED, error.code);
const browser = this.client.pupBrowser;
if (browser?.isConnected?.()) {
if (browser.process()) await browser.close();
else browser.disconnect();
}
}
async exportProjection() {
if (!this.nativePostgres) {
throw new Error('wwebjs_native_postgres_store_required');
}
await this.checkpoint({ force: true, reason: 'export' });
return this.store.exportProjection();
}
async importProjection(options) {
if (!this.nativePostgres) {
throw new Error('wwebjs_native_postgres_store_required');
}
return this.store.importProjection(options);
}
async prepareHandoff(targetProvider, expected = {}) {
if (!this.nativePostgres) {
throw new Error('wwebjs_native_postgres_store_required');
}
const handoffKey = [
targetProvider,
expected.handoffId,
expected.lifecycleOperationId,
expected.sourceRevisionId,
].join(':');
if (this.providerHandoffKey && this.providerHandoffKey !== handoffKey) {
throw new Error('wwebjs_provider_handoff_replay_mismatch');
}
this.providerHandoffKey = handoffKey;
if (this.preparedProviderHandoff) {
const leaseReleased = await this.store.close({
requireLeaseRelease: true,
});
return { ...this.preparedProviderHandoff, leaseReleased };
}
try {
await this.store.assertAuthorizedHandoff(targetProvider, expected);
} catch (error) {
// Authorization happens before any drain/checkpoint side effect.
// Do not let a rejected request poison a later legitimate target.
if (this.providerHandoffKey === handoffKey) {
this.providerHandoffKey = undefined;
}
throw error;
}
this.shutdownRequested = true;
this.stopBackupSync();
this.authReady = false;
const projection = await this.browserBridge?.exportProjection();
await this.browserBridge?.close();
const browser = this.client.pupBrowser;
if (browser?.isConnected?.()) {
if (browser.process()) await browser.close();
else browser.disconnect();
}
try {
if (this.store.revisionStatus === 'active') {
await this.store.checkpointProfile({
profilePath: this.userDataDir,
projection,
source: 'checkpoint',
});
}
const handoff = await this.store.prepareHandoff(
targetProvider,
expected,
);
this.preparedProviderHandoff = handoff;
const leaseReleased = await this.store.close({
requireLeaseRelease: true,
});
await this.removeLocalSession();
return { ...handoff, leaseReleased };
} catch (error) {
if (!this.preparedProviderHandoff) {
await this.store.close().catch(() => {});
}
throw error;
}
}
async promote(revisionId) {
if (!this.nativePostgres) {
throw new Error('wwebjs_native_postgres_store_required');
}
return this.store.promote(revisionId);
}
async rollback(errorCode) {
if (!this.nativePostgres) {
throw new Error('wwebjs_native_postgres_store_required');
}
return this.store.rollback(errorCode);
}
async extractRemoteSession() {
const compressedSessionPath = path.join(
this.zipDir,
`${this.sessionName}.zip`,
);
const sessionExists = await this.store.sessionExists({
session: this.sessionName,
});
// Remote storage is the source of truth. Local profiles are ephemeral
// and must never silently override the selected remote revision.
await this.removeLocalSession();
if (sessionExists) {
try {
await this.extractRevision(
this.store.extract.bind(this.store),
compressedSessionPath,
);
} catch (activeRevisionError) {
await this.removeLocalSession();
await fs.promises.rm(compressedSessionPath, { force: true });
if (typeof this.store.extractPrevious !== 'function') {
throw activeRevisionError;
}
try {
await this.extractRevision(
this.store.extractPrevious.bind(this.store),
compressedSessionPath,
);
} catch (previousRevisionError) {
throw new AggregateError(
[activeRevisionError, previousRevisionError],
'Unable to restore active or previous remote session revision.',
);
}
}
} else {
await fs.promises.mkdir(this.userDataDir, { recursive: true });
}
}
async extractRevision(extract, compressedSessionPath) {
await extract({
session: this.sessionName,
path: compressedSessionPath,
});
await this.unCompressSession(compressedSessionPath);
}
async deleteRemoteSession() {
const sessionExists = await this.store.sessionExists({
session: this.sessionName,
});
if (sessionExists)
await this.store.delete({ session: this.sessionName });
}
stopBackupSync() {
clearInterval(this.backupSync);
this.backupSync = undefined;
clearTimeout(this.criticalCheckpointTimer);
this.criticalCheckpointTimer = undefined;
}
assertNotShuttingDown() {
if (this.shutdownRequested) {
throw new Error(
'RemoteAuth shutdown started before authentication readiness completed.',
);
}
}
async removeLocalSession() {
if (!this.userDataDir) return;
await fs.promises.rm(this.userDataDir, {
recursive: true,
force: true,
maxRetries: this.rmMaxRetries,
});
}
async compressSession() {
const stageDefaultPath = path.join(this.tempDir, 'Default');
const userDataDefaultPath = path.join(this.userDataDir, 'Default');
await fs.emptyDir(stageDefaultPath);
await this.copyByRequiredDirs(userDataDefaultPath, stageDefaultPath);
const archive = archiver('zip');
const outPath = path.join(this.zipDir, `${this.sessionName}.zip`);
const out = fs.createWriteStream(outPath);
await new Promise((resolve, reject) => {
out.once('close', resolve);
out.once('error', reject);
archive.once('error', reject);
archive.pipe(out);
archive.directory(this.tempDir, false);
archive.finalize();
});
return outPath;
}
async unCompressSession(compressedSessionPath) {
var stream = fs.createReadStream(compressedSessionPath);
await new Promise((resolve, reject) => {
stream
.pipe(
unzipper.Extract({
path: this.userDataDir,
concurrency: 10,
}),
)
.on('error', (err) => reject(err))
.on('finish', () => resolve());
});
await fs.promises.unlink(compressedSessionPath);
}
async copyByRequiredDirs(from, to) {
for (const d of this.requiredDirs) {
const src = path.join(from, d);
if (await this.isValidPath(src)) {
const dest = path.join(to, path.basename(src));
await fs.promises.cp(src, dest, {
recursive: true,
force: true,
errorOnExist: false,
});
}
}
}
async isValidPath(path) {
try {
await fs.promises.access(path);
return true;
} catch {
return false;
}
}
async delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
module.exports = RemoteAuth;