@msout/microsoft-webauth 0.0.5

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.
@@ -0,0 +1,38 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(git add *)",
5
+ "Bash(git commit *)",
6
+ "Bash(git push)",
7
+ "Bash(cat)",
8
+ "Bash(let *)",
9
+ "Bash(beforeeach)",
10
+ "Bash(jest.clearallmocks)",
11
+ "Bash(fs.removesync)",
12
+ "Bash(config *)",
13
+ "Bash('../src/config')",
14
+ "Bash(auth *)",
15
+ "Bash('../src/auth')",
16
+ "Bash(})",
17
+ "Bash(expect)",
18
+ "Bash(.tobe)",
19
+ "Bash(eof)",
20
+ "Bash(npm test)",
21
+ "Bash(head *)",
22
+ "Bash(import *)",
23
+ "Bash(with)",
24
+ "Bash(as *)",
25
+ "Bash(content *)",
26
+ "Bash(old_test *)",
27
+ "Bash(new_test *)",
28
+ "Bash(const *)",
29
+ "Bash('https://mock.test/nonexistent')",
30
+ "Bash(// *)",
31
+ "Bash(conservative *)",
32
+ "Bash(}, *)",
33
+ "Bash('''\n\ncontent = content.replace(old_test, new_test)\n\nwith open('/users/enola/workspace-msout/microsoft-webauth-playwright-js/test/auth.test.js', 'w)",
34
+ "Bash(node *)"
35
+ ]
36
+ },
37
+ "$version": 4
38
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 enola/phptr/msout
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # microsoft-webauth
2
+
3
+ A tool to authenticate against microsoft online (live or professionnal tenants)
4
+ e.g:
5
+ - https://onenote.cloud.microsoft/notebooks
6
+ - https://outlook.live.com/mail/
7
+ - and by extension https://login.microsoft.com
8
+
9
+ Microsoft web authentication via Playwright — extracted from [MSOneNote Exporter](https://github.com/msout/Microsoft-OneNote-Exporter).
10
+
11
+ This is a standalone CLI tool for authenticating with Microsoft accounts using Playwright. It handles:
12
+ - Automated login with email/password
13
+ - Manual login in browser
14
+ - MFA/2FA support (OTC codes, number matching)
15
+ - Session persistence
16
+ ## Available on npmjs
17
+
18
+ You can find this package here: https://www.npmjs.com/package/@msout/microsoft-webauth
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install -g @msout/microsoft-webauth
24
+ ```
25
+
26
+ Or locally:
27
+
28
+ ```bash
29
+ npm install @msout/microsoft-webauth
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ### Login (Automated)
35
+
36
+ ```bash
37
+ microsoft-webauth login --email your@email.com --password yourpassword
38
+ ```
39
+
40
+ ### Login (Manual/Interactive)
41
+
42
+ ```bash
43
+ microsoft-webauth login
44
+ ```
45
+
46
+ This will open a browser window. Log in manually, then press Enter when you see the notebooks list.
47
+
48
+ ### Check Authentication Status
49
+
50
+ ```bash
51
+ microsoft-webauth check
52
+ ```
53
+
54
+ ### Logout
55
+
56
+ ```bash
57
+ microsoft-webauth logout
58
+ ```
59
+
60
+ ## Options
61
+
62
+ | Option | Description |
63
+ |--------|-------------|
64
+ | `--email <email>` | Microsoft account email (for automated login) |
65
+ | `--password <password>` | Microsoft account password (for automated login) |
66
+ | `--notheadless` | Run in visible browser mode (disable headless) |
67
+ | `--dodump` | Dump HTML content to files for debugging |
68
+
69
+ ## Output
70
+
71
+ Authentication state is saved to `auth.json` in the project directory. A metadata file `auth-meta.json` stores:
72
+ - Email used for login
73
+ - Login timestamp
74
+
75
+ ## Testing
76
+
77
+ ```bash
78
+ npm test
79
+ ```
80
+
81
+ Run with coverage:
82
+
83
+ ```bash
84
+ npm run test:coverage
85
+ ```
86
+
87
+ ## Project Structure
88
+
89
+ ```
90
+ microsoft-webauth-playwright/
91
+ ├── src/
92
+ │ ├── auth.js # Authentication logic
93
+ │ ├── config.js # Configuration (paths, URLs)
94
+ │ └── utils/
95
+ │ ├── logger.js # Logging utilities
96
+ │ └── retry.js # Retry helpers
97
+ ├── test/ # Jest tests
98
+ ├── package.json
99
+ └── README.md
100
+ ```
101
+
102
+ ## License
103
+
104
+ ISC — same as MSOneNote Exporter.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@msout/microsoft-webauth",
3
+ "version": "0.0.5",
4
+ "description": "Microsoft web authentication module, using playwright to automate the login process and retrieve cookies for authenticated sessions.",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "microsoft-webauth": "src/index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/index.js",
11
+ "login": "node src/index.js login",
12
+ "check": "node src/index.js check",
13
+ "test": "jest",
14
+ "test:watch": "jest --watch"
15
+ },
16
+ "keywords": [
17
+ "microsoft",
18
+ "authentication",
19
+ "playwright",
20
+ "webauth",
21
+ "mfa",
22
+ "javascript"
23
+ ],
24
+ "author": "msout@tuta.io",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/Ms-OneNote-Exporter/microsoft-webauth.git"
29
+ },
30
+ "dependencies": {
31
+ "chalk": "^4.1.2",
32
+ "commander": "^14.0.3",
33
+ "fs-extra": "^11.3.3",
34
+ "playwright": "^1.58.1",
35
+ "readline": "^1.3.0"
36
+ },
37
+ "devDependencies": {
38
+ "jest": "^29.7.0"
39
+ }
40
+ }
package/src/auth.js ADDED
@@ -0,0 +1,638 @@
1
+ const { chromium } = require('playwright');
2
+ const fs = require('fs-extra');
3
+ const logger = require('./utils/logger');
4
+ const { AUTH_FILE, ONENOTE_URL, OUTLOOK_URL } = require('./config');
5
+ const path = require('path');
6
+ const readline = require('readline');
7
+
8
+ // Companion metadata file — stores email + login time for display
9
+ const AUTH_META_FILE = AUTH_FILE.replace('auth.json', 'auth-meta.json');
10
+
11
+ /** Returns { email, loginTime } from auth-meta.json, or null if not found. */
12
+ async function getAuthMeta() {
13
+ try {
14
+ if (await fs.pathExists(AUTH_META_FILE)) {
15
+ return await fs.readJson(AUTH_META_FILE);
16
+ }
17
+ } catch (e) { }
18
+ return null;
19
+ }
20
+
21
+ /** Deletes auth.json and auth-meta.json (full logout). */
22
+ async function logout() {
23
+ await fs.remove(AUTH_FILE);
24
+ await fs.remove(AUTH_META_FILE);
25
+ }
26
+
27
+ /**
28
+ * Prompts the user for input in the terminal.
29
+ */
30
+ function promptUser(query) {
31
+ const rl = readline.createInterface({
32
+ input: process.stdin,
33
+ output: process.stdout
34
+ });
35
+ return new Promise(resolve => rl.question(query, ans => {
36
+ rl.close();
37
+ resolve(ans);
38
+ }));
39
+ }
40
+
41
+ /**
42
+ * Waits for successful authentication based on target URL.
43
+ * @param {import('playwright').Page} page - Playwright page object
44
+ * @param {string} targetUrl - The target URL (ONENOTE_URL or OUTLOOK_URL)
45
+ */
46
+ async function waitForAuthSuccess(page, targetUrl) {
47
+ const isOutlook = targetUrl && targetUrl.includes('outlook.cloud.microsoft');
48
+
49
+ if (isOutlook) {
50
+ logger.info('Waiting for redirection to Outlook mail...');
51
+ await Promise.any([
52
+ // Outlook: wait for email message list (table with emails)
53
+ page.waitForSelector('[aria-label*="message list"], [role="grid"][aria-label*="mail"], .messageList', { state: 'visible', timeout: 60000 }),
54
+ // Fallback: wait for folder navigation (Inbox, Sent, etc.)
55
+ page.waitForSelector('text=/Inbox|Sent Mail|Drafts/i', { state: 'visible', timeout: 60000 }),
56
+ // Fallback: wait for any email-like content
57
+ page.waitForSelector('div[role="row"]', { state: 'visible', timeout: 60000 }),
58
+ ]);
59
+ logger.success('Outlook mail interface detected.');
60
+ } else {
61
+ // OneNote default behavior
62
+ logger.info('Waiting for redirection to notebooks list...');
63
+ await Promise.any([
64
+ page.waitForURL(url => url.toString().includes('/notebooks') || url.hostname.includes('onenote.cloud.microsoft') || url.hostname.includes('onenote.com'), { timeout: 60000 }),
65
+ page.waitForSelector('text="My notebooks"', { state: 'visible', timeout: 60000 }),
66
+ page.waitForSelector('text="Create new notebook"', { state: 'visible', timeout: 60000 }),
67
+ page.waitForSelector('text="Welcome, "', { state: 'visible', timeout: 60000 })
68
+ ]);
69
+ logger.success('Notebooks list detected.');
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Clicks the page-level "Cancel" button on the FIDO/security-key page and
75
+ * waits for the browser to navigate away. Works because the addInitScript
76
+ * override makes navigator.credentials.create() reject immediately, so the
77
+ * native OS-level WebAuthn dialog never appears and the DOM is fully accessible.
78
+ *
79
+ * @param {import('playwright').Page} page
80
+ * @param {object} logger
81
+ */
82
+ async function dismissFidoPage(page, logger) {
83
+ // Try multiple selectors for the page-level Cancel button.
84
+ // On the FIDO page there are two Cancel-labelled things:
85
+ // 1. The native OS WebAuthn dialog (blocked by addInitScript — never appears)
86
+ // 2. The page-level gray "Cancel" button at the bottom of the form
87
+ const cancelSelectors = [
88
+ // Exact role button with text "Cancel" (the bottom-of-form button)
89
+ 'button:has-text("Cancel")',
90
+ '[value="Cancel"]',
91
+ 'input[type="button"][value="Cancel"]',
92
+ ];
93
+
94
+ let clicked = false;
95
+ for (const sel of cancelSelectors) {
96
+ try {
97
+ const btn = page.locator(sel).first();
98
+ await btn.waitFor({ state: 'visible', timeout: 4000 });
99
+ logger.info(`FIDO: clicking page-level Cancel via selector "${sel}"...`);
100
+ await btn.click({ force: true });
101
+ clicked = true;
102
+ break;
103
+ } catch (_) {
104
+ // try next selector
105
+ }
106
+ }
107
+
108
+ if (!clicked) {
109
+ // Last resort: JS click on any visible Cancel button
110
+ logger.warn('FIDO: DOM selectors failed, trying JS click fallback...');
111
+ await page.evaluate(() => {
112
+ const btns = Array.from(document.querySelectorAll('button, input[type="button"], input[type="submit"]'));
113
+ const cancel = btns.find(b => /^cancel$/i.test((b.textContent || b.value || '').trim()));
114
+ if (cancel) cancel.click();
115
+ });
116
+ }
117
+
118
+ // Wait for navigation away from the FIDO page (up to 8 s)
119
+ try {
120
+ await page.waitForURL(url => !url.toString().includes('/fido/'), { timeout: 8000 });
121
+ logger.debug('FIDO: navigated away from FIDO page successfully.');
122
+ } catch (_) {
123
+ logger.warn('FIDO: still on FIDO URL after Cancel — continuing anyway.');
124
+ }
125
+ }
126
+
127
+ async function login(credentials = {}) {
128
+ const { email, password, targetUrl } = credentials;
129
+ const isAutomated = !!(email && password);
130
+ const headless = !credentials.notheadless && isAutomated;
131
+ // Use targetUrl if provided, otherwise default to ONENOTE_URL for backward compatibility
132
+ const finalTargetUrl = targetUrl || ONENOTE_URL;
133
+
134
+ // Added to verify version on user's machine
135
+ logger.debug('Authentication Module: Version 4.4-DEBUG starting...');
136
+
137
+ if (isAutomated) {
138
+ logger.info(`Attempting automated login for ${email}...`);
139
+ } else {
140
+ logger.info('Launching browser for manual authentication...');
141
+ logger.warn('Please log in to your Microsoft account in the browser window.');
142
+ const serviceName = finalTargetUrl.includes('outlook') ? 'Outlook' : 'OneNote';
143
+ logger.warn(`The script will wait until you successfully reach the ${serviceName} interface.`);
144
+ }
145
+
146
+ const browser = await chromium.launch({ headless: !!headless });
147
+ const context = await browser.newContext({
148
+ // Disable WebAuthn/FIDO hardware key prompts
149
+ ignoreHTTPSErrors: false,
150
+ });
151
+ const page = await context.newPage();
152
+
153
+ // Dismiss any native browser dialogs (alert/confirm/prompt) automatically
154
+ page.on('dialog', async dialog => {
155
+ logger.debug(`[dialog] Auto-dismissing native dialog: type=${dialog.type()}, message="${dialog.message()}"`);
156
+ await dialog.dismiss();
157
+ });
158
+
159
+ try {
160
+ // Inject WebAuthn override BEFORE any navigation.
161
+ // navigator.credentials.create() triggers a native OS-level dialog that
162
+ // Playwright cannot dismiss via DOM clicks. Rejecting it programmatically
163
+ // prevents the dialog from ever appearing, making the page-level Cancel
164
+ // button accessible instead.
165
+ await page.addInitScript(() => {
166
+ if (typeof navigator !== 'undefined' && navigator.credentials) {
167
+ navigator.credentials.create = () =>
168
+ Promise.reject(new DOMException('Cancelled by automation', 'NotAllowedError'));
169
+ navigator.credentials.get = (options) => {
170
+ if (options && options.publicKey) {
171
+ return Promise.reject(new DOMException('Cancelled by automation', 'NotAllowedError'));
172
+ }
173
+ // Allow password-manager / federated credential requests through
174
+ return Promise.reject(new DOMException('Cancelled by automation', 'NotAllowedError'));
175
+ };
176
+ }
177
+ });
178
+
179
+ await page.goto(finalTargetUrl);
180
+
181
+ if (isAutomated) {
182
+ logger.step('Automating login steps...');
183
+
184
+ // 0. Handle landing page if it appears (redirection to onenote.cloud.microsoft)
185
+ try {
186
+ // Look for "Sign in" button.
187
+ const signInButton = page.getByRole('button', { name: 'Sign in' }).first();
188
+
189
+ await signInButton.waitFor({ state: 'visible', timeout: 10000 });
190
+
191
+ logger.info('Landing page detected. Clicking "Sign in"...');
192
+
193
+ await signInButton.click({ noWaitAfter: true });
194
+
195
+ logger.debug('Clicked "Sign in", waiting for login form...');
196
+ } catch (e) {
197
+ logger.debug('Landing page not detected or "Sign in" button not found within timeout.');
198
+ }
199
+
200
+ // 1. Enter Email
201
+ try {
202
+ await page.waitForSelector('input[name="loginfmt"]', { state: 'visible', timeout: 30000 });
203
+ await page.fill('input[name="loginfmt"]', email);
204
+
205
+ logger.info('Email entered. Clicking "Next"...');
206
+ await page.click('input[type="submit"]');
207
+
208
+ logger.debug('Waiting for email field to disappear...');
209
+ await page.waitForSelector('input[name="loginfmt"]', { state: 'hidden', timeout: 15000 }).catch(() => {
210
+ logger.debug('Email field still present, proceeding with caution.');
211
+ });
212
+
213
+ logger.info('Will wait 1 seconds to give the UI a moment to settle into the next screen (MFA/Password)');
214
+ await page.waitForTimeout(1000);
215
+
216
+ const usernameError = page.locator('#usernameError');
217
+ if (await usernameError.isVisible({ timeout: 2000 })) {
218
+ const errorMsg = await usernameError.textContent();
219
+ throw new Error(`Login Error (Username): ${errorMsg?.trim()}`);
220
+ }
221
+ } catch (e) {
222
+ logger.error(`Failed to enter email: ${e.message}`);
223
+ if (credentials.dodump) {
224
+ const dumpDir = await logger.getDumpDir();
225
+ const displayPath = logger.getDumpDisplayPath();
226
+ const debugFile = path.join(dumpDir, 'debug_login_error_email.html');
227
+ await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
228
+ logger.error(`Email submission failed. HTML dumped to ${displayPath}/debug_login_error_email.html`);
229
+ }
230
+ throw e;
231
+ }
232
+
233
+ // Proactive dump after email step (before MFA detection)
234
+ if (credentials.dodump) {
235
+ const dumpDir = await logger.getDumpDir();
236
+ const displayPath = logger.getDumpDisplayPath();
237
+ const debugFile = path.join(dumpDir, 'debug_after_email.html');
238
+ await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
239
+ logger.debug(`[dodump] Post-email state dumped to ${displayPath}/debug_after_email.html`);
240
+ }
241
+
242
+ // 1.5. Handle intermediate screens (MFA selection, "Other ways to sign in")
243
+ try {
244
+ const pageTitle = (await page.title()).trim();
245
+ const pageHeading = (await page.locator('h1, [role="heading"]').first().textContent().catch(() => '')).trim();
246
+
247
+ logger.debug(`Settled State: Title="${pageTitle}" | Heading="${pageHeading}"`);
248
+ logger.debug('Checking for intermediate MFA/Sign-in option screens...');
249
+
250
+ const result = await Promise.race([
251
+ page.waitForSelector('text=/Other ways to sign in/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways'),
252
+ page.waitForSelector('text=/Get a code to sign in/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways'),
253
+ page.waitForSelector('text=/Verify your identity/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways'),
254
+ page.waitForSelector('text=/Use your password/i', { state: 'visible', timeout: 15000 }).then(() => 'use_password'),
255
+ page.waitForSelector('text=/Approve a request on my Microsoft Authenticator app/i', { state: 'visible', timeout: 5000 }).then(() => 'approve_app'),
256
+ page.waitForSelector('input[name="passwd"]', { state: 'visible', timeout: 15000 }).then(() => 'password'),
257
+ page.waitForFunction(() => {
258
+ const h = document.querySelector('h1, [role="heading"]')?.textContent || '';
259
+ return h.includes('Get a code') || h.includes('Verify your identity');
260
+ }, { timeout: 15000 }).then(() => 'other_ways'),
261
+ ]).catch((err) => {
262
+ logger.debug(`Detection race timed out or failed: ${err.message}`);
263
+ return 'timeout';
264
+ });
265
+
266
+ logger.debug(`Intermediate screen detection result: ${result}`);
267
+
268
+ if (credentials.dodump) {
269
+ const dumpDir = await logger.getDumpDir();
270
+ const displayPath = logger.getDumpDisplayPath();
271
+ const debugFile = path.join(dumpDir, 'debug_intermediate_screen.html');
272
+ await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
273
+ logger.debug(`[dodump] Intermediate screen state dumped to ${displayPath}/debug_intermediate_screen.html`);
274
+ }
275
+
276
+ if (result === 'other_ways' || pageHeading.includes('Get a code') || pageHeading.includes('Verify your identity')) {
277
+ logger.info('Detected MFA/Verification screen. Attempting to locate "Other ways to sign in"...');
278
+
279
+ const otherWays = page.getByRole('button', { name: /Other ways to sign in|Sign in another way/i })
280
+ .or(page.getByText(/Other ways to sign in|Sign in another way/i))
281
+ .first();
282
+
283
+ try {
284
+ logger.debug('Waiting for "Other ways" link to appear in DOM...');
285
+ await otherWays.waitFor({ state: 'attached', timeout: 15000 });
286
+
287
+ const isVisible = await otherWays.isVisible();
288
+ logger.debug(`"Other ways" link visibility: ${isVisible}`);
289
+
290
+ logger.info('Clicking "Other ways to sign in"...');
291
+ try {
292
+ await otherWays.click({ timeout: 5000 });
293
+ } catch (e) {
294
+ logger.debug(`Standard click failed, trying forced: ${e.message}`);
295
+ await otherWays.click({ force: true, timeout: 5000 });
296
+ }
297
+ } catch (e) {
298
+ logger.warn(`MFA link interaction failed: ${e.message}`);
299
+
300
+ logger.debug('Attempting final fallback: JavaScript-based click...');
301
+ const clicked = await page.evaluate(() => {
302
+ const elements = Array.from(document.querySelectorAll('span, a, button'));
303
+ const target = elements.find(el =>
304
+ el.textContent.toLowerCase().includes('other ways to sign in') ||
305
+ el.textContent.toLowerCase().includes('sign in another way')
306
+ );
307
+ if (target) {
308
+ target.click();
309
+ return true;
310
+ }
311
+ return false;
312
+ });
313
+
314
+ if (clicked) {
315
+ logger.info('Successfully triggered click via JavaScript fallback.');
316
+ } else if (pageHeading.includes('Get a code')) {
317
+ throw new Error('STUCK: "Other ways to sign in" link not found even via JS scan.');
318
+ }
319
+ }
320
+
321
+ logger.debug('Waiting for method selection screen ("Use your password")...');
322
+ const subResult = await Promise.race([
323
+ page.waitForSelector('text=/Use your password/i', { state: 'visible', timeout: 15000 }).then(() => 'use_password'),
324
+ page.waitForSelector('#idA_PWD_SwitchToPassword', { state: 'visible', timeout: 15000 }).then(() => 'use_password'),
325
+ page.waitForSelector('text=/Select a verification method/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways_list'),
326
+ ]).catch(() => 'timeout');
327
+
328
+ logger.debug(`Sub-screen detection result: ${subResult}`);
329
+
330
+ if (subResult === 'use_password') {
331
+ logger.info('Selecting "Use your password" option...');
332
+ await page.click('text=/Use your password/i');
333
+ } else if (subResult === 'other_ways_list') {
334
+ logger.info('Selection list detected. Looking for "Password"...');
335
+ await page.click('text=/Password|Use your password/i');
336
+ }
337
+ } else if (result === 'use_password') {
338
+ logger.info('Detected "Use your password" option. Clicking...');
339
+ await page.click('text="Use your password"');
340
+ } else if (result === 'approve_app') {
341
+ logger.warn('MFA notification already sent. Attempting to switch to password...');
342
+ const otherLink = page.locator('text="Other ways to sign in", #signInAnotherWay').first();
343
+ if (await otherLink.isVisible()) {
344
+ await otherLink.click();
345
+ await page.waitForSelector('text="Use your password"', { state: 'visible', timeout: 10000 });
346
+ await page.click('text="Use your password"');
347
+ }
348
+ } else if (result === 'password') {
349
+ logger.debug('Direct password field detected.');
350
+ } else if (result === 'timeout') {
351
+ logger.debug('No intermediate screen detected within timeout. Proceeding to password entry.');
352
+ }
353
+ } catch (e) {
354
+ logger.debug(`Intermediate screen handler encountered a fatal issue: ${e.message}`);
355
+ }
356
+
357
+ // 2. Enter Password
358
+ try {
359
+ await page.waitForSelector('input[name="passwd"]', { state: 'visible', timeout: 30000 });
360
+ await page.fill('input[name="passwd"]', password);
361
+
362
+ const submitButton = page.locator('input[type="submit"], button[type="submit"]').filter({ hasText: /Sign in|Next|Finish/i }).first();
363
+
364
+ logger.debug('Waiting for submit button to be enabled...');
365
+ await submitButton.waitFor({ state: 'visible', timeout: 10000 });
366
+ if (await submitButton.isDisabled()) {
367
+ logger.debug('Submit button is disabled. It might be the wrong one or the password field is not considered filled.');
368
+ logger.info('Will wait 1 seconds to let the submit button load properly');
369
+ await page.waitForTimeout(1000);
370
+ }
371
+
372
+ await submitButton.click();
373
+
374
+ const passwordError = page.locator('#passwordError');
375
+ if (await passwordError.isVisible({ timeout: 2000 })) {
376
+ const errorMsg = await passwordError.textContent();
377
+ throw new Error(`Login Error (Password): ${errorMsg?.trim()}`);
378
+ }
379
+ } catch (e) {
380
+ if (credentials.dodump) {
381
+ const dumpDir = await logger.getDumpDir();
382
+ const displayPath = logger.getDumpDisplayPath();
383
+ const debugFile = path.join(dumpDir, 'debug_login_error_password.html');
384
+ await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
385
+ logger.error(`Password entry failed. HTML dumped to ${displayPath}/debug_login_error_password.html`);
386
+ }
387
+ throw e;
388
+ }
389
+
390
+ // Proactive dump after password submission (before post-password MFA check)
391
+ if (credentials.dodump) {
392
+ const dumpDir = await logger.getDumpDir();
393
+ const displayPath = logger.getDumpDisplayPath();
394
+ const debugFile = path.join(dumpDir, 'debug_after_password.html');
395
+ await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
396
+ logger.debug(`[dodump] Post-password state dumped to ${displayPath}/debug_after_password.html`);
397
+ }
398
+
399
+ // 2.5b. Handle FIDO/security key page (login.microsoft.com/consumers/fido/create)
400
+ // The addInitScript above makes navigator.credentials.create() reject immediately,
401
+ // so the native OS WebAuthn dialog never appears. We only need to click the
402
+ // page-level "Cancel" button and wait for navigation away from the FIDO URL.
403
+ try {
404
+ const currentUrl = page.url();
405
+ const onFidoPage = currentUrl.includes('/fido/');
406
+
407
+ if (onFidoPage) {
408
+ logger.info(`Already on FIDO page (${currentUrl}). Clicking page-level Cancel...`);
409
+ await dismissFidoPage(page, logger);
410
+ } else {
411
+ // Race: either we navigate to fido, or 8 s passes (no fido page)
412
+ const fidoHandled = await Promise.race([
413
+ page.waitForURL(url => url.toString().includes('/fido/'), { timeout: 8000 })
414
+ .then(async () => {
415
+ logger.info(`Navigated to FIDO page: ${page.url()}. Dismissing...`);
416
+ await dismissFidoPage(page, logger);
417
+ return 'fido_cancelled';
418
+ }),
419
+ page.waitForTimeout(8000).then(() => 'no_fido'),
420
+ ]);
421
+ logger.debug(`FIDO check result: ${fidoHandled}`);
422
+ }
423
+ } catch (e) {
424
+ logger.debug(`FIDO popup handler skipped: ${e.message}`);
425
+ }
426
+
427
+ // 2.5. Handle post-password MFA/Verification if needed
428
+ try {
429
+ const verificationScreen = await Promise.race([
430
+ page.waitForSelector('text="Verify your identity"', { timeout: 10000 }).then(() => 'verify'),
431
+ page.waitForSelector('text="Enter code"', { timeout: 10000 }).then(() => 'enter_code'),
432
+ page.waitForSelector('input[name="otc"]', { timeout: 10000 }).then(() => 'otc_input'),
433
+ page.waitForSelector('text=/Approve sign in request/i', { timeout: 10000 }).then(() => 'number_match'),
434
+ page.waitForSelector('.displaySign', { timeout: 10000 }).then(() => 'number_match'),
435
+ ]).catch(() => null);
436
+
437
+ if (credentials.dodump) {
438
+ const dumpDir = await logger.getDumpDir();
439
+ const displayPath = logger.getDumpDisplayPath();
440
+ const debugFile = path.join(dumpDir, 'debug_post_password_mfa.html');
441
+ await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
442
+ logger.debug(`[dodump] Post-password MFA screen state dumped to ${displayPath}/debug_post_password_mfa.html`);
443
+ }
444
+
445
+ if (verificationScreen === 'number_match') {
446
+ logger.warn('Number Matching MFA detected ("Approve sign in request" screen).');
447
+
448
+ let matchNumber = '??';
449
+ try {
450
+ matchNumber = await page.$eval('.displaySign', el => el.textContent.trim());
451
+ } catch (_) {
452
+ logger.debug('Could not extract number from .displaySign — user may still see it if --notheadless is used.');
453
+ }
454
+
455
+ logger.step('══════════════════════════════════════════════════════');
456
+ logger.step(` ACTION REQUIRED: Open Microsoft Authenticator on your phone.`);
457
+ logger.step(` Enter the number: ${matchNumber}`);
458
+ logger.step(` Then tap "Yes" / "Approve" in the app.`);
459
+ logger.step('══════════════════════════════════════════════════════');
460
+ logger.info('Waiting for phone approval (up to 120 seconds)...');
461
+
462
+ await Promise.race([
463
+ page.waitForSelector('.displaySign', { state: 'hidden', timeout: 120000 }),
464
+ page.waitForURL(url => !url.toString().includes('login.microsoftonline.com'), { timeout: 120000 }),
465
+ page.waitForSelector('text=/Stay signed in/i', { timeout: 120000 }),
466
+ ]);
467
+
468
+ logger.success('Phone approval received. Continuing login flow...');
469
+
470
+ } else if (verificationScreen) {
471
+ logger.warn('MFA/Verification screen detected.');
472
+ logger.step('A verification code is required. Please check your email or authenticator app.');
473
+
474
+ const code = await promptUser('Enter the verification code: ');
475
+
476
+ if (await page.locator('input[name="otc"]').isVisible()) {
477
+ await page.fill('input[name="otc"]', code);
478
+ } else if (await page.locator('input[type="tel"]').isVisible()) {
479
+ await page.fill('input[type="tel"]', code);
480
+ } else {
481
+ await page.locator('input[type="text"]:visible, input[type="tel"]:visible').first().fill(code);
482
+ }
483
+
484
+ await page.click('input[type="submit"]');
485
+ }
486
+ } catch (e) {
487
+ logger.debug(`Post-password verification handling skipped or failed: ${e.message}`);
488
+ }
489
+
490
+ // 2.7. Handle "Help protect your account" interrupt screen
491
+ try {
492
+ const interruptPrompt = page.getByText(/Help protect your account/i).first();
493
+ if (await interruptPrompt.isVisible({ timeout: 5000 }) || page.url().includes('account.live.com/interrupt/')) {
494
+ logger.info('Detected "Help protect your account" interrupt screen.');
495
+ const skipButton = page.getByRole('button', { name: /Skip for now/i })
496
+ .or(page.getByText(/Skip for now/i))
497
+ .first();
498
+ if (await skipButton.isVisible()) {
499
+ logger.info('Clicking "Skip for now"...');
500
+ await skipButton.click();
501
+ }
502
+ }
503
+ } catch (e) {
504
+ logger.debug(`Help protect your account interrupt screen did not appear: ${e.message}`);
505
+ }
506
+
507
+ // 3. Handle "Stay signed in?" prompt if it appears
508
+ try {
509
+ logger.debug('Checking for "Stay signed in?" prompt...');
510
+
511
+ const staySignedIn = page.getByText(/Stay signed in?/i)
512
+ .or(page.locator('#KmsiDescription'))
513
+ .first();
514
+
515
+ await staySignedIn.waitFor({ state: 'visible', timeout: 7000 });
516
+
517
+ logger.info('Detected "Stay signed in?" prompt.');
518
+
519
+ const dontShowAgain = page.locator('input[name="DontShowAgain"], #KmsiCheckboxField').first();
520
+ if (await dontShowAgain.isVisible()) {
521
+ logger.debug('Checking "Don\'t show this again" checkbox...');
522
+ await dontShowAgain.check().catch(() => { });
523
+ }
524
+
525
+ const yesButton = page.getByRole('button', { name: /^Yes$/i })
526
+ .or(page.locator('button[data-testid="primaryButton"]'))
527
+ .or(page.locator('#idSIButton9'))
528
+ .first();
529
+
530
+ logger.info('Clicking "Yes" to stay signed in...');
531
+ await yesButton.click();
532
+ } catch (e) {
533
+ logger.debug(`Stay signed in prompt did not appear or was not recognized: ${e.message}`);
534
+ }
535
+
536
+ // 4. Wait for redirection to target interface (notebooks or mail)
537
+ try {
538
+ await waitForAuthSuccess(page, finalTargetUrl);
539
+ } catch (e) {
540
+ if (credentials.dodump) {
541
+ const dumpDir = await logger.getDumpDir();
542
+ const displayPath = logger.getDumpDisplayPath();
543
+ const debugFile = path.join(dumpDir, 'debug_login_error_success.html');
544
+ await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
545
+ logger.error(`Success detection failed. HTML dumped to ${displayPath}/debug_login_error_success.html`);
546
+ }
547
+ throw e;
548
+ }
549
+ } else {
550
+ logger.warn('Login flow requires manual interaction.');
551
+ const serviceName = finalTargetUrl.includes('outlook') ? 'Outlook' : 'OneNote';
552
+ logger.step(`>>> Once you see your ${serviceName} interface in the browser, return here and press ENTER to continue. <<<`);
553
+
554
+ const rl = readline.createInterface({
555
+ input: process.stdin,
556
+ output: process.stdout
557
+ });
558
+
559
+ await new Promise(resolve => {
560
+ rl.question('', () => {
561
+ rl.close();
562
+ resolve();
563
+ });
564
+ });
565
+ }
566
+
567
+ logger.info('Saving authentication state...');
568
+ await context.storageState({ path: AUTH_FILE });
569
+
570
+ await fs.writeJson(AUTH_META_FILE, {
571
+ email: email || 'manual login',
572
+ loginTime: new Date().toISOString()
573
+ });
574
+
575
+ logger.success(`Authentication successful! State saved to ${AUTH_FILE}`);
576
+ } catch (error) {
577
+ logger.error('Authentication failed or cancelled:', error);
578
+ if (isAutomated) {
579
+ logger.debug('Possible cause: incorrect credentials, MFA requirement, or selector change.');
580
+ }
581
+ } finally {
582
+ await browser.close();
583
+ }
584
+ }
585
+
586
+ async function getAuthenticatedContext(browser) {
587
+ if (await fs.pathExists(AUTH_FILE)) {
588
+ return browser.newContext({ storageState: AUTH_FILE });
589
+ } else {
590
+ throw new Error('No authentication state found. Please run "login" command first.');
591
+ }
592
+ }
593
+
594
+ async function checkAuth(targetUrl = ONENOTE_URL) {
595
+ if (!(await fs.pathExists(AUTH_FILE))) {
596
+ return false;
597
+ }
598
+
599
+ let browser;
600
+ try {
601
+ logger.debug('Verifying authentication session...');
602
+ browser = await chromium.launch({ headless: true });
603
+ const context = await browser.newContext({ storageState: AUTH_FILE });
604
+ const page = await context.newPage();
605
+
606
+ await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
607
+
608
+ logger.info('Will wait 2 sec to allow client-side redirects to Microsoft login pages if session is dead/');
609
+ await page.waitForTimeout(2000);
610
+
611
+ const url = page.url();
612
+ const isLoginUrl = url.includes('login.live.com') || url.includes('login.microsoftonline.com');
613
+
614
+ if (isLoginUrl) {
615
+ logger.warn('Authentication session has expired. Deleting stale auth state.');
616
+ await logout();
617
+ return false;
618
+ }
619
+
620
+ return true;
621
+ } catch (e) {
622
+ logger.debug(`Session verification encountered an error (timeout/network): ${e.message}`);
623
+ return true;
624
+ } finally {
625
+ logger.debug(`Looks like user is logged in.`);
626
+ if (browser) {
627
+ await browser.close();
628
+ }
629
+ }
630
+ }
631
+
632
+ module.exports = {
633
+ login,
634
+ getAuthenticatedContext,
635
+ checkAuth,
636
+ getAuthMeta,
637
+ logout
638
+ };
package/src/config.js ADDED
@@ -0,0 +1,22 @@
1
+ const path = require('path');
2
+
3
+ /**
4
+ * Returns the directory where auth files (auth.json, auth-meta.json) are stored.
5
+ * For this standalone CLI tool, we use the project root.
6
+ */
7
+ function getUserDataDir() {
8
+ return path.resolve(__dirname, '..');
9
+ }
10
+
11
+ const USER_DATA_DIR = getUserDataDir();
12
+
13
+ const AUTH_FILE = path.join(USER_DATA_DIR, 'auth.json');
14
+ const ONENOTE_URL = 'https://onenote.cloud.microsoft/en-us';
15
+ const OUTLOOK_URL = 'https://outlook.cloud.microsoft/mail/';
16
+
17
+ module.exports = {
18
+ AUTH_FILE,
19
+ ONENOTE_URL,
20
+ OUTLOOK_URL,
21
+ USER_DATA_DIR,
22
+ };
package/src/index.js ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ const { program } = require('commander');
3
+ const logger = require('./utils/logger');
4
+ const { login, checkAuth, getAuthMeta, logout } = require('./auth');
5
+ const { ONENOTE_URL, OUTLOOK_URL } = require('./config');
6
+
7
+ program
8
+ .name('webauth')
9
+ .description('Microsoft web authentication via Playwright — extracted from MSOneNote Exporter')
10
+ .version('1.0.0');
11
+
12
+ program
13
+ .command('login')
14
+ .description('Authenticate with Microsoft Account')
15
+ .option('--email <email>', 'Microsoft account email')
16
+ .option('--password <password>', 'Microsoft account password')
17
+ .option('--notheadless', 'Run in visible browser mode for debugging')
18
+ .option('--dodump', 'Dump HTML content to files for debugging')
19
+ .option('--against <target>', 'Target service: onenote (default) or outlook', 'onenote')
20
+ .action(async (options) => {
21
+ const targetUrl = options.against === 'outlook' ? OUTLOOK_URL : ONENOTE_URL;
22
+ await login({ ...options, targetUrl });
23
+ });
24
+
25
+ program
26
+ .command('check')
27
+ .description('Check if authenticated')
28
+ .option('--against <target>', 'Target service: onenote (default) or outlook', 'onenote')
29
+ .action(async (options) => {
30
+ const targetUrl = options.against === 'outlook' ? OUTLOOK_URL : ONENOTE_URL;
31
+ const isAuth = await checkAuth(targetUrl);
32
+ if (isAuth) {
33
+ logger.success('Authentication file found. You are authenticated.');
34
+ const meta = await getAuthMeta();
35
+ if (meta && meta.email) {
36
+ const loginTime = new Date(meta.loginTime).toLocaleString();
37
+ logger.info(`Logged in as: ${meta.email}`);
38
+ logger.debug(`Session started at: ${loginTime}`);
39
+ }
40
+ } else {
41
+ logger.error('Authentication file NOT found or invalid. Run "login" first.');
42
+ }
43
+ });
44
+
45
+ program
46
+ .command('logout')
47
+ .description('Clear authentication state')
48
+ .action(async () => {
49
+ await logout();
50
+ logger.success('Logged out successfully. Authentication state cleared.');
51
+ });
52
+
53
+ program.parse();
@@ -0,0 +1,148 @@
1
+ const chalk = require('chalk');
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+
5
+ class Logger {
6
+ constructor() {
7
+ this.months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
8
+ this.logFilePath = path.resolve(__dirname, '../logs/app.log');
9
+
10
+ // Initialize dump directory name once per execution
11
+ const now = new Date();
12
+ const yyyy = now.getFullYear();
13
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
14
+ const dd = String(now.getDate()).padStart(2, '0');
15
+ const hh = String(now.getHours()).padStart(2, '0');
16
+ const min = String(now.getMinutes()).padStart(2, '0');
17
+
18
+ // Format: YYYY-MM-DD_HHhMM
19
+ this.dumpSubDir = `${yyyy}-${mm}-${dd}_${hh}h${min}`;
20
+
21
+ // Ensure logs directory exists
22
+ fs.ensureDirSync(path.dirname(this.logFilePath));
23
+ }
24
+
25
+ _getTimestamp() {
26
+ const now = new Date();
27
+ const month = this.months[now.getMonth()];
28
+ const day = String(now.getDate()).padStart(2, '0');
29
+ const time = now.toTimeString().split(' ')[0];
30
+ return `[${month} ${day} ${time}]`;
31
+ }
32
+
33
+ /**
34
+ * Returns the absolute path to the current session's dump directory.
35
+ * Ensures the directory exists.
36
+ * @returns {Promise<string>}
37
+ */
38
+ async getDumpDir() {
39
+ const dumpDir = path.resolve(__dirname, '../logs/dumps', this.dumpSubDir);
40
+ await fs.ensureDir(dumpDir);
41
+ return dumpDir;
42
+ }
43
+
44
+ /**
45
+ * Returns a user-friendly relative path for logging.
46
+ * @returns {string}
47
+ */
48
+ getDumpDisplayPath() {
49
+ return `logs/dumps/${this.dumpSubDir}`;
50
+ }
51
+
52
+ _stripColors(str) {
53
+ // eslint-disable-next-line no-control-regex
54
+ return str.replace(/\u001b\[[0-9;]*m/g, '');
55
+ }
56
+
57
+ _formatMessage(level, message, colorFunc = (m) => m) {
58
+ const timestamp = this._getTimestamp();
59
+ const coloredTimestamp = chalk.gray(timestamp);
60
+ const levelTag = `[${level}]`;
61
+ const coloredLevelTag = colorFunc(levelTag);
62
+
63
+ // Handle multi-line messages
64
+ let formattedMessage = '';
65
+ if (typeof message === 'string' && message.includes('\n')) {
66
+ formattedMessage = message.split('\n').map(line => `${coloredTimestamp} ${coloredLevelTag} ${line}`).join('\n');
67
+ } else if (typeof message !== 'string') {
68
+ // Handle objects/errors
69
+ try {
70
+ const stringified = JSON.stringify(message, null, 2);
71
+ formattedMessage = `${coloredTimestamp} ${coloredLevelTag} ${stringified}`;
72
+ } catch (e) {
73
+ formattedMessage = `${coloredTimestamp} ${coloredLevelTag} [Complex Object]`;
74
+ }
75
+ } else {
76
+ formattedMessage = `${coloredTimestamp} ${coloredLevelTag} ${message}`;
77
+ }
78
+
79
+ // Write to log file (no colors)
80
+ const plainTimestamp = timestamp;
81
+ const plainLevelTag = levelTag;
82
+ let plainMessage = '';
83
+
84
+ if (typeof message === 'string' && message.includes('\n')) {
85
+ plainMessage = message.split('\n').map(line => `${plainTimestamp} ${plainLevelTag} ${line}`).join('\n');
86
+ } else if (typeof message !== 'string') {
87
+ try {
88
+ const stringified = JSON.stringify(message, null, 2);
89
+ plainMessage = `${plainTimestamp} ${plainLevelTag} ${stringified}`;
90
+ } catch (e) {
91
+ plainMessage = `${plainTimestamp} ${plainLevelTag} [Complex Object]`;
92
+ }
93
+ } else {
94
+ plainMessage = `${plainTimestamp} ${plainLevelTag} ${message}`;
95
+ }
96
+
97
+ // Append to log file
98
+ fs.appendFileSync(this.logFilePath, plainMessage + '\n');
99
+
100
+ return formattedMessage;
101
+ }
102
+
103
+ /** Generic log method for programmatic use */
104
+ log(level, message) {
105
+ const lv = (level || 'info').toLowerCase();
106
+ if (this[lv] && typeof this[lv] === 'function') {
107
+ this[lv](message);
108
+ } else {
109
+ this.info(message);
110
+ }
111
+ }
112
+
113
+ info(message) {
114
+ process.stdout.write(this._formatMessage('INFO', message, chalk.blue) + '\n');
115
+ }
116
+
117
+ warn(message) {
118
+ process.stdout.write(this._formatMessage('WARN', message, chalk.yellow) + '\n');
119
+ }
120
+
121
+ error(message, error = null) {
122
+ process.stderr.write(this._formatMessage('ERROR', message, chalk.red) + '\n');
123
+ if (error) {
124
+ if (error.stack) {
125
+ const stack = chalk.red(error.stack);
126
+ process.stderr.write(stack + '\n');
127
+ // Also write stack to file
128
+ fs.appendFileSync(this.logFilePath, this._stripColors(stack) + '\n');
129
+ } else {
130
+ process.stderr.write(this._formatMessage('ERROR', error, chalk.red) + '\n');
131
+ }
132
+ }
133
+ }
134
+
135
+ success(message) {
136
+ process.stdout.write(this._formatMessage('SUCCESS', message, chalk.green) + '\n');
137
+ }
138
+
139
+ debug(message) {
140
+ process.stdout.write(this._formatMessage('DEBUG', message, chalk.gray) + '\n');
141
+ }
142
+
143
+ step(message) {
144
+ process.stdout.write(this._formatMessage('STEP', message, chalk.magenta) + '\n');
145
+ }
146
+ }
147
+
148
+ module.exports = new Logger();
@@ -0,0 +1,59 @@
1
+ const logger = require('./logger');
2
+
3
+ /**
4
+ * Retry a function with exponential backoff
5
+ * @param {Function} fn - Async function to retry
6
+ * @param {Object} options - Retry options
7
+ * @param {number} options.maxAttempts - Maximum number of attempts (default: 3)
8
+ * @param {number} options.initialDelayMs - Initial delay in milliseconds (default: 500)
9
+ * @param {number} options.maxDelayMs - Maximum delay in milliseconds (default: 5000)
10
+ * @param {number} options.backoffMultiplier - Backoff multiplier (default: 2)
11
+ * @param {string} options.operationName - Name of operation for logging (default: 'Operation')
12
+ * @param {boolean} options.silent - Suppress retry logging (default: false)
13
+ * @returns {Promise<any>} Result of the function
14
+ * @throws {Error} If all attempts fail
15
+ */
16
+ async function withRetry(fn, options = {}) {
17
+ const {
18
+ maxAttempts = 3,
19
+ initialDelayMs = 500,
20
+ maxDelayMs = 5000,
21
+ backoffMultiplier = 2,
22
+ operationName = 'Operation',
23
+ silent = false
24
+ } = options;
25
+
26
+ let lastError;
27
+ let delayMs = initialDelayMs;
28
+
29
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
30
+ try {
31
+ return await fn();
32
+ } catch (error) {
33
+ lastError = error;
34
+
35
+ if (attempt === maxAttempts) {
36
+ if (!silent) {
37
+ logger.error(`${operationName} failed after ${maxAttempts} attempts:`, error);
38
+ }
39
+ throw error;
40
+ }
41
+
42
+ if (!silent) {
43
+ logger.warn(`${operationName} failed (attempt ${attempt}/${maxAttempts}): ${error.message}`);
44
+ logger.info(`Will wait ${delayMs / 1000} seconds to retry`);
45
+ logger.debug(` Retrying in ${delayMs}ms...`);
46
+ }
47
+
48
+ // Wait before retrying
49
+ await new Promise(resolve => setTimeout(resolve, delayMs));
50
+
51
+ // Exponential backoff with max cap
52
+ delayMs = Math.min(delayMs * backoffMultiplier, maxDelayMs);
53
+ }
54
+ }
55
+
56
+ throw lastError;
57
+ }
58
+
59
+ module.exports = { withRetry };
@@ -0,0 +1,134 @@
1
+ const path = require('path');
2
+ const fs = require('fs-extra');
3
+
4
+ // Mock the modules before requiring auth
5
+ jest.mock('../src/config', () => ({
6
+ AUTH_FILE: '/tmp/test-auth.json',
7
+ ONENOTE_URL: 'https://www.onenote.com/notebooks',
8
+ OUTLOOK_URL: 'https://outlook.cloud.microsoft/mail/',
9
+ USER_DATA_DIR: '/tmp'
10
+ }));
11
+
12
+ jest.mock('../src/utils/logger', () => ({
13
+ info: jest.fn(),
14
+ warn: jest.fn(),
15
+ error: jest.fn(),
16
+ success: jest.fn(),
17
+ debug: jest.fn(),
18
+ step: jest.fn(),
19
+ getDumpDir: async () => '/tmp/dumps',
20
+ getDumpDisplayPath: () => 'logs/dumps/test'
21
+ }));
22
+
23
+ describe('Auth Module', () => {
24
+ let auth;
25
+ let config;
26
+
27
+ beforeEach(() => {
28
+ jest.clearAllMocks();
29
+ // Clean up test files before each test
30
+ fs.removeSync('/tmp/test-auth.json');
31
+ fs.removeSync('/tmp/test-auth-meta.json');
32
+
33
+ // Re-import after mocks are set up
34
+ config = require('../src/config');
35
+ auth = require('../src/auth');
36
+ });
37
+
38
+ afterAll(async () => {
39
+ // Clean up after all tests
40
+ fs.removeSync('/tmp/test-auth.json');
41
+ fs.removeSync('/tmp/test-auth-meta.json');
42
+ });
43
+
44
+ describe('getAuthMeta', () => {
45
+ it('should return null when no auth meta file exists', async () => {
46
+ const { getAuthMeta } = require('../src/auth');
47
+ const meta = await getAuthMeta();
48
+ expect(meta).toBeNull();
49
+ });
50
+
51
+ it('should return metadata when auth meta file exists', async () => {
52
+ // Create a test meta file
53
+ await fs.writeJson('/tmp/test-auth-meta.json', {
54
+ email: 'test@example.com',
55
+ loginTime: new Date().toISOString()
56
+ });
57
+
58
+ const { getAuthMeta } = require('../src/auth');
59
+ const meta = await getAuthMeta();
60
+
61
+ expect(meta).toBeDefined();
62
+ expect(meta.email).toBe('test@example.com');
63
+ expect(meta.loginTime).toBeDefined();
64
+ });
65
+ });
66
+
67
+ describe('logout', () => {
68
+ it('should remove auth files', async () => {
69
+ // Create test files
70
+ await fs.writeJson('/tmp/test-auth.json', { test: true });
71
+ await fs.writeJson('/tmp/test-auth-meta.json', { email: 'test@example.com' });
72
+
73
+ const { logout } = require('../src/auth');
74
+ await logout();
75
+
76
+ expect(await fs.pathExists('/tmp/test-auth.json')).toBe(false);
77
+ expect(await fs.pathExists('/tmp/test-auth-meta.json')).toBe(false);
78
+ });
79
+ });
80
+
81
+ describe('checkAuth', () => {
82
+ it('should return false when no auth file exists', async () => {
83
+ const { checkAuth } = require('../src/auth');
84
+ const isAuth = await checkAuth();
85
+ expect(isAuth).toBe(false);
86
+ });
87
+
88
+ it('should handle network errors gracefully', async () => {
89
+ // Create a dummy auth file
90
+ await fs.writeJson('/tmp/test-auth.json', { test: true });
91
+
92
+ const { checkAuth } = require('../src/auth');
93
+ // Use mock URL that won't actually connect
94
+ const isAuth = await checkAuth('https://mock.test/nonexistent');
95
+
96
+ // Should return true on error (conservative approach)
97
+ expect(isAuth).toBe(true);
98
+ }, 10000);
99
+ });
100
+
101
+ describe('getAuthenticatedContext', () => {
102
+ it('should throw error when no auth file exists', async () => {
103
+ const { getAuthenticatedContext } = require('../src/auth');
104
+
105
+ await expect(getAuthenticatedContext(null)).rejects.toThrow(
106
+ 'No authentication state found. Please run "login" command first.'
107
+ );
108
+ });
109
+ });
110
+ });
111
+
112
+ describe('config', () => {
113
+ let config;
114
+
115
+ beforeEach(() => {
116
+ jest.clearAllMocks();
117
+ fs.removeSync('/tmp/test-auth.json');
118
+ fs.removeSync('/tmp/test-auth-meta.json');
119
+ config = require('../src/config');
120
+ auth = require('../src/auth');
121
+ });
122
+
123
+ it('should export AUTH_FILE', () => {
124
+ expect(config.AUTH_FILE).toBe('/tmp/test-auth.json');
125
+ });
126
+
127
+ it('should export ONENOTE_URL', () => {
128
+ expect(config.ONENOTE_URL).toBe('https://www.onenote.com/notebooks');
129
+ });
130
+
131
+ it('should export OUTLOOK_URL', () => {
132
+ expect(config.OUTLOOK_URL).toBe('https://outlook.cloud.microsoft/mail/');
133
+ });
134
+ });