Files
Microsoft-Rewards-Bot/src/functions/login/PasskeyHandler.ts

286 lines
12 KiB
TypeScript

import type { Page } from 'playwright'
import { MicrosoftRewardsBot } from '../../index'
import { waitForElementSmart } from '../../util/browser/SmartWait'
import { logError } from '../../util/notifications/Logger'
export class PasskeyHandler {
private bot: MicrosoftRewardsBot
private passkeyHandled = false
private noPromptIterations = 0
private lastNoPromptLog = 0
private static readonly SELECTORS = {
passkeySecondary: 'button[data-testid="secondaryButton"]',
passkeyPrimary: 'button[data-testid="primaryButton"]',
passkeyTitle: '[data-testid="title"]',
kmsiVideo: '[data-testid="kmsiVideo"]',
biometricVideo: '[data-testid="biometricVideo"]'
} as const
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
public async disableFido(page: Page) {
await page.route('**/GetCredentialType.srf*', route => {
try {
const body = JSON.parse(route.request().postData() || '{}')
body.isFidoSupported = false
route.continue({ postData: JSON.stringify(body) })
} catch { /* Route continue on parse failure */ route.continue() }
}).catch(logError('LOGIN-FIDO', 'Route interception setup failed', this.bot.isMobile))
}
/**
* Setup dialog handlers to automatically dismiss native browser dialogs
* CRITICAL: This handles Bluetooth/Windows Hello/Passkey prompts that appear as native browser dialogs
* These are NOT DOM elements and cannot be clicked - they must be dismissed via page.on('dialog')
*/
public setupDialogHandlers(page: Page) {
// Remove any existing listeners to prevent duplicates
page.removeAllListeners('dialog')
page.on('dialog', async (dialog) => {
const message = dialog.message()
const type = dialog.type()
this.bot.log(
this.bot.isMobile,
'LOGIN-DIALOG',
`Native browser dialog detected: [${type}] "${message.substring(0, 100)}"`,
'warn'
)
// Auto-dismiss all dialogs (alert, confirm, prompt, beforeunload)
// For passkey/Bluetooth prompts, we want to DISMISS (equivalent to Cancel)
try {
if (type === 'beforeunload') {
// Accept beforeunload to allow navigation
await dialog.accept()
this.bot.log(this.bot.isMobile, 'LOGIN-DIALOG', 'Accepted beforeunload dialog', 'log', 'green')
} else {
// Dismiss all other dialogs (passkey, Bluetooth, alerts)
await dialog.dismiss()
this.bot.log(
this.bot.isMobile,
'LOGIN-DIALOG',
`Dismissed ${type} dialog: "${message.substring(0, 50)}"`,
'log',
'green'
)
}
} catch (error) {
this.bot.log(
this.bot.isMobile,
'LOGIN-DIALOG',
`Failed to handle dialog: ${error instanceof Error ? error.message : String(error)}`,
'error'
)
}
})
this.bot.log(this.bot.isMobile, 'LOGIN-DIALOG', 'Dialog handlers installed (auto-dismiss enabled)', 'log', 'cyan')
}
public async handlePasskeyPrompts(page: Page, context: 'main' | 'oauth') {
let did = false
// Early exit for passkey creation flows (common on mobile): hit cancel/skip if present
const currentUrl = page.url()
if (/fido\/create|passkey/i.test(currentUrl)) {
const cancelled = await this.clickFirstVisible(page, [
'button:has-text("Cancel")',
'button:has-text("Not now")',
'button:has-text("Skip")',
'button:has-text("No thanks")',
'[data-testid="secondaryButton"]',
'button[class*="secondary"]'
], 500)
if (cancelled) {
did = true
this.logPasskeyOnce('fido/create cancel')
}
}
// Priority 1: Direct detection of "Skip for now" button by data-testid
const skipBtnResult = await waitForElementSmart(page, 'button[data-testid="secondaryButton"]', {
initialTimeoutMs: 300,
extendedTimeoutMs: 500,
state: 'visible'
})
if (skipBtnResult.found && skipBtnResult.element) {
const text = (await skipBtnResult.element.textContent() || '').trim()
// Check if it's actually a skip button (could be other secondary buttons)
if (/skip|later|not now|non merci|pas maintenant/i.test(text)) {
await skipBtnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Skip button click failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('data-testid secondaryButton')
}
}
// Priority 2: Video heuristic (biometric prompt)
if (!did) {
const biometricResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.biometricVideo, {
initialTimeoutMs: 300,
extendedTimeoutMs: 500,
state: 'visible'
})
if (biometricResult.found) {
const btnResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeySecondary, {
initialTimeoutMs: 200,
extendedTimeoutMs: 300,
state: 'visible'
})
if (btnResult.found && btnResult.element) {
await btnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Video heuristic click failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('video heuristic')
}
}
}
// Priority 3: Title + secondary button detection
if (!did) {
const titleResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeyTitle, {
initialTimeoutMs: 300,
extendedTimeoutMs: 500,
state: 'attached'
})
if (titleResult.found && titleResult.element) {
const title = (await titleResult.element.textContent() || '').trim()
const looksLike = /sign in faster|passkey|fingerprint|face|pin|empreinte|visage|windows hello|hello/i.test(title)
if (looksLike) {
const secBtnResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeySecondary, {
initialTimeoutMs: 200,
extendedTimeoutMs: 300,
state: 'visible'
})
if (secBtnResult.found && secBtnResult.element) {
await secBtnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Title heuristic click failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('title heuristic ' + title)
}
}
}
// Check secondary button text if title heuristic didn't work
if (!did) {
const secBtnResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeySecondary, {
initialTimeoutMs: 200,
extendedTimeoutMs: 300,
state: 'visible'
})
if (secBtnResult.found && secBtnResult.element) {
const text = (await secBtnResult.element.textContent() || '').trim()
if (/skip for now|not now|later|passer|plus tard/i.test(text)) {
await secBtnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Secondary button text click failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('secondary button text')
}
}
}
}
// Priority 4: XPath fallback (includes Windows Hello specific patterns)
if (!did) {
const textBtn = await page.locator('xpath=//button[contains(normalize-space(.),"Skip for now") or contains(normalize-space(.),"Not now") or contains(normalize-space(.),"Passer") or contains(normalize-space(.),"No thanks")]').first()
// FIXED: Add explicit timeout to isVisible
if (await textBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await textBtn.click().catch(logError('LOGIN-PASSKEY', 'XPath fallback click failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('xpath fallback')
}
}
// Priority 4.5: Windows Hello specific detection
if (!did) {
// FIXED: Add explicit timeout
const windowsHelloTitle = await page.locator('text=/windows hello/i').first().isVisible({ timeout: 500 }).catch(() => false)
if (windowsHelloTitle) {
// Try common Windows Hello skip patterns
const skipPatterns = [
'button:has-text("Skip")',
'button:has-text("No thanks")',
'button:has-text("Maybe later")',
'button:has-text("Cancel")',
'[data-testid="secondaryButton"]',
'button[class*="secondary"]'
]
for (const pattern of skipPatterns) {
const btn = await page.locator(pattern).first()
// FIXED: Add explicit timeout
if (await btn.isVisible({ timeout: 300 }).catch(() => false)) {
await btn.click().catch(logError('LOGIN-PASSKEY', 'Windows Hello skip failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('Windows Hello skip')
break
}
}
}
}
// Priority 5: Close button fallback (FIXED: Add explicit timeout instead of using page.$)
if (!did) {
const closeResult = await waitForElementSmart(page, '#close-button', {
initialTimeoutMs: 300,
extendedTimeoutMs: 500,
state: 'visible'
})
if (closeResult.found && closeResult.element) {
await closeResult.element.click().catch(logError('LOGIN-PASSKEY', 'Close button fallback failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('close button')
}
}
// KMSI prompt
const kmsi = await page.waitForSelector(PasskeyHandler.SELECTORS.kmsiVideo, { timeout: 400 }).catch(() => null)
if (kmsi) {
const yes = await page.$(PasskeyHandler.SELECTORS.passkeyPrimary)
if (yes) {
await yes.click().catch(logError('LOGIN-KMSI', 'KMSI accept click failed', this.bot.isMobile))
did = true
this.bot.log(this.bot.isMobile, 'LOGIN-KMSI', 'Accepted KMSI prompt')
}
}
if (!did && context === 'main') {
this.noPromptIterations++
const now = Date.now()
if (this.noPromptIterations === 1 || now - this.lastNoPromptLog > 10000) {
this.lastNoPromptLog = now
this.bot.log(this.bot.isMobile, 'LOGIN-NO-PROMPT', `No dialogs (x${this.noPromptIterations})`)
if (this.noPromptIterations > 50) this.noPromptIterations = 0
}
} else if (did) {
this.noPromptIterations = 0
}
}
private async clickFirstVisible(page: Page, selectors: string[], timeoutMs = 300): Promise<boolean> {
for (const selector of selectors) {
const el = page.locator(selector).first()
const visible = await el.isVisible({ timeout: timeoutMs }).catch(() => false)
if (!visible) continue
await el.click().catch(logError('LOGIN-PASSKEY', `Click failed for ${selector}`, this.bot.isMobile))
return true
}
return false
}
private logPasskeyOnce(reason: string) {
if (this.passkeyHandled) return
this.passkeyHandled = true
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Dismissed passkey prompt (${reason})`)
}
}