@msout/microsoft-webauth 0.1.3 → 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/package.json +1 -1
- package/src/auth.js +87 -18
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
|
}
|
|
@@ -576,6 +617,14 @@ async function readSignInState(page) {
|
|
|
576
617
|
return await page.evaluate(labels => {
|
|
577
618
|
const visible = el => {
|
|
578
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;
|
|
579
628
|
const rect = el.getBoundingClientRect();
|
|
580
629
|
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
581
630
|
const style = window.getComputedStyle(el);
|
|
@@ -780,15 +829,23 @@ async function reachPasswordScreen(page, options = {}) {
|
|
|
780
829
|
let dumped = false;
|
|
781
830
|
|
|
782
831
|
for (let steps = 0; steps < maxSteps; steps++) {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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) {
|
|
788
841
|
logger.debug(`Password field reached after ${steps} step(s).`);
|
|
789
842
|
return { reached: true, reason: 'password_field', steps, state };
|
|
790
843
|
}
|
|
791
844
|
|
|
845
|
+
if (!state || !isActionableSignInState(state)) {
|
|
846
|
+
return { reached: false, reason: 'unreadable', steps, state };
|
|
847
|
+
}
|
|
848
|
+
|
|
792
849
|
if (dodump && !dumped) {
|
|
793
850
|
dumped = true;
|
|
794
851
|
const displayPath = await dumpPage(page, `${dumpFile}.html`);
|
|
@@ -1019,9 +1076,17 @@ async function login(credentials = {}) {
|
|
|
1019
1076
|
logger.info('Email entered. Clicking "Next"...');
|
|
1020
1077
|
await page.click('input[type="submit"]');
|
|
1021
1078
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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.');
|
|
1025
1090
|
});
|
|
1026
1091
|
|
|
1027
1092
|
logger.info('Will wait 1 seconds to give the UI a moment to settle into the next screen (MFA/Password)');
|
|
@@ -1345,5 +1410,9 @@ module.exports = {
|
|
|
1345
1410
|
submitSignInForm,
|
|
1346
1411
|
// Exported for tests: the single path every --dodump write goes through, so
|
|
1347
1412
|
// that credentials cannot reach a dump file.
|
|
1348
|
-
dumpPage
|
|
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
|
|
1349
1418
|
};
|