@msout/microsoft-webauth 0.0.9 → 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 +25 -1
- package/package.json +2 -2
- package/src/auth.js +370 -75
- package/.github/workflows/npm-publish.yml +0 -25
- package/jest.config.js +0 -12
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
|
-
|
|
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
|
|
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": "
|
|
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
|
|
|
@@ -155,6 +156,7 @@ async function waitForAuthSuccess(page, targetUrl) {
|
|
|
155
156
|
*
|
|
156
157
|
* @param {import('playwright').Page} page
|
|
157
158
|
* @param {object} logger
|
|
159
|
+
* @returns {Promise<string|null>} 'Cancel' once dismissed, null if nothing was clickable
|
|
158
160
|
*/
|
|
159
161
|
async function dismissFidoPage(page, logger) {
|
|
160
162
|
// Try multiple selectors for the page-level Cancel button.
|
|
@@ -185,13 +187,19 @@ async function dismissFidoPage(page, logger) {
|
|
|
185
187
|
if (!clicked) {
|
|
186
188
|
// Last resort: JS click on any visible Cancel button
|
|
187
189
|
logger.warn('FIDO: DOM selectors failed, trying JS click fallback...');
|
|
188
|
-
await page.evaluate(() => {
|
|
190
|
+
clicked = await page.evaluate(() => {
|
|
189
191
|
const btns = Array.from(document.querySelectorAll('button, input[type="button"], input[type="submit"]'));
|
|
190
192
|
const cancel = btns.find(b => /^cancel$/i.test((b.textContent || b.value || '').trim()));
|
|
191
|
-
if (cancel)
|
|
193
|
+
if (cancel) {
|
|
194
|
+
cancel.click();
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
192
198
|
});
|
|
193
199
|
}
|
|
194
200
|
|
|
201
|
+
if (!clicked) return null;
|
|
202
|
+
|
|
195
203
|
// Wait for navigation away from the FIDO page (up to 8 s)
|
|
196
204
|
try {
|
|
197
205
|
await page.waitForURL(url => !url.toString().includes('/fido/'), { timeout: 8000 });
|
|
@@ -199,6 +207,311 @@ async function dismissFidoPage(page, logger) {
|
|
|
199
207
|
} catch (_) {
|
|
200
208
|
logger.warn('FIDO: still on FIDO URL after Cancel — continuing anyway.');
|
|
201
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' };
|
|
202
515
|
}
|
|
203
516
|
|
|
204
517
|
async function login(credentials = {}) {
|
|
@@ -207,13 +520,17 @@ async function login(credentials = {}) {
|
|
|
207
520
|
const headless = !credentials.notheadless && isAutomated;
|
|
208
521
|
// Use targetUrl if provided, otherwise default to ONENOTE_URL for backward compatibility
|
|
209
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 };
|
|
210
526
|
|
|
211
527
|
// Get the auth file path (use provided or default)
|
|
212
528
|
const filePath = getAuthFilePath(authFile);
|
|
213
529
|
const metaPath = getAuthMetaFilePath(filePath);
|
|
214
530
|
|
|
215
|
-
// Added to verify version on user's machine
|
|
216
|
-
|
|
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...`);
|
|
217
534
|
|
|
218
535
|
logger.debug(`Using auth file path: ${filePath}`);
|
|
219
536
|
logger.debug(`Using meta file path: ${metaPath}`);
|
|
@@ -483,35 +800,21 @@ async function login(credentials = {}) {
|
|
|
483
800
|
logger.debug(`[dodump] Post-password state dumped to ${displayPath}/debug_after_password.html`);
|
|
484
801
|
}
|
|
485
802
|
|
|
486
|
-
// 2.
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
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.
|
|
490
807
|
try {
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
await dismissFidoPage(page, logger);
|
|
497
|
-
} else {
|
|
498
|
-
// Race: either we navigate to fido, or 8 s passes (no fido page)
|
|
499
|
-
const fidoHandled = await Promise.race([
|
|
500
|
-
page.waitForURL(url => url.toString().includes('/fido/'), { timeout: 8000 })
|
|
501
|
-
.then(async () => {
|
|
502
|
-
logger.info(`Navigated to FIDO page: ${page.url()}. Dismissing...`);
|
|
503
|
-
await dismissFidoPage(page, logger);
|
|
504
|
-
return 'fido_cancelled';
|
|
505
|
-
}),
|
|
506
|
-
page.waitForTimeout(8000).then(() => 'no_fido'),
|
|
507
|
-
]);
|
|
508
|
-
logger.debug(`FIDO check result: ${fidoHandled}`);
|
|
509
|
-
}
|
|
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})`);
|
|
510
813
|
} catch (e) {
|
|
511
|
-
logger.debug(`
|
|
814
|
+
logger.debug(`Blocking screen pass skipped: ${e.message}`);
|
|
512
815
|
}
|
|
513
816
|
|
|
514
|
-
// 2.
|
|
817
|
+
// 2.5b. Handle post-password MFA/Verification if needed
|
|
515
818
|
try {
|
|
516
819
|
const verificationScreen = await Promise.race([
|
|
517
820
|
page.waitForSelector('text="Verify your identity"', { timeout: 10000 }).then(() => 'verify'),
|
|
@@ -574,56 +877,42 @@ async function login(credentials = {}) {
|
|
|
574
877
|
logger.debug(`Post-password verification handling skipped or failed: ${e.message}`);
|
|
575
878
|
}
|
|
576
879
|
|
|
577
|
-
// 2.7.
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (await interruptPrompt.isVisible({ timeout: 5000 }) || page.url().includes('account.live.com/interrupt/')) {
|
|
581
|
-
logger.info('Detected "Help protect your account" interrupt screen.');
|
|
582
|
-
const skipButton = page.getByRole('button', { name: /Skip for now/i })
|
|
583
|
-
.or(page.getByText(/Skip for now/i))
|
|
584
|
-
.first();
|
|
585
|
-
if (await skipButton.isVisible()) {
|
|
586
|
-
logger.info('Clicking "Skip for now"...');
|
|
587
|
-
await skipButton.click();
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
} catch (e) {
|
|
591
|
-
logger.debug(`Help protect your account interrupt screen did not appear: ${e.message}`);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// 3. Handle "Stay signed in?" prompt if it appears
|
|
595
|
-
try {
|
|
596
|
-
logger.debug('Checking for "Stay signed in?" prompt...');
|
|
597
|
-
|
|
598
|
-
const staySignedIn = page.getByText(/Stay signed in?/i)
|
|
599
|
-
.or(page.locator('#KmsiDescription'))
|
|
600
|
-
.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.
|
|
601
883
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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(() => {});
|
|
610
901
|
}
|
|
902
|
+
})();
|
|
611
903
|
|
|
612
|
-
const yesButton = page.getByRole('button', { name: /^Yes$/i })
|
|
613
|
-
.or(page.locator('button[data-testid="primaryButton"]'))
|
|
614
|
-
.or(page.locator('#idSIButton9'))
|
|
615
|
-
.first();
|
|
616
|
-
|
|
617
|
-
logger.info('Clicking "Yes" to stay signed in...');
|
|
618
|
-
await yesButton.click();
|
|
619
|
-
} catch (e) {
|
|
620
|
-
logger.debug(`Stay signed in prompt did not appear or was not recognized: ${e.message}`);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// 4. Wait for redirection to target interface (notebooks or mail)
|
|
624
904
|
try {
|
|
625
|
-
await waitForAuthSuccess(page, finalTargetUrl);
|
|
905
|
+
await Promise.race([waitForAuthSuccess(page, finalTargetUrl), blockerWatcher]);
|
|
626
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
|
+
}
|
|
627
916
|
if (credentials.dodump) {
|
|
628
917
|
const dumpDir = await logger.getDumpDir();
|
|
629
918
|
const displayPath = logger.getDumpDisplayPath();
|
|
@@ -632,6 +921,9 @@ async function login(credentials = {}) {
|
|
|
632
921
|
logger.error(`Success detection failed. HTML dumped to ${displayPath}/debug_login_error_success.html`);
|
|
633
922
|
}
|
|
634
923
|
throw e;
|
|
924
|
+
} finally {
|
|
925
|
+
stopWatcher = true;
|
|
926
|
+
await blockerWatcher;
|
|
635
927
|
}
|
|
636
928
|
} else {
|
|
637
929
|
logger.warn('Login flow requires manual interaction.');
|
|
@@ -724,5 +1016,8 @@ module.exports = {
|
|
|
724
1016
|
getAuthenticatedContext,
|
|
725
1017
|
checkAuth,
|
|
726
1018
|
getAuthMeta,
|
|
727
|
-
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
|
|
728
1023
|
};
|
|
@@ -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
|
-
};
|