@msout/microsoft-webauth 0.1.0 → 0.1.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 enoola/phptr/msout
3
+ Copyright (c) 2026 msout@tuta.io
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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
@@ -166,5 +166,5 @@ microsoft-webauth-playwright/
166
166
 
167
167
  ## License
168
168
 
169
- MIT — see [LICENSE](LICENSE).
169
+ MIT — see [LICENSE](LICENSE), and read [NOTICE.md](NOTICE.md).
170
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msout/microsoft-webauth",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
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
@@ -483,10 +483,7 @@ async function clearBlockingScreens(page, options = {}) {
483
483
  logger.info(`Blocking screen detected: ${screen.name} (${shortUrl(state.url)}). Accepting it...`);
484
484
 
485
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} -->`));
486
+ const displayPath = await dumpPage(page, `debug_blocking_screen_${i + 1}.html`);
490
487
  logger.debug(`[dodump] Blocking screen state dumped to ${displayPath}/debug_blocking_screen_${i + 1}.html`);
491
488
  }
492
489
 
@@ -514,6 +511,419 @@ async function clearBlockingScreens(page, options = {}) {
514
511
  return { handled, reason: 'max_screens' };
515
512
  }
516
513
 
514
+ /* ------------------------------------------------------------------------- *
515
+ * "How do you want to sign in?" — the screens between email and password
516
+ *
517
+ * For a passwordless-enabled account, Microsoft serves this after the email
518
+ * step (captured in logs/dumps, PageID i5030):
519
+ *
520
+ * <h1 data-testid="title">Get a code to sign in</h1>
521
+ * <button type="submit" data-testid="primaryButton">Send code</button>
522
+ * <span role="button" class="fui-Link" tabindex="0">Use your password</span>
523
+ *
524
+ * Note what is *not* there: an "Other ways to sign in" link. The password is
525
+ * only reachable via the "Use your password" link in the footer of that very
526
+ * screen, and it is a <span role="button">, not an <a>, so it only responds to
527
+ * real pointer events. The previous implementation assumed "Other ways to sign
528
+ * in" always came first: it waited 15 s for a link that does not exist, threw
529
+ * STUCK, swallowed it, and then let the password selector time out 30 s later.
530
+ * A ~45 s stall ending in a misleading "incorrect credentials" message.
531
+ *
532
+ * So instead of racing text selectors and betting on which one wins, the state
533
+ * is read from the DOM — what is actually on offer — and "Use your password" is
534
+ * pressed wherever it appears.
535
+ * ------------------------------------------------------------------------- */
536
+
537
+ /**
538
+ * Label patterns for the sign-in-choice screens, as *source strings*: they are
539
+ * handed into page.evaluate(), where RegExp objects do not survive
540
+ * serialization and must be rebuilt on the far side.
541
+ */
542
+ const SIGN_IN_LABELS = {
543
+ usePassword: 'use your password|use a password instead|use password instead|sign in with a password',
544
+ otherWays: 'other ways to sign in|sign in another way|look for another way|try another way',
545
+ // The list of methods, where "Password" is one entry among several.
546
+ passwordEntry: '^password$|use your password|use a password',
547
+ methodList: 'select a (?:sign-in |verification )?method|choose (?:a |another )?way to sign in|how do you want to sign in',
548
+ sendCode: 'send code|get a code to sign in|text me a code|email me a code',
549
+ approveApp: 'approve a request on my microsoft authenticator app|approve sign in request',
550
+ otcPrompt: 'enter (?:the )?code|type (?:the )?code|verification code',
551
+ };
552
+
553
+ /** Compiles a SIGN_IN_LABELS entry into an anchored, case-insensitive RegExp. */
554
+ function signInLabel(key) {
555
+ return new RegExp(SIGN_IN_LABELS[key], 'i');
556
+ }
557
+
558
+ /** Selectors for anything on the page that can be clicked, on either UI generation. */
559
+ const CLICKABLE_SELECTOR = 'a[href], button, input[type="submit"], input[type="button"], [role="button"], [role="link"]';
560
+
561
+ /** The password box, on both the legacy and the Fluent sign-in pages. */
562
+ const PASSWORD_FIELD_SELECTOR = 'input[name="passwd"], input[type="password"]';
563
+
564
+ /**
565
+ * Reads what the current sign-in screen actually offers, in a single round trip.
566
+ *
567
+ * Everything is answered from one evaluate() so the reading is a consistent
568
+ * snapshot: seven separate locators would each sample the page at a slightly
569
+ * different moment, which is how a screen mid-navigation gets misclassified.
570
+ *
571
+ * @param {import('playwright').Page} page
572
+ * @returns {Promise<object|null>} null while the document is being swapped
573
+ */
574
+ async function readSignInState(page) {
575
+ try {
576
+ return await page.evaluate(labels => {
577
+ const visible = el => {
578
+ if (!el) return false;
579
+ const rect = el.getBoundingClientRect();
580
+ if (rect.width <= 0 || rect.height <= 0) return false;
581
+ const style = window.getComputedStyle(el);
582
+ return style.visibility !== 'hidden' && style.display !== 'none';
583
+ };
584
+
585
+ const labelOf = el => `${el.value || ''} ${el.textContent || ''}`.replace(/\s+/g, ' ').trim();
586
+
587
+ // True when a *control* carrying this label is on screen. Deliberately
588
+ // not a body-text search: prose mentioning "another way" must never
589
+ // be mistaken for the button that acts on it.
590
+ const offersAction = key => {
591
+ const rx = new RegExp(labels[key], 'i');
592
+ return Array.from(document.querySelectorAll(
593
+ 'a[href], button, input[type="submit"], input[type="button"], [role="button"], [role="link"]'
594
+ )).some(el => visible(el) && rx.test(labelOf(el)));
595
+ };
596
+
597
+ const headingEl = document.querySelector('h1, [role="heading"], [data-testid="title"]');
598
+ const body = document.body ? (document.body.innerText || '').replace(/\s+/g, ' ') : '';
599
+
600
+ return {
601
+ url: location.href,
602
+ heading: (headingEl ? headingEl.textContent : '').replace(/\s+/g, ' ').trim(),
603
+ // Still on the email form: the step after it has not rendered yet.
604
+ emailField: visible(document.querySelector('input[name="loginfmt"]')),
605
+ passwordField: visible(document.querySelector('input[name="passwd"], input[type="password"]')),
606
+ otcField: visible(document.querySelector('input[name="otc"], input[type="tel"]')),
607
+ usePassword: offersAction('usePassword'),
608
+ otherWays: offersAction('otherWays'),
609
+ sendCode: offersAction('sendCode') || new RegExp(labels.sendCode, 'i').test(body),
610
+ // Prose-only screens: read from the page text, nothing to click.
611
+ methodList: new RegExp(labels.methodList, 'i').test(body),
612
+ approveApp: new RegExp(labels.approveApp, 'i').test(body),
613
+ otcPrompt: new RegExp(labels.otcPrompt, 'i').test(body)
614
+ };
615
+ }, SIGN_IN_LABELS);
616
+ } catch (_) {
617
+ // Execution context destroyed mid-navigation — caller should retry.
618
+ return null;
619
+ }
620
+ }
621
+
622
+ /** True when the screen is one this handler knows how to act on. */
623
+ function isActionableSignInState(state) {
624
+ if (!state || state.emailField) return false;
625
+ return !!(state.passwordField || state.usePassword || state.otherWays
626
+ || state.methodList || state.sendCode || state.approveApp || state.otcPrompt);
627
+ }
628
+
629
+ /** Identity of a sign-in screen, to tell a real step change from a re-render. */
630
+ function signInStateSignature(state) {
631
+ if (!state) return null;
632
+ return [state.url, state.heading, state.passwordField, state.usePassword,
633
+ state.otherWays, state.methodList, state.sendCode, state.approveApp].join('::');
634
+ }
635
+
636
+ /**
637
+ * Polls until `accept` is satisfied or the timeout runs out.
638
+ * @returns {Promise<object|null>} the accepted state, else the last readable one
639
+ */
640
+ async function waitForSignInState(page, timeout, accept = isActionableSignInState) {
641
+ const deadline = Date.now() + timeout;
642
+ let state = null;
643
+ do {
644
+ const read = await readSignInState(page);
645
+ if (read) {
646
+ state = read;
647
+ if (accept(read)) return read;
648
+ }
649
+ await page.waitForTimeout(400).catch(() => {});
650
+ } while (Date.now() < deadline);
651
+ // The last *readable* state, not whatever a mid-navigation read happened to
652
+ // return: null would discard the only evidence of why the step is stuck.
653
+ return state;
654
+ }
655
+
656
+ /**
657
+ * Clicks the control carrying a given label.
658
+ *
659
+ * The Fluent pages render links as <span role="button">, which only react to a
660
+ * real pointer event: a .click() on an ancestor wrapper fires nothing. So the
661
+ * lookup escalates from the most semantic to the most forceful, and the last
662
+ * step targets the *innermost* match rather than the first in document order.
663
+ *
664
+ * @param {import('playwright').Page} page
665
+ * @param {RegExp} rx
666
+ * @param {{ timeout?: number }} [options]
667
+ * @returns {Promise<string|null>} the label clicked, or null if nothing matched
668
+ */
669
+ async function clickByLabel(page, rx, { timeout = 8000 } = {}) {
670
+ // Precise strategies first: they match on the accessible name, so they hit
671
+ // the control the user sees rather than a wrapper.
672
+ const precise = [
673
+ page.getByRole('button', { name: rx }),
674
+ page.getByRole('link', { name: rx })
675
+ ];
676
+
677
+ // Raced, not sequential: a control is a button on one screen generation and a
678
+ // link on the other, so trying them in turn means paying the full timeout on
679
+ // whichever role does not apply.
680
+ const hit = await Promise.any(
681
+ precise.map((locator, i) => locator.first().waitFor({ state: 'visible', timeout }).then(() => i))
682
+ ).catch(() => -1);
683
+
684
+ // getByText is the fuzzier fallback, and the JS click the forceful one.
685
+ if (hit < 0) {
686
+ try {
687
+ await page.getByText(rx).first().waitFor({ state: 'visible', timeout: Math.min(timeout, 2000) });
688
+ precise.push(page.getByText(rx));
689
+ hit = precise.length - 1;
690
+ } catch (_) { /* fall through to the JS click */ }
691
+ }
692
+
693
+ if (hit >= 0) {
694
+ const first = precise[hit].first();
695
+ try {
696
+ // Read the label *before* clicking. These controls navigate on click,
697
+ // and textContent() against a locator whose element the navigation just
698
+ // removed blocks for the full 30 s default timeout before rejecting —
699
+ // which is exactly the stall this function is meant to avoid.
700
+ const label = ((await first.textContent({ timeout: 2000 }).catch(() => '')) || '')
701
+ .replace(/\s+/g, ' ').trim();
702
+ await first.click({ timeout: Math.min(timeout, 5000) });
703
+ return label || '(clicked)';
704
+ } catch (_) {
705
+ // Found but not clickable — let the JS click have a go.
706
+ }
707
+ }
708
+
709
+ return await page.evaluate(({ selector, source }) => {
710
+ const rx = new RegExp(source, 'i');
711
+ const labelOf = el => `${el.value || ''} ${el.textContent || ''}`.replace(/\s+/g, ' ').trim();
712
+ const hits = Array.from(document.querySelectorAll(selector)).filter(el => rx.test(labelOf(el)));
713
+ // Innermost hit: an ancestor's textContent includes the descendant's, so
714
+ // the first match in document order is usually a wrapper whose click
715
+ // never reaches the handler the user can see.
716
+ const target = hits.find(el => !hits.some(other => other !== el && el.contains(other)));
717
+ if (!target) return null;
718
+ target.click();
719
+ return labelOf(target);
720
+ }, { selector: CLICKABLE_SELECTOR, source: rx.source }).catch(() => null);
721
+ }
722
+
723
+ /**
724
+ * Presses the sign-in submit control.
725
+ *
726
+ * The legacy pages use <input type="submit" value="Sign in">, where the label
727
+ * lives in `value` and `filter({ hasText })` can never match it; the Fluent pages
728
+ * use <button type="submit" data-testid="primaryButton">Sign in</button>. Matching
729
+ * on the accessible role name covers both.
730
+ *
731
+ * @param {import('playwright').Page} page
732
+ * @returns {Promise<boolean>} true if a submit control was clicked
733
+ */
734
+ async function submitSignInForm(page) {
735
+ const strategies = [
736
+ page.getByRole('button', { name: /^(sign in|next|finish|continue)$/i }),
737
+ page.locator('input[type="submit"]'),
738
+ page.locator('button[type="submit"]')
739
+ ];
740
+
741
+ for (const locator of strategies) {
742
+ const button = locator.first();
743
+ if (!(await button.isVisible().catch(() => false))) continue;
744
+ // click() waits for the element to become enabled, which covers the
745
+ // short window after fill() while the page validates the password.
746
+ await button.click({ timeout: 10000 });
747
+ return true;
748
+ }
749
+ return false;
750
+ }
751
+
752
+ /**
753
+ * Walks the "how do you want to sign in?" screens and lands on the password box.
754
+ *
755
+ * Prefers "Use your password" wherever it is offered, because on the
756
+ * passwordless screen that link is the only route to the password — there is no
757
+ * "Other ways to sign in" step to go through first. Only falls back to that
758
+ * step when the screen really does present it, then picks "Password" out of the
759
+ * resulting method list.
760
+ *
761
+ * @param {import('playwright').Page} page
762
+ * @param {object} [options]
763
+ * @param {number} [options.maxSteps]
764
+ * @param {number} [options.stateTimeout] how long to wait for the first screen
765
+ * @param {number} [options.transitionTimeout] how long to wait after each click
766
+ * @param {boolean} [options.dodump]
767
+ * @param {string} [options.dumpFile] basename written under the dump dir
768
+ * @returns {Promise<{ reached: boolean, reason: string, steps: number, state: object|null }>}
769
+ */
770
+ async function reachPasswordScreen(page, options = {}) {
771
+ const {
772
+ maxSteps = 4,
773
+ stateTimeout = 15000,
774
+ transitionTimeout = 10000,
775
+ dodump = false,
776
+ dumpFile = 'debug_intermediate_screen'
777
+ } = options;
778
+
779
+ let state = await waitForSignInState(page, stateTimeout);
780
+ let dumped = false;
781
+
782
+ 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) {
788
+ logger.debug(`Password field reached after ${steps} step(s).`);
789
+ return { reached: true, reason: 'password_field', steps, state };
790
+ }
791
+
792
+ if (dodump && !dumped) {
793
+ dumped = true;
794
+ const displayPath = await dumpPage(page, `${dumpFile}.html`);
795
+ logger.debug(`[dodump] Intermediate screen state dumped to ${displayPath}/${dumpFile}.html`);
796
+ }
797
+
798
+ const before = signInStateSignature(state);
799
+ let clicked = null;
800
+
801
+ if (state.usePassword) {
802
+ logger.info('Password is offered on this screen — clicking "Use your password"...');
803
+ clicked = await clickByLabel(page, signInLabel('usePassword'));
804
+ } else if (state.otherWays) {
805
+ logger.info('Opening "Other ways to sign in"...');
806
+ clicked = await clickByLabel(page, signInLabel('otherWays'));
807
+ } else if (state.methodList) {
808
+ logger.info('Choosing "Password" from the sign-in method list...');
809
+ clicked = await clickByLabel(page, signInLabel('passwordEntry'));
810
+ }
811
+
812
+ if (!clicked) {
813
+ // No route to a password from here. Say which kind of screen it is,
814
+ // so a mandatory code/approval is not reported as a bad password.
815
+ const reason = state.approveApp ? 'approver_prompt'
816
+ : state.otcPrompt || state.sendCode || state.otcField ? 'code_prompt'
817
+ : 'no_password_route';
818
+ logger.warn(`No way to reach the password screen from "${state.heading || shortUrl(state.url)}" (${reason}).`);
819
+ return { reached: false, reason, steps, state };
820
+ }
821
+
822
+ logger.debug(`Clicked "${clicked}". Waiting for the next screen...`);
823
+
824
+ // Wait for a *different* screen, not just the password box: the method
825
+ // list is a legitimate hop in the middle of this walk, and waiting only
826
+ // for the password field would burn the whole timeout on it. The
827
+ // signature guard keeps the not-yet-navigated pre-click state from
828
+ // satisfying the wait immediately.
829
+ state = await waitForSignInState(page, transitionTimeout,
830
+ s => s.passwordField || (isActionableSignInState(s) && signInStateSignature(s) !== before));
831
+ if (state && signInStateSignature(state) === before) {
832
+ logger.warn(`Screen did not change after clicking "${clicked}".`);
833
+ return { reached: false, reason: 'unchanged', steps, state };
834
+ }
835
+ }
836
+
837
+ return { reached: false, reason: 'max_steps', steps: maxSteps, state };
838
+ }
839
+
840
+ /**
841
+ * Writes the current page to a debug dump, with credentials removed.
842
+ *
843
+ * `--dodump` calls page.content(), and that serialises the *live value* of every
844
+ * form control. On the page where the password is typed that means the user's
845
+ * actual Microsoft password lands on disk in cleartext:
846
+ *
847
+ * <input type="password" name="passwd" value="the-real-password">
848
+ *
849
+ * The dumps are gitignored and never packed, but they land in the working
850
+ * tree, where backups, sync clients and file-sharing will pick them up, and
851
+ * they get pasted into issues and chat. Every dump goes through here so the
852
+ * redaction cannot be forgotten at the next call site.
853
+ *
854
+ * The scrubbing happens on a *clone* of the document rather than by rewriting
855
+ * the HTML string: PPFT and the other flow tokens are the values the login
856
+ * form is about to POST, so blanking them in the live DOM would break the very
857
+ * login being debugged. A clone cannot affect the page.
858
+ *
859
+ * @param {import('playwright').Page} page
860
+ * @param {string} fileName basename, e.g. debug_after_password.html
861
+ */
862
+ async function dumpPage(page, fileName) {
863
+ const dumpDir = await logger.getDumpDir();
864
+ const displayPath = logger.getDumpDisplayPath();
865
+ await fs.writeFile(path.join(dumpDir, fileName), await redactedPageContent(page));
866
+ return displayPath;
867
+ }
868
+
869
+ /** Placeholder written over any redacted value, so a scrubbed dump is obvious. */
870
+ const REDACTED = '[redacted]';
871
+
872
+ /**
873
+ * Serialises the page with credential-bearing control values blanked.
874
+ *
875
+ * Redacted: every `input[type=password]` whatever it is named, plus any control
876
+ * whose name or id reads as a credential or a bearer token — PPFT and the other
877
+ * pre-auth flow tokens, id/access/refresh tokens, secrets, and OTP / one-time
878
+ * code fields.
879
+ *
880
+ * Deliberately *not* redacted: the account identifier (`loginfmt`, `login`).
881
+ * It is already written in cleartext by the "Attempting automated login for
882
+ * <email>" line and is on the command line, so hiding it in the HTML would
883
+ * protect nothing while removing the one field worth having when a login fails
884
+ * on the wrong account.
885
+ *
886
+ * @param {import('playwright').Page} page
887
+ * @returns {Promise<string>}
888
+ */
889
+ async function redactedPageContent(page) {
890
+ return await page.evaluate(REDACTED => {
891
+ // Any control whose name or id holds a credential or a bearer token
892
+ // rather than UI state. Substring matching on purpose: these names are
893
+ // compound in the wild (otc, otcFallback, verificationCode, srfSFT) and
894
+ // an anchored pattern misses all but the exact spelling. Over-redacting
895
+ // one extra field costs a debug dump nothing; under-redacting leaks a
896
+ // credential. PPFT is Microsoft's pre-auth flow token and matches no
897
+ // generic word, so it is named outright.
898
+ const SENSITIVE = /ppft|token|canary|secret|passw|credential|otp|otc|code$|pin$/i;
899
+
900
+ const isSensitive = el => {
901
+ if (String(el.type || '').toLowerCase() === 'password') return true;
902
+ return SENSITIVE.test(`${el.name || ''}`.trim()) || SENSITIVE.test(`${el.id || ''}`.trim());
903
+ };
904
+
905
+ const clone = document.documentElement.cloneNode(true);
906
+ for (const el of clone.querySelectorAll('input, textarea')) {
907
+ // Only a value that is actually there needs hiding.
908
+ if (!el.value || !isSensitive(el)) continue;
909
+
910
+ if (el.tagName === 'TEXTAREA') {
911
+ el.textContent = REDACTED;
912
+ } else {
913
+ // setAttribute, never `.value =`. Assigning the IDL property on a
914
+ // *visible* input puts the element into "dirty value mode": the IDL
915
+ // value changes but the content attribute is left alone, and
916
+ // outerHTML serialises the content attribute — so the secret comes
917
+ // out unchanged. Only type="hidden" inputs are saved by that
918
+ // accident, and a password field is a visible input, which is
919
+ // exactly the case that would have leaked.
920
+ el.setAttribute('value', REDACTED);
921
+ }
922
+ }
923
+ return `<!DOCTYPE html>\n${clone.outerHTML}`;
924
+ }, REDACTED).catch(e => `<!-- Error redacting or reading page: ${e.message} -->`);
925
+ }
926
+
517
927
  async function login(credentials = {}) {
518
928
  const { email, password, targetUrl, authFile } = credentials;
519
929
  const isAutomated = !!(email && password);
@@ -625,10 +1035,7 @@ async function login(credentials = {}) {
625
1035
  } catch (e) {
626
1036
  logger.error(`Failed to enter email: ${e.message}`);
627
1037
  if (credentials.dodump) {
628
- const dumpDir = await logger.getDumpDir();
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} -->`));
1038
+ const displayPath = await dumpPage(page, 'debug_login_error_email.html');
632
1039
  logger.error(`Email submission failed. HTML dumped to ${displayPath}/debug_login_error_email.html`);
633
1040
  }
634
1041
  throw e;
@@ -636,145 +1043,61 @@ async function login(credentials = {}) {
636
1043
 
637
1044
  // Proactive dump after email step (before MFA detection)
638
1045
  if (credentials.dodump) {
639
- const dumpDir = await logger.getDumpDir();
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} -->`));
1046
+ const displayPath = await dumpPage(page, 'debug_after_email.html');
643
1047
  logger.debug(`[dodump] Post-email state dumped to ${displayPath}/debug_after_email.html`);
644
1048
  }
645
1049
 
646
- // 1.5. Handle intermediate screens (MFA selection, "Other ways to sign in")
1050
+ // 1.5. Get from the email step to the password box. This screen has
1051
+ // no "Other ways to sign in" step on it — the "Use your password" link
1052
+ // in its footer is the only route to the password — so the state is
1053
+ // read from the DOM rather than guessed from a race between text
1054
+ // selectors. See reachPasswordScreen() above.
647
1055
  try {
648
- const pageTitle = (await page.title()).trim();
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}`);
1056
+ const nav = await reachPasswordScreen(page, { dodump: credentials.dodump });
671
1057
 
672
- if (credentials.dodump) {
673
- const dumpDir = await logger.getDumpDir();
674
- const displayPath = logger.getDumpDisplayPath();
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`);
1058
+ logger.debug(`Sign-in method step: reached=${nav.reached} (${nav.reason}) after ${nav.steps} step(s)`);
1059
+ if (nav.state) {
1060
+ logger.debug(`Current screen: ${shortUrl(nav.state.url)} — heading: "${nav.state.heading || '(none)'}"`);
678
1061
  }
679
1062
 
680
- if (result === 'other_ways' || pageHeading.includes('Get a code') || pageHeading.includes('Verify your identity')) {
681
- logger.info('Detected MFA/Verification screen. Attempting to locate "Other ways to sign in"...');
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.');
1063
+ if (!nav.reached && nav.reason === 'code_prompt') {
1064
+ logger.warn('Microsoft is asking for a verification code instead of a password. This account cannot finish a password-only login.');
756
1065
  }
757
1066
  } catch (e) {
758
- logger.debug(`Intermediate screen handler encountered a fatal issue: ${e.message}`);
1067
+ // Never fatal: step 2 still waits for the password box and reports
1068
+ // precisely which screen is in the way if it is not there.
1069
+ logger.debug(`Sign-in method step skipped: ${e.message}`);
759
1070
  }
760
1071
 
761
1072
  // 2. Enter Password
762
1073
  try {
763
- await page.waitForSelector('input[name="passwd"]', { state: 'visible', timeout: 30000 });
764
- await page.fill('input[name="passwd"]', password);
1074
+ const passwordField = page.locator(PASSWORD_FIELD_SELECTOR).first();
1075
+ try {
1076
+ await passwordField.waitFor({ state: 'visible', timeout: 30000 });
1077
+ } catch (e) {
1078
+ // "page.waitForSelector: Timeout 30000ms exceeded" is the least
1079
+ // actionable error this tool can emit, and it is what a
1080
+ // passwordless screen used to produce. Name the screen instead.
1081
+ const stuck = await readSignInState(page) || await readScreenState(page);
1082
+ if (stuck) {
1083
+ const needsCode = stuck.sendCode || stuck.approveApp || stuck.otcPrompt;
1084
+ throw new Error(
1085
+ `Password field never appeared. Still on ${shortUrl(stuck.url)} — heading: "${stuck.heading || '(none)'}".` +
1086
+ (needsCode
1087
+ ? ' Microsoft is offering a code/phone approval here, not a password.'
1088
+ : ' This screen does not offer a password sign-in.')
1089
+ );
1090
+ }
1091
+ throw e;
1092
+ }
765
1093
 
766
- const submitButton = page.locator('input[type="submit"], button[type="submit"]').filter({ hasText: /Sign in|Next|Finish/i }).first();
1094
+ await passwordField.fill(password);
767
1095
 
768
- logger.debug('Waiting for submit button to be enabled...');
769
- await submitButton.waitFor({ state: 'visible', timeout: 10000 });
770
- if (await submitButton.isDisabled()) {
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);
1096
+ logger.debug('Submitting the sign-in form...');
1097
+ if (!(await submitSignInForm(page))) {
1098
+ throw new Error('Password filled but no sign-in submit control was found.');
774
1099
  }
775
1100
 
776
- await submitButton.click();
777
-
778
1101
  const passwordError = page.locator('#passwordError');
779
1102
  if (await passwordError.isVisible({ timeout: 2000 })) {
780
1103
  const errorMsg = await passwordError.textContent();
@@ -782,10 +1105,7 @@ async function login(credentials = {}) {
782
1105
  }
783
1106
  } catch (e) {
784
1107
  if (credentials.dodump) {
785
- const dumpDir = await logger.getDumpDir();
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} -->`));
1108
+ const displayPath = await dumpPage(page, 'debug_login_error_password.html');
789
1109
  logger.error(`Password entry failed. HTML dumped to ${displayPath}/debug_login_error_password.html`);
790
1110
  }
791
1111
  throw e;
@@ -793,10 +1113,7 @@ async function login(credentials = {}) {
793
1113
 
794
1114
  // Proactive dump after password submission (before post-password MFA check)
795
1115
  if (credentials.dodump) {
796
- const dumpDir = await logger.getDumpDir();
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} -->`));
1116
+ const displayPath = await dumpPage(page, 'debug_after_password.html');
800
1117
  logger.debug(`[dodump] Post-password state dumped to ${displayPath}/debug_after_password.html`);
801
1118
  }
802
1119
 
@@ -816,19 +1133,19 @@ async function login(credentials = {}) {
816
1133
 
817
1134
  // 2.5b. Handle post-password MFA/Verification if needed
818
1135
  try {
1136
+ // ".displaySign" is the legacy number-match element; the Fluent
1137
+ // pages put the same number under a data-testid instead.
1138
+ const NUMBER_MATCH = '.displaySign, [data-testid="displaySign"]';
819
1139
  const verificationScreen = await Promise.race([
820
1140
  page.waitForSelector('text="Verify your identity"', { timeout: 10000 }).then(() => 'verify'),
821
1141
  page.waitForSelector('text="Enter code"', { timeout: 10000 }).then(() => 'enter_code'),
822
1142
  page.waitForSelector('input[name="otc"]', { timeout: 10000 }).then(() => 'otc_input'),
823
1143
  page.waitForSelector('text=/Approve sign in request/i', { timeout: 10000 }).then(() => 'number_match'),
824
- page.waitForSelector('.displaySign', { timeout: 10000 }).then(() => 'number_match'),
1144
+ page.waitForSelector(NUMBER_MATCH, { timeout: 10000 }).then(() => 'number_match'),
825
1145
  ]).catch(() => null);
826
1146
 
827
1147
  if (credentials.dodump) {
828
- const dumpDir = await logger.getDumpDir();
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} -->`));
1148
+ const displayPath = await dumpPage(page, 'debug_post_password_mfa.html');
832
1149
  logger.debug(`[dodump] Post-password MFA screen state dumped to ${displayPath}/debug_post_password_mfa.html`);
833
1150
  }
834
1151
 
@@ -837,20 +1154,20 @@ async function login(credentials = {}) {
837
1154
 
838
1155
  let matchNumber = '??';
839
1156
  try {
840
- matchNumber = await page.$eval('.displaySign', el => el.textContent.trim());
1157
+ matchNumber = await page.locator(NUMBER_MATCH).first().textContent().catch(() => null) || '??';
841
1158
  } catch (_) {
842
- logger.debug('Could not extract number from .displaySign — user may still see it if --notheadless is used.');
1159
+ logger.debug('Could not extract the number-match code — user may still see it if --notheadless is used.');
843
1160
  }
844
1161
 
845
1162
  logger.step('══════════════════════════════════════════════════════');
846
1163
  logger.step(` ACTION REQUIRED: Open Microsoft Authenticator on your phone.`);
847
- logger.step(` Enter the number: ${matchNumber}`);
1164
+ logger.step(` Enter the number: ${matchNumber.trim()}`);
848
1165
  logger.step(` Then tap "Yes" / "Approve" in the app.`);
849
1166
  logger.step('══════════════════════════════════════════════════════');
850
1167
  logger.info('Waiting for phone approval (up to 120 seconds)...');
851
1168
 
852
1169
  await Promise.race([
853
- page.waitForSelector('.displaySign', { state: 'hidden', timeout: 120000 }),
1170
+ page.waitForSelector(NUMBER_MATCH, { state: 'hidden', timeout: 120000 }),
854
1171
  page.waitForURL(url => !url.toString().includes('login.microsoftonline.com'), { timeout: 120000 }),
855
1172
  page.waitForSelector('text=/Stay signed in/i', { timeout: 120000 }),
856
1173
  ]);
@@ -871,7 +1188,9 @@ async function login(credentials = {}) {
871
1188
  await page.locator('input[type="text"]:visible, input[type="tel"]:visible').first().fill(code);
872
1189
  }
873
1190
 
874
- await page.click('input[type="submit"]');
1191
+ if (!(await submitSignInForm(page))) {
1192
+ logger.debug('No submit control found on the verification screen.');
1193
+ }
875
1194
  }
876
1195
  } catch (e) {
877
1196
  logger.debug(`Post-password verification handling skipped or failed: ${e.message}`);
@@ -914,10 +1233,7 @@ async function login(credentials = {}) {
914
1233
  }
915
1234
  }
916
1235
  if (credentials.dodump) {
917
- const dumpDir = await logger.getDumpDir();
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} -->`));
1236
+ const displayPath = await dumpPage(page, 'debug_login_error_success.html');
921
1237
  logger.error(`Success detection failed. HTML dumped to ${displayPath}/debug_login_error_success.html`);
922
1238
  }
923
1239
  throw e;
@@ -1019,5 +1335,15 @@ module.exports = {
1019
1335
  logout,
1020
1336
  // Exported for tests: clears the consent/interrupt screens that Microsoft can
1021
1337
  // inject mid-login (e.g. the Terms of Use update at account.live.com/tou/accrue).
1022
- clearBlockingScreens
1338
+ clearBlockingScreens,
1339
+ // Exported for tests: walks the "how do you want to sign in?" screens
1340
+ // (passwordless "Get a code to sign in", "Other ways to sign in", method
1341
+ // list) and lands on the password box.
1342
+ reachPasswordScreen,
1343
+ // Exported for tests: presses the sign-in submit control on both the legacy
1344
+ // (<input type="submit" value="Sign in">) and Fluent (<button>) pages.
1345
+ submitSignInForm,
1346
+ // Exported for tests: the single path every --dodump write goes through, so
1347
+ // that credentials cannot reach a dump file.
1348
+ dumpPage
1023
1349
  };
package/src/index.js CHANGED
@@ -8,11 +8,12 @@ const { program } = require('commander');
8
8
  const logger = require('./utils/logger');
9
9
  const { login, checkAuth, getAuthMeta, logout } = require('./auth');
10
10
  const { DEFAULT_AUTH_FILE, ONENOTE_URL, OUTLOOK_URL } = require('./config');
11
+ const { version: PKG_VERSION } = require('../package.json');
11
12
 
12
13
  program
13
14
  .name('webauth')
14
15
  .description('Microsoft web authentication via Playwright — extracted from MSOneNote Exporter')
15
- .version('1.0.0');
16
+ .version(PKG_VERSION);
16
17
 
17
18
  program
18
19
  .command('login')