@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.
- package/.qwen/settings.json +38 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/package.json +40 -0
- package/src/auth.js +638 -0
- package/src/config.js +22 -0
- package/src/index.js +53 -0
- package/src/utils/logger.js +148 -0
- package/src/utils/retry.js +59 -0
- package/test/auth.test.js +134 -0
|
@@ -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
|
+
});
|