@msout/microsoft-webauth 0.1.1 → 0.1.4
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/LICENSE +1 -1
- package/NOTICE.md +63 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/auth.js +560 -165
package/LICENSE
CHANGED
package/NOTICE.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# NOTICE
|
|
2
|
+
|
|
3
|
+
## License
|
|
4
|
+
|
|
5
|
+
This project is released under the **MIT License**. See [LICENSE](LICENSE) for
|
|
6
|
+
the full text.
|
|
7
|
+
|
|
8
|
+
## Commercial use
|
|
9
|
+
|
|
10
|
+
The MIT License grants anyone the right to use, copy, modify, merge, publish,
|
|
11
|
+
distribute, sublicense and sell copies of this software, including for
|
|
12
|
+
commercial purposes. **No permission is required, and none is withheld.**
|
|
13
|
+
|
|
14
|
+
This NOTICE cannot add conditions to the MIT License, and does not attempt to.
|
|
15
|
+
If you are reading this hoping it sets rules, it does not: the terms in
|
|
16
|
+
[LICENSE](LICENSE) are the terms.
|
|
17
|
+
|
|
18
|
+
**A courtesy request, not a restriction:** if you use this commercially, or build
|
|
19
|
+
on it in a way you make money from, please **let the author know** — an issue or
|
|
20
|
+
a note is welcome. This is a request out of interest in the project, not a
|
|
21
|
+
condition of use. Nobody can enforce it, and no licence condition depends on it.
|
|
22
|
+
|
|
23
|
+
## Attribution
|
|
24
|
+
|
|
25
|
+
The copyright notice and the MIT permission notice must be retained in all
|
|
26
|
+
copies or substantial portions of the Software. Keeping the author's name in the
|
|
27
|
+
files is the one real obligation MIT does impose, and the reason the author
|
|
28
|
+
field is populated in `package.json` for every package in this organisation.
|
|
29
|
+
|
|
30
|
+
## Paid features
|
|
31
|
+
|
|
32
|
+
If a paid or hosted version of this tool ever appears, it will be paid for as a
|
|
33
|
+
**service** — hosting, support, or convenience — never as a licence condition.
|
|
34
|
+
The code in this repository stays MIT for everyone, permanently. A licence
|
|
35
|
+
cannot be both permissively open and conditional, so the open-source grant is
|
|
36
|
+
not something that will ever be withdrawn or moved behind a paywall.
|
|
37
|
+
|
|
38
|
+
## Origin
|
|
39
|
+
|
|
40
|
+
Extracted from [MSOneNote Exporter](https://github.com/enoola/Microsoft-OneNote-Exporter).
|
|
41
|
+
Sibling packages: [microsoft-onenote-list-notebooks](https://github.com/Ms-OneNote-Exporter/microsoft-onenote-list-notebooks),
|
|
42
|
+
[microsoft-onenote-export-notebook](https://github.com/Ms-OneNote-Exporter/microsoft-onenote-export-notebook),
|
|
43
|
+
[microsoft-outlook-list-emails](https://github.com/Ms-OneNote-Exporter/microsoft-outlook-list-emails).
|
|
44
|
+
|
|
45
|
+
## Why this project exists
|
|
46
|
+
|
|
47
|
+
Microsoft does not provide a convenient way to export a whole OneNote notebook,
|
|
48
|
+
and the Graph API path is not a usable substitute: it caps page retrieval and
|
|
49
|
+
requires Entra admin rights. This tool produces the authenticated session state
|
|
50
|
+
those other tools consume, using Playwright instead of the Graph API.
|
|
51
|
+
|
|
52
|
+
It deliberately does **not** rely on Graph, and it deliberately does not ask you
|
|
53
|
+
to hand it your password in a hosted web form. Authentication state is written
|
|
54
|
+
to a local file (`auth-file.json`, mode `600`) that you own, can inspect, and can
|
|
55
|
+
delete at any time.
|
|
56
|
+
|
|
57
|
+
## Handling of credentials
|
|
58
|
+
|
|
59
|
+
`auth-file.json` contains a Playwright `storageState` — live session cookies.
|
|
60
|
+
Anyone who reads that file can act as you until the session expires. Treat it
|
|
61
|
+
like a password file: do not commit it, do not upload it to a service you do not
|
|
62
|
+
control, and delete it when you are done. The default path is under
|
|
63
|
+
`~/.microsoft-webauth/`, outside any repository.
|
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@msout/microsoft-webauth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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": {
|
package/src/auth.js
CHANGED
|
@@ -116,6 +116,52 @@ function promptUser(query) {
|
|
|
116
116
|
* @param {import('playwright').Page} page - Playwright page object
|
|
117
117
|
* @param {string} targetUrl - The target URL (ONENOTE_URL or OUTLOOK_URL)
|
|
118
118
|
*/
|
|
119
|
+
/**
|
|
120
|
+
* The authenticated OneNote web app's path. It is not stable: the Microsoft 365
|
|
121
|
+
* Copilot rebrand moved it from /notebooks to /copilotnotebooks, and
|
|
122
|
+
* "/copilotnotebooks".includes("/notebooks") is false — so a substring check on
|
|
123
|
+
* "/notebooks" alone timed out on a login that had in fact fully succeeded, with
|
|
124
|
+
* the notebooks UI rendered and the account name on screen, and never saved the
|
|
125
|
+
* auth state.
|
|
126
|
+
*
|
|
127
|
+
* Both spellings are listed, and the "notebooks" component is still required so
|
|
128
|
+
* the unauthenticated marketing page (onenote.cloud.microsoft/en-us) can never
|
|
129
|
+
* satisfy this. A bare hostname check would, and did: it caused premature
|
|
130
|
+
* auth saving.
|
|
131
|
+
*/
|
|
132
|
+
const ONENOTE_APP_PATH = /\/copilotnotebooks\b|\/notebooks\b/;
|
|
133
|
+
|
|
134
|
+
/** UI markers that only render once the session is actually signed in. */
|
|
135
|
+
const ONENOTE_SIGNED_IN_MARKERS = [
|
|
136
|
+
'text="My notebooks"',
|
|
137
|
+
'text="Create new notebook"',
|
|
138
|
+
'text="All Notebooks"',
|
|
139
|
+
'text="Welcome, "'
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Waits for the authenticated app, bounded by `timeoutMs` instead of the full
|
|
144
|
+
* production timeout. Returns whether it arrived, so it can be asserted on.
|
|
145
|
+
* @returns {Promise<boolean>}
|
|
146
|
+
*/
|
|
147
|
+
async function waitForAuthSuccessProbe(page, targetUrl, timeoutMs) {
|
|
148
|
+
const isOutlook = targetUrl && targetUrl.includes('outlook.cloud.microsoft');
|
|
149
|
+
const attempts = isOutlook
|
|
150
|
+
? [
|
|
151
|
+
page.waitForSelector('[aria-label*="message list"], [role="grid"][aria-label*="mail"], .messageList', { state: 'visible', timeout: timeoutMs }),
|
|
152
|
+
page.waitForSelector('text=/Inbox|Sent Mail|Drafts/i', { state: 'visible', timeout: timeoutMs }),
|
|
153
|
+
page.waitForSelector('div[role="row"]', { state: 'visible', timeout: timeoutMs }),
|
|
154
|
+
]
|
|
155
|
+
: [
|
|
156
|
+
page.waitForURL(url => ONENOTE_APP_PATH.test(url.toString()), { timeout: timeoutMs }),
|
|
157
|
+
...ONENOTE_SIGNED_IN_MARKERS.map(marker =>
|
|
158
|
+
page.waitForSelector(marker, { state: 'visible', timeout: timeoutMs })),
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const won = await Promise.any(attempts.map(p => p.then(() => true))).catch(() => false);
|
|
162
|
+
return won;
|
|
163
|
+
}
|
|
164
|
+
|
|
119
165
|
async function waitForAuthSuccess(page, targetUrl) {
|
|
120
166
|
const isOutlook = targetUrl && targetUrl.includes('outlook.cloud.microsoft');
|
|
121
167
|
|
|
@@ -131,18 +177,13 @@ async function waitForAuthSuccess(page, targetUrl) {
|
|
|
131
177
|
]);
|
|
132
178
|
logger.success('Outlook mail interface detected.');
|
|
133
179
|
} else {
|
|
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
180
|
logger.info('Waiting for redirection to authenticated notebooks interface...');
|
|
139
181
|
await Promise.any([
|
|
140
|
-
// Primary: URL must
|
|
141
|
-
page.waitForURL(url => url.toString()
|
|
182
|
+
// Primary: the URL must be the authenticated app, not the marketing page
|
|
183
|
+
page.waitForURL(url => ONENOTE_APP_PATH.test(url.toString()), { timeout: 60000 }),
|
|
142
184
|
// Fallback UI elements that only appear when actually signed in
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
page.waitForSelector('text="Welcome, "', { state: 'visible', timeout: 60000 }),
|
|
185
|
+
...ONENOTE_SIGNED_IN_MARKERS.map(marker =>
|
|
186
|
+
page.waitForSelector(marker, { state: 'visible', timeout: 60000 })),
|
|
146
187
|
]);
|
|
147
188
|
logger.success('Authenticated notebooks interface detected.');
|
|
148
189
|
}
|
|
@@ -483,10 +524,7 @@ async function clearBlockingScreens(page, options = {}) {
|
|
|
483
524
|
logger.info(`Blocking screen detected: ${screen.name} (${shortUrl(state.url)}). Accepting it...`);
|
|
484
525
|
|
|
485
526
|
if (dodump) {
|
|
486
|
-
const
|
|
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} -->`));
|
|
527
|
+
const displayPath = await dumpPage(page, `debug_blocking_screen_${i + 1}.html`);
|
|
490
528
|
logger.debug(`[dodump] Blocking screen state dumped to ${displayPath}/debug_blocking_screen_${i + 1}.html`);
|
|
491
529
|
}
|
|
492
530
|
|
|
@@ -514,6 +552,435 @@ async function clearBlockingScreens(page, options = {}) {
|
|
|
514
552
|
return { handled, reason: 'max_screens' };
|
|
515
553
|
}
|
|
516
554
|
|
|
555
|
+
/* ------------------------------------------------------------------------- *
|
|
556
|
+
* "How do you want to sign in?" — the screens between email and password
|
|
557
|
+
*
|
|
558
|
+
* For a passwordless-enabled account, Microsoft serves this after the email
|
|
559
|
+
* step (captured in logs/dumps, PageID i5030):
|
|
560
|
+
*
|
|
561
|
+
* <h1 data-testid="title">Get a code to sign in</h1>
|
|
562
|
+
* <button type="submit" data-testid="primaryButton">Send code</button>
|
|
563
|
+
* <span role="button" class="fui-Link" tabindex="0">Use your password</span>
|
|
564
|
+
*
|
|
565
|
+
* Note what is *not* there: an "Other ways to sign in" link. The password is
|
|
566
|
+
* only reachable via the "Use your password" link in the footer of that very
|
|
567
|
+
* screen, and it is a <span role="button">, not an <a>, so it only responds to
|
|
568
|
+
* real pointer events. The previous implementation assumed "Other ways to sign
|
|
569
|
+
* in" always came first: it waited 15 s for a link that does not exist, threw
|
|
570
|
+
* STUCK, swallowed it, and then let the password selector time out 30 s later.
|
|
571
|
+
* A ~45 s stall ending in a misleading "incorrect credentials" message.
|
|
572
|
+
*
|
|
573
|
+
* So instead of racing text selectors and betting on which one wins, the state
|
|
574
|
+
* is read from the DOM — what is actually on offer — and "Use your password" is
|
|
575
|
+
* pressed wherever it appears.
|
|
576
|
+
* ------------------------------------------------------------------------- */
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Label patterns for the sign-in-choice screens, as *source strings*: they are
|
|
580
|
+
* handed into page.evaluate(), where RegExp objects do not survive
|
|
581
|
+
* serialization and must be rebuilt on the far side.
|
|
582
|
+
*/
|
|
583
|
+
const SIGN_IN_LABELS = {
|
|
584
|
+
usePassword: 'use your password|use a password instead|use password instead|sign in with a password',
|
|
585
|
+
otherWays: 'other ways to sign in|sign in another way|look for another way|try another way',
|
|
586
|
+
// The list of methods, where "Password" is one entry among several.
|
|
587
|
+
passwordEntry: '^password$|use your password|use a password',
|
|
588
|
+
methodList: 'select a (?:sign-in |verification )?method|choose (?:a |another )?way to sign in|how do you want to sign in',
|
|
589
|
+
sendCode: 'send code|get a code to sign in|text me a code|email me a code',
|
|
590
|
+
approveApp: 'approve a request on my microsoft authenticator app|approve sign in request',
|
|
591
|
+
otcPrompt: 'enter (?:the )?code|type (?:the )?code|verification code',
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
/** Compiles a SIGN_IN_LABELS entry into an anchored, case-insensitive RegExp. */
|
|
595
|
+
function signInLabel(key) {
|
|
596
|
+
return new RegExp(SIGN_IN_LABELS[key], 'i');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Selectors for anything on the page that can be clicked, on either UI generation. */
|
|
600
|
+
const CLICKABLE_SELECTOR = 'a[href], button, input[type="submit"], input[type="button"], [role="button"], [role="link"]';
|
|
601
|
+
|
|
602
|
+
/** The password box, on both the legacy and the Fluent sign-in pages. */
|
|
603
|
+
const PASSWORD_FIELD_SELECTOR = 'input[name="passwd"], input[type="password"]';
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Reads what the current sign-in screen actually offers, in a single round trip.
|
|
607
|
+
*
|
|
608
|
+
* Everything is answered from one evaluate() so the reading is a consistent
|
|
609
|
+
* snapshot: seven separate locators would each sample the page at a slightly
|
|
610
|
+
* different moment, which is how a screen mid-navigation gets misclassified.
|
|
611
|
+
*
|
|
612
|
+
* @param {import('playwright').Page} page
|
|
613
|
+
* @returns {Promise<object|null>} null while the document is being swapped
|
|
614
|
+
*/
|
|
615
|
+
async function readSignInState(page) {
|
|
616
|
+
try {
|
|
617
|
+
return await page.evaluate(labels => {
|
|
618
|
+
const visible = el => {
|
|
619
|
+
if (!el) return false;
|
|
620
|
+
// aria-hidden means the element is not exposed to the user, so it
|
|
621
|
+
// is not something the user can interact with. login.microsoftonline.com
|
|
622
|
+
// parks its leftover fields in the DOM exactly this way:
|
|
623
|
+
// <input name="loginfmt" class="moveOffScreen" aria-hidden="true">.
|
|
624
|
+
// Those are off-screen rather than display:none, so they still have
|
|
625
|
+
// a non-zero box and pass a pure geometry test — which is how a
|
|
626
|
+
// work account's password page was mistaken for the email step.
|
|
627
|
+
if (el.getAttribute('aria-hidden') === 'true') return false;
|
|
628
|
+
const rect = el.getBoundingClientRect();
|
|
629
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
630
|
+
const style = window.getComputedStyle(el);
|
|
631
|
+
return style.visibility !== 'hidden' && style.display !== 'none';
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const labelOf = el => `${el.value || ''} ${el.textContent || ''}`.replace(/\s+/g, ' ').trim();
|
|
635
|
+
|
|
636
|
+
// True when a *control* carrying this label is on screen. Deliberately
|
|
637
|
+
// not a body-text search: prose mentioning "another way" must never
|
|
638
|
+
// be mistaken for the button that acts on it.
|
|
639
|
+
const offersAction = key => {
|
|
640
|
+
const rx = new RegExp(labels[key], 'i');
|
|
641
|
+
return Array.from(document.querySelectorAll(
|
|
642
|
+
'a[href], button, input[type="submit"], input[type="button"], [role="button"], [role="link"]'
|
|
643
|
+
)).some(el => visible(el) && rx.test(labelOf(el)));
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const headingEl = document.querySelector('h1, [role="heading"], [data-testid="title"]');
|
|
647
|
+
const body = document.body ? (document.body.innerText || '').replace(/\s+/g, ' ') : '';
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
url: location.href,
|
|
651
|
+
heading: (headingEl ? headingEl.textContent : '').replace(/\s+/g, ' ').trim(),
|
|
652
|
+
// Still on the email form: the step after it has not rendered yet.
|
|
653
|
+
emailField: visible(document.querySelector('input[name="loginfmt"]')),
|
|
654
|
+
passwordField: visible(document.querySelector('input[name="passwd"], input[type="password"]')),
|
|
655
|
+
otcField: visible(document.querySelector('input[name="otc"], input[type="tel"]')),
|
|
656
|
+
usePassword: offersAction('usePassword'),
|
|
657
|
+
otherWays: offersAction('otherWays'),
|
|
658
|
+
sendCode: offersAction('sendCode') || new RegExp(labels.sendCode, 'i').test(body),
|
|
659
|
+
// Prose-only screens: read from the page text, nothing to click.
|
|
660
|
+
methodList: new RegExp(labels.methodList, 'i').test(body),
|
|
661
|
+
approveApp: new RegExp(labels.approveApp, 'i').test(body),
|
|
662
|
+
otcPrompt: new RegExp(labels.otcPrompt, 'i').test(body)
|
|
663
|
+
};
|
|
664
|
+
}, SIGN_IN_LABELS);
|
|
665
|
+
} catch (_) {
|
|
666
|
+
// Execution context destroyed mid-navigation — caller should retry.
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** True when the screen is one this handler knows how to act on. */
|
|
672
|
+
function isActionableSignInState(state) {
|
|
673
|
+
if (!state || state.emailField) return false;
|
|
674
|
+
return !!(state.passwordField || state.usePassword || state.otherWays
|
|
675
|
+
|| state.methodList || state.sendCode || state.approveApp || state.otcPrompt);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Identity of a sign-in screen, to tell a real step change from a re-render. */
|
|
679
|
+
function signInStateSignature(state) {
|
|
680
|
+
if (!state) return null;
|
|
681
|
+
return [state.url, state.heading, state.passwordField, state.usePassword,
|
|
682
|
+
state.otherWays, state.methodList, state.sendCode, state.approveApp].join('::');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Polls until `accept` is satisfied or the timeout runs out.
|
|
687
|
+
* @returns {Promise<object|null>} the accepted state, else the last readable one
|
|
688
|
+
*/
|
|
689
|
+
async function waitForSignInState(page, timeout, accept = isActionableSignInState) {
|
|
690
|
+
const deadline = Date.now() + timeout;
|
|
691
|
+
let state = null;
|
|
692
|
+
do {
|
|
693
|
+
const read = await readSignInState(page);
|
|
694
|
+
if (read) {
|
|
695
|
+
state = read;
|
|
696
|
+
if (accept(read)) return read;
|
|
697
|
+
}
|
|
698
|
+
await page.waitForTimeout(400).catch(() => {});
|
|
699
|
+
} while (Date.now() < deadline);
|
|
700
|
+
// The last *readable* state, not whatever a mid-navigation read happened to
|
|
701
|
+
// return: null would discard the only evidence of why the step is stuck.
|
|
702
|
+
return state;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Clicks the control carrying a given label.
|
|
707
|
+
*
|
|
708
|
+
* The Fluent pages render links as <span role="button">, which only react to a
|
|
709
|
+
* real pointer event: a .click() on an ancestor wrapper fires nothing. So the
|
|
710
|
+
* lookup escalates from the most semantic to the most forceful, and the last
|
|
711
|
+
* step targets the *innermost* match rather than the first in document order.
|
|
712
|
+
*
|
|
713
|
+
* @param {import('playwright').Page} page
|
|
714
|
+
* @param {RegExp} rx
|
|
715
|
+
* @param {{ timeout?: number }} [options]
|
|
716
|
+
* @returns {Promise<string|null>} the label clicked, or null if nothing matched
|
|
717
|
+
*/
|
|
718
|
+
async function clickByLabel(page, rx, { timeout = 8000 } = {}) {
|
|
719
|
+
// Precise strategies first: they match on the accessible name, so they hit
|
|
720
|
+
// the control the user sees rather than a wrapper.
|
|
721
|
+
const precise = [
|
|
722
|
+
page.getByRole('button', { name: rx }),
|
|
723
|
+
page.getByRole('link', { name: rx })
|
|
724
|
+
];
|
|
725
|
+
|
|
726
|
+
// Raced, not sequential: a control is a button on one screen generation and a
|
|
727
|
+
// link on the other, so trying them in turn means paying the full timeout on
|
|
728
|
+
// whichever role does not apply.
|
|
729
|
+
const hit = await Promise.any(
|
|
730
|
+
precise.map((locator, i) => locator.first().waitFor({ state: 'visible', timeout }).then(() => i))
|
|
731
|
+
).catch(() => -1);
|
|
732
|
+
|
|
733
|
+
// getByText is the fuzzier fallback, and the JS click the forceful one.
|
|
734
|
+
if (hit < 0) {
|
|
735
|
+
try {
|
|
736
|
+
await page.getByText(rx).first().waitFor({ state: 'visible', timeout: Math.min(timeout, 2000) });
|
|
737
|
+
precise.push(page.getByText(rx));
|
|
738
|
+
hit = precise.length - 1;
|
|
739
|
+
} catch (_) { /* fall through to the JS click */ }
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (hit >= 0) {
|
|
743
|
+
const first = precise[hit].first();
|
|
744
|
+
try {
|
|
745
|
+
// Read the label *before* clicking. These controls navigate on click,
|
|
746
|
+
// and textContent() against a locator whose element the navigation just
|
|
747
|
+
// removed blocks for the full 30 s default timeout before rejecting —
|
|
748
|
+
// which is exactly the stall this function is meant to avoid.
|
|
749
|
+
const label = ((await first.textContent({ timeout: 2000 }).catch(() => '')) || '')
|
|
750
|
+
.replace(/\s+/g, ' ').trim();
|
|
751
|
+
await first.click({ timeout: Math.min(timeout, 5000) });
|
|
752
|
+
return label || '(clicked)';
|
|
753
|
+
} catch (_) {
|
|
754
|
+
// Found but not clickable — let the JS click have a go.
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return await page.evaluate(({ selector, source }) => {
|
|
759
|
+
const rx = new RegExp(source, 'i');
|
|
760
|
+
const labelOf = el => `${el.value || ''} ${el.textContent || ''}`.replace(/\s+/g, ' ').trim();
|
|
761
|
+
const hits = Array.from(document.querySelectorAll(selector)).filter(el => rx.test(labelOf(el)));
|
|
762
|
+
// Innermost hit: an ancestor's textContent includes the descendant's, so
|
|
763
|
+
// the first match in document order is usually a wrapper whose click
|
|
764
|
+
// never reaches the handler the user can see.
|
|
765
|
+
const target = hits.find(el => !hits.some(other => other !== el && el.contains(other)));
|
|
766
|
+
if (!target) return null;
|
|
767
|
+
target.click();
|
|
768
|
+
return labelOf(target);
|
|
769
|
+
}, { selector: CLICKABLE_SELECTOR, source: rx.source }).catch(() => null);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Presses the sign-in submit control.
|
|
774
|
+
*
|
|
775
|
+
* The legacy pages use <input type="submit" value="Sign in">, where the label
|
|
776
|
+
* lives in `value` and `filter({ hasText })` can never match it; the Fluent pages
|
|
777
|
+
* use <button type="submit" data-testid="primaryButton">Sign in</button>. Matching
|
|
778
|
+
* on the accessible role name covers both.
|
|
779
|
+
*
|
|
780
|
+
* @param {import('playwright').Page} page
|
|
781
|
+
* @returns {Promise<boolean>} true if a submit control was clicked
|
|
782
|
+
*/
|
|
783
|
+
async function submitSignInForm(page) {
|
|
784
|
+
const strategies = [
|
|
785
|
+
page.getByRole('button', { name: /^(sign in|next|finish|continue)$/i }),
|
|
786
|
+
page.locator('input[type="submit"]'),
|
|
787
|
+
page.locator('button[type="submit"]')
|
|
788
|
+
];
|
|
789
|
+
|
|
790
|
+
for (const locator of strategies) {
|
|
791
|
+
const button = locator.first();
|
|
792
|
+
if (!(await button.isVisible().catch(() => false))) continue;
|
|
793
|
+
// click() waits for the element to become enabled, which covers the
|
|
794
|
+
// short window after fill() while the page validates the password.
|
|
795
|
+
await button.click({ timeout: 10000 });
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Walks the "how do you want to sign in?" screens and lands on the password box.
|
|
803
|
+
*
|
|
804
|
+
* Prefers "Use your password" wherever it is offered, because on the
|
|
805
|
+
* passwordless screen that link is the only route to the password — there is no
|
|
806
|
+
* "Other ways to sign in" step to go through first. Only falls back to that
|
|
807
|
+
* step when the screen really does present it, then picks "Password" out of the
|
|
808
|
+
* resulting method list.
|
|
809
|
+
*
|
|
810
|
+
* @param {import('playwright').Page} page
|
|
811
|
+
* @param {object} [options]
|
|
812
|
+
* @param {number} [options.maxSteps]
|
|
813
|
+
* @param {number} [options.stateTimeout] how long to wait for the first screen
|
|
814
|
+
* @param {number} [options.transitionTimeout] how long to wait after each click
|
|
815
|
+
* @param {boolean} [options.dodump]
|
|
816
|
+
* @param {string} [options.dumpFile] basename written under the dump dir
|
|
817
|
+
* @returns {Promise<{ reached: boolean, reason: string, steps: number, state: object|null }>}
|
|
818
|
+
*/
|
|
819
|
+
async function reachPasswordScreen(page, options = {}) {
|
|
820
|
+
const {
|
|
821
|
+
maxSteps = 4,
|
|
822
|
+
stateTimeout = 15000,
|
|
823
|
+
transitionTimeout = 10000,
|
|
824
|
+
dodump = false,
|
|
825
|
+
dumpFile = 'debug_intermediate_screen'
|
|
826
|
+
} = options;
|
|
827
|
+
|
|
828
|
+
let state = await waitForSignInState(page, stateTimeout);
|
|
829
|
+
let dumped = false;
|
|
830
|
+
|
|
831
|
+
for (let steps = 0; steps < maxSteps; steps++) {
|
|
832
|
+
// The password box is checked FIRST, ahead of the "still on the email
|
|
833
|
+
// step" gate below. A work/school account signs in on
|
|
834
|
+
// login.microsoftonline.com, whose password page keeps the username
|
|
835
|
+
// field in the DOM as <input name="loginfmt" class="moveOffScreen"> —
|
|
836
|
+
// rendered off-screen, and therefore "visible" by any geometry test.
|
|
837
|
+
// Gating on emailField first made every such login report "unreadable"
|
|
838
|
+
// and burn the whole stateTimeout while the password box sat right
|
|
839
|
+
// there, ready to be filled.
|
|
840
|
+
if (state && state.passwordField) {
|
|
841
|
+
logger.debug(`Password field reached after ${steps} step(s).`);
|
|
842
|
+
return { reached: true, reason: 'password_field', steps, state };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (!state || !isActionableSignInState(state)) {
|
|
846
|
+
return { reached: false, reason: 'unreadable', steps, state };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (dodump && !dumped) {
|
|
850
|
+
dumped = true;
|
|
851
|
+
const displayPath = await dumpPage(page, `${dumpFile}.html`);
|
|
852
|
+
logger.debug(`[dodump] Intermediate screen state dumped to ${displayPath}/${dumpFile}.html`);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const before = signInStateSignature(state);
|
|
856
|
+
let clicked = null;
|
|
857
|
+
|
|
858
|
+
if (state.usePassword) {
|
|
859
|
+
logger.info('Password is offered on this screen — clicking "Use your password"...');
|
|
860
|
+
clicked = await clickByLabel(page, signInLabel('usePassword'));
|
|
861
|
+
} else if (state.otherWays) {
|
|
862
|
+
logger.info('Opening "Other ways to sign in"...');
|
|
863
|
+
clicked = await clickByLabel(page, signInLabel('otherWays'));
|
|
864
|
+
} else if (state.methodList) {
|
|
865
|
+
logger.info('Choosing "Password" from the sign-in method list...');
|
|
866
|
+
clicked = await clickByLabel(page, signInLabel('passwordEntry'));
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (!clicked) {
|
|
870
|
+
// No route to a password from here. Say which kind of screen it is,
|
|
871
|
+
// so a mandatory code/approval is not reported as a bad password.
|
|
872
|
+
const reason = state.approveApp ? 'approver_prompt'
|
|
873
|
+
: state.otcPrompt || state.sendCode || state.otcField ? 'code_prompt'
|
|
874
|
+
: 'no_password_route';
|
|
875
|
+
logger.warn(`No way to reach the password screen from "${state.heading || shortUrl(state.url)}" (${reason}).`);
|
|
876
|
+
return { reached: false, reason, steps, state };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
logger.debug(`Clicked "${clicked}". Waiting for the next screen...`);
|
|
880
|
+
|
|
881
|
+
// Wait for a *different* screen, not just the password box: the method
|
|
882
|
+
// list is a legitimate hop in the middle of this walk, and waiting only
|
|
883
|
+
// for the password field would burn the whole timeout on it. The
|
|
884
|
+
// signature guard keeps the not-yet-navigated pre-click state from
|
|
885
|
+
// satisfying the wait immediately.
|
|
886
|
+
state = await waitForSignInState(page, transitionTimeout,
|
|
887
|
+
s => s.passwordField || (isActionableSignInState(s) && signInStateSignature(s) !== before));
|
|
888
|
+
if (state && signInStateSignature(state) === before) {
|
|
889
|
+
logger.warn(`Screen did not change after clicking "${clicked}".`);
|
|
890
|
+
return { reached: false, reason: 'unchanged', steps, state };
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return { reached: false, reason: 'max_steps', steps: maxSteps, state };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Writes the current page to a debug dump, with credentials removed.
|
|
899
|
+
*
|
|
900
|
+
* `--dodump` calls page.content(), and that serialises the *live value* of every
|
|
901
|
+
* form control. On the page where the password is typed that means the user's
|
|
902
|
+
* actual Microsoft password lands on disk in cleartext:
|
|
903
|
+
*
|
|
904
|
+
* <input type="password" name="passwd" value="the-real-password">
|
|
905
|
+
*
|
|
906
|
+
* The dumps are gitignored and never packed, but they land in the working
|
|
907
|
+
* tree, where backups, sync clients and file-sharing will pick them up, and
|
|
908
|
+
* they get pasted into issues and chat. Every dump goes through here so the
|
|
909
|
+
* redaction cannot be forgotten at the next call site.
|
|
910
|
+
*
|
|
911
|
+
* The scrubbing happens on a *clone* of the document rather than by rewriting
|
|
912
|
+
* the HTML string: PPFT and the other flow tokens are the values the login
|
|
913
|
+
* form is about to POST, so blanking them in the live DOM would break the very
|
|
914
|
+
* login being debugged. A clone cannot affect the page.
|
|
915
|
+
*
|
|
916
|
+
* @param {import('playwright').Page} page
|
|
917
|
+
* @param {string} fileName basename, e.g. debug_after_password.html
|
|
918
|
+
*/
|
|
919
|
+
async function dumpPage(page, fileName) {
|
|
920
|
+
const dumpDir = await logger.getDumpDir();
|
|
921
|
+
const displayPath = logger.getDumpDisplayPath();
|
|
922
|
+
await fs.writeFile(path.join(dumpDir, fileName), await redactedPageContent(page));
|
|
923
|
+
return displayPath;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Placeholder written over any redacted value, so a scrubbed dump is obvious. */
|
|
927
|
+
const REDACTED = '[redacted]';
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Serialises the page with credential-bearing control values blanked.
|
|
931
|
+
*
|
|
932
|
+
* Redacted: every `input[type=password]` whatever it is named, plus any control
|
|
933
|
+
* whose name or id reads as a credential or a bearer token — PPFT and the other
|
|
934
|
+
* pre-auth flow tokens, id/access/refresh tokens, secrets, and OTP / one-time
|
|
935
|
+
* code fields.
|
|
936
|
+
*
|
|
937
|
+
* Deliberately *not* redacted: the account identifier (`loginfmt`, `login`).
|
|
938
|
+
* It is already written in cleartext by the "Attempting automated login for
|
|
939
|
+
* <email>" line and is on the command line, so hiding it in the HTML would
|
|
940
|
+
* protect nothing while removing the one field worth having when a login fails
|
|
941
|
+
* on the wrong account.
|
|
942
|
+
*
|
|
943
|
+
* @param {import('playwright').Page} page
|
|
944
|
+
* @returns {Promise<string>}
|
|
945
|
+
*/
|
|
946
|
+
async function redactedPageContent(page) {
|
|
947
|
+
return await page.evaluate(REDACTED => {
|
|
948
|
+
// Any control whose name or id holds a credential or a bearer token
|
|
949
|
+
// rather than UI state. Substring matching on purpose: these names are
|
|
950
|
+
// compound in the wild (otc, otcFallback, verificationCode, srfSFT) and
|
|
951
|
+
// an anchored pattern misses all but the exact spelling. Over-redacting
|
|
952
|
+
// one extra field costs a debug dump nothing; under-redacting leaks a
|
|
953
|
+
// credential. PPFT is Microsoft's pre-auth flow token and matches no
|
|
954
|
+
// generic word, so it is named outright.
|
|
955
|
+
const SENSITIVE = /ppft|token|canary|secret|passw|credential|otp|otc|code$|pin$/i;
|
|
956
|
+
|
|
957
|
+
const isSensitive = el => {
|
|
958
|
+
if (String(el.type || '').toLowerCase() === 'password') return true;
|
|
959
|
+
return SENSITIVE.test(`${el.name || ''}`.trim()) || SENSITIVE.test(`${el.id || ''}`.trim());
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
const clone = document.documentElement.cloneNode(true);
|
|
963
|
+
for (const el of clone.querySelectorAll('input, textarea')) {
|
|
964
|
+
// Only a value that is actually there needs hiding.
|
|
965
|
+
if (!el.value || !isSensitive(el)) continue;
|
|
966
|
+
|
|
967
|
+
if (el.tagName === 'TEXTAREA') {
|
|
968
|
+
el.textContent = REDACTED;
|
|
969
|
+
} else {
|
|
970
|
+
// setAttribute, never `.value =`. Assigning the IDL property on a
|
|
971
|
+
// *visible* input puts the element into "dirty value mode": the IDL
|
|
972
|
+
// value changes but the content attribute is left alone, and
|
|
973
|
+
// outerHTML serialises the content attribute — so the secret comes
|
|
974
|
+
// out unchanged. Only type="hidden" inputs are saved by that
|
|
975
|
+
// accident, and a password field is a visible input, which is
|
|
976
|
+
// exactly the case that would have leaked.
|
|
977
|
+
el.setAttribute('value', REDACTED);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return `<!DOCTYPE html>\n${clone.outerHTML}`;
|
|
981
|
+
}, REDACTED).catch(e => `<!-- Error redacting or reading page: ${e.message} -->`);
|
|
982
|
+
}
|
|
983
|
+
|
|
517
984
|
async function login(credentials = {}) {
|
|
518
985
|
const { email, password, targetUrl, authFile } = credentials;
|
|
519
986
|
const isAutomated = !!(email && password);
|
|
@@ -609,9 +1076,17 @@ async function login(credentials = {}) {
|
|
|
609
1076
|
logger.info('Email entered. Clicking "Next"...');
|
|
610
1077
|
await page.click('input[type="submit"]');
|
|
611
1078
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
1079
|
+
// Wait for the email form to actually be replaced, but not for the
|
|
1080
|
+
// loginfmt input to vanish: on login.microsoftonline.com it is
|
|
1081
|
+
// parked as <input name="loginfmt" class="moveOffScreen"> and stays
|
|
1082
|
+
// in the DOM for the rest of the login, so "hidden" never happens
|
|
1083
|
+
// and this used to burn the full timeout on every work account.
|
|
1084
|
+
// The next step appearing is the real signal that we have moved on.
|
|
1085
|
+
await Promise.race([
|
|
1086
|
+
page.waitForSelector(PASSWORD_FIELD_SELECTOR, { state: 'visible', timeout: 15000 }).then(() => 'password'),
|
|
1087
|
+
page.waitForSelector('input[name="loginfmt"]', { state: 'hidden', timeout: 15000 }).then(() => 'advanced'),
|
|
1088
|
+
]).catch(() => {
|
|
1089
|
+
logger.debug('Email form did not visibly change yet; the sign-in method step will wait for the next screen.');
|
|
615
1090
|
});
|
|
616
1091
|
|
|
617
1092
|
logger.info('Will wait 1 seconds to give the UI a moment to settle into the next screen (MFA/Password)');
|
|
@@ -625,10 +1100,7 @@ async function login(credentials = {}) {
|
|
|
625
1100
|
} catch (e) {
|
|
626
1101
|
logger.error(`Failed to enter email: ${e.message}`);
|
|
627
1102
|
if (credentials.dodump) {
|
|
628
|
-
const
|
|
629
|
-
const displayPath = logger.getDumpDisplayPath();
|
|
630
|
-
const debugFile = path.join(dumpDir, 'debug_login_error_email.html');
|
|
631
|
-
await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
|
|
1103
|
+
const displayPath = await dumpPage(page, 'debug_login_error_email.html');
|
|
632
1104
|
logger.error(`Email submission failed. HTML dumped to ${displayPath}/debug_login_error_email.html`);
|
|
633
1105
|
}
|
|
634
1106
|
throw e;
|
|
@@ -636,145 +1108,61 @@ async function login(credentials = {}) {
|
|
|
636
1108
|
|
|
637
1109
|
// Proactive dump after email step (before MFA detection)
|
|
638
1110
|
if (credentials.dodump) {
|
|
639
|
-
const
|
|
640
|
-
const displayPath = logger.getDumpDisplayPath();
|
|
641
|
-
const debugFile = path.join(dumpDir, 'debug_after_email.html');
|
|
642
|
-
await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
|
|
1111
|
+
const displayPath = await dumpPage(page, 'debug_after_email.html');
|
|
643
1112
|
logger.debug(`[dodump] Post-email state dumped to ${displayPath}/debug_after_email.html`);
|
|
644
1113
|
}
|
|
645
1114
|
|
|
646
|
-
// 1.5.
|
|
1115
|
+
// 1.5. Get from the email step to the password box. This screen has
|
|
1116
|
+
// no "Other ways to sign in" step on it — the "Use your password" link
|
|
1117
|
+
// in its footer is the only route to the password — so the state is
|
|
1118
|
+
// read from the DOM rather than guessed from a race between text
|
|
1119
|
+
// selectors. See reachPasswordScreen() above.
|
|
647
1120
|
try {
|
|
648
|
-
const
|
|
649
|
-
const pageHeading = (await page.locator('h1, [role="heading"]').first().textContent().catch(() => '')).trim();
|
|
650
|
-
|
|
651
|
-
logger.debug(`Settled State: Title="${pageTitle}" | Heading="${pageHeading}"`);
|
|
652
|
-
logger.debug('Checking for intermediate MFA/Sign-in option screens...');
|
|
653
|
-
|
|
654
|
-
const result = await Promise.race([
|
|
655
|
-
page.waitForSelector('text=/Other ways to sign in/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways'),
|
|
656
|
-
page.waitForSelector('text=/Get a code to sign in/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways'),
|
|
657
|
-
page.waitForSelector('text=/Verify your identity/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways'),
|
|
658
|
-
page.waitForSelector('text=/Use your password/i', { state: 'visible', timeout: 15000 }).then(() => 'use_password'),
|
|
659
|
-
page.waitForSelector('text=/Approve a request on my Microsoft Authenticator app/i', { state: 'visible', timeout: 5000 }).then(() => 'approve_app'),
|
|
660
|
-
page.waitForSelector('input[name="passwd"]', { state: 'visible', timeout: 15000 }).then(() => 'password'),
|
|
661
|
-
page.waitForFunction(() => {
|
|
662
|
-
const h = document.querySelector('h1, [role="heading"]')?.textContent || '';
|
|
663
|
-
return h.includes('Get a code') || h.includes('Verify your identity');
|
|
664
|
-
}, { timeout: 15000 }).then(() => 'other_ways'),
|
|
665
|
-
]).catch((err) => {
|
|
666
|
-
logger.debug(`Detection race timed out or failed: ${err.message}`);
|
|
667
|
-
return 'timeout';
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
logger.debug(`Intermediate screen detection result: ${result}`);
|
|
1121
|
+
const nav = await reachPasswordScreen(page, { dodump: credentials.dodump });
|
|
671
1122
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
const debugFile = path.join(dumpDir, 'debug_intermediate_screen.html');
|
|
676
|
-
await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
|
|
677
|
-
logger.debug(`[dodump] Intermediate screen state dumped to ${displayPath}/debug_intermediate_screen.html`);
|
|
1123
|
+
logger.debug(`Sign-in method step: reached=${nav.reached} (${nav.reason}) after ${nav.steps} step(s)`);
|
|
1124
|
+
if (nav.state) {
|
|
1125
|
+
logger.debug(`Current screen: ${shortUrl(nav.state.url)} — heading: "${nav.state.heading || '(none)'}"`);
|
|
678
1126
|
}
|
|
679
1127
|
|
|
680
|
-
if (
|
|
681
|
-
logger.
|
|
682
|
-
|
|
683
|
-
const otherWays = page.getByRole('button', { name: /Other ways to sign in|Sign in another way/i })
|
|
684
|
-
.or(page.getByText(/Other ways to sign in|Sign in another way/i))
|
|
685
|
-
.first();
|
|
686
|
-
|
|
687
|
-
try {
|
|
688
|
-
logger.debug('Waiting for "Other ways" link to appear in DOM...');
|
|
689
|
-
await otherWays.waitFor({ state: 'attached', timeout: 15000 });
|
|
690
|
-
|
|
691
|
-
const isVisible = await otherWays.isVisible();
|
|
692
|
-
logger.debug(`"Other ways" link visibility: ${isVisible}`);
|
|
693
|
-
|
|
694
|
-
logger.info('Clicking "Other ways to sign in"...');
|
|
695
|
-
try {
|
|
696
|
-
await otherWays.click({ timeout: 5000 });
|
|
697
|
-
} catch (e) {
|
|
698
|
-
logger.debug(`Standard click failed, trying forced: ${e.message}`);
|
|
699
|
-
await otherWays.click({ force: true, timeout: 5000 });
|
|
700
|
-
}
|
|
701
|
-
} catch (e) {
|
|
702
|
-
logger.warn(`MFA link interaction failed: ${e.message}`);
|
|
703
|
-
|
|
704
|
-
logger.debug('Attempting final fallback: JavaScript-based click...');
|
|
705
|
-
const clicked = await page.evaluate(() => {
|
|
706
|
-
const elements = Array.from(document.querySelectorAll('span, a, button'));
|
|
707
|
-
const target = elements.find(el =>
|
|
708
|
-
el.textContent.toLowerCase().includes('other ways to sign in') ||
|
|
709
|
-
el.textContent.toLowerCase().includes('sign in another way')
|
|
710
|
-
);
|
|
711
|
-
if (target) {
|
|
712
|
-
target.click();
|
|
713
|
-
return true;
|
|
714
|
-
}
|
|
715
|
-
return false;
|
|
716
|
-
});
|
|
717
|
-
|
|
718
|
-
if (clicked) {
|
|
719
|
-
logger.info('Successfully triggered click via JavaScript fallback.');
|
|
720
|
-
} else if (pageHeading.includes('Get a code')) {
|
|
721
|
-
throw new Error('STUCK: "Other ways to sign in" link not found even via JS scan.');
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
logger.debug('Waiting for method selection screen ("Use your password")...');
|
|
726
|
-
const subResult = await Promise.race([
|
|
727
|
-
page.waitForSelector('text=/Use your password/i', { state: 'visible', timeout: 15000 }).then(() => 'use_password'),
|
|
728
|
-
page.waitForSelector('#idA_PWD_SwitchToPassword', { state: 'visible', timeout: 15000 }).then(() => 'use_password'),
|
|
729
|
-
page.waitForSelector('text=/Select a verification method/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways_list'),
|
|
730
|
-
]).catch(() => 'timeout');
|
|
731
|
-
|
|
732
|
-
logger.debug(`Sub-screen detection result: ${subResult}`);
|
|
733
|
-
|
|
734
|
-
if (subResult === 'use_password') {
|
|
735
|
-
logger.info('Selecting "Use your password" option...');
|
|
736
|
-
await page.click('text=/Use your password/i');
|
|
737
|
-
} else if (subResult === 'other_ways_list') {
|
|
738
|
-
logger.info('Selection list detected. Looking for "Password"...');
|
|
739
|
-
await page.click('text=/Password|Use your password/i');
|
|
740
|
-
}
|
|
741
|
-
} else if (result === 'use_password') {
|
|
742
|
-
logger.info('Detected "Use your password" option. Clicking...');
|
|
743
|
-
await page.click('text="Use your password"');
|
|
744
|
-
} else if (result === 'approve_app') {
|
|
745
|
-
logger.warn('MFA notification already sent. Attempting to switch to password...');
|
|
746
|
-
const otherLink = page.locator('text="Other ways to sign in", #signInAnotherWay').first();
|
|
747
|
-
if (await otherLink.isVisible()) {
|
|
748
|
-
await otherLink.click();
|
|
749
|
-
await page.waitForSelector('text="Use your password"', { state: 'visible', timeout: 10000 });
|
|
750
|
-
await page.click('text="Use your password"');
|
|
751
|
-
}
|
|
752
|
-
} else if (result === 'password') {
|
|
753
|
-
logger.debug('Direct password field detected.');
|
|
754
|
-
} else if (result === 'timeout') {
|
|
755
|
-
logger.debug('No intermediate screen detected within timeout. Proceeding to password entry.');
|
|
1128
|
+
if (!nav.reached && nav.reason === 'code_prompt') {
|
|
1129
|
+
logger.warn('Microsoft is asking for a verification code instead of a password. This account cannot finish a password-only login.');
|
|
756
1130
|
}
|
|
757
1131
|
} catch (e) {
|
|
758
|
-
|
|
1132
|
+
// Never fatal: step 2 still waits for the password box and reports
|
|
1133
|
+
// precisely which screen is in the way if it is not there.
|
|
1134
|
+
logger.debug(`Sign-in method step skipped: ${e.message}`);
|
|
759
1135
|
}
|
|
760
1136
|
|
|
761
1137
|
// 2. Enter Password
|
|
762
1138
|
try {
|
|
763
|
-
|
|
764
|
-
|
|
1139
|
+
const passwordField = page.locator(PASSWORD_FIELD_SELECTOR).first();
|
|
1140
|
+
try {
|
|
1141
|
+
await passwordField.waitFor({ state: 'visible', timeout: 30000 });
|
|
1142
|
+
} catch (e) {
|
|
1143
|
+
// "page.waitForSelector: Timeout 30000ms exceeded" is the least
|
|
1144
|
+
// actionable error this tool can emit, and it is what a
|
|
1145
|
+
// passwordless screen used to produce. Name the screen instead.
|
|
1146
|
+
const stuck = await readSignInState(page) || await readScreenState(page);
|
|
1147
|
+
if (stuck) {
|
|
1148
|
+
const needsCode = stuck.sendCode || stuck.approveApp || stuck.otcPrompt;
|
|
1149
|
+
throw new Error(
|
|
1150
|
+
`Password field never appeared. Still on ${shortUrl(stuck.url)} — heading: "${stuck.heading || '(none)'}".` +
|
|
1151
|
+
(needsCode
|
|
1152
|
+
? ' Microsoft is offering a code/phone approval here, not a password.'
|
|
1153
|
+
: ' This screen does not offer a password sign-in.')
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
throw e;
|
|
1157
|
+
}
|
|
765
1158
|
|
|
766
|
-
|
|
1159
|
+
await passwordField.fill(password);
|
|
767
1160
|
|
|
768
|
-
logger.debug('
|
|
769
|
-
await
|
|
770
|
-
|
|
771
|
-
logger.debug('Submit button is disabled. It might be the wrong one or the password field is not considered filled.');
|
|
772
|
-
logger.info('Will wait 1 seconds to let the submit button load properly');
|
|
773
|
-
await page.waitForTimeout(1000);
|
|
1161
|
+
logger.debug('Submitting the sign-in form...');
|
|
1162
|
+
if (!(await submitSignInForm(page))) {
|
|
1163
|
+
throw new Error('Password filled but no sign-in submit control was found.');
|
|
774
1164
|
}
|
|
775
1165
|
|
|
776
|
-
await submitButton.click();
|
|
777
|
-
|
|
778
1166
|
const passwordError = page.locator('#passwordError');
|
|
779
1167
|
if (await passwordError.isVisible({ timeout: 2000 })) {
|
|
780
1168
|
const errorMsg = await passwordError.textContent();
|
|
@@ -782,10 +1170,7 @@ async function login(credentials = {}) {
|
|
|
782
1170
|
}
|
|
783
1171
|
} catch (e) {
|
|
784
1172
|
if (credentials.dodump) {
|
|
785
|
-
const
|
|
786
|
-
const displayPath = logger.getDumpDisplayPath();
|
|
787
|
-
const debugFile = path.join(dumpDir, 'debug_login_error_password.html');
|
|
788
|
-
await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
|
|
1173
|
+
const displayPath = await dumpPage(page, 'debug_login_error_password.html');
|
|
789
1174
|
logger.error(`Password entry failed. HTML dumped to ${displayPath}/debug_login_error_password.html`);
|
|
790
1175
|
}
|
|
791
1176
|
throw e;
|
|
@@ -793,10 +1178,7 @@ async function login(credentials = {}) {
|
|
|
793
1178
|
|
|
794
1179
|
// Proactive dump after password submission (before post-password MFA check)
|
|
795
1180
|
if (credentials.dodump) {
|
|
796
|
-
const
|
|
797
|
-
const displayPath = logger.getDumpDisplayPath();
|
|
798
|
-
const debugFile = path.join(dumpDir, 'debug_after_password.html');
|
|
799
|
-
await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
|
|
1181
|
+
const displayPath = await dumpPage(page, 'debug_after_password.html');
|
|
800
1182
|
logger.debug(`[dodump] Post-password state dumped to ${displayPath}/debug_after_password.html`);
|
|
801
1183
|
}
|
|
802
1184
|
|
|
@@ -816,19 +1198,19 @@ async function login(credentials = {}) {
|
|
|
816
1198
|
|
|
817
1199
|
// 2.5b. Handle post-password MFA/Verification if needed
|
|
818
1200
|
try {
|
|
1201
|
+
// ".displaySign" is the legacy number-match element; the Fluent
|
|
1202
|
+
// pages put the same number under a data-testid instead.
|
|
1203
|
+
const NUMBER_MATCH = '.displaySign, [data-testid="displaySign"]';
|
|
819
1204
|
const verificationScreen = await Promise.race([
|
|
820
1205
|
page.waitForSelector('text="Verify your identity"', { timeout: 10000 }).then(() => 'verify'),
|
|
821
1206
|
page.waitForSelector('text="Enter code"', { timeout: 10000 }).then(() => 'enter_code'),
|
|
822
1207
|
page.waitForSelector('input[name="otc"]', { timeout: 10000 }).then(() => 'otc_input'),
|
|
823
1208
|
page.waitForSelector('text=/Approve sign in request/i', { timeout: 10000 }).then(() => 'number_match'),
|
|
824
|
-
page.waitForSelector(
|
|
1209
|
+
page.waitForSelector(NUMBER_MATCH, { timeout: 10000 }).then(() => 'number_match'),
|
|
825
1210
|
]).catch(() => null);
|
|
826
1211
|
|
|
827
1212
|
if (credentials.dodump) {
|
|
828
|
-
const
|
|
829
|
-
const displayPath = logger.getDumpDisplayPath();
|
|
830
|
-
const debugFile = path.join(dumpDir, 'debug_post_password_mfa.html');
|
|
831
|
-
await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
|
|
1213
|
+
const displayPath = await dumpPage(page, 'debug_post_password_mfa.html');
|
|
832
1214
|
logger.debug(`[dodump] Post-password MFA screen state dumped to ${displayPath}/debug_post_password_mfa.html`);
|
|
833
1215
|
}
|
|
834
1216
|
|
|
@@ -837,20 +1219,20 @@ async function login(credentials = {}) {
|
|
|
837
1219
|
|
|
838
1220
|
let matchNumber = '??';
|
|
839
1221
|
try {
|
|
840
|
-
matchNumber = await page
|
|
1222
|
+
matchNumber = await page.locator(NUMBER_MATCH).first().textContent().catch(() => null) || '??';
|
|
841
1223
|
} catch (_) {
|
|
842
|
-
logger.debug('Could not extract number
|
|
1224
|
+
logger.debug('Could not extract the number-match code — user may still see it if --notheadless is used.');
|
|
843
1225
|
}
|
|
844
1226
|
|
|
845
1227
|
logger.step('══════════════════════════════════════════════════════');
|
|
846
1228
|
logger.step(` ACTION REQUIRED: Open Microsoft Authenticator on your phone.`);
|
|
847
|
-
logger.step(` Enter the number: ${matchNumber}`);
|
|
1229
|
+
logger.step(` Enter the number: ${matchNumber.trim()}`);
|
|
848
1230
|
logger.step(` Then tap "Yes" / "Approve" in the app.`);
|
|
849
1231
|
logger.step('══════════════════════════════════════════════════════');
|
|
850
1232
|
logger.info('Waiting for phone approval (up to 120 seconds)...');
|
|
851
1233
|
|
|
852
1234
|
await Promise.race([
|
|
853
|
-
page.waitForSelector(
|
|
1235
|
+
page.waitForSelector(NUMBER_MATCH, { state: 'hidden', timeout: 120000 }),
|
|
854
1236
|
page.waitForURL(url => !url.toString().includes('login.microsoftonline.com'), { timeout: 120000 }),
|
|
855
1237
|
page.waitForSelector('text=/Stay signed in/i', { timeout: 120000 }),
|
|
856
1238
|
]);
|
|
@@ -871,7 +1253,9 @@ async function login(credentials = {}) {
|
|
|
871
1253
|
await page.locator('input[type="text"]:visible, input[type="tel"]:visible').first().fill(code);
|
|
872
1254
|
}
|
|
873
1255
|
|
|
874
|
-
await page
|
|
1256
|
+
if (!(await submitSignInForm(page))) {
|
|
1257
|
+
logger.debug('No submit control found on the verification screen.');
|
|
1258
|
+
}
|
|
875
1259
|
}
|
|
876
1260
|
} catch (e) {
|
|
877
1261
|
logger.debug(`Post-password verification handling skipped or failed: ${e.message}`);
|
|
@@ -914,10 +1298,7 @@ async function login(credentials = {}) {
|
|
|
914
1298
|
}
|
|
915
1299
|
}
|
|
916
1300
|
if (credentials.dodump) {
|
|
917
|
-
const
|
|
918
|
-
const displayPath = logger.getDumpDisplayPath();
|
|
919
|
-
const debugFile = path.join(dumpDir, 'debug_login_error_success.html');
|
|
920
|
-
await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
|
|
1301
|
+
const displayPath = await dumpPage(page, 'debug_login_error_success.html');
|
|
921
1302
|
logger.error(`Success detection failed. HTML dumped to ${displayPath}/debug_login_error_success.html`);
|
|
922
1303
|
}
|
|
923
1304
|
throw e;
|
|
@@ -1019,5 +1400,19 @@ module.exports = {
|
|
|
1019
1400
|
logout,
|
|
1020
1401
|
// Exported for tests: clears the consent/interrupt screens that Microsoft can
|
|
1021
1402
|
// inject mid-login (e.g. the Terms of Use update at account.live.com/tou/accrue).
|
|
1022
|
-
clearBlockingScreens
|
|
1403
|
+
clearBlockingScreens,
|
|
1404
|
+
// Exported for tests: walks the "how do you want to sign in?" screens
|
|
1405
|
+
// (passwordless "Get a code to sign in", "Other ways to sign in", method
|
|
1406
|
+
// list) and lands on the password box.
|
|
1407
|
+
reachPasswordScreen,
|
|
1408
|
+
// Exported for tests: presses the sign-in submit control on both the legacy
|
|
1409
|
+
// (<input type="submit" value="Sign in">) and Fluent (<button>) pages.
|
|
1410
|
+
submitSignInForm,
|
|
1411
|
+
// Exported for tests: the single path every --dodump write goes through, so
|
|
1412
|
+
// that credentials cannot reach a dump file.
|
|
1413
|
+
dumpPage,
|
|
1414
|
+
// Exported for tests: the same success detection with a caller-supplied
|
|
1415
|
+
// timeout, so "is this the authenticated app?" can be asserted on directly
|
|
1416
|
+
// rather than through a 60 s wait.
|
|
1417
|
+
waitForAuthSuccessProbe
|
|
1023
1418
|
};
|