@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 +25 -1
- package/package.json +2 -2
- package/src/auth.js +380 -80
- package/src/config.js +1 -1
- 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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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('
|
|
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)
|
|
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
|
-
|
|
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.
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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(`
|
|
814
|
+
logger.debug(`Blocking screen pass skipped: ${e.message}`);
|
|
507
815
|
}
|
|
508
816
|
|
|
509
|
-
// 2.
|
|
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.
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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/
|
|
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
|
-
};
|