@fink-andreas/pi-linear-tools 0.1.0 → 0.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fink-andreas/pi-linear-tools",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension with Linear SDK tools and configuration commands",
5
5
  "type": "module",
6
6
  "engines": {
@@ -28,6 +28,7 @@
28
28
  "scripts": {
29
29
  "start": "node index.js",
30
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",
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": [
@@ -44,6 +45,7 @@
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
+ }