Fix OTP/passkey login flow handling (#450)

Merge of various other commits regarding fixing
OTP/passkey handling and the bot getting lost in
the login flow.

Co-authored-by: eejay <eejay@eejay-2.local>
This commit is contained in:
Simon Gardling
2026-01-19 04:26:19 -05:00
committed by GitHub
parent b0c01fd433
commit 7e51bff52b
4 changed files with 139 additions and 19 deletions

2
.gitignore vendored
View File

@@ -12,3 +12,5 @@ accounts.main.json
.DS_Store .DS_Store
.playwright-chromium-installed .playwright-chromium-installed
bun.lock bun.lock
logs/
.stfolder/

View File

@@ -27,6 +27,7 @@ type LoginState =
| 'LOGIN_PASSWORDLESS' | 'LOGIN_PASSWORDLESS'
| 'GET_A_CODE' | 'GET_A_CODE'
| 'GET_A_CODE_2' | 'GET_A_CODE_2'
| 'OTP_CODE_ENTRY'
| 'UNKNOWN' | 'UNKNOWN'
| 'CHROMEWEBDATA_ERROR' | 'CHROMEWEBDATA_ERROR'
@@ -56,9 +57,13 @@ export class Login {
totpInputOld: 'form[name="OneTimeCodeViewForm"]', totpInputOld: 'form[name="OneTimeCodeViewForm"]',
identityBanner: '[data-testid="identityBanner"]', identityBanner: '[data-testid="identityBanner"]',
viewFooter: '[data-testid="viewFooter"] >> [role="button"]', viewFooter: '[data-testid="viewFooter"] >> [role="button"]',
otherWaysToSignIn: '[data-testid="viewFooter"] span[role="button"]',
otpCodeEntry: '[data-testid="codeEntry"]',
backButton: '#back-button',
bingProfile: '#id_n', bingProfile: '#id_n',
requestToken: 'input[name="__RequestVerificationToken"]', requestToken: 'input[name="__RequestVerificationToken"]',
requestTokenMeta: 'meta[name="__RequestVerificationToken"]' requestTokenMeta: 'meta[name="__RequestVerificationToken"]',
otpInput: 'div[data-testid="codeEntry"]'
} as const } as const
constructor(private bot: MicrosoftRewardsBot) { constructor(private bot: MicrosoftRewardsBot) {
@@ -73,7 +78,7 @@ export class Login {
try { try {
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting login process') 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.utils.wait(2000)
await this.bot.browser.utils.reloadBadPage(page) await this.bot.browser.utils.reloadBadPage(page)
await this.bot.browser.utils.disableFido(page) await this.bot.browser.utils.disableFido(page)
@@ -149,7 +154,7 @@ export class Login {
} }
private async detectCurrentState(page: Page, account?: Account): Promise<LoginState> { private async detectCurrentState(page: Page, account?: Account): Promise<LoginState> {
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { })
const url = new URL(page.url()) const url = new URL(page.url())
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Current URL: ${url.hostname}${url.pathname}`) 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.emailIconOld, 'SIGN_IN_ANOTHER_WAY_EMAIL'],
[this.selectors.passwordlessCheck, 'LOGIN_PASSWORDLESS'], [this.selectors.passwordlessCheck, 'LOGIN_PASSWORDLESS'],
[this.selectors.totpInput, '2FA_TOTP'], [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( const results = await Promise.all(
@@ -243,8 +250,11 @@ export class Login {
'KMSI_PROMPT', 'KMSI_PROMPT',
'PASSWORD_INPUT', 'PASSWORD_INPUT',
'EMAIL_INPUT', 'EMAIL_INPUT',
'SIGN_IN_ANOTHER_WAY', // Prefer password option over email code
'SIGN_IN_ANOTHER_WAY_EMAIL', 'SIGN_IN_ANOTHER_WAY_EMAIL',
'SIGN_IN_ANOTHER_WAY', 'OTP_CODE_ENTRY',
'GET_A_CODE',
'GET_A_CODE_2',
'LOGIN_PASSWORDLESS', 'LOGIN_PASSWORDLESS',
'2FA_TOTP' '2FA_TOTP'
] ]
@@ -308,12 +318,56 @@ export class Login {
} }
case 'GET_A_CODE': { case 'GET_A_CODE': {
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Attempting to bypass "Get code" via footer') this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Attempting to bypass "Get code" page')
await this.bot.browser.utils.ghostClick(page, this.selectors.viewFooter)
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { // Try to find "Other ways to sign in" link
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after footer click') const otherWaysLink = await page
}) .waitForSelector(this.selectors.otherWaysToSignIn, { state: 'visible', timeout: 3000 })
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Footer clicked, proceeding') .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 return true
} }
@@ -381,7 +435,7 @@ export class Login {
waitUntil: 'domcontentloaded', waitUntil: 'domcontentloaded',
timeout: 10000 timeout: 10000
}) })
.catch(() => {}) .catch(() => { })
await this.bot.utils.wait(3000) await this.bot.utils.wait(3000)
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery navigation successful') this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery navigation successful')
return true return true
@@ -392,7 +446,7 @@ export class Login {
waitUntil: 'domcontentloaded', waitUntil: 'domcontentloaded',
timeout: 10000 timeout: 10000
}) })
.catch(() => {}) .catch(() => { })
await this.bot.utils.wait(3000) await this.bot.utils.wait(3000)
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Fallback navigation successful') this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Fallback navigation successful')
return true return true
@@ -447,6 +501,38 @@ export class Login {
return true 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': { case 'UNKNOWN': {
const url = new URL(page.url()) const url = new URL(page.url())
this.bot.logger.warn( this.bot.logger.warn(
@@ -466,7 +552,7 @@ export class Login {
private async finalizeLogin(page: Page, email: string) { private async finalizeLogin(page: Page, email: string) {
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Finalizing login') 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' const loginRewardsSuccess = new URL(page.url()).hostname === 'rewards.bing.com'
if (loginRewardsSuccess) { if (loginRewardsSuccess) {
@@ -497,7 +583,7 @@ export class Login {
this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing session') this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing session')
try { 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++) { for (let i = 0; i < loopMax; i++) {
if (page.isClosed()) break if (page.isClosed()) break
@@ -519,7 +605,7 @@ export class Login {
) )
if (atBingHome) { if (atBingHome) {
await this.bot.browser.utils.tryDismissAllMessages(page).catch(() => {}) await this.bot.browser.utils.tryDismissAllMessages(page).catch(() => { })
const signedIn = await page const signedIn = await page
.waitForSelector(this.selectors.bingProfile, { timeout: 3000 }) .waitForSelector(this.selectors.bingProfile, { timeout: 3000 })
@@ -555,7 +641,7 @@ export class Login {
try { try {
await page await page
.goto(`${this.bot.config.baseURL}?_=${Date.now()}`, { waitUntil: 'networkidle', timeout: 10000 }) .goto(`${this.bot.config.baseURL}?_=${Date.now()}`, { waitUntil: 'networkidle', timeout: 10000 })
.catch(() => {}) .catch(() => { })
for (let i = 0; i < loopMax; i++) { for (let i = 0; i < loopMax; i++) {
if (page.isClosed()) break if (page.isClosed()) break

View File

@@ -12,11 +12,40 @@ export class MobileAccessLogin {
private scope = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL' private scope = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL'
private maxTimeout = 180_000 // 3min 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( constructor(
private bot: MicrosoftRewardsBot, private bot: MicrosoftRewardsBot,
private page: Page private page: Page
) {} ) {}
private async checkSelector(selector: string): Promise<boolean> {
return this.page
.waitForSelector(selector, { state: 'visible', timeout: 200 })
.then(() => true)
.catch(() => false)
}
private async handlePasskeyPrompt(): Promise<void> {
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<string> { async get(email: string): Promise<string> {
try { try {
const authorizeUrl = new URL(this.authUrl) const authorizeUrl = new URL(this.authUrl)
@@ -72,6 +101,9 @@ export class MobileAccessLogin {
break break
} }
} }
// Handle Passkey prompt if it appears
await this.handlePasskeyPrompt()
} catch (err) { } catch (err) {
this.bot.logger.debug( this.bot.logger.debug(
this.bot.isMobile, this.bot.isMobile,

View File

@@ -45,7 +45,7 @@ function formatMessage(message: string | Error): string {
} }
export class Logger { export class Logger {
constructor(private bot: MicrosoftRewardsBot) {} constructor(private bot: MicrosoftRewardsBot) { }
info(isMobile: Platform, title: string, message: string, color?: ColorKey) { info(isMobile: Platform, title: string, message: string, color?: ColorKey) {
return this.baseLog('info', isMobile, title, message, color) return this.baseLog('info', isMobile, title, message, color)
@@ -180,7 +180,7 @@ export class Logger {
isMatch = true isMatch = true
break break
} }
} catch {} } catch { }
} }
} }