@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.
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Secure token storage for OAuth 2.0 tokens
3
+ *
4
+ * Stores access and refresh tokens securely using OS keychain.
5
+ * Falls back to local file storage when keychain is unavailable,
6
+ * and supports environment variables for CI/headless environments.
7
+ */
8
+
9
+ import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
10
+ import { dirname, join } from 'node:path';
11
+ import { debug, warn, error as logError } from '../logger.js';
12
+
13
+ // Keytar service name for pi-linear-tools
14
+ const KEYTAR_SERVICE = 'pi-linear-tools';
15
+
16
+ // Keytar account name
17
+ const KEYTAR_ACCOUNT = 'linear-oauth-tokens';
18
+
19
+ // In-memory fallback for environments without keychain
20
+ let inMemoryTokens = null;
21
+
22
+ // Lazy-loaded keytar module
23
+ let keytarModule = null;
24
+
25
+ function getTokenFilePath() {
26
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '.';
27
+ return join(homeDir, '.pi', 'agent', 'extensions', 'pi-linear-tools', 'oauth-tokens.json');
28
+ }
29
+
30
+ function normalizeTokens(tokens, source = 'unknown') {
31
+ if (!tokens || !tokens.accessToken || !tokens.refreshToken || !tokens.expiresAt) {
32
+ logError(`Invalid token structure in ${source}`, { tokens });
33
+ return null;
34
+ }
35
+
36
+ return {
37
+ accessToken: tokens.accessToken,
38
+ refreshToken: tokens.refreshToken,
39
+ expiresAt: tokens.expiresAt,
40
+ scope: tokens.scope || [],
41
+ tokenType: tokens.tokenType || 'Bearer',
42
+ };
43
+ }
44
+
45
+ async function readTokensFromFile() {
46
+ const tokenFilePath = getTokenFilePath();
47
+
48
+ try {
49
+ const content = await readFile(tokenFilePath, 'utf-8');
50
+ const parsed = JSON.parse(content);
51
+ const tokens = normalizeTokens(parsed, 'fallback token file');
52
+
53
+ if (!tokens) {
54
+ await unlink(tokenFilePath).catch(() => {});
55
+ return null;
56
+ }
57
+
58
+ debug('Tokens retrieved from fallback token file', {
59
+ path: tokenFilePath,
60
+ expiresAt: new Date(tokens.expiresAt).toISOString(),
61
+ });
62
+
63
+ return tokens;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ async function writeTokensToFile(tokenData) {
70
+ const tokenFilePath = getTokenFilePath();
71
+ const parentDir = dirname(tokenFilePath);
72
+
73
+ await mkdir(parentDir, { recursive: true, mode: 0o700 });
74
+ await writeFile(tokenFilePath, tokenData, { encoding: 'utf-8', mode: 0o600 });
75
+
76
+ warn('Stored OAuth tokens in fallback file storage because keychain is unavailable', {
77
+ path: tokenFilePath,
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Check if keytar is available
83
+ *
84
+ * @returns {boolean} True if keytar is available
85
+ */
86
+ async function isKeytarAvailable() {
87
+ if (keytarModule !== null) {
88
+ return keytarModule !== false;
89
+ }
90
+
91
+ try {
92
+ const module = await import('keytar');
93
+ keytarModule = module.default;
94
+ debug('keytar module loaded successfully');
95
+ return true;
96
+ } catch (error) {
97
+ warn('keytar module not available, using fallback storage', { error: error.message });
98
+ keytarModule = false;
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Store tokens securely in OS keychain or in-memory fallback
105
+ *
106
+ * @param {TokenRecord} tokens - Token record to store
107
+ * @returns {Promise<void>}
108
+ * @throws {Error} If storage fails
109
+ */
110
+ export async function storeTokens(tokens) {
111
+ const useKeytar = await isKeytarAvailable();
112
+ debug('Storing tokens', { method: useKeytar ? 'keychain' : 'in-memory' });
113
+
114
+ // Validate token structure
115
+ if (!tokens.accessToken) {
116
+ throw new Error('Missing accessToken in token record');
117
+ }
118
+
119
+ if (!tokens.refreshToken) {
120
+ throw new Error('Missing refreshToken in token record');
121
+ }
122
+
123
+ if (!tokens.expiresAt || typeof tokens.expiresAt !== 'number') {
124
+ throw new Error('Missing or invalid expiresAt in token record');
125
+ }
126
+
127
+ // Serialize tokens to JSON
128
+ const tokenData = JSON.stringify({
129
+ accessToken: tokens.accessToken,
130
+ refreshToken: tokens.refreshToken,
131
+ expiresAt: tokens.expiresAt,
132
+ scope: tokens.scope || [],
133
+ tokenType: tokens.tokenType || 'Bearer',
134
+ });
135
+
136
+ // Always keep the latest tokens in memory for this process.
137
+ // This ensures refreshed tokens immediately override env-sourced tokens.
138
+ inMemoryTokens = tokenData;
139
+
140
+ // Persist to keychain when available
141
+ if (useKeytar) {
142
+ try {
143
+ const result = await keytarModule.setPassword(
144
+ KEYTAR_SERVICE,
145
+ KEYTAR_ACCOUNT,
146
+ tokenData
147
+ );
148
+
149
+ if (!result) {
150
+ throw new Error('keytar.setPassword returned false');
151
+ }
152
+
153
+ // Clean up fallback file if keychain works again
154
+ await unlink(getTokenFilePath()).catch(() => {});
155
+ } catch (error) {
156
+ warn('Failed to store tokens in keychain, falling back to file storage', {
157
+ error: error.message,
158
+ });
159
+ await writeTokensToFile(tokenData);
160
+ }
161
+ } else {
162
+ await writeTokensToFile(tokenData);
163
+ }
164
+
165
+ debug('Tokens stored successfully', {
166
+ expiresAt: new Date(tokens.expiresAt).toISOString(),
167
+ scope: tokens.scope,
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Retrieve tokens from in-memory cache, environment variables, or keychain.
173
+ *
174
+ * Precedence is:
175
+ * 1) in-memory cache (latest tokens in this process, e.g. after refresh)
176
+ * 2) environment variables (CI/headless bootstrap)
177
+ * 3) keychain
178
+ *
179
+ * @returns {Promise<TokenRecord|null>} Token record or null if not found
180
+ */
181
+ export async function getTokens() {
182
+ debug('Retrieving tokens');
183
+
184
+ if (inMemoryTokens) {
185
+ try {
186
+ const parsed = JSON.parse(inMemoryTokens);
187
+ const tokens = normalizeTokens(parsed, 'memory');
188
+
189
+ if (!tokens) {
190
+ inMemoryTokens = null;
191
+ } else {
192
+ debug('Tokens retrieved from in-memory storage', {
193
+ expiresAt: new Date(tokens.expiresAt).toISOString(),
194
+ });
195
+
196
+ return tokens;
197
+ }
198
+ } catch (error) {
199
+ logError('Invalid token JSON in memory', { error: error.message });
200
+ inMemoryTokens = null;
201
+ }
202
+ }
203
+
204
+ const envTokens = getTokensFromEnv();
205
+ if (envTokens) {
206
+ debug('Retrieved tokens from environment variables');
207
+ return envTokens;
208
+ }
209
+
210
+ const useKeytar = await isKeytarAvailable();
211
+
212
+ if (useKeytar) {
213
+ try {
214
+ const tokenData = await keytarModule.getPassword(
215
+ KEYTAR_SERVICE,
216
+ KEYTAR_ACCOUNT
217
+ );
218
+
219
+ if (tokenData) {
220
+ const parsed = JSON.parse(tokenData);
221
+ const tokens = normalizeTokens(parsed, 'keychain');
222
+
223
+ if (!tokens) {
224
+ await clearTokens(); // Clear corrupted tokens
225
+ return null;
226
+ }
227
+
228
+ debug('Tokens retrieved successfully', {
229
+ expiresAt: new Date(tokens.expiresAt).toISOString(),
230
+ scope: tokens.scope,
231
+ });
232
+
233
+ return tokens;
234
+ }
235
+
236
+ debug('No tokens found in keychain');
237
+ } catch (error) {
238
+ warn('Failed to retrieve tokens from keychain, trying fallback storage', {
239
+ error: error.message,
240
+ });
241
+ }
242
+ }
243
+
244
+ const fileTokens = await readTokensFromFile();
245
+ if (fileTokens) {
246
+ inMemoryTokens = JSON.stringify(fileTokens);
247
+ return fileTokens;
248
+ }
249
+
250
+ debug('No tokens found');
251
+ return null;
252
+ }
253
+
254
+ /**
255
+ * Clear tokens from OS keychain or in-memory storage
256
+ *
257
+ * @returns {Promise<void>}
258
+ */
259
+ export async function clearTokens() {
260
+ debug('Clearing tokens');
261
+
262
+ const useKeytar = await isKeytarAvailable();
263
+
264
+ if (useKeytar) {
265
+ try {
266
+ const result = await keytarModule.deletePassword(
267
+ KEYTAR_SERVICE,
268
+ KEYTAR_ACCOUNT
269
+ );
270
+
271
+ if (result) {
272
+ debug('Tokens cleared successfully from keychain');
273
+ } else {
274
+ debug('No tokens to clear from keychain');
275
+ }
276
+ } catch (error) {
277
+ const message = String(error?.message || 'Unknown error');
278
+ // Keychain providers (e.g. DBus Secret Service) may be unavailable at runtime.
279
+ // Clearing is best-effort and we still clear fallback file/in-memory tokens below.
280
+ warn('Skipping keychain token clear; keychain backend unavailable', {
281
+ error: message,
282
+ });
283
+ if (/org\.freedesktop\.secrets/i.test(message)) {
284
+ debug('Keychain secret service unavailable during clearTokens');
285
+ }
286
+ }
287
+ }
288
+
289
+ // Clear fallback file storage
290
+ await unlink(getTokenFilePath()).catch(() => {});
291
+
292
+ // Clear in-memory tokens
293
+ inMemoryTokens = null;
294
+ debug('In-memory tokens cleared');
295
+ }
296
+
297
+ /**
298
+ * Get tokens from environment variables
299
+ *
300
+ * For CI/headless environments where keychain is unavailable.
301
+ *
302
+ * Required environment variables:
303
+ * - LINEAR_ACCESS_TOKEN: OAuth access token
304
+ * - LINEAR_REFRESH_TOKEN: OAuth refresh token
305
+ * - LINEAR_EXPIRES_AT: Token expiry timestamp (milliseconds since epoch)
306
+ *
307
+ * @returns {TokenRecord|null} Token record or null if env vars not set
308
+ */
309
+ function getTokensFromEnv() {
310
+ const accessToken = process.env.LINEAR_ACCESS_TOKEN;
311
+ const refreshToken = process.env.LINEAR_REFRESH_TOKEN;
312
+ const expiresAtStr = process.env.LINEAR_EXPIRES_AT;
313
+
314
+ if (!accessToken || !refreshToken || !expiresAtStr) {
315
+ return null;
316
+ }
317
+
318
+ const expiresAt = parseInt(expiresAtStr, 10);
319
+ if (isNaN(expiresAt)) {
320
+ warn('Invalid LINEAR_EXPIRES_AT value in environment');
321
+ return null;
322
+ }
323
+
324
+ return {
325
+ accessToken,
326
+ refreshToken,
327
+ expiresAt,
328
+ scope: ['read', 'issues:create', 'comments:create'], // Assume default scopes
329
+ tokenType: 'Bearer',
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Check if tokens exist and are not expired
335
+ *
336
+ * @returns {Promise<boolean>} True if valid tokens exist
337
+ */
338
+ export async function hasValidTokens() {
339
+ const tokens = await getTokens();
340
+
341
+ if (!tokens) {
342
+ return false;
343
+ }
344
+
345
+ // Check if token is expired (with 60-second buffer)
346
+ const now = Date.now();
347
+ const isExpired = now >= tokens.expiresAt - 60000;
348
+
349
+ if (isExpired) {
350
+ debug('Tokens are expired', {
351
+ expiresAt: new Date(tokens.expiresAt).toISOString(),
352
+ now: new Date(now).toISOString(),
353
+ });
354
+ return false;
355
+ }
356
+
357
+ return true;
358
+ }
359
+
360
+ /**
361
+ * Check if token needs refresh
362
+ *
363
+ * @param {TokenRecord} tokens - Token record to check
364
+ * @param {number} [bufferSeconds=60] - Buffer in seconds before expiry
365
+ * @returns {boolean} True if token needs refresh
366
+ */
367
+ export function needsRefresh(tokens, bufferSeconds = 60) {
368
+ const now = Date.now();
369
+ const bufferMs = bufferSeconds * 1000;
370
+ const needsRefresh = now >= tokens.expiresAt - bufferMs;
371
+
372
+ if (needsRefresh) {
373
+ debug('Token needs refresh', {
374
+ expiresAt: new Date(tokens.expiresAt).toISOString(),
375
+ now: new Date(now).toISOString(),
376
+ bufferSeconds,
377
+ });
378
+ }
379
+
380
+ return needsRefresh;
381
+ }
382
+
383
+ /**
384
+ * Get access token from storage, with optional refresh
385
+ *
386
+ * @param {Function} refreshFn - Optional refresh function that returns new tokens
387
+ * @returns {Promise<string|null>} Access token or null if not available
388
+ */
389
+ export async function getAccessToken(refreshFn) {
390
+ const tokens = await getTokens();
391
+
392
+ if (!tokens) {
393
+ return null;
394
+ }
395
+
396
+ // Check if token needs refresh
397
+ if (needsRefresh(tokens)) {
398
+ if (refreshFn) {
399
+ debug('Token needs refresh, calling refresh function');
400
+ try {
401
+ const newTokens = await refreshFn(tokens.refreshToken);
402
+ await storeTokens(newTokens);
403
+ return newTokens.accessToken;
404
+ } catch (error) {
405
+ logError('Failed to refresh token', { error: error.message });
406
+ return null;
407
+ }
408
+ } else {
409
+ debug('Token needs refresh but no refresh function provided');
410
+ return tokens.accessToken; // Return expired token, caller should handle
411
+ }
412
+ }
413
+
414
+ return tokens.accessToken;
415
+ }