@fink-andreas/pi-linear-tools 0.1.0 → 0.2.1
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/CHANGELOG.md +20 -1
- package/README.md +18 -2
- package/extensions/pi-linear-tools.js +449 -113
- package/index.js +916 -6
- package/package.json +6 -4
- package/src/auth/callback-server.js +337 -0
- package/src/auth/index.js +246 -0
- package/src/auth/oauth.js +281 -0
- package/src/auth/pkce.js +111 -0
- package/src/auth/token-refresh.js +210 -0
- package/src/auth/token-store.js +415 -0
- package/src/cli.js +238 -65
- package/src/handlers.js +18 -10
- package/src/linear-client.js +36 -6
- package/src/linear.js +16 -9
- package/src/settings.js +107 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fink-andreas/pi-linear-tools",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Pi extension with Linear SDK tools and configuration commands",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"start": "node index.js",
|
|
30
|
-
"test": "node tests/test-package-manifest.js && node tests/test-extension-registration.js && node tests/test-settings.js && node tests/test-assignee-update.js && node tests/test-full-assignee-flow.js",
|
|
30
|
+
"test": "node tests/test-package-manifest.js && node tests/test-extension-registration.js && node tests/test-settings.js && node tests/test-assignee-update.js && node tests/test-full-assignee-flow.js && node tests/test-branch-param.js",
|
|
31
|
+
"dev:sync-local-extension": "node scripts/dev-sync-local-extension.mjs",
|
|
31
32
|
"release:check": "npm test && npm pack --dry-run"
|
|
32
33
|
},
|
|
33
34
|
"keywords": [
|
|
@@ -39,11 +40,12 @@
|
|
|
39
40
|
],
|
|
40
41
|
"pi": {
|
|
41
42
|
"extensions": [
|
|
42
|
-
"./
|
|
43
|
+
"./index.js"
|
|
43
44
|
]
|
|
44
45
|
},
|
|
45
46
|
"license": "MIT",
|
|
46
47
|
"dependencies": {
|
|
47
|
-
"@linear/sdk": "^75.0.0"
|
|
48
|
+
"@linear/sdk": "^75.0.0",
|
|
49
|
+
"keytar": "^7.9.0"
|
|
48
50
|
}
|
|
49
51
|
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local HTTP callback server for OAuth 2.0 authorization flow
|
|
3
|
+
*
|
|
4
|
+
* Creates an ephemeral HTTP server on localhost to receive the OAuth callback
|
|
5
|
+
* from Linear after user authorization.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import { URL } from 'node:url';
|
|
10
|
+
import { debug, warn, error as logError } from '../logger.js';
|
|
11
|
+
|
|
12
|
+
// Default callback server configuration
|
|
13
|
+
const SERVER_CONFIG = {
|
|
14
|
+
port: 34711,
|
|
15
|
+
host: '127.0.0.1', // Bind to localhost only for security
|
|
16
|
+
timeout: 5 * 60 * 1000, // 5 minutes
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* HTML page to show on successful authentication
|
|
21
|
+
*/
|
|
22
|
+
const SUCCESS_HTML = `
|
|
23
|
+
<!DOCTYPE html>
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="UTF-8">
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
28
|
+
<title>Authentication Successful</title>
|
|
29
|
+
<style>
|
|
30
|
+
body {
|
|
31
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
32
|
+
display: flex;
|
|
33
|
+
justify-content: center;
|
|
34
|
+
align-items: center;
|
|
35
|
+
height: 100vh;
|
|
36
|
+
margin: 0;
|
|
37
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
38
|
+
color: white;
|
|
39
|
+
}
|
|
40
|
+
.container {
|
|
41
|
+
text-align: center;
|
|
42
|
+
padding: 40px;
|
|
43
|
+
background: rgba(255, 255, 255, 0.1);
|
|
44
|
+
border-radius: 12px;
|
|
45
|
+
backdrop-filter: blur(10px);
|
|
46
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
47
|
+
}
|
|
48
|
+
h1 {
|
|
49
|
+
margin: 0 0 16px 0;
|
|
50
|
+
font-size: 32px;
|
|
51
|
+
}
|
|
52
|
+
p {
|
|
53
|
+
font-size: 18px;
|
|
54
|
+
opacity: 0.9;
|
|
55
|
+
margin: 0;
|
|
56
|
+
}
|
|
57
|
+
.icon {
|
|
58
|
+
font-size: 64px;
|
|
59
|
+
margin-bottom: 24px;
|
|
60
|
+
}
|
|
61
|
+
</style>
|
|
62
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
<div class="container">
|
|
65
|
+
<div class="icon">✓</div>
|
|
66
|
+
<h1>Authentication Successful</h1>
|
|
67
|
+
<p>You may safely close this window and return to your terminal.</p>
|
|
68
|
+
</div>
|
|
69
|
+
</body>
|
|
70
|
+
</html>
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* HTML page to show on authentication error
|
|
75
|
+
*/
|
|
76
|
+
const ERROR_HTML = (errorMessage) => `
|
|
77
|
+
<!DOCTYPE html>
|
|
78
|
+
<html lang="en">
|
|
79
|
+
<head>
|
|
80
|
+
<meta charset="UTF-8">
|
|
81
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
82
|
+
<title>Authentication Failed</title>
|
|
83
|
+
<style>
|
|
84
|
+
body {
|
|
85
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
86
|
+
display: flex;
|
|
87
|
+
justify-content: center;
|
|
88
|
+
align-items: center;
|
|
89
|
+
height: 100vh;
|
|
90
|
+
margin: 0;
|
|
91
|
+
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
92
|
+
color: white;
|
|
93
|
+
}
|
|
94
|
+
.container {
|
|
95
|
+
text-align: center;
|
|
96
|
+
padding: 40px;
|
|
97
|
+
background: rgba(255, 255, 255, 0.1);
|
|
98
|
+
border-radius: 12px;
|
|
99
|
+
backdrop-filter: blur(10px);
|
|
100
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
101
|
+
max-width: 500px;
|
|
102
|
+
}
|
|
103
|
+
h1 {
|
|
104
|
+
margin: 0 0 16px 0;
|
|
105
|
+
font-size: 32px;
|
|
106
|
+
}
|
|
107
|
+
p {
|
|
108
|
+
font-size: 18px;
|
|
109
|
+
opacity: 0.9;
|
|
110
|
+
margin: 0 0 24px 0;
|
|
111
|
+
}
|
|
112
|
+
.error {
|
|
113
|
+
background: rgba(0, 0, 0, 0.2);
|
|
114
|
+
padding: 16px;
|
|
115
|
+
border-radius: 8px;
|
|
116
|
+
font-family: monospace;
|
|
117
|
+
font-size: 14px;
|
|
118
|
+
word-break: break-all;
|
|
119
|
+
}
|
|
120
|
+
.icon {
|
|
121
|
+
font-size: 64px;
|
|
122
|
+
margin-bottom: 24px;
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
125
|
+
</head>
|
|
126
|
+
<body>
|
|
127
|
+
<div class="container">
|
|
128
|
+
<div class="icon">✕</div>
|
|
129
|
+
<h1>Authentication Failed</h1>
|
|
130
|
+
<p>An error occurred during authentication:</p>
|
|
131
|
+
<div class="error">${errorMessage}</div>
|
|
132
|
+
</div>
|
|
133
|
+
</body>
|
|
134
|
+
</html>
|
|
135
|
+
`;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Start callback server and wait for OAuth callback
|
|
139
|
+
*
|
|
140
|
+
* @param {object} options - Server options
|
|
141
|
+
* @param {string} options.expectedState - Expected state parameter (for CSRF validation)
|
|
142
|
+
* @param {number} [options.port] - Port to listen on (default: 34711)
|
|
143
|
+
* @param {number} [options.timeout] - Timeout in milliseconds (default: 5 minutes)
|
|
144
|
+
* @param {AbortSignal} [options.signal] - Optional abort signal to cancel waiting
|
|
145
|
+
* @returns {Promise<object>} Callback result with code and state
|
|
146
|
+
* @throws {Error} If callback fails, times out, state validation fails, or is aborted
|
|
147
|
+
*/
|
|
148
|
+
export async function waitForCallback({
|
|
149
|
+
expectedState,
|
|
150
|
+
port = SERVER_CONFIG.port,
|
|
151
|
+
timeout = SERVER_CONFIG.timeout,
|
|
152
|
+
signal,
|
|
153
|
+
}) {
|
|
154
|
+
debug('Starting callback server', { port, expectedState });
|
|
155
|
+
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
let server;
|
|
158
|
+
let timeoutId;
|
|
159
|
+
let settled = false;
|
|
160
|
+
|
|
161
|
+
const cleanup = () => {
|
|
162
|
+
clearTimeout(timeoutId);
|
|
163
|
+
if (signal) {
|
|
164
|
+
signal.removeEventListener('abort', abortHandler);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const fail = (err) => {
|
|
169
|
+
if (settled) return;
|
|
170
|
+
settled = true;
|
|
171
|
+
cleanup();
|
|
172
|
+
reject(err);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const succeed = (value) => {
|
|
176
|
+
if (settled) return;
|
|
177
|
+
settled = true;
|
|
178
|
+
cleanup();
|
|
179
|
+
resolve(value);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Create timeout handler
|
|
183
|
+
const timeoutHandler = () => {
|
|
184
|
+
debug('Callback server timeout');
|
|
185
|
+
if (server) {
|
|
186
|
+
server.close();
|
|
187
|
+
}
|
|
188
|
+
fail(new Error('OAuth callback timed out. Please try again.'));
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const abortHandler = () => {
|
|
192
|
+
debug('Callback server aborted by caller');
|
|
193
|
+
if (server) {
|
|
194
|
+
server.close();
|
|
195
|
+
}
|
|
196
|
+
fail(new Error('OAuth authentication was cancelled.'));
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (signal?.aborted) {
|
|
200
|
+
abortHandler();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (signal) {
|
|
205
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Start timeout
|
|
209
|
+
timeoutId = setTimeout(timeoutHandler, timeout);
|
|
210
|
+
|
|
211
|
+
// Create HTTP server
|
|
212
|
+
server = http.createServer((req, res) => {
|
|
213
|
+
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
|
|
214
|
+
|
|
215
|
+
debug('Received request', { path: parsedUrl.pathname });
|
|
216
|
+
|
|
217
|
+
// Only handle the callback path
|
|
218
|
+
if (parsedUrl.pathname === '/callback') {
|
|
219
|
+
const code = parsedUrl.searchParams.get('code');
|
|
220
|
+
const state = parsedUrl.searchParams.get('state');
|
|
221
|
+
const error = parsedUrl.searchParams.get('error');
|
|
222
|
+
const errorDescription = parsedUrl.searchParams.get(
|
|
223
|
+
'error_description'
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Check for OAuth error
|
|
227
|
+
if (error) {
|
|
228
|
+
debug('OAuth error received', {
|
|
229
|
+
error,
|
|
230
|
+
errorDescription,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
234
|
+
res.end(
|
|
235
|
+
ERROR_HTML(
|
|
236
|
+
errorDescription || error || 'Unknown OAuth error'
|
|
237
|
+
)
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
server.close();
|
|
241
|
+
fail(new Error(`OAuth error: ${errorDescription || error}`));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Check for authorization code
|
|
246
|
+
if (!code) {
|
|
247
|
+
debug('Missing authorization code');
|
|
248
|
+
|
|
249
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
250
|
+
res.end(ERROR_HTML('Missing authorization code in callback'));
|
|
251
|
+
|
|
252
|
+
server.close();
|
|
253
|
+
fail(new Error('Missing authorization code in callback'));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Validate state parameter (CSRF protection)
|
|
258
|
+
if (!state || state !== expectedState) {
|
|
259
|
+
debug('State validation failed', {
|
|
260
|
+
received: state,
|
|
261
|
+
expected: expectedState,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
265
|
+
res.end(
|
|
266
|
+
ERROR_HTML('Security error: State mismatch. Possible CSRF attack.')
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
server.close();
|
|
270
|
+
fail(
|
|
271
|
+
new Error('State validation failed. Possible CSRF attack.')
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Success!
|
|
277
|
+
debug('OAuth callback successful', { hasCode: !!code });
|
|
278
|
+
|
|
279
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
280
|
+
res.end(SUCCESS_HTML);
|
|
281
|
+
|
|
282
|
+
// Close server after a short delay to ensure response is sent
|
|
283
|
+
setTimeout(() => {
|
|
284
|
+
server.close();
|
|
285
|
+
}, 100);
|
|
286
|
+
|
|
287
|
+
succeed({ code, state });
|
|
288
|
+
} else {
|
|
289
|
+
// Return 404 for other paths
|
|
290
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
291
|
+
res.end('Not Found');
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Handle server errors
|
|
296
|
+
server.on('error', (err) => {
|
|
297
|
+
logError('Callback server error', {
|
|
298
|
+
error: err.message,
|
|
299
|
+
code: err.code,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (err.code === 'EADDRINUSE') {
|
|
303
|
+
fail(
|
|
304
|
+
new Error(
|
|
305
|
+
`Port ${port} is already in use. Please check if another process is using it.`
|
|
306
|
+
)
|
|
307
|
+
);
|
|
308
|
+
} else if (err.code === 'EACCES') {
|
|
309
|
+
fail(
|
|
310
|
+
new Error(
|
|
311
|
+
`Permission denied to bind to port ${port}. Try a different port.`
|
|
312
|
+
)
|
|
313
|
+
);
|
|
314
|
+
} else {
|
|
315
|
+
fail(new Error(`Failed to start callback server: ${err.message}`));
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Start listening
|
|
320
|
+
server.listen(port, SERVER_CONFIG.host, () => {
|
|
321
|
+
debug('Callback server listening', {
|
|
322
|
+
host: SERVER_CONFIG.host,
|
|
323
|
+
port,
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Get callback URL for OAuth authorization
|
|
331
|
+
*
|
|
332
|
+
* @param {number} [port] - Port number (default: 34711)
|
|
333
|
+
* @returns {string} Full callback URL
|
|
334
|
+
*/
|
|
335
|
+
export function getCallbackUrl(port = SERVER_CONFIG.port) {
|
|
336
|
+
return `http://localhost:${port}/callback`;
|
|
337
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 authentication orchestrator for pi-linear-tools
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the complete OAuth flow including PKCE generation,
|
|
5
|
+
* local callback server, token exchange, and storage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { generatePkceParams } from './pkce.js';
|
|
9
|
+
import { buildAuthorizationUrl, exchangeCodeForToken } from './oauth.js';
|
|
10
|
+
import { waitForCallback } from './callback-server.js';
|
|
11
|
+
import { storeTokens, getTokens, clearTokens, hasValidTokens } from './token-store.js';
|
|
12
|
+
import { getValidAccessToken } from './token-refresh.js';
|
|
13
|
+
import { debug, info, warn, error as logError } from '../logger.js';
|
|
14
|
+
|
|
15
|
+
function parseManualCallbackInput(rawInput) {
|
|
16
|
+
const value = String(rawInput || '').trim();
|
|
17
|
+
if (!value) return null;
|
|
18
|
+
|
|
19
|
+
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
20
|
+
const parsed = new URL(value);
|
|
21
|
+
return {
|
|
22
|
+
code: parsed.searchParams.get('code'),
|
|
23
|
+
state: parsed.searchParams.get('state'),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (value.includes('code=')) {
|
|
28
|
+
const parsed = new URL(value.startsWith('?') ? `http://localhost/${value}` : `http://localhost/?${value}`);
|
|
29
|
+
return {
|
|
30
|
+
code: parsed.searchParams.get('code'),
|
|
31
|
+
state: parsed.searchParams.get('state'),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { code: value, state: null };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Perform the complete OAuth authentication flow
|
|
40
|
+
*
|
|
41
|
+
* This function:
|
|
42
|
+
* 1. Generates PKCE parameters
|
|
43
|
+
* 2. Starts a local callback server
|
|
44
|
+
* 3. Opens the browser with the authorization URL
|
|
45
|
+
* 4. Waits for the callback
|
|
46
|
+
* 5. Exchanges the authorization code for tokens
|
|
47
|
+
* 6. Stores the tokens securely
|
|
48
|
+
*
|
|
49
|
+
* @param {object} options - Authentication options
|
|
50
|
+
* @param {Function} [options.openBrowser] - Function to open browser (default: use 'open' package)
|
|
51
|
+
* @param {number} [options.port] - Port for callback server (default: 34711)
|
|
52
|
+
* @param {number} [options.timeout] - Timeout for callback in milliseconds (default: 5 minutes)
|
|
53
|
+
* @param {Function} [options.onAuthorizationUrl] - Optional callback invoked with the authorization URL
|
|
54
|
+
* @param {Function} [options.manualCodeInput] - Optional async callback for manual callback URL/code input
|
|
55
|
+
* @returns {Promise<object>} Authentication result with tokens
|
|
56
|
+
* @throws {Error} If authentication fails
|
|
57
|
+
*/
|
|
58
|
+
export async function authenticate({
|
|
59
|
+
openBrowser,
|
|
60
|
+
port = 34711,
|
|
61
|
+
timeout = 5 * 60 * 1000,
|
|
62
|
+
onAuthorizationUrl,
|
|
63
|
+
manualCodeInput,
|
|
64
|
+
} = {}) {
|
|
65
|
+
debug('Starting OAuth authentication flow', { port, timeout });
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// Step 1: Generate PKCE parameters
|
|
69
|
+
const pkceParams = generatePkceParams();
|
|
70
|
+
debug('Generated PKCE parameters', {
|
|
71
|
+
challengeLength: pkceParams.challenge.length,
|
|
72
|
+
stateLength: pkceParams.state.length,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Step 2: Build authorization URL
|
|
76
|
+
const authUrl = buildAuthorizationUrl({
|
|
77
|
+
challenge: pkceParams.challenge,
|
|
78
|
+
state: pkceParams.state,
|
|
79
|
+
redirectUri: `http://localhost:${port}/callback`,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
debug('Built authorization URL');
|
|
83
|
+
|
|
84
|
+
const abortController = new AbortController();
|
|
85
|
+
|
|
86
|
+
// Step 3: Start callback server (this will wait for the callback)
|
|
87
|
+
const callbackPromise = waitForCallback({
|
|
88
|
+
expectedState: pkceParams.state,
|
|
89
|
+
port,
|
|
90
|
+
timeout,
|
|
91
|
+
signal: abortController.signal,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (typeof onAuthorizationUrl === 'function') {
|
|
95
|
+
await onAuthorizationUrl(authUrl);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let shouldPromptManual = false;
|
|
99
|
+
|
|
100
|
+
// Step 4: Open browser with authorization URL
|
|
101
|
+
if (openBrowser) {
|
|
102
|
+
await openBrowser(authUrl);
|
|
103
|
+
info('Opening browser for authentication...');
|
|
104
|
+
} else {
|
|
105
|
+
// Default: use 'open' package if available
|
|
106
|
+
try {
|
|
107
|
+
const { default: open } = await import('open');
|
|
108
|
+
await open(authUrl);
|
|
109
|
+
info('Opening browser for authentication...');
|
|
110
|
+
} catch (error) {
|
|
111
|
+
warn('Failed to open browser automatically', { error: error.message });
|
|
112
|
+
if (typeof onAuthorizationUrl !== 'function') {
|
|
113
|
+
info('Please open the following URL in your browser:');
|
|
114
|
+
console.log(authUrl);
|
|
115
|
+
}
|
|
116
|
+
shouldPromptManual = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Step 5: Wait for callback (or optional manual input fallback)
|
|
121
|
+
info('Waiting for authentication callback...');
|
|
122
|
+
|
|
123
|
+
let callback;
|
|
124
|
+
if (typeof manualCodeInput === 'function' && shouldPromptManual) {
|
|
125
|
+
const manualPromise = (async () => {
|
|
126
|
+
const raw = await manualCodeInput({ authUrl, expectedState: pkceParams.state, port });
|
|
127
|
+
const parsed = parseManualCallbackInput(raw);
|
|
128
|
+
|
|
129
|
+
if (!parsed || !parsed.code) {
|
|
130
|
+
throw new Error('OAuth authentication cancelled by user.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (parsed.state && parsed.state !== pkceParams.state) {
|
|
134
|
+
throw new Error('State validation failed. Possible CSRF attack.');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { code: parsed.code, state: parsed.state || pkceParams.state };
|
|
138
|
+
})();
|
|
139
|
+
|
|
140
|
+
callback = await Promise.race([callbackPromise, manualPromise]);
|
|
141
|
+
abortController.abort();
|
|
142
|
+
} else {
|
|
143
|
+
callback = await callbackPromise;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
debug('Received callback', { hasCode: !!callback.code });
|
|
147
|
+
|
|
148
|
+
// Step 6: Exchange code for tokens
|
|
149
|
+
info('Exchanging authorization code for tokens...');
|
|
150
|
+
const tokenResponse = await exchangeCodeForToken({
|
|
151
|
+
code: callback.code,
|
|
152
|
+
verifier: pkceParams.verifier,
|
|
153
|
+
redirectUri: `http://localhost:${port}/callback`,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
debug('Token exchange successful');
|
|
157
|
+
|
|
158
|
+
// Step 7: Store tokens securely
|
|
159
|
+
const expiresAt = Date.now() + tokenResponse.expires_in * 1000;
|
|
160
|
+
const tokens = {
|
|
161
|
+
accessToken: tokenResponse.access_token,
|
|
162
|
+
refreshToken: tokenResponse.refresh_token,
|
|
163
|
+
expiresAt: expiresAt,
|
|
164
|
+
scope: tokenResponse.scope ? tokenResponse.scope.split(' ') : [],
|
|
165
|
+
tokenType: tokenResponse.token_type || 'Bearer',
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
await storeTokens(tokens);
|
|
169
|
+
debug('Tokens stored successfully');
|
|
170
|
+
|
|
171
|
+
info('Authentication successful!');
|
|
172
|
+
info(`Token expires at: ${new Date(expiresAt).toISOString()}`);
|
|
173
|
+
|
|
174
|
+
return tokens;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
logError('OAuth authentication failed', {
|
|
177
|
+
error: error.message,
|
|
178
|
+
stack: error.stack,
|
|
179
|
+
});
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get a valid access token, refreshing if necessary
|
|
186
|
+
*
|
|
187
|
+
* @returns {Promise<string|null>} Valid access token or null if not authenticated
|
|
188
|
+
*/
|
|
189
|
+
export async function getAccessToken() {
|
|
190
|
+
return getValidAccessToken(getTokens);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check if the user is authenticated
|
|
195
|
+
*
|
|
196
|
+
* @returns {Promise<boolean>} True if authenticated with valid tokens
|
|
197
|
+
*/
|
|
198
|
+
export async function isAuthenticated() {
|
|
199
|
+
return hasValidTokens();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get authentication status
|
|
204
|
+
*
|
|
205
|
+
* @returns {Promise<object|null>} Authentication status or null if not authenticated
|
|
206
|
+
*/
|
|
207
|
+
export async function getAuthStatus() {
|
|
208
|
+
const tokens = await getTokens();
|
|
209
|
+
|
|
210
|
+
if (!tokens) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
const isExpired = now >= tokens.expiresAt;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
authenticated: !isExpired,
|
|
219
|
+
expiresAt: new Date(tokens.expiresAt).toISOString(),
|
|
220
|
+
expiresIn: Math.max(0, tokens.expiresAt - now),
|
|
221
|
+
scopes: tokens.scope,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Logout (clear stored tokens)
|
|
227
|
+
*
|
|
228
|
+
* @returns {Promise<void>}
|
|
229
|
+
*/
|
|
230
|
+
export async function logout() {
|
|
231
|
+
info('Logging out...');
|
|
232
|
+
await clearTokens();
|
|
233
|
+
info('Logged out successfully');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Re-authenticate (logout and then authenticate)
|
|
238
|
+
*
|
|
239
|
+
* @param {object} options - Authentication options (passed to authenticate())
|
|
240
|
+
* @returns {Promise<object>} Authentication result with tokens
|
|
241
|
+
*/
|
|
242
|
+
export async function reAuthenticate(options) {
|
|
243
|
+
info('Re-authenticating...');
|
|
244
|
+
await logout();
|
|
245
|
+
return authenticate(options);
|
|
246
|
+
}
|