@msout/microsoft-webauth 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,30 @@ This is a standalone CLI tool for authenticating with Microsoft accounts using P
18
18
  - Manual login in browser
19
19
  - MFA/2FA support (OTC codes, number matching)
20
20
  - Session persistence
21
+ - The interstitial screens Microsoft injects mid-login (see below)
22
+
23
+ ## Interstitial screens
24
+
25
+ Microsoft interrupts an otherwise successful login with full-page forms that
26
+ take over the navigation. If they are not answered, the login silently hangs and
27
+ ends in a timeout. These are handled automatically:
28
+
29
+ | Screen | Action taken |
30
+ |--------|--------------|
31
+ | "We're updating our terms" (`account.live.com/tou/accrue`) | Next — accepts the updated Services Agreement |
32
+ | "Is your security info still accurate?" (`account.live.com/interrupt/…`, `/proofs/remind`) | Looks good! — keeps existing recovery methods |
33
+ | Passkey / security key prompt (`…/consumers/fido/create`) | Cancel |
34
+ | "Stay signed in?" | Yes, with "don't show again" ticked |
35
+ | Microsoft consent pages (`consent.microsoft.com`) | Accept / Continue |
36
+
37
+ Each screen only accepts a fixed set of button labels, so nothing else on the
38
+ page can be pressed by accident. In particular the tool never chooses "Update
39
+ now" or "I don't have any of these" on the security-info screen, since both would
40
+ change or delete the account's recovery methods.
41
+
42
+ Note that accepting the Services Agreement is a real change to the account, and
43
+ is done on your behalf. If you would rather see it, run without `--email` and
44
+ `--password` and sign in manually.
21
45
 
22
46
  ## Why this project ?
23
47
 
@@ -142,5 +166,5 @@ microsoft-webauth-playwright/
142
166
 
143
167
  ## License
144
168
 
145
- ISC — same as MSOneNote Exporter.
169
+ MIT — see [LICENSE](LICENSE).
146
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msout/microsoft-webauth",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "description": "Microsoft web authentication module, using playwright to automate the login process and retrieve cookies for authenticated sessions.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "javascript"
23
23
  ],
24
24
  "author": "msout@tuta.io",
25
- "license": "Apache-2.0",
25
+ "license": "MIT",
26
26
  "repository": {
27
27
  "type": "git",
28
28
  "url": "git+https://github.com/Ms-OneNote-Exporter/microsoft-webauth.git"
package/src/auth.js CHANGED
@@ -7,6 +7,7 @@ const { chromium } = require('playwright');
7
7
  const fs = require('fs-extra');
8
8
  const logger = require('./utils/logger');
9
9
  const { DEFAULT_AUTH_FILE, getAuthMetaFilePath, ensureAuthDir, ONENOTE_URL } = require('./config');
10
+ const { version: PKG_VERSION } = require('../package.json');
10
11
  const path = require('path');
11
12
  const readline = require('readline');
12
13
 
@@ -130,15 +131,20 @@ async function waitForAuthSuccess(page, targetUrl) {
130
131
  ]);
131
132
  logger.success('Outlook mail interface detected.');
132
133
  } else {
133
- // OneNote default behavior
134
- logger.info('Waiting for redirection to notebooks list...');
134
+ // OneNote default behavior — wait for the authenticated notebooks interface.
135
+ // IMPORTANT: We must require /notebooks in the URL to avoid matching the
136
+ // unauthenticated marketing landing page (onenote.cloud.microsoft/en-us)
137
+ // which also matches the old hostname-only check and caused premature auth saving.
138
+ logger.info('Waiting for redirection to authenticated notebooks interface...');
135
139
  await Promise.any([
136
- page.waitForURL(url => url.toString().includes('/notebooks') || url.hostname.includes('onenote.cloud.microsoft') || url.hostname.includes('onenote.com'), { timeout: 60000 }),
140
+ // Primary: URL must contain /notebooks (authenticated app)
141
+ page.waitForURL(url => url.toString().includes('/notebooks'), { timeout: 60000 }),
142
+ // Fallback UI elements that only appear when actually signed in
137
143
  page.waitForSelector('text="My notebooks"', { state: 'visible', timeout: 60000 }),
138
144
  page.waitForSelector('text="Create new notebook"', { state: 'visible', timeout: 60000 }),
139
- page.waitForSelector('text="Welcome, "', { state: 'visible', timeout: 60000 })
145
+ page.waitForSelector('text="Welcome, "', { state: 'visible', timeout: 60000 }),
140
146
  ]);
141
- logger.success('Notebooks list detected.');
147
+ logger.success('Authenticated notebooks interface detected.');
142
148
  }
143
149
  }
144
150
 
@@ -150,6 +156,7 @@ async function waitForAuthSuccess(page, targetUrl) {
150
156
  *
151
157
  * @param {import('playwright').Page} page
152
158
  * @param {object} logger
159
+ * @returns {Promise<string|null>} 'Cancel' once dismissed, null if nothing was clickable
153
160
  */
154
161
  async function dismissFidoPage(page, logger) {
155
162
  // Try multiple selectors for the page-level Cancel button.
@@ -180,13 +187,19 @@ async function dismissFidoPage(page, logger) {
180
187
  if (!clicked) {
181
188
  // Last resort: JS click on any visible Cancel button
182
189
  logger.warn('FIDO: DOM selectors failed, trying JS click fallback...');
183
- await page.evaluate(() => {
190
+ clicked = await page.evaluate(() => {
184
191
  const btns = Array.from(document.querySelectorAll('button, input[type="button"], input[type="submit"]'));
185
192
  const cancel = btns.find(b => /^cancel$/i.test((b.textContent || b.value || '').trim()));
186
- if (cancel) cancel.click();
193
+ if (cancel) {
194
+ cancel.click();
195
+ return true;
196
+ }
197
+ return false;
187
198
  });
188
199
  }
189
200
 
201
+ if (!clicked) return null;
202
+
190
203
  // Wait for navigation away from the FIDO page (up to 8 s)
191
204
  try {
192
205
  await page.waitForURL(url => !url.toString().includes('/fido/'), { timeout: 8000 });
@@ -194,6 +207,311 @@ async function dismissFidoPage(page, logger) {
194
207
  } catch (_) {
195
208
  logger.warn('FIDO: still on FIDO URL after Cancel — continuing anyway.');
196
209
  }
210
+
211
+ return 'Cancel';
212
+ }
213
+
214
+ /**
215
+ * Answers the "Stay signed in?" prompt with "Yes" and ticks "Don't show this
216
+ * again" so later logins skip the screen entirely.
217
+ * @param {import('playwright').Page} page
218
+ * @returns {Promise<string|null>} 'Yes' once clicked, null if the prompt is absent
219
+ */
220
+ async function dismissStaySignedIn(page) {
221
+ const staySignedIn = page.getByText(/Stay signed in?/i)
222
+ .or(page.locator('#KmsiDescription'))
223
+ .first();
224
+
225
+ if (!(await staySignedIn.isVisible().catch(() => false))) return null;
226
+
227
+ logger.info('Detected "Stay signed in?" prompt.');
228
+
229
+ const dontShowAgain = page.locator('input[name="DontShowAgain"], #KmsiCheckboxField').first();
230
+ if (await dontShowAgain.isVisible().catch(() => false)) {
231
+ logger.debug('Checking "Don\'t show this again" checkbox...');
232
+ await dontShowAgain.check().catch(() => { });
233
+ }
234
+
235
+ const yesButton = page.getByRole('button', { name: /^Yes$/i })
236
+ .or(page.locator('button[data-testid="primaryButton"]'))
237
+ .or(page.locator('#idSIButton9'))
238
+ .first();
239
+
240
+ logger.info('Clicking "Yes" to stay signed in...');
241
+ await yesButton.click({ timeout: 10000 });
242
+ return 'Yes';
243
+ }
244
+
245
+ /**
246
+ * Screens Microsoft injects in the middle of an otherwise successful login.
247
+ * They are full-page forms that hijack the navigation, so nothing after them
248
+ * (MFA checks, "Stay signed in?", the redirect to OneNote/Outlook) can be
249
+ * reached until they are dismissed. All of them are full-page forms that hijack
250
+ * the navigation, and all of them arrive *late* — typically 20-60 s after the
251
+ * password is accepted, once per account:
252
+ *
253
+ * 1. account.live.com/interrupt/credentialaction or /proofs/remind
254
+ * "Is your security info still accurate?" -> Looks good! (proof freshness)
255
+ * 2. account.live.com/tou/accrue
256
+ * "We're updating our terms" -> Next (Services Agreement update)
257
+ * 3. account.live.com/interrupt/passkey -> login.microsoft.com/consumers/fido/create
258
+ * the passkey prompt -> Cancel
259
+ * 4. login.live.com/... "Stay signed in?" -> Yes
260
+ *
261
+ * Each entry carries the only action labels that may be pressed on it, so a
262
+ * broad label like "Yes" can never be pressed on a screen that does not offer it.
263
+ * Screens are matched on the URL *and/or* the heading, because Microsoft moves
264
+ * them between paths (and serves the same screen from several) over time.
265
+ */
266
+ const BLOCKING_SCREENS = [
267
+ {
268
+ name: 'FIDO / passkey prompt',
269
+ match: state => /consumers\/fido\//i.test(state.url)
270
+ || /passkey/i.test(state.url)
271
+ || /passkey|security key/i.test(state.heading),
272
+ // Dedicated WebAuthn dismisser rather than a label match: the page-level
273
+ // "Cancel" is the only safe action and it also waits out the navigation.
274
+ handle: page => dismissFidoPage(page, logger)
275
+ },
276
+ {
277
+ name: 'Terms of Use / Services Agreement update',
278
+ match: state => /account\.live\.com\/tou\//i.test(state.url),
279
+ actions: /^(next|accept|i accept|i agree|agree|continue|finish|done)$/i
280
+ },
281
+ {
282
+ name: 'Microsoft consent prompt',
283
+ match: state => /consent\./i.test(state.url),
284
+ actions: /^(accept|i accept|i agree|agree|continue|next)$/i
285
+ },
286
+ {
287
+ name: 'Security info freshness check',
288
+ match: state => (/account\.live\.com\/(pf|proofs|interrupt)/i.test(state.url) && !/passkey/i.test(state.url))
289
+ || /is your security info still accurate/i.test(state.heading)
290
+ || /help protect your account/i.test(state.heading),
291
+ // "Update now" and "I don't have any of these" are deliberately absent:
292
+ // either would rewrite or wipe the account's recovery methods.
293
+ actions: /^(looks good!?|skip for now|skip|continue|next)$/i
294
+ },
295
+ {
296
+ name: '"Stay signed in?" prompt',
297
+ match: state => /stay signed in/i.test(state.heading) || /kmsi/i.test(state.url),
298
+ handle: page => dismissStaySignedIn(page)
299
+ },
300
+ ];
301
+
302
+ /**
303
+ * Buttons that may carry an action label, most specific first. account.live.com
304
+ * renders its pages with Fluent UI, where the action is always the primary
305
+ * button; the plain selectors are the fallback for the older server-rendered
306
+ * account pages.
307
+ */
308
+ const BLOCKING_SCREEN_BUTTONS = [
309
+ 'button[data-testid="primaryButton"]',
310
+ 'input[data-testid="primaryButton"]',
311
+ 'button',
312
+ 'input[type="submit"]',
313
+ 'input[type="button"]',
314
+ '[role="button"]',
315
+ 'a',
316
+ ];
317
+
318
+ /** Do not re-click the same unchanged screen more often than this. */
319
+ const BLOCKING_SCREEN_RETRY_MS = 10000;
320
+
321
+ /** Returns the blocking-screen descriptor matching { url, heading }, or null. */
322
+ function matchBlockingScreen(state) {
323
+ if (!state) return null;
324
+ for (const screen of BLOCKING_SCREENS) {
325
+ try {
326
+ if (screen.match(state)) return screen;
327
+ } catch (_) {
328
+ // A malformed heading/url must never abort the whole login.
329
+ }
330
+ }
331
+ return null;
332
+ }
333
+
334
+ /**
335
+ * Reads the current URL + heading of the page.
336
+ * Returns null while the document is being swapped (mid-navigation), so callers
337
+ * must retry rather than treat null as "no blocking screen".
338
+ * @param {import('playwright').Page} page
339
+ */
340
+ async function readScreenState(page) {
341
+ try {
342
+ return await page.evaluate(() => {
343
+ const heading = document.querySelector('h1, [role="heading"], [data-testid="title"]');
344
+ return {
345
+ url: location.href,
346
+ heading: (heading ? heading.textContent : '').replace(/\s+/g, ' ').trim()
347
+ };
348
+ });
349
+ } catch (_) {
350
+ // Execution context destroyed while navigating — caller should retry.
351
+ return null;
352
+ }
353
+ }
354
+
355
+ /** Shortens a URL for logging: keeps host + path, drops the query string. */
356
+ function shortUrl(url) {
357
+ try {
358
+ const parsed = new URL(url);
359
+ return `${parsed.host}${parsed.pathname}`;
360
+ } catch (_) {
361
+ return url;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Stable identity of a screen: same URL + same heading means the same step.
367
+ * Used to tell a genuine step change from a re-render of the same page.
368
+ */
369
+ function screenSignature(state) {
370
+ return state ? `${state.url}::${state.heading}` : null;
371
+ }
372
+
373
+ /** Reads the visible label of a button-like element. */
374
+ async function readActionLabel(handle) {
375
+ const text = await handle.textContent().catch(() => '') || '';
376
+ const value = await handle.getAttribute('value').catch(() => '') || '';
377
+ return `${text} ${value}`.replace(/\s+/g, ' ').trim();
378
+ }
379
+
380
+ /**
381
+ * Clicks the single accept/continue action on a blocking screen.
382
+ * Only labels accepted by *this* screen's `actions` regex are eligible, so a
383
+ * stray "Skip" in a footer or a "Yes" meant for a different screen is never
384
+ * pressed by mistake.
385
+ * @param {import('playwright').Page} page
386
+ * @param {RegExp} actions
387
+ * @returns {Promise<string|null>} the label that was clicked, or null
388
+ */
389
+ async function clickBlockingScreenAction(page, actions) {
390
+ for (const selector of BLOCKING_SCREEN_BUTTONS) {
391
+ const buttons = page.locator(selector);
392
+ const count = await buttons.count().catch(() => 0);
393
+
394
+ for (let i = 0; i < Math.min(count, 30); i++) {
395
+ const button = buttons.nth(i);
396
+ if (!(await button.isVisible().catch(() => false))) continue;
397
+
398
+ const label = await readActionLabel(button);
399
+ if (!label || !actions.test(label)) continue;
400
+
401
+ await button.click({ timeout: 10000 });
402
+ return label;
403
+ }
404
+ }
405
+ return null;
406
+ }
407
+
408
+ /**
409
+ * Waits until the blocking screen actually moves on. The Terms of Use flow is a
410
+ * single-page app, so the URL stays put across steps and only the heading (or the
411
+ * presence of the button) changes — a navigation wait alone would always time out.
412
+ * @returns {Promise<string|null>} the new signature, or null on timeout
413
+ */
414
+ async function waitForBlockingScreenChange(page, previousSignature, timeout) {
415
+ const deadline = Date.now() + timeout;
416
+
417
+ while (Date.now() < deadline) {
418
+ await page.waitForTimeout(400).catch(() => {});
419
+
420
+ const state = await readScreenState(page);
421
+ if (!state) continue; // mid-navigation, keep polling
422
+ if (screenSignature(state) !== previousSignature) return screenSignature(state);
423
+ }
424
+ return null;
425
+ }
426
+
427
+ /**
428
+ * Clears every blocking screen currently in the way of the login.
429
+ *
430
+ * A single acceptance usually leads to one or two more (e.g. the Services
431
+ * Agreement summary, then a "Finish" confirmation), so this loops until the
432
+ * page is no longer a blocking screen. `progress` is shared with the caller so
433
+ * that repeated invocations from the watcher do not hammer an unchanged screen.
434
+ *
435
+ * @param {import('playwright').Page} page
436
+ * @param {object} [options]
437
+ * @param {{ signatures: Set<string>, lastClickAt: number }} [options.progress]
438
+ * @param {boolean} [options.dodump]
439
+ * @param {() => boolean} [options.shouldStop]
440
+ * @returns {Promise<{ handled: number, reason: string }>}
441
+ */
442
+ async function clearBlockingScreens(page, options = {}) {
443
+ const {
444
+ progress = { signatures: new Set(), lastClickAt: 0 },
445
+ maxScreens = 5,
446
+ stateTimeout = 10000,
447
+ changeTimeout = 20000,
448
+ dodump = false,
449
+ shouldStop = null
450
+ } = options;
451
+
452
+ let handled = 0;
453
+
454
+ for (let i = 0; i < maxScreens; i++) {
455
+ if (shouldStop && shouldStop()) return { handled, reason: 'stopped' };
456
+
457
+ // The screen may still be loading; give it a bounded number of chances.
458
+ let state = null;
459
+ const stateDeadline = Date.now() + stateTimeout;
460
+ do {
461
+ state = await readScreenState(page);
462
+ if (!state) await page.waitForTimeout(500).catch(() => {});
463
+ } while (!state && Date.now() < stateDeadline && !(shouldStop && shouldStop()));
464
+
465
+ if (!state) return { handled, reason: 'unreadable' };
466
+ const signature = screenSignature(state);
467
+
468
+ const screen = matchBlockingScreen(state);
469
+ if (!screen) return { handled, reason: 'no_blocking_screen' };
470
+
471
+ // Never click the exact same screen twice in quick succession: the click
472
+ // either worked (signature changes) or the page is stuck, and a tight
473
+ // retry loop would only spam requests at Microsoft.
474
+ if (progress.signatures.has(signature)) {
475
+ if (Date.now() - progress.lastClickAt < BLOCKING_SCREEN_RETRY_MS) {
476
+ logger.debug(`Blocking screen unchanged since last attempt — not re-clicking.`);
477
+ return { handled, reason: 'unchanged' };
478
+ }
479
+ } else {
480
+ progress.signatures.add(signature);
481
+ }
482
+
483
+ logger.info(`Blocking screen detected: ${screen.name} (${shortUrl(state.url)}). Accepting it...`);
484
+
485
+ if (dodump) {
486
+ const dumpDir = await logger.getDumpDir();
487
+ const displayPath = logger.getDumpDisplayPath();
488
+ const debugFile = path.join(dumpDir, `debug_blocking_screen_${i + 1}.html`);
489
+ await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
490
+ logger.debug(`[dodump] Blocking screen state dumped to ${displayPath}/debug_blocking_screen_${i + 1}.html`);
491
+ }
492
+
493
+ let label = null;
494
+ try {
495
+ label = screen.handle
496
+ ? await screen.handle(page)
497
+ : await clickBlockingScreenAction(page, screen.actions);
498
+ } catch (e) {
499
+ logger.debug(`Blocking screen click failed: ${e.message}`);
500
+ }
501
+
502
+ if (!label) {
503
+ logger.warn(`No acceptable action button found on "${screen.name}". Stopping.`);
504
+ return { handled, reason: 'no_action' };
505
+ }
506
+
507
+ handled++;
508
+ progress.lastClickAt = Date.now();
509
+ logger.debug(`Clicked "${label}" on ${screen.name}.`);
510
+
511
+ await waitForBlockingScreenChange(page, signature, changeTimeout);
512
+ }
513
+
514
+ return { handled, reason: 'max_screens' };
197
515
  }
198
516
 
199
517
  async function login(credentials = {}) {
@@ -202,13 +520,17 @@ async function login(credentials = {}) {
202
520
  const headless = !credentials.notheadless && isAutomated;
203
521
  // Use targetUrl if provided, otherwise default to ONENOTE_URL for backward compatibility
204
522
  const finalTargetUrl = targetUrl || ONENOTE_URL;
523
+ // Shared across every clearBlockingScreens() call in this login so a screen that
524
+ // never changes is clicked once, not once per polling round.
525
+ const blockerProgress = { signatures: new Set(), lastClickAt: 0 };
205
526
 
206
527
  // Get the auth file path (use provided or default)
207
528
  const filePath = getAuthFilePath(authFile);
208
529
  const metaPath = getAuthMetaFilePath(filePath);
209
530
 
210
- // Added to verify version on user's machine
211
- logger.debug('Authentication Module: Version 4.4-DEBUG starting...');
531
+ // Added to verify version on user's machine. Read from package.json so it
532
+ // cannot drift away from the published version.
533
+ logger.debug(`Authentication Module: v${PKG_VERSION} starting...`);
212
534
 
213
535
  logger.debug(`Using auth file path: ${filePath}`);
214
536
  logger.debug(`Using meta file path: ${metaPath}`);
@@ -478,35 +800,21 @@ async function login(credentials = {}) {
478
800
  logger.debug(`[dodump] Post-password state dumped to ${displayPath}/debug_after_password.html`);
479
801
  }
480
802
 
481
- // 2.5b. Handle FIDO/security key page (login.microsoft.com/consumers/fido/create)
482
- // The addInitScript above makes navigator.credentials.create() reject immediately,
483
- // so the native OS WebAuthn dialog never appears. We only need to click the
484
- // page-level "Cancel" button and wait for navigation away from the FIDO URL.
803
+ // 2.5a. Clear blocking screens (consent, proof freshness, FIDO, "Stay signed
804
+ // in?"). All of them hijack the navigation after the password is accepted.
805
+ // This first pass catches the ones that appear immediately; the watcher in
806
+ // step 4 covers the rest, which is where they usually turn up.
485
807
  try {
486
- const currentUrl = page.url();
487
- const onFidoPage = currentUrl.includes('/fido/');
488
-
489
- if (onFidoPage) {
490
- logger.info(`Already on FIDO page (${currentUrl}). Clicking page-level Cancel...`);
491
- await dismissFidoPage(page, logger);
492
- } else {
493
- // Race: either we navigate to fido, or 8 s passes (no fido page)
494
- const fidoHandled = await Promise.race([
495
- page.waitForURL(url => url.toString().includes('/fido/'), { timeout: 8000 })
496
- .then(async () => {
497
- logger.info(`Navigated to FIDO page: ${page.url()}. Dismissing...`);
498
- await dismissFidoPage(page, logger);
499
- return 'fido_cancelled';
500
- }),
501
- page.waitForTimeout(8000).then(() => 'no_fido'),
502
- ]);
503
- logger.debug(`FIDO check result: ${fidoHandled}`);
504
- }
808
+ const cleared = await clearBlockingScreens(page, {
809
+ progress: blockerProgress,
810
+ dodump: credentials.dodump
811
+ });
812
+ logger.debug(`Blocking screen pass: handled=${cleared.handled} (${cleared.reason})`);
505
813
  } catch (e) {
506
- logger.debug(`FIDO popup handler skipped: ${e.message}`);
814
+ logger.debug(`Blocking screen pass skipped: ${e.message}`);
507
815
  }
508
816
 
509
- // 2.5. Handle post-password MFA/Verification if needed
817
+ // 2.5b. Handle post-password MFA/Verification if needed
510
818
  try {
511
819
  const verificationScreen = await Promise.race([
512
820
  page.waitForSelector('text="Verify your identity"', { timeout: 10000 }).then(() => 'verify'),
@@ -569,56 +877,42 @@ async function login(credentials = {}) {
569
877
  logger.debug(`Post-password verification handling skipped or failed: ${e.message}`);
570
878
  }
571
879
 
572
- // 2.7. Handle "Help protect your account" interrupt screen
573
- try {
574
- const interruptPrompt = page.getByText(/Help protect your account/i).first();
575
- if (await interruptPrompt.isVisible({ timeout: 5000 }) || page.url().includes('account.live.com/interrupt/')) {
576
- logger.info('Detected "Help protect your account" interrupt screen.');
577
- const skipButton = page.getByRole('button', { name: /Skip for now/i })
578
- .or(page.getByText(/Skip for now/i))
579
- .first();
580
- if (await skipButton.isVisible()) {
581
- logger.info('Clicking "Skip for now"...');
582
- await skipButton.click();
583
- }
584
- }
585
- } catch (e) {
586
- logger.debug(`Help protect your account interrupt screen did not appear: ${e.message}`);
587
- }
588
-
589
- // 3. Handle "Stay signed in?" prompt if it appears
590
- try {
591
- logger.debug('Checking for "Stay signed in?" prompt...');
592
-
593
- const staySignedIn = page.getByText(/Stay signed in?/i)
594
- .or(page.locator('#KmsiDescription'))
595
- .first();
880
+ // 2.7/3. "Help protect your account", "Stay signed in?" and the FIDO page are
881
+ // all entries in the BLOCKING_SCREENS table: answered here, and again by the
882
+ // watcher in step 4 for the copies that arrive after this point.
596
883
 
597
- await staySignedIn.waitFor({ state: 'visible', timeout: 7000 });
598
-
599
- logger.info('Detected "Stay signed in?" prompt.');
600
-
601
- const dontShowAgain = page.locator('input[name="DontShowAgain"], #KmsiCheckboxField').first();
602
- if (await dontShowAgain.isVisible()) {
603
- logger.debug('Checking "Don\'t show this again" checkbox...');
604
- await dontShowAgain.check().catch(() => { });
884
+ // 4. Wait for redirection to target interface (notebooks or mail)
885
+ // A consent screen can still show up after any of the steps above (e.g. a
886
+ // terms update queued behind "Stay signed in?"), so keep clearing them
887
+ // while we wait instead of only checking once up front.
888
+ let stopWatcher = false;
889
+ const blockerWatcher = (async () => {
890
+ while (!stopWatcher) {
891
+ try {
892
+ await clearBlockingScreens(page, {
893
+ progress: blockerProgress,
894
+ maxScreens: 2,
895
+ shouldStop: () => stopWatcher
896
+ });
897
+ } catch (e) {
898
+ logger.debug(`Blocking screen watcher error: ${e.message}`);
899
+ }
900
+ await page.waitForTimeout(1000).catch(() => {});
605
901
  }
902
+ })();
606
903
 
607
- const yesButton = page.getByRole('button', { name: /^Yes$/i })
608
- .or(page.locator('button[data-testid="primaryButton"]'))
609
- .or(page.locator('#idSIButton9'))
610
- .first();
611
-
612
- logger.info('Clicking "Yes" to stay signed in...');
613
- await yesButton.click();
614
- } catch (e) {
615
- logger.debug(`Stay signed in prompt did not appear or was not recognized: ${e.message}`);
616
- }
617
-
618
- // 4. Wait for redirection to target interface (notebooks or mail)
619
904
  try {
620
- await waitForAuthSuccess(page, finalTargetUrl);
905
+ await Promise.race([waitForAuthSuccess(page, finalTargetUrl), blockerWatcher]);
621
906
  } catch (e) {
907
+ // Name the screen we are stuck on: a plain timeout is the single most
908
+ // common report for this tool and "still on X" is what makes it fixable.
909
+ const stuck = await readScreenState(page);
910
+ if (stuck) {
911
+ logger.error(`Timed out waiting for the authenticated interface. Still on ${shortUrl(stuck.url)} — heading: "${stuck.heading || '(none)'}"`);
912
+ if (!matchBlockingScreen(stuck)) {
913
+ logger.warn('That screen is not one this tool knows how to dismiss automatically.');
914
+ }
915
+ }
622
916
  if (credentials.dodump) {
623
917
  const dumpDir = await logger.getDumpDir();
624
918
  const displayPath = logger.getDumpDisplayPath();
@@ -627,6 +921,9 @@ async function login(credentials = {}) {
627
921
  logger.error(`Success detection failed. HTML dumped to ${displayPath}/debug_login_error_success.html`);
628
922
  }
629
923
  throw e;
924
+ } finally {
925
+ stopWatcher = true;
926
+ await blockerWatcher;
630
927
  }
631
928
  } else {
632
929
  logger.warn('Login flow requires manual interaction.');
@@ -719,5 +1016,8 @@ module.exports = {
719
1016
  getAuthenticatedContext,
720
1017
  checkAuth,
721
1018
  getAuthMeta,
722
- logout
1019
+ logout,
1020
+ // Exported for tests: clears the consent/interrupt screens that Microsoft can
1021
+ // inject mid-login (e.g. the Terms of Use update at account.live.com/tou/accrue).
1022
+ clearBlockingScreens
723
1023
  };
package/src/config.js CHANGED
@@ -37,7 +37,7 @@ async function ensureAuthDir(authFilePath) {
37
37
  }
38
38
 
39
39
  const DEFAULT_AUTH_FILE = getDefaultAuthFilePath();
40
- const ONENOTE_URL = 'https://onenote.cloud.microsoft/en-us';
40
+ const ONENOTE_URL = 'https://onenote.cloud.microsoft/notebooks';
41
41
  const OUTLOOK_URL = 'https://outlook.cloud.microsoft/mail/';
42
42
 
43
43
  module.exports = {
@@ -1,25 +0,0 @@
1
- name: Publish Package
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v*'
7
-
8
- permissions:
9
- id-token: write # Required for OIDC
10
- contents: read
11
-
12
- jobs:
13
- publish:
14
- runs-on: ubuntu-latest
15
- steps:
16
- - uses: actions/checkout@v6
17
-
18
- - uses: actions/setup-node@v6
19
- with:
20
- node-version: '24'
21
- package-manager-cache: false # never use caching in release builds
22
- - run: npm ci
23
- - run: npm run build --if-present
24
- - run: npm test
25
- - run: npm publish --access public --provenance
package/jest.config.js DELETED
@@ -1,12 +0,0 @@
1
- /**
2
- * @fileoverview Jest configuration.
3
- * @author phptr,enoola,msout
4
- * @copyright 2026 phptr,enoola,msout
5
- */
6
- module.exports = {
7
- testEnvironment: 'node',
8
- testPathIgnorePatterns: ['/node_modules/', '/dist/'],
9
- collectCoverageFrom: ['src/**/*.js'],
10
- coverageDirectory: 'coverage',
11
- verbose: true
12
- };