mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-11 17:56:15 +00:00
feat: Implement LoginStateDetector for improved login flow state management and error handling
This commit is contained in:
@@ -8,6 +8,7 @@ import { saveSessionData } from '../util/Load'
|
|||||||
import { MicrosoftRewardsBot } from '../index'
|
import { MicrosoftRewardsBot } from '../index'
|
||||||
import { OAuth } from '../interface/OAuth'
|
import { OAuth } from '../interface/OAuth'
|
||||||
import { Retry } from '../util/Retry'
|
import { Retry } from '../util/Retry'
|
||||||
|
import { LoginState, LoginStateDetector } from '../util/LoginStateDetector'
|
||||||
|
|
||||||
// -------------------------------
|
// -------------------------------
|
||||||
// Constants / Tunables
|
// Constants / Tunables
|
||||||
@@ -246,7 +247,17 @@ export class Login {
|
|||||||
await this.bot.browser.utils.reloadBadPage(page)
|
await this.bot.browser.utils.reloadBadPage(page)
|
||||||
await this.bot.utils.wait(250)
|
await this.bot.utils.wait(250)
|
||||||
|
|
||||||
const portalSelector = await this.waitForRewardsRoot(page, 3500)
|
// IMPROVED: Increased timeout from 3.5s to 8s for slow connections
|
||||||
|
let portalSelector = await this.waitForRewardsRoot(page, 8000)
|
||||||
|
|
||||||
|
// IMPROVED: Retry once if initial check failed
|
||||||
|
if (!portalSelector) {
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal not detected (8s), retrying once...', 'warn')
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
await this.bot.browser.utils.reloadBadPage(page)
|
||||||
|
portalSelector = await this.waitForRewardsRoot(page, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
if (portalSelector) {
|
if (portalSelector) {
|
||||||
// Additional validation: make sure we're not just on the page but actually logged in
|
// Additional validation: make sure we're not just on the page but actually logged in
|
||||||
// Check if we're redirected to login
|
// Check if we're redirected to login
|
||||||
@@ -256,7 +267,7 @@ export class Login {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
this.bot.log(this.bot.isMobile, 'LOGIN', `Existing session still valid (${portalSelector})`)
|
this.bot.log(this.bot.isMobile, 'LOGIN', `✅ Existing session still valid (${portalSelector}) — saved 2-3 minutes!`)
|
||||||
await this.checkAccountLocked(page)
|
await this.checkAccountLocked(page)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -280,22 +291,49 @@ export class Login {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async performLoginFlow(page: Page, email: string, password: string) {
|
private async performLoginFlow(page: Page, email: string, password: string) {
|
||||||
|
// Step 1: Input email
|
||||||
await this.inputEmail(page, email)
|
await this.inputEmail(page, email)
|
||||||
await this.bot.utils.wait(1000)
|
|
||||||
await this.bot.browser.utils.reloadBadPage(page)
|
// Step 2: Wait for transition to password page (VALIDATION PROGRESSIVE)
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for password page transition...')
|
||||||
|
const passwordPageReached = await LoginStateDetector.waitForAnyState(
|
||||||
|
page,
|
||||||
|
[LoginState.PasswordPage, LoginState.TwoFactorRequired, LoginState.LoggedIn],
|
||||||
|
8000
|
||||||
|
)
|
||||||
|
|
||||||
|
if (passwordPageReached === LoginState.LoggedIn) {
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN', 'Already authenticated after email (fast path)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordPageReached) {
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN', 'Password page not reached after 8s, continuing anyway...', 'warn')
|
||||||
|
} else {
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN', `Transitioned to state: ${passwordPageReached}`)
|
||||||
|
}
|
||||||
|
|
||||||
await this.bot.utils.wait(500)
|
await this.bot.utils.wait(500)
|
||||||
|
await this.bot.browser.utils.reloadBadPage(page)
|
||||||
|
|
||||||
|
// Step 3: Recovery mismatch check
|
||||||
await this.tryRecoveryMismatchCheck(page, email)
|
await this.tryRecoveryMismatchCheck(page, email)
|
||||||
if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'recovery-mismatch') {
|
if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'recovery-mismatch') {
|
||||||
this.bot.log(this.bot.isMobile,'LOGIN','Recovery mismatch detected – stopping before password entry','warn')
|
this.bot.log(this.bot.isMobile,'LOGIN','Recovery mismatch detected – stopping before password entry','warn')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Try switching to password if a locale link is present (FR/EN)
|
|
||||||
|
// Step 4: Try switching to password if needed
|
||||||
await this.switchToPasswordLink(page)
|
await this.switchToPasswordLink(page)
|
||||||
|
|
||||||
|
// Step 5: Input password or handle 2FA
|
||||||
await this.inputPasswordOr2FA(page, password)
|
await this.inputPasswordOr2FA(page, password)
|
||||||
if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') {
|
if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') {
|
||||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Blocked sign-in detected — halting.', 'warn')
|
this.bot.log(this.bot.isMobile, 'LOGIN', 'Blocked sign-in detected — halting.', 'warn')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 6: Final checks
|
||||||
await this.checkAccountLocked(page)
|
await this.checkAccountLocked(page)
|
||||||
await this.awaitRewardsPortal(page)
|
await this.awaitRewardsPortal(page)
|
||||||
}
|
}
|
||||||
@@ -460,17 +498,26 @@ export class Login {
|
|||||||
const usedTotp = await this.tryAutoTotp(page, 'manual 2FA entry')
|
const usedTotp = await this.tryAutoTotp(page, 'manual 2FA entry')
|
||||||
if (usedTotp) return
|
if (usedTotp) return
|
||||||
|
|
||||||
// Manual prompt - simplified without interval checking
|
// Manual prompt with 120s timeout
|
||||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for user 2FA code (SMS / Email / App fallback)')
|
this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for user 2FA code (SMS / Email / App fallback)')
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const code = await new Promise<string>(res => {
|
// IMPROVED: Add 120s timeout to prevent infinite blocking
|
||||||
|
const code = await Promise.race([
|
||||||
|
new Promise<string>(res => {
|
||||||
rl.question('Enter 2FA code:\n', ans => {
|
rl.question('Enter 2FA code:\n', ans => {
|
||||||
rl.close()
|
rl.close()
|
||||||
res(ans.trim())
|
res(ans.trim())
|
||||||
})
|
})
|
||||||
|
}),
|
||||||
|
new Promise<string>((_, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
rl.close()
|
||||||
|
reject(new Error('2FA code input timeout after 120s'))
|
||||||
|
}, 120000)
|
||||||
})
|
})
|
||||||
|
])
|
||||||
|
|
||||||
// Check if input field still exists before trying to fill
|
// Check if input field still exists before trying to fill
|
||||||
const inputExists = await page.locator('input[name="otc"]').first().isVisible({ timeout: 1000 }).catch(() => false)
|
const inputExists = await page.locator('input[name="otc"]').first().isVisible({ timeout: 1000 }).catch(() => false)
|
||||||
@@ -483,6 +530,13 @@ export class Login {
|
|||||||
await page.fill('input[name="otc"]', code)
|
await page.fill('input[name="otc"]', code)
|
||||||
await page.keyboard.press('Enter')
|
await page.keyboard.press('Enter')
|
||||||
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted')
|
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted')
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('timeout')) {
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code input timeout (120s) - user AFK', 'error')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
// Other errors, just log and continue
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code entry error: ' + error, 'warn')
|
||||||
} finally {
|
} finally {
|
||||||
try { rl.close() } catch {/* ignore */}
|
try { rl.close() } catch {/* ignore */}
|
||||||
}
|
}
|
||||||
@@ -811,6 +865,13 @@ export class Login {
|
|||||||
let lastUrl = ''
|
let lastUrl = ''
|
||||||
let checkCount = 0
|
let checkCount = 0
|
||||||
|
|
||||||
|
// EARLY EXIT: Check if already logged in immediately
|
||||||
|
const initialState = await LoginStateDetector.detectState(page)
|
||||||
|
if (initialState.state === LoginState.LoggedIn) {
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN', 'Already on rewards portal (early exit)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
while (Date.now() - start < DEFAULT_TIMEOUTS.loginMaxMs) {
|
while (Date.now() - start < DEFAULT_TIMEOUTS.loginMaxMs) {
|
||||||
checkCount++
|
checkCount++
|
||||||
|
|
||||||
@@ -820,6 +881,19 @@ export class Login {
|
|||||||
lastUrl = currentUrl
|
lastUrl = currentUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SMART CHECK: Use LoginStateDetector every 5 iterations for fast detection
|
||||||
|
if (checkCount % 5 === 0) {
|
||||||
|
const state = await LoginStateDetector.detectState(page)
|
||||||
|
if (state.state === LoginState.LoggedIn) {
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN', `State detector confirmed: ${state.state} (confidence: ${state.confidence})`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (state.state === LoginState.Blocked) {
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN', 'Blocked state detected during portal wait', 'error')
|
||||||
|
throw new Error('Account blocked during login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// OPTIMIZATION: Quick URL check first
|
// OPTIMIZATION: Quick URL check first
|
||||||
const u = new URL(currentUrl)
|
const u = new URL(currentUrl)
|
||||||
const isRewardsHost = u.hostname === LOGIN_TARGET.host
|
const isRewardsHost = u.hostname === LOGIN_TARGET.host
|
||||||
|
|||||||
184
src/util/LoginStateDetector.ts
Normal file
184
src/util/LoginStateDetector.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import type { Page } from 'playwright'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login flow states for better tracking and debugging
|
||||||
|
*/
|
||||||
|
export enum LoginState {
|
||||||
|
Unknown = 'UNKNOWN',
|
||||||
|
InitialLoad = 'INITIAL_LOAD',
|
||||||
|
EmailPage = 'EMAIL_PAGE',
|
||||||
|
EmailSubmitted = 'EMAIL_SUBMITTED',
|
||||||
|
PasswordPage = 'PASSWORD_PAGE',
|
||||||
|
PasswordSubmitted = 'PASSWORD_SUBMITTED',
|
||||||
|
TwoFactorRequired = '2FA_REQUIRED',
|
||||||
|
TwoFactorSubmitted = '2FA_SUBMITTED',
|
||||||
|
PasskeyPrompt = 'PASSKEY_PROMPT',
|
||||||
|
RecoveryCheck = 'RECOVERY_CHECK',
|
||||||
|
LoggedIn = 'LOGGED_IN',
|
||||||
|
Blocked = 'BLOCKED',
|
||||||
|
Error = 'ERROR'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of login state detection
|
||||||
|
*/
|
||||||
|
export interface LoginStateDetection {
|
||||||
|
state: LoginState
|
||||||
|
confidence: 'high' | 'medium' | 'low'
|
||||||
|
url: string
|
||||||
|
indicators: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LoginStateDetector: Intelligent detection of current login flow state
|
||||||
|
* Helps avoid assumptions and provides clear visibility of where we are
|
||||||
|
*/
|
||||||
|
export class LoginStateDetector {
|
||||||
|
/**
|
||||||
|
* Detect current state of login flow based on page URL and DOM
|
||||||
|
*/
|
||||||
|
static async detectState(page: Page): Promise<LoginStateDetection> {
|
||||||
|
const url = page.url()
|
||||||
|
const indicators: string[] = []
|
||||||
|
|
||||||
|
// State 1: Already logged in (rewards portal)
|
||||||
|
if (url.includes('rewards.bing.com') || url.includes('rewards.microsoft.com')) {
|
||||||
|
const hasPortal = await page.locator('html[data-role-name*="RewardsPortal"], #dashboard, main[data-bi-name="dashboard"]')
|
||||||
|
.first()
|
||||||
|
.isVisible({ timeout: 1000 })
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (hasPortal) {
|
||||||
|
indicators.push('Rewards portal detected')
|
||||||
|
return { state: LoginState.LoggedIn, confidence: 'high', url, indicators }
|
||||||
|
}
|
||||||
|
|
||||||
|
// On rewards domain but no portal = might be loading or error
|
||||||
|
const bodyLength = await page.evaluate(() => document.body?.innerText?.length || 0).catch(() => 0)
|
||||||
|
if (bodyLength > 100) {
|
||||||
|
indicators.push('On rewards domain, checking content...')
|
||||||
|
return { state: LoginState.LoggedIn, confidence: 'medium', url, indicators }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State 2: Microsoft login pages
|
||||||
|
if (url.includes('login.live.com') || url.includes('login.microsoftonline.com')) {
|
||||||
|
// Check for email input
|
||||||
|
const hasEmailInput = await page.locator('input[type="email"], input[name="loginfmt"]')
|
||||||
|
.first()
|
||||||
|
.isVisible({ timeout: 800 })
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (hasEmailInput) {
|
||||||
|
indicators.push('Email input field detected')
|
||||||
|
return { state: LoginState.EmailPage, confidence: 'high', url, indicators }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for password input
|
||||||
|
const hasPasswordInput = await page.locator('input[type="password"], input[name="passwd"]')
|
||||||
|
.first()
|
||||||
|
.isVisible({ timeout: 800 })
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (hasPasswordInput) {
|
||||||
|
indicators.push('Password input field detected')
|
||||||
|
return { state: LoginState.PasswordPage, confidence: 'high', url, indicators }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for 2FA/OTP input
|
||||||
|
const hasTotpInput = await page.locator('input[name="otc"], input[autocomplete="one-time-code"]')
|
||||||
|
.first()
|
||||||
|
.isVisible({ timeout: 800 })
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (hasTotpInput) {
|
||||||
|
indicators.push('2FA/TOTP input field detected')
|
||||||
|
return { state: LoginState.TwoFactorRequired, confidence: 'high', url, indicators }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for passkey/Windows Hello prompts
|
||||||
|
const hasPasskeyPrompt = await page.locator('[data-testid="title"]')
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
.then((text: string | null) => /sign in faster|passkey|fingerprint|windows hello/i.test(text || ''))
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (hasPasskeyPrompt) {
|
||||||
|
indicators.push('Passkey/Windows Hello prompt detected')
|
||||||
|
return { state: LoginState.PasskeyPrompt, confidence: 'high', url, indicators }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for blocked/error messages
|
||||||
|
const hasBlockedMessage = await page.locator('[data-testid="title"], h1')
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
.then((text: string | null) => /can[''`]?t sign you in|blocked|locked/i.test(text || ''))
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (hasBlockedMessage) {
|
||||||
|
indicators.push('Account blocked/error message detected')
|
||||||
|
return { state: LoginState.Blocked, confidence: 'high', url, indicators }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic login page
|
||||||
|
indicators.push('On Microsoft login domain, state unclear')
|
||||||
|
return { state: LoginState.Unknown, confidence: 'low', url, indicators }
|
||||||
|
}
|
||||||
|
|
||||||
|
// State 3: OAuth flow (mobile token)
|
||||||
|
if (url.includes('/oauth20_authorize.srf') || url.includes('/oauth20_desktop.srf')) {
|
||||||
|
indicators.push('OAuth flow detected')
|
||||||
|
return { state: LoginState.EmailSubmitted, confidence: 'medium', url, indicators }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown state
|
||||||
|
indicators.push('Unknown page')
|
||||||
|
return { state: LoginState.Unknown, confidence: 'low', url, indicators }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for state transition with timeout
|
||||||
|
* Returns true if expected state reached, false if timeout
|
||||||
|
*/
|
||||||
|
static async waitForState(
|
||||||
|
page: Page,
|
||||||
|
expectedState: LoginState,
|
||||||
|
timeoutMs: number = 10000
|
||||||
|
): Promise<boolean> {
|
||||||
|
const start = Date.now()
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const detection = await LoginStateDetector.detectState(page)
|
||||||
|
if (detection.state === expectedState) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast polling for quick transitions
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for any of multiple states (whichever comes first)
|
||||||
|
*/
|
||||||
|
static async waitForAnyState(
|
||||||
|
page: Page,
|
||||||
|
expectedStates: LoginState[],
|
||||||
|
timeoutMs: number = 10000
|
||||||
|
): Promise<LoginState | null> {
|
||||||
|
const start = Date.now()
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const detection = await LoginStateDetector.detectState(page)
|
||||||
|
if (expectedStates.includes(detection.state)) {
|
||||||
|
return detection.state
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
210
src/util/SecurityDetector.ts
Normal file
210
src/util/SecurityDetector.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import type { Page } from 'playwright'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security incident detected during login/authentication
|
||||||
|
*/
|
||||||
|
export interface SecurityIncident {
|
||||||
|
kind: string
|
||||||
|
account: string
|
||||||
|
details?: string[]
|
||||||
|
next?: string[]
|
||||||
|
docsUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SecurityDetector: Centralized detection of login security blocks and anomalies
|
||||||
|
* Extracted from Login.ts for testability and separation of concerns
|
||||||
|
*/
|
||||||
|
export class SecurityDetector {
|
||||||
|
// Sign-in block patterns (Microsoft security messages)
|
||||||
|
private static readonly SIGN_IN_BLOCK_PATTERNS: { re: RegExp; label: string }[] = [
|
||||||
|
{ re: /we can[''`]?t sign you in/i, label: 'cant-sign-in' },
|
||||||
|
{ re: /incorrect account or password too many times/i, label: 'too-many-incorrect' },
|
||||||
|
{ re: /used an incorrect account or password too many times/i, label: 'too-many-incorrect-variant' },
|
||||||
|
{ re: /sign-in has been blocked/i, label: 'sign-in-blocked-phrase' },
|
||||||
|
{ re: /your account has been locked/i, label: 'account-locked' },
|
||||||
|
{ re: /your account or password is incorrect too many times/i, label: 'incorrect-too-many-times' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if page contains sign-in blocked message
|
||||||
|
* Returns matched pattern label or null
|
||||||
|
*/
|
||||||
|
static async detectSignInBlocked(page: Page): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
let text = ''
|
||||||
|
const selectors = ['[data-testid="title"]', 'h1', 'div[role="heading"]', 'div.text-title']
|
||||||
|
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = await page.waitForSelector(sel, { timeout: 600 }).catch(() => null)
|
||||||
|
if (el) {
|
||||||
|
const t = (await el.textContent() || '').trim()
|
||||||
|
if (t && t.length < 300) text += ' ' + t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
for (const p of SecurityDetector.SIGN_IN_BLOCK_PATTERNS) {
|
||||||
|
if (p.re.test(lower)) {
|
||||||
|
return p.label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse masked email from Microsoft recovery prompts
|
||||||
|
* Returns { prefix: string, domain: string } or null
|
||||||
|
* Examples: "k*****@domain.com" → { prefix: "k", domain: "domain.com" }
|
||||||
|
* "ko****@domain.com" → { prefix: "ko", domain: "domain.com" }
|
||||||
|
*/
|
||||||
|
static parseMaskedEmail(masked: string): { prefix: string; domain: string } | null {
|
||||||
|
// Pattern: 1-2 visible chars, then masked chars, then @domain
|
||||||
|
const regex = /([a-zA-Z0-9]{1,2})[a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/
|
||||||
|
const match = regex.exec(masked)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
// Fallback: try looser pattern
|
||||||
|
const loose = /([a-zA-Z0-9])[*•][a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/.exec(masked)
|
||||||
|
if (!loose) return null
|
||||||
|
return {
|
||||||
|
prefix: (loose[1] || '').toLowerCase(),
|
||||||
|
domain: (loose[2] || '').toLowerCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefix: (match[1] || '').toLowerCase(),
|
||||||
|
domain: (match[2] || '').toLowerCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if masked email matches expected recovery email
|
||||||
|
* Lenient matching: only compare visible prefix (1-2 chars) + domain
|
||||||
|
*/
|
||||||
|
static matchesMaskedEmail(
|
||||||
|
observed: { prefix: string; domain: string },
|
||||||
|
expected: { prefix: string; domain: string }
|
||||||
|
): boolean {
|
||||||
|
if (observed.domain !== expected.domain) return false
|
||||||
|
|
||||||
|
// If only 1 char visible, match first char only
|
||||||
|
if (observed.prefix.length === 1) {
|
||||||
|
return expected.prefix.startsWith(observed.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If 2 chars visible, both must match
|
||||||
|
return expected.prefix === observed.prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all masked emails from page (candidates for recovery email check)
|
||||||
|
* Returns array of masked email strings found in DOM
|
||||||
|
*/
|
||||||
|
static async extractRecoveryCandidates(page: Page): Promise<string[]> {
|
||||||
|
const candidates: string[] = []
|
||||||
|
|
||||||
|
// Priority 1: Direct selectors (Microsoft variants + French)
|
||||||
|
const directSelectors = [
|
||||||
|
'[data-testid="recoveryEmailHint"]',
|
||||||
|
'#recoveryEmail',
|
||||||
|
'[id*="ProofEmail"]',
|
||||||
|
'[id*="EmailProof"]',
|
||||||
|
'[data-testid*="Email"]',
|
||||||
|
'span:has(span.fui-Text)'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const sel of directSelectors) {
|
||||||
|
const el = await page.waitForSelector(sel, { timeout: 1000 }).catch(() => null)
|
||||||
|
if (el) {
|
||||||
|
const t = (await el.textContent() || '').trim()
|
||||||
|
if (t) candidates.push(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: List items
|
||||||
|
const listItems = page.locator('[role="listitem"], li')
|
||||||
|
const count = await listItems.count().catch(() => 0)
|
||||||
|
for (let i = 0; i < Math.min(count, 12); i++) {
|
||||||
|
const t = (await listItems.nth(i).textContent().catch(() => ''))?.trim() || ''
|
||||||
|
if (t && /@/.test(t)) candidates.push(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: XPath generic masked patterns
|
||||||
|
const xpath = page.locator('xpath=//*[contains(normalize-space(.), "@") and (contains(normalize-space(.), "*") or contains(normalize-space(.), "•"))]')
|
||||||
|
const xpCount = await xpath.count().catch(() => 0)
|
||||||
|
for (let i = 0; i < Math.min(xpCount, 12); i++) {
|
||||||
|
const t = (await xpath.nth(i).textContent().catch(() => ''))?.trim() || ''
|
||||||
|
if (t && t.length < 300) candidates.push(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 4: Full HTML scan fallback
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
try {
|
||||||
|
const html = await page.content()
|
||||||
|
const generic = /[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+/g
|
||||||
|
const frPhrase = /Nous\s+enverrons\s+un\s+code\s+à\s+([^<@]*[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+)[^.]{0,120}?Pour\s+vérifier/gi
|
||||||
|
|
||||||
|
const found = new Set<string>()
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = generic.exec(html)) !== null) found.add(m[0])
|
||||||
|
while ((m = frPhrase.exec(html)) !== null) {
|
||||||
|
const raw = m[1]?.replace(/<[^>]+>/g, '').trim()
|
||||||
|
if (raw) found.add(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push(...Array.from(found))
|
||||||
|
} catch {
|
||||||
|
/* ignore HTML scan errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return candidates
|
||||||
|
.map(s => s.replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(t => t && !seen.has(t) && seen.add(t))
|
||||||
|
.filter(t => /@/.test(t) && /[*•]/.test(t)) // Must be masked email
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if page contains account locked indicator
|
||||||
|
*/
|
||||||
|
static async detectAccountLocked(page: Page): Promise<boolean> {
|
||||||
|
return await page.waitForSelector('#serviceAbuseLandingTitle', { timeout: 1200 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse email into { prefix: first 2 chars, domain }
|
||||||
|
*/
|
||||||
|
static parseEmailReference(email: string): { prefix: string; domain: string } | null {
|
||||||
|
if (!email || !/@/.test(email)) return null
|
||||||
|
|
||||||
|
const [local, domain] = email.split('@')
|
||||||
|
if (!local || !domain) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefix: local.slice(0, 2).toLowerCase(),
|
||||||
|
domain: domain.toLowerCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get documentation URL for security incident
|
||||||
|
*/
|
||||||
|
static getDocsUrl(anchor?: string): string {
|
||||||
|
const base = process.env.DOCS_BASE?.trim() || 'https://github.com/Obsidian-wtf/Microsoft-Rewards-Bot/blob/main/docs/security.md'
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'recovery-email-mismatch': '#recovery-email-mismatch',
|
||||||
|
'we-cant-sign-you-in': '#we-cant-sign-you-in-blocked'
|
||||||
|
}
|
||||||
|
return anchor && map[anchor] ? `${base}${map[anchor]}` : base
|
||||||
|
}
|
||||||
|
}
|
||||||
239
tests/loginStateDetector.test.ts
Normal file
239
tests/loginStateDetector.test.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { LoginState, LoginStateDetector } from '../src/util/LoginStateDetector'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for LoginStateDetector - login flow state machine
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('LoginState enum contains expected states', () => {
|
||||||
|
assert.ok(LoginState.EmailPage, 'Should have EmailPage state')
|
||||||
|
assert.ok(LoginState.PasswordPage, 'Should have PasswordPage state')
|
||||||
|
assert.ok(LoginState.TwoFactorRequired, 'Should have TwoFactorRequired state')
|
||||||
|
assert.ok(LoginState.LoggedIn, 'Should have LoggedIn state')
|
||||||
|
assert.ok(LoginState.Blocked, 'Should have Blocked state')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detectState returns LoginStateDetection structure', async () => {
|
||||||
|
// Mock page object
|
||||||
|
const mockPage = {
|
||||||
|
url: () => 'https://rewards.bing.com/',
|
||||||
|
locator: (selector: string) => ({
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(true),
|
||||||
|
textContent: () => Promise.resolve('Test')
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
evaluate: () => Promise.resolve(150)
|
||||||
|
}
|
||||||
|
|
||||||
|
const detection = await LoginStateDetector.detectState(mockPage as any)
|
||||||
|
|
||||||
|
assert.ok(detection, 'Should return detection object')
|
||||||
|
assert.ok(typeof detection.state === 'string', 'Should have state property')
|
||||||
|
assert.ok(['high', 'medium', 'low'].includes(detection.confidence), 'Should have valid confidence')
|
||||||
|
assert.ok(typeof detection.url === 'string', 'Should have url property')
|
||||||
|
assert.ok(Array.isArray(detection.indicators), 'Should have indicators array')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detectState identifies LoggedIn state on rewards domain', async () => {
|
||||||
|
const mockPage = {
|
||||||
|
url: () => 'https://rewards.bing.com/dashboard',
|
||||||
|
locator: (selector: string) => {
|
||||||
|
if (selector.includes('RewardsPortal') || selector.includes('dashboard')) {
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
evaluate: () => Promise.resolve(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const detection = await LoginStateDetector.detectState(mockPage as any)
|
||||||
|
|
||||||
|
assert.equal(detection.state, LoginState.LoggedIn, 'Should detect LoggedIn state')
|
||||||
|
assert.equal(detection.confidence, 'high', 'Should have high confidence')
|
||||||
|
assert.ok(detection.indicators.length > 0, 'Should have indicators')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detectState identifies EmailPage state on login.live.com', async () => {
|
||||||
|
const mockPage = {
|
||||||
|
url: () => 'https://login.live.com/login.srf',
|
||||||
|
locator: (selector: string) => {
|
||||||
|
if (selector.includes('email') || selector.includes('loginfmt')) {
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(false),
|
||||||
|
textContent: () => Promise.resolve(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
evaluate: () => Promise.resolve(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const detection = await LoginStateDetector.detectState(mockPage as any)
|
||||||
|
|
||||||
|
assert.equal(detection.state, LoginState.EmailPage, 'Should detect EmailPage state')
|
||||||
|
assert.equal(detection.confidence, 'high', 'Should have high confidence')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detectState identifies PasswordPage state', async () => {
|
||||||
|
const mockPage = {
|
||||||
|
url: () => 'https://login.live.com/ppsecure/post.srf',
|
||||||
|
locator: (selector: string) => {
|
||||||
|
if (selector.includes('password') || selector.includes('passwd')) {
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(false),
|
||||||
|
textContent: () => Promise.resolve(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
evaluate: () => Promise.resolve(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const detection = await LoginStateDetector.detectState(mockPage as any)
|
||||||
|
|
||||||
|
assert.equal(detection.state, LoginState.PasswordPage, 'Should detect PasswordPage state')
|
||||||
|
assert.equal(detection.confidence, 'high', 'Should have high confidence')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detectState identifies TwoFactorRequired state', async () => {
|
||||||
|
const mockPage = {
|
||||||
|
url: () => 'https://login.live.com/proofs.srf',
|
||||||
|
locator: (selector: string) => {
|
||||||
|
if (selector.includes('otc') || selector.includes('one-time-code')) {
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(false),
|
||||||
|
textContent: () => Promise.resolve(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
evaluate: () => Promise.resolve(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const detection = await LoginStateDetector.detectState(mockPage as any)
|
||||||
|
|
||||||
|
assert.equal(detection.state, LoginState.TwoFactorRequired, 'Should detect TwoFactorRequired state')
|
||||||
|
assert.equal(detection.confidence, 'high', 'Should have high confidence')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detectState identifies PasskeyPrompt state', async () => {
|
||||||
|
const mockPage = {
|
||||||
|
url: () => 'https://login.live.com/login.srf',
|
||||||
|
locator: (selector: string) => {
|
||||||
|
if (selector.includes('[data-testid="title"]')) {
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(false),
|
||||||
|
textContent: () => Promise.resolve('Sign in faster with passkey')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(false),
|
||||||
|
textContent: () => Promise.resolve(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
evaluate: () => Promise.resolve(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const detection = await LoginStateDetector.detectState(mockPage as any)
|
||||||
|
|
||||||
|
assert.equal(detection.state, LoginState.PasskeyPrompt, 'Should detect PasskeyPrompt state')
|
||||||
|
assert.equal(detection.confidence, 'high', 'Should have high confidence')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detectState identifies Blocked state', async () => {
|
||||||
|
const mockPage = {
|
||||||
|
url: () => 'https://login.live.com/err.srf',
|
||||||
|
locator: (selector: string) => {
|
||||||
|
if (selector.includes('[data-testid="title"]') || selector.includes('h1')) {
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(false),
|
||||||
|
textContent: () => Promise.resolve("We can't sign you in")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(false),
|
||||||
|
textContent: () => Promise.resolve(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
evaluate: () => Promise.resolve(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const detection = await LoginStateDetector.detectState(mockPage as any)
|
||||||
|
|
||||||
|
assert.equal(detection.state, LoginState.Blocked, 'Should detect Blocked state')
|
||||||
|
assert.equal(detection.confidence, 'high', 'Should have high confidence')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detectState returns Unknown for ambiguous pages', async () => {
|
||||||
|
const mockPage = {
|
||||||
|
url: () => 'https://login.live.com/unknown.srf',
|
||||||
|
locator: () => ({
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.resolve(false),
|
||||||
|
textContent: () => Promise.resolve(null)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
evaluate: () => Promise.resolve(50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const detection = await LoginStateDetector.detectState(mockPage as any)
|
||||||
|
|
||||||
|
assert.equal(detection.state, LoginState.Unknown, 'Should return Unknown for ambiguous pages')
|
||||||
|
assert.equal(detection.confidence, 'low', 'Should have low confidence')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detectState handles errors gracefully', async () => {
|
||||||
|
const mockPage = {
|
||||||
|
url: () => { throw new Error('Network error') },
|
||||||
|
locator: () => ({
|
||||||
|
first: () => ({
|
||||||
|
isVisible: () => Promise.reject(new Error('Element not found'))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
evaluate: () => Promise.reject(new Error('Evaluation failed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await LoginStateDetector.detectState(mockPage as any)
|
||||||
|
assert.fail('Should throw error')
|
||||||
|
} catch (e) {
|
||||||
|
assert.ok(e instanceof Error, 'Should throw Error instance')
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user