diff --git a/.gitignore b/.gitignore index 4a366f99..2b74f19f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ accounts.main.json .DS_Store .playwright-chromium-installed bun.lock +logs/ +.stfolder/ diff --git a/src/browser/auth/Login.ts b/src/browser/auth/Login.ts index 3c6bb2ff..ada42d7e 100644 --- a/src/browser/auth/Login.ts +++ b/src/browser/auth/Login.ts @@ -27,6 +27,7 @@ type LoginState = | 'LOGIN_PASSWORDLESS' | 'GET_A_CODE' | 'GET_A_CODE_2' + | 'OTP_CODE_ENTRY' | 'UNKNOWN' | 'CHROMEWEBDATA_ERROR' @@ -56,9 +57,13 @@ export class Login { totpInputOld: 'form[name="OneTimeCodeViewForm"]', identityBanner: '[data-testid="identityBanner"]', viewFooter: '[data-testid="viewFooter"] >> [role="button"]', + otherWaysToSignIn: '[data-testid="viewFooter"] span[role="button"]', + otpCodeEntry: '[data-testid="codeEntry"]', + backButton: '#back-button', bingProfile: '#id_n', requestToken: 'input[name="__RequestVerificationToken"]', - requestTokenMeta: 'meta[name="__RequestVerificationToken"]' + requestTokenMeta: 'meta[name="__RequestVerificationToken"]', + otpInput: 'div[data-testid="codeEntry"]' } as const constructor(private bot: MicrosoftRewardsBot) { @@ -73,7 +78,7 @@ export class Login { try { this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting login process') - await page.goto('https://www.bing.com/rewards/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {}) + await page.goto('https://www.bing.com/rewards/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => { }) await this.bot.utils.wait(2000) await this.bot.browser.utils.reloadBadPage(page) await this.bot.browser.utils.disableFido(page) @@ -149,7 +154,7 @@ export class Login { } private async detectCurrentState(page: Page, account?: Account): Promise { - await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { }) const url = new URL(page.url()) this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Current URL: ${url.hostname}${url.pathname}`) @@ -183,7 +188,9 @@ export class Login { [this.selectors.emailIconOld, 'SIGN_IN_ANOTHER_WAY_EMAIL'], [this.selectors.passwordlessCheck, 'LOGIN_PASSWORDLESS'], [this.selectors.totpInput, '2FA_TOTP'], - [this.selectors.totpInputOld, '2FA_TOTP'] + [this.selectors.totpInputOld, '2FA_TOTP'], + [this.selectors.otpCodeEntry, 'OTP_CODE_ENTRY'], // PR 450 + [this.selectors.otpInput, 'OTP_CODE_ENTRY'] // My Fix ] const results = await Promise.all( @@ -243,8 +250,11 @@ export class Login { 'KMSI_PROMPT', 'PASSWORD_INPUT', 'EMAIL_INPUT', + 'SIGN_IN_ANOTHER_WAY', // Prefer password option over email code 'SIGN_IN_ANOTHER_WAY_EMAIL', - 'SIGN_IN_ANOTHER_WAY', + 'OTP_CODE_ENTRY', + 'GET_A_CODE', + 'GET_A_CODE_2', 'LOGIN_PASSWORDLESS', '2FA_TOTP' ] @@ -308,12 +318,56 @@ export class Login { } case 'GET_A_CODE': { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Attempting to bypass "Get code" via footer') - await this.bot.browser.utils.ghostClick(page, this.selectors.viewFooter) - await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { - this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after footer click') - }) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Footer clicked, proceeding') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Attempting to bypass "Get code" page') + + // Try to find "Other ways to sign in" link + const otherWaysLink = await page + .waitForSelector(this.selectors.otherWaysToSignIn, { state: 'visible', timeout: 3000 }) + .catch(() => null) + + if (otherWaysLink) { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Found "Other ways to sign in" link') + await this.bot.browser.utils.ghostClick(page, this.selectors.otherWaysToSignIn) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug( + this.bot.isMobile, + 'LOGIN', + 'Network idle timeout after clicking other ways' + ) + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '"Other ways to sign in" clicked') + return true + } + + // Fallback: try the generic viewFooter selector + const footerLink = await page + .waitForSelector(this.selectors.viewFooter, { state: 'visible', timeout: 2000 }) + .catch(() => null) + + if (footerLink) { + await this.bot.browser.utils.ghostClick(page, this.selectors.viewFooter) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after footer click') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Footer link clicked') + return true + } + + // If no links found, try clicking back button + const backBtn = await page + .waitForSelector(this.selectors.backButton, { state: 'visible', timeout: 2000 }) + .catch(() => null) + + if (backBtn) { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'No sign in options found, clicking back button') + await this.bot.browser.utils.ghostClick(page, this.selectors.backButton) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after back button') + }) + return true + } + + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not find way to bypass Get Code page') return true } @@ -381,7 +435,7 @@ export class Login { waitUntil: 'domcontentloaded', timeout: 10000 }) - .catch(() => {}) + .catch(() => { }) await this.bot.utils.wait(3000) this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery navigation successful') return true @@ -392,7 +446,7 @@ export class Login { waitUntil: 'domcontentloaded', timeout: 10000 }) - .catch(() => {}) + .catch(() => { }) await this.bot.utils.wait(3000) this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Fallback navigation successful') return true @@ -447,6 +501,38 @@ export class Login { return true } + case 'OTP_CODE_ENTRY': { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'OTP code entry page detected, attempting to find password option') + + // My Fix: Click "Use your password" footer + const footerLink = await page + .waitForSelector(this.selectors.viewFooter, { state: 'visible', timeout: 2000 }) + .catch(() => null) + + if (footerLink) { + await this.bot.browser.utils.ghostClick(page, this.selectors.viewFooter) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Footer link clicked') + } else { + // PR 450 Fix: Click Back Button if footer not found + const backButton = await page + .waitForSelector(this.selectors.backButton, { state: 'visible', timeout: 2000 }) + .catch(() => null) + + if (backButton) { + await this.bot.browser.utils.ghostClick(page, this.selectors.backButton) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Back button clicked') + } else { + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'No navigation option found on OTP page') + } + } + + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after OTP navigation') + }) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Navigated back from OTP entry page') + return true + } + case 'UNKNOWN': { const url = new URL(page.url()) this.bot.logger.warn( @@ -466,7 +552,7 @@ export class Login { private async finalizeLogin(page: Page, email: string) { this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Finalizing login') - await page.goto(this.bot.config.baseURL, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {}) + await page.goto(this.bot.config.baseURL, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => { }) const loginRewardsSuccess = new URL(page.url()).hostname === 'rewards.bing.com' if (loginRewardsSuccess) { @@ -497,7 +583,7 @@ export class Login { this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing session') try { - await page.goto(url, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {}) + await page.goto(url, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => { }) for (let i = 0; i < loopMax; i++) { if (page.isClosed()) break @@ -519,7 +605,7 @@ export class Login { ) if (atBingHome) { - await this.bot.browser.utils.tryDismissAllMessages(page).catch(() => {}) + await this.bot.browser.utils.tryDismissAllMessages(page).catch(() => { }) const signedIn = await page .waitForSelector(this.selectors.bingProfile, { timeout: 3000 }) @@ -555,7 +641,7 @@ export class Login { try { await page .goto(`${this.bot.config.baseURL}?_=${Date.now()}`, { waitUntil: 'networkidle', timeout: 10000 }) - .catch(() => {}) + .catch(() => { }) for (let i = 0; i < loopMax; i++) { if (page.isClosed()) break diff --git a/src/browser/auth/methods/MobileAccessLogin.ts b/src/browser/auth/methods/MobileAccessLogin.ts index fdacf853..c1f11162 100644 --- a/src/browser/auth/methods/MobileAccessLogin.ts +++ b/src/browser/auth/methods/MobileAccessLogin.ts @@ -12,11 +12,40 @@ export class MobileAccessLogin { private scope = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL' private maxTimeout = 180_000 // 3min + // Selectors for handling Passkey prompt during OAuth + private readonly selectors = { + secondaryButton: 'button[data-testid="secondaryButton"]', + passKeyError: '[data-testid="registrationImg"]', + passKeyVideo: '[data-testid="biometricVideo"]' + } as const + constructor( private bot: MicrosoftRewardsBot, private page: Page ) {} + private async checkSelector(selector: string): Promise { + return this.page + .waitForSelector(selector, { state: 'visible', timeout: 200 }) + .then(() => true) + .catch(() => false) + } + + private async handlePasskeyPrompt(): Promise { + try { + // Handle Passkey prompt - click secondary button to skip + const hasPasskeyError = await this.checkSelector(this.selectors.passKeyError) + const hasPasskeyVideo = await this.checkSelector(this.selectors.passKeyVideo) + if (hasPasskeyError || hasPasskeyVideo) { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Found Passkey prompt on OAuth page, skipping') + await this.bot.browser.utils.ghostClick(this.page, this.selectors.secondaryButton) + await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + } + } catch { + // Ignore errors in prompt handling + } + } + async get(email: string): Promise { try { const authorizeUrl = new URL(this.authUrl) @@ -72,6 +101,9 @@ export class MobileAccessLogin { break } } + + // Handle Passkey prompt if it appears + await this.handlePasskeyPrompt() } catch (err) { this.bot.logger.debug( this.bot.isMobile, diff --git a/src/logging/Logger.ts b/src/logging/Logger.ts index 2919a205..159927ea 100644 --- a/src/logging/Logger.ts +++ b/src/logging/Logger.ts @@ -45,7 +45,7 @@ function formatMessage(message: string | Error): string { } export class Logger { - constructor(private bot: MicrosoftRewardsBot) {} + constructor(private bot: MicrosoftRewardsBot) { } info(isMobile: Platform, title: string, message: string, color?: ColorKey) { return this.baseLog('info', isMobile, title, message, color) @@ -180,7 +180,7 @@ export class Logger { isMatch = true break } - } catch {} + } catch { } } }