mirror of
https://github.com/TheNetsky/Microsoft-Rewards-Script.git
synced 2026-01-19 14:33:58 +00:00
v3.1.0 initial
This commit is contained in:
@@ -2,25 +2,31 @@ import type { Page } from 'patchright'
|
||||
import type { MicrosoftRewardsBot } from '../../index'
|
||||
import { saveSessionData } from '../../util/Load'
|
||||
|
||||
// Methods
|
||||
import { MobileAccessLogin } from './methods/MobileAccessLogin'
|
||||
import { EmailLogin } from './methods/EmailLogin'
|
||||
import { PasswordlessLogin } from './methods/PasswordlessLogin'
|
||||
import { TotpLogin } from './methods/Totp2FALogin'
|
||||
import { CodeLogin } from './methods/GetACodeLogin'
|
||||
import { RecoveryLogin } from './methods/RecoveryEmailLogin'
|
||||
|
||||
import type { Account } from '../../interface/Account'
|
||||
|
||||
type LoginState =
|
||||
| 'EMAIL_INPUT'
|
||||
| 'PASSWORD_INPUT'
|
||||
| 'SIGN_IN_ANOTHER_WAY'
|
||||
| 'SIGN_IN_ANOTHER_WAY_EMAIL'
|
||||
| 'PASSKEY_ERROR'
|
||||
| 'PASSKEY_VIDEO'
|
||||
| 'KMSI_PROMPT'
|
||||
| 'LOGGED_IN'
|
||||
| 'RECOVERY_EMAIL_INPUT'
|
||||
| 'ACCOUNT_LOCKED'
|
||||
| 'ERROR_ALERT'
|
||||
| '2FA_TOTP'
|
||||
| 'LOGIN_PASSWORDLESS'
|
||||
| 'GET_A_CODE'
|
||||
| 'GET_A_CODE_2'
|
||||
| 'UNKNOWN'
|
||||
| 'CHROMEWEBDATA_ERROR'
|
||||
|
||||
@@ -28,28 +34,52 @@ export class Login {
|
||||
emailLogin: EmailLogin
|
||||
passwordlessLogin: PasswordlessLogin
|
||||
totp2FALogin: TotpLogin
|
||||
codeLogin: CodeLogin
|
||||
recoveryLogin: RecoveryLogin
|
||||
|
||||
private readonly selectors = {
|
||||
primaryButton: 'button[data-testid="primaryButton"]',
|
||||
secondaryButton: 'button[data-testid="secondaryButton"]',
|
||||
emailIcon: '[data-testid="tile"]:has(svg path[d*="M5.25 4h13.5a3.25"])',
|
||||
emailIconOld: 'img[data-testid="accessibleImg"][src*="picker_verify_email"]',
|
||||
recoveryEmail: '[data-testid="proof-confirmation"]',
|
||||
passwordIcon: '[data-testid="tile"]:has(svg path[d*="M11.78 10.22a.75.75"])',
|
||||
accountLocked: '#serviceAbuseLandingTitle',
|
||||
errorAlert: 'div[role="alert"]',
|
||||
passwordEntry: '[data-testid="passwordEntry"]',
|
||||
emailEntry: 'input#usernameEntry',
|
||||
kmsiVideo: '[data-testid="kmsiVideo"]',
|
||||
passKeyVideo: '[data-testid="biometricVideo"]',
|
||||
passKeyError: '[data-testid="registrationImg"]',
|
||||
passwordlessCheck: '[data-testid="deviceShieldCheckmarkVideo"]',
|
||||
totpInput: 'input[name="otc"]',
|
||||
totpInputOld: 'form[name="OneTimeCodeViewForm"]',
|
||||
identityBanner: '[data-testid="identityBanner"]',
|
||||
viewFooter: '[data-testid="viewFooter"] span[role="button"]',
|
||||
bingProfile: '#id_n',
|
||||
requestToken: 'input[name="__RequestVerificationToken"]',
|
||||
requestTokenMeta: 'meta[name="__RequestVerificationToken"]'
|
||||
} as const
|
||||
|
||||
constructor(private bot: MicrosoftRewardsBot) {
|
||||
this.emailLogin = new EmailLogin(this.bot)
|
||||
this.passwordlessLogin = new PasswordlessLogin(this.bot)
|
||||
this.totp2FALogin = new TotpLogin(this.bot)
|
||||
this.codeLogin = new CodeLogin(this.bot)
|
||||
this.recoveryLogin = new RecoveryLogin(this.bot)
|
||||
}
|
||||
|
||||
private readonly primaryButtonSelector = 'button[data-testid="primaryButton"]'
|
||||
private readonly secondaryButtonSelector = 'button[data-testid="secondaryButton"]'
|
||||
|
||||
async login(page: Page, email: string, password: string, totpSecret?: string) {
|
||||
async login(page: Page, account: Account) {
|
||||
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 this.bot.utils.wait(2000)
|
||||
await this.bot.browser.utils.reloadBadPage(page)
|
||||
|
||||
await this.bot.browser.utils.disableFido(page)
|
||||
|
||||
const maxIterations = 25
|
||||
let iteration = 0
|
||||
|
||||
let previousState: LoginState = 'UNKNOWN'
|
||||
let sameStateCount = 0
|
||||
|
||||
@@ -59,7 +89,7 @@ export class Login {
|
||||
iteration++
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `State check iteration ${iteration}/${maxIterations}`)
|
||||
|
||||
const state = await this.detectCurrentState(page)
|
||||
const state = await this.detectCurrentState(page, account)
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `Current state: ${state}`)
|
||||
|
||||
if (state !== previousState && previousState !== 'UNKNOWN') {
|
||||
@@ -68,11 +98,16 @@ export class Login {
|
||||
|
||||
if (state === previousState && state !== 'LOGGED_IN' && state !== 'UNKNOWN') {
|
||||
sameStateCount++
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
`Same state count: ${sameStateCount}/4 for state "${state}"`
|
||||
)
|
||||
if (sameStateCount >= 4) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
`Stuck in state "${state}" for 4 loops. Refreshing page...`
|
||||
`Stuck in state "${state}" for 4 loops, refreshing page`
|
||||
)
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await this.bot.utils.wait(3000)
|
||||
@@ -90,8 +125,7 @@ export class Login {
|
||||
break
|
||||
}
|
||||
|
||||
const shouldContinue = await this.handleState(state, page, email, password, totpSecret)
|
||||
|
||||
const shouldContinue = await this.handleState(state, page, account)
|
||||
if (!shouldContinue) {
|
||||
throw new Error(`Login failed or aborted at state: ${state}`)
|
||||
}
|
||||
@@ -103,142 +137,151 @@ export class Login {
|
||||
throw new Error('Login timeout: exceeded maximum iterations')
|
||||
}
|
||||
|
||||
await this.finalizeLogin(page, email)
|
||||
await this.finalizeLogin(page, account.email)
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async detectCurrentState(page: Page): Promise<LoginState> {
|
||||
// Make sure we settled before getting a URL
|
||||
private async detectCurrentState(page: Page, account?: Account): Promise<LoginState> {
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
const url = new URL(page.url())
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-CURRENT-STATE', `Current URL: ${url}`)
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Current URL: ${url.hostname}${url.pathname}`)
|
||||
|
||||
if (url.hostname === 'chromewebdata') {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'DETECT-CURRENT-STATE', 'Detected chromewebdata error page')
|
||||
this.bot.logger.warn(this.bot.isMobile, 'DETECT-STATE', 'Detected chromewebdata error page')
|
||||
return 'CHROMEWEBDATA_ERROR'
|
||||
}
|
||||
|
||||
const isLocked = await page
|
||||
.waitForSelector('#serviceAbuseLandingTitle', { state: 'visible', timeout: 200 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
const isLocked = await this.checkSelector(page, this.selectors.accountLocked)
|
||||
if (isLocked) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'Account locked selector found')
|
||||
return 'ACCOUNT_LOCKED'
|
||||
}
|
||||
|
||||
// If instantly loading rewards dash, logged in
|
||||
if (url.hostname === 'rewards.bing.com') {
|
||||
if (url.hostname === 'rewards.bing.com' || url.hostname === 'account.microsoft.com') {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'On rewards/account page, assuming logged in')
|
||||
return 'LOGGED_IN'
|
||||
}
|
||||
|
||||
// If account dash, logged in
|
||||
if (url.hostname === 'account.microsoft.com') {
|
||||
return 'LOGGED_IN'
|
||||
const stateChecks: Array<[string, LoginState]> = [
|
||||
[this.selectors.errorAlert, 'ERROR_ALERT'],
|
||||
[this.selectors.passwordEntry, 'PASSWORD_INPUT'],
|
||||
[this.selectors.emailEntry, 'EMAIL_INPUT'],
|
||||
[this.selectors.recoveryEmail, 'RECOVERY_EMAIL_INPUT'],
|
||||
[this.selectors.kmsiVideo, 'KMSI_PROMPT'],
|
||||
[this.selectors.passKeyVideo, 'PASSKEY_VIDEO'],
|
||||
[this.selectors.passKeyError, 'PASSKEY_ERROR'],
|
||||
[this.selectors.passwordIcon, 'SIGN_IN_ANOTHER_WAY'],
|
||||
[this.selectors.emailIcon, 'SIGN_IN_ANOTHER_WAY_EMAIL'],
|
||||
[this.selectors.emailIconOld, 'SIGN_IN_ANOTHER_WAY_EMAIL'],
|
||||
[this.selectors.passwordlessCheck, 'LOGIN_PASSWORDLESS'],
|
||||
[this.selectors.totpInput, '2FA_TOTP'],
|
||||
[this.selectors.totpInputOld, '2FA_TOTP']
|
||||
]
|
||||
|
||||
const results = await Promise.all(
|
||||
stateChecks.map(async ([sel, state]) => {
|
||||
const visible = await this.checkSelector(page, sel)
|
||||
return visible ? state : null
|
||||
})
|
||||
)
|
||||
|
||||
const visibleStates = results.filter((s): s is LoginState => s !== null)
|
||||
if (visibleStates.length > 0) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Visible states: [${visibleStates.join(', ')}]`)
|
||||
}
|
||||
|
||||
const check = async (selector: string, state: LoginState): Promise<LoginState | null> => {
|
||||
return page
|
||||
.waitForSelector(selector, { state: 'visible', timeout: 200 })
|
||||
.then(visible => (visible ? state : null))
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
const results = await Promise.all([
|
||||
check('div[role="alert"]', 'ERROR_ALERT'),
|
||||
check('[data-testid="passwordEntry"]', 'PASSWORD_INPUT'),
|
||||
check('input#usernameEntry', 'EMAIL_INPUT'),
|
||||
check('[data-testid="kmsiVideo"]', 'KMSI_PROMPT'),
|
||||
check('[data-testid="biometricVideo"]', 'PASSKEY_VIDEO'),
|
||||
check('[data-testid="registrationImg"]', 'PASSKEY_ERROR'),
|
||||
check('[data-testid="tile"]:has(svg path[d*="M11.78 10.22a.75.75"])', 'SIGN_IN_ANOTHER_WAY'),
|
||||
check('[data-testid="deviceShieldCheckmarkVideo"]', 'LOGIN_PASSWORDLESS'),
|
||||
check('input[name="otc"]', '2FA_TOTP'),
|
||||
check('form[name="OneTimeCodeViewForm"]', '2FA_TOTP')
|
||||
const [identityBanner, primaryButton, passwordEntry] = await Promise.all([
|
||||
this.checkSelector(page, this.selectors.identityBanner),
|
||||
this.checkSelector(page, this.selectors.primaryButton),
|
||||
this.checkSelector(page, this.selectors.passwordEntry)
|
||||
])
|
||||
|
||||
// Get a code
|
||||
const identityBanner = await page
|
||||
.waitForSelector('[data-testid="identityBanner"]', { state: 'visible', timeout: 200 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
const primaryButton = await page
|
||||
.waitForSelector(this.primaryButtonSelector, { state: 'visible', timeout: 200 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
const passwordEntry = await page
|
||||
.waitForSelector('[data-testid="passwordEntry"]', { state: 'visible', timeout: 200 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (identityBanner && primaryButton && !passwordEntry && !results.includes('2FA_TOTP')) {
|
||||
results.push('GET_A_CODE') // Lower prio
|
||||
const codeState = account?.password ? 'GET_A_CODE' : 'GET_A_CODE_2'
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DETECT-STATE',
|
||||
`Get code state detected: ${codeState} (has password: ${!!account?.password})`
|
||||
)
|
||||
results.push(codeState)
|
||||
}
|
||||
|
||||
// Final
|
||||
let foundStates = results.filter((s): s is LoginState => s !== null)
|
||||
|
||||
if (foundStates.length === 0) return 'UNKNOWN'
|
||||
if (foundStates.length === 0) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'No matching states found')
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
if (foundStates.includes('ERROR_ALERT')) {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DETECT-STATE',
|
||||
`ERROR_ALERT found - hostname: ${url.hostname}, has 2FA: ${foundStates.includes('2FA_TOTP')}`
|
||||
)
|
||||
if (url.hostname !== 'login.live.com') {
|
||||
// Remove ERROR_ALERT if not on login.live.com
|
||||
foundStates = foundStates.filter(s => s !== 'ERROR_ALERT')
|
||||
}
|
||||
if (foundStates.includes('2FA_TOTP')) {
|
||||
// Don't throw on TOTP if expired code is entered
|
||||
foundStates = foundStates.filter(s => s !== 'ERROR_ALERT')
|
||||
}
|
||||
|
||||
// On login.live.com, keep it
|
||||
return 'ERROR_ALERT'
|
||||
if (foundStates.includes('ERROR_ALERT')) return 'ERROR_ALERT'
|
||||
}
|
||||
|
||||
if (foundStates.includes('ERROR_ALERT')) return 'ERROR_ALERT'
|
||||
if (foundStates.includes('ACCOUNT_LOCKED')) return 'ACCOUNT_LOCKED'
|
||||
if (foundStates.includes('PASSKEY_VIDEO')) return 'PASSKEY_VIDEO'
|
||||
if (foundStates.includes('PASSKEY_ERROR')) return 'PASSKEY_ERROR'
|
||||
if (foundStates.includes('KMSI_PROMPT')) return 'KMSI_PROMPT'
|
||||
if (foundStates.includes('PASSWORD_INPUT')) return 'PASSWORD_INPUT'
|
||||
if (foundStates.includes('EMAIL_INPUT')) return 'EMAIL_INPUT'
|
||||
if (foundStates.includes('SIGN_IN_ANOTHER_WAY')) return 'SIGN_IN_ANOTHER_WAY'
|
||||
if (foundStates.includes('LOGIN_PASSWORDLESS')) return 'LOGIN_PASSWORDLESS'
|
||||
if (foundStates.includes('2FA_TOTP')) return '2FA_TOTP'
|
||||
const priorities: LoginState[] = [
|
||||
'ACCOUNT_LOCKED',
|
||||
'PASSKEY_VIDEO',
|
||||
'PASSKEY_ERROR',
|
||||
'KMSI_PROMPT',
|
||||
'PASSWORD_INPUT',
|
||||
'EMAIL_INPUT',
|
||||
'SIGN_IN_ANOTHER_WAY_EMAIL',
|
||||
'SIGN_IN_ANOTHER_WAY',
|
||||
'LOGIN_PASSWORDLESS',
|
||||
'2FA_TOTP'
|
||||
]
|
||||
|
||||
const mainState = foundStates[0] as LoginState
|
||||
for (const priority of priorities) {
|
||||
if (foundStates.includes(priority)) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Selected state by priority: ${priority}`)
|
||||
return priority
|
||||
}
|
||||
}
|
||||
|
||||
return mainState
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Returning first found state: ${foundStates[0]}`)
|
||||
return foundStates[0] as LoginState
|
||||
}
|
||||
|
||||
private async handleState(
|
||||
state: LoginState,
|
||||
page: Page,
|
||||
email: string,
|
||||
password: string,
|
||||
totpSecret?: string
|
||||
): Promise<boolean> {
|
||||
private async checkSelector(page: Page, selector: string): Promise<boolean> {
|
||||
return page
|
||||
.waitForSelector(selector, { state: 'visible', timeout: 200 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
private async handleState(state: LoginState, page: Page, account: Account): Promise<boolean> {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `Processing state: ${state}`)
|
||||
|
||||
switch (state) {
|
||||
case 'ACCOUNT_LOCKED': {
|
||||
const msg = 'This account has been locked! Remove from config and restart!'
|
||||
this.bot.logger.error(this.bot.isMobile, 'CHECK-LOCKED', msg)
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN', msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
case 'ERROR_ALERT': {
|
||||
const alertEl = page.locator('div[role="alert"]')
|
||||
const alertEl = page.locator(this.selectors.errorAlert)
|
||||
const errorMsg = await alertEl.innerText().catch(() => 'Unknown Error')
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN', `Account error: ${errorMsg}`)
|
||||
throw new Error(`Microsoft login error message: ${errorMsg}`)
|
||||
throw new Error(`Microsoft login error: ${errorMsg}`)
|
||||
}
|
||||
|
||||
case 'LOGGED_IN':
|
||||
@@ -246,96 +289,161 @@ export class Login {
|
||||
|
||||
case 'EMAIL_INPUT': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Entering email')
|
||||
await this.emailLogin.enterEmail(page, email)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
await this.emailLogin.enterEmail(page, account.email)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after email entry')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Email entered successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'PASSWORD_INPUT': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Entering password')
|
||||
await this.emailLogin.enterPassword(page, password)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
await this.emailLogin.enterPassword(page, account.password)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after password entry')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Password entered successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'GET_A_CODE': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Attempting to bypass "Get code"')
|
||||
// Select sign in other way
|
||||
await this.bot.browser.utils.ghostClick(page, '[data-testid="viewFooter"] span[role="button"]')
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
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')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'GET_A_CODE_2': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Handling "Get a code" flow')
|
||||
await this.bot.browser.utils.ghostClick(page, this.selectors.primaryButton)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after primary button click')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Initiating code login handler')
|
||||
await this.codeLogin.handle(page)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Code login handler completed successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'SIGN_IN_ANOTHER_WAY_EMAIL': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting "Send a code to email"')
|
||||
|
||||
const emailSelector = await Promise.race([
|
||||
this.checkSelector(page, this.selectors.emailIcon).then(found =>
|
||||
found ? this.selectors.emailIcon : null
|
||||
),
|
||||
this.checkSelector(page, this.selectors.emailIconOld).then(found =>
|
||||
found ? this.selectors.emailIconOld : null
|
||||
)
|
||||
])
|
||||
|
||||
if (!emailSelector) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Email icon not found')
|
||||
return false
|
||||
}
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
`Using ${emailSelector === this.selectors.emailIcon ? 'new' : 'old'} email icon selector`
|
||||
)
|
||||
await this.bot.browser.utils.ghostClick(page, emailSelector)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after email icon click')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Initiating code login handler')
|
||||
await this.codeLogin.handle(page)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Code login handler completed successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'RECOVERY_EMAIL_INPUT': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery email input detected')
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout on recovery page')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Initiating recovery email handler')
|
||||
await this.recoveryLogin.handle(page, account?.recoveryEmail)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery email handler completed successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'CHROMEWEBDATA_ERROR': {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
'chromewebdata error page detected, attempting to recover to Rewards home'
|
||||
)
|
||||
// Try go to Rewards dashboard
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'chromewebdata error detected, attempting recovery')
|
||||
try {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', `Navigating to ${this.bot.config.baseURL}`)
|
||||
await page
|
||||
.goto(this.bot.config.baseURL, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 10000
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await this.bot.utils.wait(3000)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery navigation successful')
|
||||
return true
|
||||
} catch {
|
||||
// If even that fails, fall back to login.live.com
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
'Failed to navigate to baseURL from chromewebdata, retrying login.live.com'
|
||||
)
|
||||
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Fallback to login.live.com')
|
||||
await page
|
||||
.goto('https://login.live.com/', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 10000
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await this.bot.utils.wait(3000)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Fallback navigation successful')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
case '2FA_TOTP': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA required')
|
||||
await this.totp2FALogin.handle(page, totpSecret)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA authentication required')
|
||||
await this.totp2FALogin.handle(page, account.totpSecret)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA handler completed successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'SIGN_IN_ANOTHER_WAY': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting "Use my password"')
|
||||
const passwordOption = '[data-testid="tile"]:has(svg path[d*="M11.78 10.22a.75.75"])'
|
||||
await this.bot.browser.utils.ghostClick(page, passwordOption)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
await this.bot.browser.utils.ghostClick(page, this.selectors.passwordIcon)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after password icon click')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Password option selected')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'KMSI_PROMPT': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Accepting KMSI prompt')
|
||||
await this.bot.browser.utils.ghostClick(page, this.primaryButtonSelector)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
await this.bot.browser.utils.ghostClick(page, this.selectors.primaryButton)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after KMSI acceptance')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'KMSI prompt accepted')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'PASSKEY_VIDEO':
|
||||
case 'PASSKEY_ERROR': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Skipping Passkey prompt')
|
||||
await this.bot.browser.utils.ghostClick(page, this.secondaryButtonSelector)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
await this.bot.browser.utils.ghostClick(page, this.selectors.secondaryButton)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after Passkey skip')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Passkey prompt skipped')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'LOGIN_PASSWORDLESS': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Handling passwordless authentication')
|
||||
await this.passwordlessLogin.handle(page)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after passwordless auth')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Passwordless authentication completed successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -344,12 +452,13 @@ export class Login {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
`Unknown state at host:${url.hostname} path:${url.pathname}. Waiting...`
|
||||
`Unknown state at ${url.hostname}${url.pathname}, waiting`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
default:
|
||||
this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `Unhandled state: ${state}, continuing`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -363,21 +472,21 @@ export class Login {
|
||||
if (loginRewardsSuccess) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Logged into Microsoft Rewards successfully')
|
||||
} else {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
'Could not verify Rewards Dashboard. Assuming login valid anyway.'
|
||||
)
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not verify Rewards Dashboard, assuming login valid')
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting Bing session verification')
|
||||
await this.verifyBingSession(page)
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting rewards session verification')
|
||||
await this.getRewardsSession(page)
|
||||
|
||||
const browser = page.context()
|
||||
const cookies = await browser.cookies()
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `Retrieved ${cookies.length} cookies`)
|
||||
await saveSessionData(this.bot.config.sessionPath, cookies, email, this.bot.isMobile)
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Login completed! Session saved!')
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Login completed, session saved')
|
||||
}
|
||||
|
||||
async verifyBingSession(page: Page) {
|
||||
@@ -393,30 +502,34 @@ export class Login {
|
||||
for (let i = 0; i < loopMax; i++) {
|
||||
if (page.isClosed()) break
|
||||
|
||||
// Rare error state
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-BING', `Verification loop ${i + 1}/${loopMax}`)
|
||||
|
||||
const state = await this.detectCurrentState(page)
|
||||
if (state === 'PASSKEY_ERROR') {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-BING',
|
||||
'Verification landed on Passkey error state! Trying to dismiss.'
|
||||
)
|
||||
await this.bot.browser.utils.ghostClick(page, this.secondaryButtonSelector)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Dismissing Passkey error state')
|
||||
await this.bot.browser.utils.ghostClick(page, this.selectors.secondaryButton)
|
||||
}
|
||||
|
||||
const u = new URL(page.url())
|
||||
const atBingHome = u.hostname === 'www.bing.com' && u.pathname === '/'
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-BING',
|
||||
`At Bing home: ${atBingHome} (${u.hostname}${u.pathname})`
|
||||
)
|
||||
|
||||
if (atBingHome) {
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page).catch(() => {})
|
||||
|
||||
const signedIn = await page
|
||||
.waitForSelector('#id_n', { timeout: 3000 })
|
||||
.waitForSelector(this.selectors.bingProfile, { timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-BING', `Profile element found: ${signedIn}`)
|
||||
|
||||
if (signedIn || this.bot.isMobile) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Bing session established')
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Bing session verified successfully')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -424,16 +537,12 @@ export class Login {
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-BING',
|
||||
'Could not confirm Bing session after retries; continuing'
|
||||
)
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-BING', 'Could not verify Bing session, continuing anyway')
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-BING',
|
||||
`Bing verification error: ${error instanceof Error ? error.message : String(error)}`
|
||||
`Verification error: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -441,7 +550,7 @@ export class Login {
|
||||
private async getRewardsSession(page: Page) {
|
||||
const loopMax = 5
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'GET-REQUEST-TOKEN', 'Fetching request token')
|
||||
this.bot.logger.info(this.bot.isMobile, 'GET-REWARD-SESSION', 'Fetching request token')
|
||||
|
||||
try {
|
||||
await page
|
||||
@@ -451,11 +560,7 @@ export class Login {
|
||||
for (let i = 0; i < loopMax; i++) {
|
||||
if (page.isClosed()) break
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'GET-REWARD-SESSION',
|
||||
`Loop ${i + 1}/${loopMax} | URL=${page.url()}`
|
||||
)
|
||||
this.bot.logger.debug(this.bot.isMobile, 'GET-REWARD-SESSION', `Token fetch loop ${i + 1}/${loopMax}`)
|
||||
|
||||
const u = new URL(page.url())
|
||||
const atRewardHome = u.hostname === 'rewards.bing.com' && u.pathname === '/'
|
||||
@@ -467,23 +572,27 @@ export class Login {
|
||||
const $ = await this.bot.browser.utils.loadInCheerio(html)
|
||||
|
||||
const token =
|
||||
$('input[name="__RequestVerificationToken"]').attr('value') ??
|
||||
$('meta[name="__RequestVerificationToken"]').attr('content') ??
|
||||
$(this.selectors.requestToken).attr('value') ??
|
||||
$(this.selectors.requestTokenMeta).attr('content') ??
|
||||
null
|
||||
|
||||
if (token) {
|
||||
this.bot.requestToken = token
|
||||
this.bot.logger.info(this.bot.isMobile, 'GET-REQUEST-TOKEN', 'Request token has been set!')
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'GET-REWARD-SESSION',
|
||||
`Token extracted: ${token.substring(0, 10)}...`
|
||||
`Request token retrieved: ${token.substring(0, 10)}...`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'GET-REWARD-SESSION', 'Token NOT found on page')
|
||||
this.bot.logger.debug(this.bot.isMobile, 'GET-REWARD-SESSION', 'Token not found on page')
|
||||
} else {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'GET-REWARD-SESSION',
|
||||
`Not at reward home: ${u.hostname}${u.pathname}`
|
||||
)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
@@ -491,19 +600,20 @@ export class Login {
|
||||
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'GET-REQUEST-TOKEN',
|
||||
'No RequestVerificationToken found — some activities may not work'
|
||||
'GET-REWARD-SESSION',
|
||||
'No RequestVerificationToken found, some activities may not work'
|
||||
)
|
||||
} catch (error) {
|
||||
throw this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'GET-REQUEST-TOKEN',
|
||||
`Reward session error: ${error instanceof Error ? error.message : String(error)}`
|
||||
'GET-REWARD-SESSION',
|
||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async getAppAccessToken(page: Page, email: string) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'GET-APP-TOKEN', 'Requesting mobile access token')
|
||||
return await new MobileAccessLogin(this.bot, page).get(email)
|
||||
}
|
||||
}
|
||||
|
||||
129
src/browser/auth/methods/GetACodeLogin.ts
Normal file
129
src/browser/auth/methods/GetACodeLogin.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Page } from 'patchright'
|
||||
import type { MicrosoftRewardsBot } from '../../../index'
|
||||
import { getErrorMessage, getSubtitleMessage, promptInput } from './LoginUtils'
|
||||
|
||||
export class CodeLogin {
|
||||
private readonly textInputSelector = '[data-testid="codeInputWrapper"]'
|
||||
private readonly secondairyInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]'
|
||||
private readonly maxManualSeconds = 60
|
||||
private readonly maxManualAttempts = 5
|
||||
|
||||
constructor(private bot: MicrosoftRewardsBot) {}
|
||||
|
||||
private async fillCode(page: Page, code: string): Promise<boolean> {
|
||||
try {
|
||||
const visibleInput = await page
|
||||
.waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 })
|
||||
.catch(() => null)
|
||||
|
||||
if (visibleInput) {
|
||||
await page.keyboard.type(code, { delay: 50 })
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Filled code input')
|
||||
return true
|
||||
}
|
||||
|
||||
const secondairyInput = await page.$(this.secondairyInputSelector)
|
||||
if (secondairyInput) {
|
||||
await page.keyboard.type(code, { delay: 50 })
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Filled code input')
|
||||
return true
|
||||
}
|
||||
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'No code input field found')
|
||||
return false
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-CODE',
|
||||
`Failed to fill code input: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async handle(page: Page): Promise<void> {
|
||||
try {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Code login authentication requested')
|
||||
|
||||
const emailMessage = await getSubtitleMessage(page)
|
||||
if (emailMessage) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', `Page message: "${emailMessage}"`)
|
||||
} else {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'Unable to retrieve email code destination')
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) {
|
||||
const code = await promptInput({
|
||||
question: `Enter the 6-digit code (waiting ${this.maxManualSeconds}s): `,
|
||||
timeoutSeconds: this.maxManualSeconds,
|
||||
validate: code => /^\d{6}$/.test(code)
|
||||
})
|
||||
|
||||
if (!code || !/^\d{6}$/.test(code)) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-CODE',
|
||||
`Invalid or missing code (attempt ${attempt}/${this.maxManualAttempts}) | input length=${code?.length}`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Manual code input failed or timed out')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const filled = await this.fillCode(page, code)
|
||||
if (!filled) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-CODE',
|
||||
`Unable to fill code input (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Code input field not found')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(500)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
// Check if wrong code was entered
|
||||
const errorMessage = await getErrorMessage(page)
|
||||
if (errorMessage) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-CODE',
|
||||
`Incorrect code: ${errorMessage} (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error(`Maximum attempts reached: ${errorMessage}`)
|
||||
}
|
||||
|
||||
// Clear the input field before retrying
|
||||
const inputToClear = await page.$(this.textInputSelector).catch(() => null)
|
||||
if (inputToClear) {
|
||||
await inputToClear.click()
|
||||
await page.keyboard.press('Control+A')
|
||||
await page.keyboard.press('Backspace')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Code authentication completed successfully')
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Code input failed after ${this.maxManualAttempts} attempts`)
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-CODE',
|
||||
`Error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/browser/auth/methods/LoginUtils.ts
Normal file
66
src/browser/auth/methods/LoginUtils.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Page } from 'patchright'
|
||||
import readline from 'readline'
|
||||
|
||||
export interface PromptOptions {
|
||||
question: string
|
||||
timeoutSeconds?: number
|
||||
validate?: (input: string) => boolean
|
||||
transform?: (input: string) => string
|
||||
}
|
||||
|
||||
export function promptInput(options: PromptOptions): Promise<string | null> {
|
||||
const { question, timeoutSeconds = 60, validate, transform } = options
|
||||
|
||||
return new Promise(resolve => {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
let resolved = false
|
||||
|
||||
const cleanup = (result: string | null) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
clearTimeout(timer)
|
||||
rl.close()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => cleanup(null), timeoutSeconds * 1000)
|
||||
|
||||
rl.question(question, answer => {
|
||||
let value = answer.trim()
|
||||
if (transform) value = transform(value)
|
||||
|
||||
if (validate && !validate(value)) {
|
||||
cleanup(null)
|
||||
return
|
||||
}
|
||||
|
||||
cleanup(value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSubtitleMessage(page: Page): Promise<string | null> {
|
||||
const message = await page
|
||||
.waitForSelector('[data-testid="subtitle"]', { state: 'visible', timeout: 1000 })
|
||||
.catch(() => null)
|
||||
|
||||
if (!message) return null
|
||||
|
||||
const text = await message.innerText()
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
export async function getErrorMessage(page: Page): Promise<string | null> {
|
||||
const errorAlert = await page
|
||||
.waitForSelector('div[role="alert"]', { state: 'visible', timeout: 1000 })
|
||||
.catch(() => null)
|
||||
|
||||
if (!errorAlert) return null
|
||||
|
||||
const text = await errorAlert.innerText()
|
||||
return text.trim()
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Page } from 'patchright'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { URLSearchParams } from 'url'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
|
||||
import type { MicrosoftRewardsBot } from '../../../index'
|
||||
|
||||
@@ -29,25 +28,70 @@ export class MobileAccessLogin {
|
||||
authorizeUrl.searchParams.append('access_type', 'offline_access')
|
||||
authorizeUrl.searchParams.append('login_hint', email)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`Auth URL constructed: ${authorizeUrl.origin}${authorizeUrl.pathname}`
|
||||
)
|
||||
|
||||
await this.bot.browser.utils.disableFido(this.page)
|
||||
|
||||
await this.page.goto(authorizeUrl.href).catch(() => {})
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Navigating to OAuth authorize URL')
|
||||
|
||||
await this.page.goto(authorizeUrl.href).catch(err => {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`page.goto() failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
})
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Waiting for mobile OAuth code...')
|
||||
|
||||
const start = Date.now()
|
||||
let code = ''
|
||||
let lastUrl = ''
|
||||
|
||||
while (Date.now() - start < this.maxTimeout) {
|
||||
const url = new URL(this.page.url())
|
||||
if (url.hostname === 'login.live.com' && url.pathname === '/oauth20_desktop.srf') {
|
||||
code = url.searchParams.get('code') || ''
|
||||
if (code) break
|
||||
const currentUrl = this.page.url()
|
||||
|
||||
// Log only when URL changes (high signal, no spam)
|
||||
if (currentUrl !== lastUrl) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', `OAuth poll URL changed → ${currentUrl}`)
|
||||
lastUrl = currentUrl
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(currentUrl)
|
||||
|
||||
if (url.hostname === 'login.live.com' && url.pathname === '/oauth20_desktop.srf') {
|
||||
code = url.searchParams.get('code') || ''
|
||||
|
||||
if (code) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'OAuth code detected in redirect URL')
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`Invalid URL while polling: ${String(currentUrl)}`
|
||||
)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'Timed out waiting for OAuth code')
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`Timed out waiting for OAuth code after ${Math.round((Date.now() - start) / 1000)}s`
|
||||
)
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', `Final page URL: ${this.page.url()}`)
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -57,18 +101,24 @@ export class MobileAccessLogin {
|
||||
data.append('code', code)
|
||||
data.append('redirect_uri', this.redirectUrl)
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Exchanging OAuth code for access token')
|
||||
|
||||
const response = await this.bot.axios.request({
|
||||
url: this.tokenUrl,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
data: data.toString()
|
||||
}
|
||||
})
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
const token = (response?.data?.access_token as string) ?? ''
|
||||
|
||||
if (!token) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'No access_token in token response')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`Token response payload: ${JSON.stringify(response?.data)}`
|
||||
)
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -78,10 +128,11 @@ export class MobileAccessLogin {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`MobileAccess error: ${error instanceof Error ? error.message : String(error)}`
|
||||
`MobileAccess error: ${error instanceof Error ? error.stack || error.message : String(error)}`
|
||||
)
|
||||
return ''
|
||||
} finally {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Returning to base URL')
|
||||
await this.page.goto(this.bot.config.baseURL, { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
187
src/browser/auth/methods/RecoveryEmailLogin.ts
Normal file
187
src/browser/auth/methods/RecoveryEmailLogin.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { Page } from 'patchright'
|
||||
import type { MicrosoftRewardsBot } from '../../../index'
|
||||
import { getErrorMessage, promptInput } from './LoginUtils'
|
||||
|
||||
export class RecoveryLogin {
|
||||
private readonly textInputSelector = '[data-testid="proof-confirmation"]'
|
||||
private readonly maxManualSeconds = 60
|
||||
private readonly maxManualAttempts = 5
|
||||
|
||||
constructor(private bot: MicrosoftRewardsBot) {}
|
||||
|
||||
private async fillEmail(page: Page, email: string): Promise<boolean> {
|
||||
try {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', `Attempting to fill email: ${email}`)
|
||||
|
||||
const visibleInput = await page
|
||||
.waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 })
|
||||
.catch(() => null)
|
||||
|
||||
if (visibleInput) {
|
||||
await page.keyboard.type(email, { delay: 50 })
|
||||
await page.keyboard.press('Enter')
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Successfully filled email input field')
|
||||
return true
|
||||
}
|
||||
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Email input field not found with selector: ${this.textInputSelector}`
|
||||
)
|
||||
return false
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Failed to fill email input: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async handle(page: Page, recoveryEmail: string): Promise<void> {
|
||||
try {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email recovery authentication flow initiated')
|
||||
|
||||
if (recoveryEmail) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Using provided recovery email: ${recoveryEmail}`
|
||||
)
|
||||
|
||||
const filled = await this.fillEmail(page, recoveryEmail)
|
||||
if (!filled) {
|
||||
throw new Error('Email input field not found')
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Waiting for page response')
|
||||
await this.bot.utils.wait(500)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', 'Network idle timeout reached')
|
||||
})
|
||||
|
||||
const errorMessage = await getErrorMessage(page)
|
||||
if (errorMessage) {
|
||||
throw new Error(`Email verification failed: ${errorMessage}`)
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email authentication completed successfully')
|
||||
return
|
||||
}
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
'No recovery email provided, will prompt user for input'
|
||||
)
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Starting attempt ${attempt}/${this.maxManualAttempts}`
|
||||
)
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Prompting user for email input (timeout: ${this.maxManualSeconds}s)`
|
||||
)
|
||||
|
||||
const email = await promptInput({
|
||||
question: `Recovery email (waiting ${this.maxManualSeconds}s): `,
|
||||
timeoutSeconds: this.maxManualSeconds,
|
||||
validate: email => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`No or invalid email input received (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Manual email input failed: no input received')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Invalid email format received (attempt ${attempt}/${this.maxManualAttempts}) | length=${email.length}`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Manual email input failed: invalid format')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', `Valid email received from user: ${email}`)
|
||||
|
||||
const filled = await this.fillEmail(page, email)
|
||||
if (!filled) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Failed to fill email input field (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Email input field not found after maximum attempts')
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Waiting for page response')
|
||||
await this.bot.utils.wait(500)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', 'Network idle timeout reached')
|
||||
})
|
||||
|
||||
const errorMessage = await getErrorMessage(page)
|
||||
if (errorMessage) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Error from page: "${errorMessage}" (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error(`Maximum attempts reached. Last error: ${errorMessage}`)
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Clearing input field for retry')
|
||||
const inputToClear = await page.$(this.textInputSelector).catch(() => null)
|
||||
if (inputToClear) {
|
||||
await inputToClear.click()
|
||||
await page.keyboard.press('Control+A')
|
||||
await page.keyboard.press('Backspace')
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Input field cleared')
|
||||
} else {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-RECOVERY', 'Could not find input field to clear')
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email authentication completed successfully')
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Email input failed after ${this.maxManualAttempts} attempts`)
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN-RECOVERY', `Fatal error: ${errorMsg}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Page } from 'patchright'
|
||||
import * as OTPAuth from 'otpauth'
|
||||
import readline from 'readline'
|
||||
import type { MicrosoftRewardsBot } from '../../../index'
|
||||
import { getErrorMessage, promptInput } from './LoginUtils'
|
||||
|
||||
export class TotpLogin {
|
||||
private readonly textInputSelector =
|
||||
'form[name="OneTimeCodeViewForm"] input[type="text"], input#floatingLabelInput5'
|
||||
private readonly hiddenInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]'
|
||||
private readonly secondairyInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]'
|
||||
private readonly submitButtonSelector = 'button[type="submit"]'
|
||||
private readonly maxManualSeconds = 60
|
||||
private readonly maxManualAttempts = 5
|
||||
@@ -17,31 +17,6 @@ export class TotpLogin {
|
||||
return new OTPAuth.TOTP({ secret, digits: 6 }).generate()
|
||||
}
|
||||
|
||||
private async promptManualCode(): Promise<string | null> {
|
||||
return await new Promise(resolve => {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
let resolved = false
|
||||
|
||||
const cleanup = (result: string | null) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
clearTimeout(timer)
|
||||
rl.close()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => cleanup(null), this.maxManualSeconds * 1000)
|
||||
|
||||
rl.question(`Enter the 6-digit TOTP code (waiting ${this.maxManualSeconds}s): `, answer => {
|
||||
cleanup(answer.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async fillCode(page: Page, code: string): Promise<boolean> {
|
||||
try {
|
||||
const visibleInput = await page
|
||||
@@ -50,19 +25,18 @@ export class TotpLogin {
|
||||
|
||||
if (visibleInput) {
|
||||
await visibleInput.fill(code)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled visible TOTP text input')
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled TOTP input')
|
||||
return true
|
||||
}
|
||||
|
||||
const hiddenInput = await page.$(this.hiddenInputSelector)
|
||||
|
||||
if (hiddenInput) {
|
||||
await hiddenInput.fill(code)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled hidden TOTP input')
|
||||
const secondairyInput = await page.$(this.secondairyInputSelector)
|
||||
if (secondairyInput) {
|
||||
await secondairyInput.fill(code)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled TOTP input')
|
||||
return true
|
||||
}
|
||||
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP input field found (visible or hidden)')
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP input field found')
|
||||
return false
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
@@ -83,9 +57,8 @@ export class TotpLogin {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Generated TOTP code from secret')
|
||||
|
||||
const filled = await this.fillCode(page, code)
|
||||
|
||||
if (!filled) {
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', 'Unable to locate or fill TOTP input field')
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', 'Unable to fill TOTP input field')
|
||||
throw new Error('TOTP input field not found')
|
||||
}
|
||||
|
||||
@@ -93,6 +66,12 @@ export class TotpLogin {
|
||||
await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
const errorMessage = await getErrorMessage(page)
|
||||
if (errorMessage) {
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', `TOTP failed: ${errorMessage}`)
|
||||
throw new Error(`TOTP authentication failed: ${errorMessage}`)
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully')
|
||||
return
|
||||
}
|
||||
@@ -100,45 +79,36 @@ export class TotpLogin {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP secret provided, awaiting manual input')
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) {
|
||||
const code = await this.promptManualCode()
|
||||
const code = await promptInput({
|
||||
question: `Enter the 6-digit TOTP code (waiting ${this.maxManualSeconds}s): `,
|
||||
timeoutSeconds: this.maxManualSeconds,
|
||||
validate: code => /^\d{6}$/.test(code)
|
||||
})
|
||||
|
||||
if (!code || !/^\d{6}$/.test(code)) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-TOTP',
|
||||
`Invalid or missing TOTP code (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
`Invalid or missing code (attempt ${attempt}/${this.maxManualAttempts}) | input length=${code?.length}`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Manual TOTP input failed or timed out')
|
||||
}
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-TOTP',
|
||||
'Retrying manual TOTP input due to invalid code'
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const filled = await this.fillCode(page, code)
|
||||
|
||||
if (!filled) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-TOTP',
|
||||
`Unable to locate or fill TOTP input field (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
`Unable to fill TOTP input (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('TOTP input field not found')
|
||||
}
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-TOTP',
|
||||
'Retrying manual TOTP input due to fill failure'
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -146,16 +116,31 @@ export class TotpLogin {
|
||||
await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
// Check if wrong code was entered
|
||||
const errorMessage = await getErrorMessage(page)
|
||||
if (errorMessage) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-TOTP',
|
||||
`Incorrect code: ${errorMessage} (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error(`Maximum attempts reached: ${errorMessage}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully')
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Manual TOTP input failed after ${this.maxManualAttempts} attempts`)
|
||||
throw new Error(`TOTP input failed after ${this.maxManualAttempts} attempts`)
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-TOTP',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
`Error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user