@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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",
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 contain /notebooks (authenticated app)
141
- page.waitForURL(url => url.toString().includes('/notebooks'), { timeout: 60000 }),
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
- page.waitForSelector('text="My notebooks"', { state: 'visible', timeout: 60000 }),
144
- page.waitForSelector('text="Create new notebook"', { state: 'visible', timeout: 60000 }),
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
- if (!state || !isActionableSignInState(state)) {
784
- return { reached: false, reason: 'unreadable', steps, state };
785
- }
786
-
787
- if (state.passwordField) {
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
- logger.debug('Waiting for email field to disappear...');
1023
- await page.waitForSelector('input[name="loginfmt"]', { state: 'hidden', timeout: 15000 }).catch(() => {
1024
- logger.debug('Email field still present, proceeding with caution.');
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
  };