From bb9b5ea077046c2871c53de7a5d326daf430c8de Mon Sep 17 00:00:00 2001 From: VishuThePlayer Date: Fri, 24 Jul 2026 12:40:12 +0530 Subject: [PATCH] feat: support passwordless login without requiring ACCOUNT password Make ACCOUNT_N_PASSWORD optional and correctly handle Microsoft Authenticator number-match after Send notification, instead of prompting for a typed OTP. Also keep .env out of Docker images. Co-authored-by: Cursor --- .dockerignore | 4 + env.example | 6 +- package.json | 2 +- scripts/docker/entrypoint.sh | 2 +- src/browser/auth/Login.ts | 185 +++++++++++++++--- src/browser/auth/methods/GetACodeLogin.ts | 14 +- src/browser/auth/methods/LoginUtils.ts | 8 + src/browser/auth/methods/PasswordlessLogin.ts | 23 ++- src/util/Load.ts | 8 +- src/util/Validator.ts | 3 +- 10 files changed, 213 insertions(+), 42 deletions(-) diff --git a/.dockerignore b/.dockerignore index 6847f1c2..300ea059 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,4 +11,8 @@ note accounts.dev.json accounts.main.json config.json +.env +.env.* +config/ +sessions/ .playwright-chromium-installed diff --git a/env.example b/env.example index c8f0d0b5..8849f44c 100644 --- a/env.example +++ b/env.example @@ -7,7 +7,11 @@ # Account 1 ACCOUNT_1_EMAIL=email@example.com -ACCOUNT_1_PASSWORD=your_password +# Password is optional. Leave blank/unset to sign in with: +# - Microsoft Authenticator approval (passwordless / number match) +# - Email code (prompted in the terminal) +# - TOTP authenticator (set ACCOUNT_1_TOTP_SECRET) +#ACCOUNT_1_PASSWORD= #ACCOUNT_1_TOTP_SECRET= #ACCOUNT_1_RECOVERY_EMAIL= #ACCOUNT_1_GEO_LOCALE=auto diff --git a/package.json b/package.json index fcf21ea2..5f00311f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "author": "Netsky", "license": "GPL-3.0-or-later", "engines": { - "node": ">=24.0.0" + "node": ">=22.0.0" }, "scripts": { "pre-build": "npm i && rimraf dist && npx patchright install chromium", diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh index d0a47c8f..98736b23 100644 --- a/scripts/docker/entrypoint.sh +++ b/scripts/docker/entrypoint.sh @@ -32,7 +32,7 @@ fi # environment. This is just a fail-fast presence check. if [ -z "${ACCOUNT_1_EMAIL:-}" ]; then echo "WARNING: No ACCOUNT_1_EMAIL found in environment - the script will fail." >&2 - echo " Set ACCOUNT_1_EMAIL and ACCOUNT_1_PASSWORD in your .env file." >&2 + echo " Set ACCOUNT_1_EMAIL in your .env file (PASSWORD is optional for passwordless login)." >&2 else # Count configured accounts for the startup log (stops at first gap) acct_count=0 diff --git a/src/browser/auth/Login.ts b/src/browser/auth/Login.ts index 2f989a07..70d18c40 100644 --- a/src/browser/auth/Login.ts +++ b/src/browser/auth/Login.ts @@ -10,6 +10,7 @@ import { PasswordlessLogin } from './methods/PasswordlessLogin' import { TotpLogin } from './methods/Totp2FALogin' import { CodeLogin } from './methods/GetACodeLogin' import { RecoveryLogin } from './methods/RecoveryEmailLogin' +import { getSubtitleMessage, isPasswordlessNumberMatchMessage } from './methods/LoginUtils' import type { Account } from '../../interface/Account' @@ -57,6 +58,7 @@ export class Login { passKeyVideo: '[data-testid="biometricVideo"]', passKeyError: '[data-testid="registrationImg"]', passwordlessCheck: '[data-testid="deviceShieldCheckmarkVideo"]', + displaySign: 'div[data-testid="displaySign"]', totpInput: 'input[name="otc"]', totpInputOld: 'form[name="OneTimeCodeViewForm"]', identityBanner: '[data-testid="identityBanner"]', @@ -194,6 +196,7 @@ export class Login { [this.selectors.emailIcon, 'SIGN_IN_ANOTHER_WAY_EMAIL'], [this.selectors.emailIconOld, 'SIGN_IN_ANOTHER_WAY_EMAIL'], [this.selectors.passwordlessCheck, 'LOGIN_PASSWORDLESS'], + [this.selectors.displaySign, 'LOGIN_PASSWORDLESS'], [this.selectors.totpInput, '2FA_TOTP'], [this.selectors.totpInputOld, '2FA_TOTP'], [this.selectors.otpCodeEntry, 'OTP_CODE_ENTRY'], @@ -212,20 +215,36 @@ export class Login { this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Visible states: [${visibleStates.join(', ')}]`) } - const [identityBanner, primaryButton, passwordEntry] = await Promise.all([ + const [identityBanner, primaryButton, passwordEntry, displaySign] = await Promise.all([ this.checkSelector(page, this.selectors.identityBanner), this.checkSelector(page, this.selectors.primaryButton), - this.checkSelector(page, this.selectors.passwordEntry) + this.checkSelector(page, this.selectors.passwordEntry), + this.checkSelector(page, this.selectors.displaySign) ]) - if (identityBanner && primaryButton && !passwordEntry && !results.includes('2FA_TOTP')) { - 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) + // Authenticator number-match ("Select this number on your phone") — not an email code + if (displaySign || results.includes('LOGIN_PASSWORDLESS')) { + if (!results.includes('LOGIN_PASSWORDLESS')) results.push('LOGIN_PASSWORDLESS') + } else if (identityBanner && primaryButton && !passwordEntry && !results.includes('2FA_TOTP')) { + const subtitle = (await getSubtitleMessage(page)) || '' + if (isPasswordlessNumberMatchMessage(subtitle)) { + this.bot.logger.debug( + this.bot.isMobile, + 'DETECT-STATE', + `Passwordless number-match detected from subtitle: "${subtitle}"` + ) + results.push('LOGIN_PASSWORDLESS') + } else { + const hasPassword = Boolean(account?.password?.trim()) + // With a password: try to bypass the code page. Without: actually use the email-code flow. + const codeState = hasPassword ? 'GET_A_CODE' : 'GET_A_CODE_2' + this.bot.logger.debug( + this.bot.isMobile, + 'DETECT-STATE', + `Get code state detected: ${codeState} (has password: ${hasPassword})` + ) + results.push(codeState) + } } let foundStates = results.filter((s): s is LoginState => s !== null) @@ -246,21 +265,40 @@ export class Login { foundStates = foundStates.filter(s => s !== 'ERROR_ALERT') } - const priorities: LoginState[] = [ - 'ACCOUNT_LOCKED', - 'PASSKEY_VIDEO', - 'PASSKEY_ERROR', - 'KMSI_PROMPT', - 'PASSWORD_INPUT', - 'EMAIL_INPUT', - 'SIGN_IN_ANOTHER_WAY', // Prefer password option over email code - 'SIGN_IN_ANOTHER_WAY_EMAIL', - 'OTP_CODE_ENTRY', - 'GET_A_CODE', - 'GET_A_CODE_2', - 'LOGIN_PASSWORDLESS', - '2FA_TOTP' - ] + const hasPassword = Boolean(account?.password?.trim()) + + // No password → prefer authenticator / email-code over typing a password + const priorities: LoginState[] = hasPassword + ? [ + 'ACCOUNT_LOCKED', + 'PASSKEY_VIDEO', + 'PASSKEY_ERROR', + 'KMSI_PROMPT', + 'PASSWORD_INPUT', + 'EMAIL_INPUT', + 'SIGN_IN_ANOTHER_WAY', // Prefer password option over email code + 'SIGN_IN_ANOTHER_WAY_EMAIL', + 'OTP_CODE_ENTRY', + 'GET_A_CODE', + 'GET_A_CODE_2', + 'LOGIN_PASSWORDLESS', + '2FA_TOTP' + ] + : [ + 'ACCOUNT_LOCKED', + 'PASSKEY_VIDEO', + 'PASSKEY_ERROR', + 'KMSI_PROMPT', + 'LOGIN_PASSWORDLESS', + '2FA_TOTP', + 'GET_A_CODE_2', + 'OTP_CODE_ENTRY', + 'SIGN_IN_ANOTHER_WAY_EMAIL', + 'SIGN_IN_ANOTHER_WAY', // Will select email/code, not password + 'EMAIL_INPUT', + 'GET_A_CODE', + 'PASSWORD_INPUT' // Last: will click "other ways" instead of submitting a password + ] for (const priority of priorities) { if (foundStates.includes(priority)) { @@ -296,6 +334,17 @@ export class Login { return true } + private async isPasswordlessNumberMatchPage(page: Page): Promise { + const hasDisplaySign = await this.checkSelector(page, this.selectors.displaySign) + if (hasDisplaySign) return true + + const hasPasswordlessVideo = await this.checkSelector(page, this.selectors.passwordlessCheck) + if (hasPasswordlessVideo) return true + + const subtitle = (await getSubtitleMessage(page)) || '' + return isPasswordlessNumberMatchMessage(subtitle) + } + private async handleState(state: LoginState, page: Page, account: Account): Promise { this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `Processing state: ${state}`) @@ -325,6 +374,26 @@ export class Login { } case 'PASSWORD_INPUT': { + if (!account.password?.trim()) { + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN', + 'Password page shown but no password configured — trying other sign-in methods' + ) + if (await this.tryClick(page, this.selectors.otherWaysToSignIn, 'Other ways to sign in', 3000)) { + return true + } + if (await this.tryClick(page, this.selectors.viewFooter, 'Footer link')) { + return true + } + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN', + 'Could not leave password page — set ACCOUNT_1_PASSWORD or enable Authenticator / email code on the Microsoft account' + ) + return false + } + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Entering password') await this.emailLogin.enterPassword(page, account.password) await this.waitForIdle(page, 'after password entry') @@ -351,9 +420,35 @@ export class Login { } case 'GET_A_CODE_2': { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Handling "Get a code" flow') + // Already on Authenticator number-match? Wait for phone approval. + if (await this.isPasswordlessNumberMatchPage(page)) { + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN', + 'Detected Authenticator number-match — waiting for phone approval (do not type a code)' + ) + await this.passwordlessLogin.handle(page) + await this.waitForIdle(page, 'after passwordless auth') + return true + } + + // "Get a code to sign in" / "Send notification" — click send, then wait for number-match or email OTP + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Handling "Get a code" / Send notification flow') await this.bot.browser.utils.ghostClick(page, this.selectors.primaryButton) await this.waitForIdle(page, 'after primary button click') + await this.bot.utils.wait(1500) + + if (await this.isPasswordlessNumberMatchPage(page)) { + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN', + 'Notification sent — waiting for you to approve in Microsoft Authenticator' + ) + await this.passwordlessLogin.handle(page) + await this.waitForIdle(page, 'after passwordless auth') + return true + } + 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') @@ -436,6 +531,34 @@ export class Login { } case 'SIGN_IN_ANOTHER_WAY': { + if (!account.password?.trim()) { + // Prefer email code over password when no password is configured + const [emailIconFound, emailIconOldFound] = await Promise.all([ + this.checkSelector(page, this.selectors.emailIcon), + this.checkSelector(page, this.selectors.emailIconOld) + ]) + const emailSelector = emailIconFound + ? this.selectors.emailIcon + : emailIconOldFound + ? this.selectors.emailIconOld + : null + + if (emailSelector) { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting "Send a code" (no password set)') + await this.bot.browser.utils.ghostClick(page, emailSelector) + await this.waitForIdle(page, 'after email icon click') + await this.codeLogin.handle(page) + return true + } + + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN', + 'No email-code option found on "Sign in another way" page' + ) + return false + } + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting "Use my password"') await this.bot.browser.utils.ghostClick(page, this.selectors.passwordIcon) await this.waitForIdle(page, 'after password icon click') @@ -469,6 +592,16 @@ export class Login { } case 'OTP_CODE_ENTRY': { + if (!account.password?.trim()) { + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN', + 'OTP code entry page detected — using email/SMS code (no password set)' + ) + await this.codeLogin.handle(page) + return true + } + this.bot.logger.info( this.bot.isMobile, 'LOGIN', diff --git a/src/browser/auth/methods/GetACodeLogin.ts b/src/browser/auth/methods/GetACodeLogin.ts index 79524813..f166d873 100644 --- a/src/browser/auth/methods/GetACodeLogin.ts +++ b/src/browser/auth/methods/GetACodeLogin.ts @@ -1,6 +1,7 @@ import type { Page } from 'patchright' import type { MicrosoftRewardsBot } from '../../../index' -import { getErrorMessage, getSubtitleMessage, promptInput } from './LoginUtils' +import { getErrorMessage, getSubtitleMessage, isPasswordlessNumberMatchMessage, promptInput } from './LoginUtils' +import { PasswordlessLogin } from './PasswordlessLogin' export class CodeLogin { private readonly textInputSelector = '[data-testid="codeInputWrapper"]' @@ -94,6 +95,17 @@ export class CodeLogin { this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'Unable to retrieve email code destination') } + // Authenticator number-match — wait for phone approval (do not prompt for a typed code) + if (isPasswordlessNumberMatchMessage(emailMessage)) { + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN-CODE', + 'Page is Authenticator number-match — waiting for phone approval' + ) + await new PasswordlessLogin(this.bot).handle(page) + return + } + const emailProofInput = await page .waitForSelector(this.emailInputSelector, { state: 'visible', timeout: 500 }) .catch(() => null) diff --git a/src/browser/auth/methods/LoginUtils.ts b/src/browser/auth/methods/LoginUtils.ts index f750c98b..2827066f 100644 --- a/src/browser/auth/methods/LoginUtils.ts +++ b/src/browser/auth/methods/LoginUtils.ts @@ -67,3 +67,11 @@ export async function getErrorMessage(page: Page): Promise { const text = await errorAlert.innerText() return text.trim() } + +/** Microsoft Authenticator number-match / approve-on-phone prompts (not email OTP). */ +export function isPasswordlessNumberMatchMessage(message: string | null | undefined): boolean { + if (!message) return false + return /select this number|sign-in request on your (mobile )?device|approve.*(authenticator|notification)|open your authenticator/i.test( + message + ) +} diff --git a/src/browser/auth/methods/PasswordlessLogin.ts b/src/browser/auth/methods/PasswordlessLogin.ts index bfc280c6..f8a95ec2 100644 --- a/src/browser/auth/methods/PasswordlessLogin.ts +++ b/src/browser/auth/methods/PasswordlessLogin.ts @@ -2,7 +2,7 @@ import type { Page } from 'patchright' import type { MicrosoftRewardsBot } from '../../../index' export class PasswordlessLogin { - private readonly maxAttempts = 60 + private readonly maxAttempts = 120 private readonly numberDisplaySelector = 'div[data-testid="displaySign"]' private readonly approvalPath = '/ppsecure/post.srf' @@ -31,7 +31,7 @@ export class PasswordlessLogin { this.bot.logger.info( this.bot.isMobile, 'LOGIN-PASSWORDLESS', - `Waiting for approval... (timeout after ${this.maxAttempts} seconds)` + `Waiting for phone approval... (timeout after ${this.maxAttempts} seconds)` ) for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { @@ -41,12 +41,18 @@ export class PasswordlessLogin { return true } + // Also succeed if we already landed on Rewards / account home + if (currentUrl.hostname === 'rewards.bing.com' || currentUrl.hostname === 'account.microsoft.com') { + this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Logged-in redirect detected') + return true + } + // Every 5 seconds to show it's still waiting if (attempt % 5 === 0) { this.bot.logger.info( this.bot.isMobile, 'LOGIN-PASSWORDLESS', - `Still waiting... (${attempt}/${this.maxAttempts} seconds elapsed)` + `Still waiting for Authenticator approval... (${attempt}/${this.maxAttempts}s)` ) } @@ -73,20 +79,25 @@ export class PasswordlessLogin { try { this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Passwordless authentication requested') - const displayedNumber = await this.getDisplayedNumber(page) + // Number may appear a moment after "Send notification" + let displayedNumber = await this.getDisplayedNumber(page) + if (!displayedNumber) { + await this.bot.utils.wait(2000) + displayedNumber = await this.getDisplayedNumber(page) + } if (displayedNumber) { this.bot.logger.info( this.bot.isMobile, 'LOGIN-PASSWORDLESS', - `Please approve login and select number: ${displayedNumber}`, + `>>> Open Microsoft Authenticator and select number: ${displayedNumber} (waiting — do NOT type here)`, 'yellowBright' ) } else { this.bot.logger.info( this.bot.isMobile, 'LOGIN-PASSWORDLESS', - 'Please approve login on your authenticator app', + '>>> Approve the sign-in in Microsoft Authenticator on your phone (waiting — do NOT type here)', 'yellowBright' ) } diff --git a/src/util/Load.ts b/src/util/Load.ts index 28395ffc..ebe9916c 100644 --- a/src/util/Load.ts +++ b/src/util/Load.ts @@ -124,10 +124,8 @@ export function loadAccounts(): Account[] { if (!email) break - const password = envStr(`ACCOUNT_${index}_PASSWORD`) - if (!password) { - throw new Error(`ACCOUNT_${index}_EMAIL is set but ACCOUNT_${index}_PASSWORD is missing`) - } + // Password is optional — leave blank to use passwordless / email code / TOTP instead + const password = envStr(`ACCOUNT_${index}_PASSWORD`) ?? '' accounts.push({ email, @@ -143,7 +141,7 @@ export function loadAccounts(): Account[] { if (!accounts.length) { throw new Error( - 'No accounts found in environment. Set ACCOUNT_1_EMAIL / ACCOUNT_1_PASSWORD (see env.example).' + 'No accounts found in environment. Set ACCOUNT_1_EMAIL (see env.example). Password is optional.' ) } diff --git a/src/util/Validator.ts b/src/util/Validator.ts index 6a5d59e9..d971ddc3 100644 --- a/src/util/Validator.ts +++ b/src/util/Validator.ts @@ -115,7 +115,8 @@ export const ConfigSchema = z.object({ // Account export const AccountSchema = z.object({ email: z.string(), - password: z.string(), + // Empty string = skip password and use passwordless / email code / TOTP + password: z.string().default(''), totpSecret: z.string().optional(), recoveryEmail: z.string(), geoLocale: z.string(),