@fink-andreas/pi-linear-tools 0.2.1 → 0.4.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/index.js CHANGED
@@ -1,75 +1,12 @@
1
1
  import { loadSettings, saveSettings } from './src/settings.js';
2
- import { createLinearClient } from './src/linear-client.js';
2
+ import { createLinearClient, checkAndClearRateLimit, markRateLimited } from './src/linear-client.js';
3
3
  import { setQuietMode } from './src/logger.js';
4
4
  import {
5
5
  resolveProjectRef,
6
6
  fetchTeams,
7
7
  fetchWorkspaces,
8
8
  } from './src/linear.js';
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
- import { pathToFileURL } from 'node:url';
12
-
13
- function isPiCodingAgentRoot(dir) {
14
- const pkgPath = path.join(dir, 'package.json');
15
- if (!fs.existsSync(pkgPath)) return false;
16
- try {
17
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
18
- return pkg?.name === '@mariozechner/pi-coding-agent';
19
- } catch {
20
- return false;
21
- }
22
- }
23
-
24
- function findPiCodingAgentRoot() {
25
- const entry = process.argv?.[1];
26
- if (!entry) return null;
27
-
28
- // Method 1: walk up from argv1 (works when argv1 is .../pi-coding-agent/dist/cli.js)
29
- {
30
- let dir = path.dirname(entry);
31
- for (let i = 0; i < 20; i += 1) {
32
- if (isPiCodingAgentRoot(dir)) {
33
- return dir;
34
- }
35
- const parent = path.dirname(dir);
36
- if (parent === dir) break;
37
- dir = parent;
38
- }
39
- }
40
-
41
- // Method 2: npm global layout guess (works when argv1 is .../<prefix>/bin/pi)
42
- // <prefix>/bin/pi -> <prefix>/lib/node_modules/@mariozechner/pi-coding-agent
43
- {
44
- const binDir = path.dirname(entry);
45
- const prefix = path.resolve(binDir, '..');
46
- const candidate = path.join(prefix, 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent');
47
- if (isPiCodingAgentRoot(candidate)) {
48
- return candidate;
49
- }
50
- }
51
-
52
- // Method 3: common global node_modules locations
53
- for (const candidate of [
54
- '/usr/local/lib/node_modules/@mariozechner/pi-coding-agent',
55
- '/usr/lib/node_modules/@mariozechner/pi-coding-agent',
56
- ]) {
57
- if (isPiCodingAgentRoot(candidate)) {
58
- return candidate;
59
- }
60
- }
61
-
62
- return null;
63
- }
64
-
65
- async function importFromPiRoot(relativePathFromPiRoot) {
66
- const piRoot = findPiCodingAgentRoot();
67
-
68
- if (!piRoot) throw new Error('Unable to locate @mariozechner/pi-coding-agent installation');
69
-
70
- const absPath = path.join(piRoot, relativePathFromPiRoot);
71
- return import(pathToFileURL(absPath).href);
72
- }
9
+ import { isPiCodingAgentRoot, findPiCodingAgentRoot, importFromPiRoot, parseArgs, readFlag } from './src/shared.js';
73
10
 
74
11
  async function importPiCodingAgent() {
75
12
  try {
@@ -125,23 +62,7 @@ import {
125
62
  executeMilestoneDelete,
126
63
  } from './src/handlers.js';
127
64
  import { authenticate, getAccessToken, logout } from './src/auth/index.js';
128
-
129
- function parseArgs(argsString) {
130
- if (!argsString || !argsString.trim()) return [];
131
- const tokens = argsString.match(/"[^"]*"|'[^']*'|\S+/g) || [];
132
- return tokens.map((t) => {
133
- if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
134
- return t.slice(1, -1);
135
- }
136
- return t;
137
- });
138
- }
139
-
140
- function readFlag(args, flag) {
141
- const idx = args.indexOf(flag);
142
- if (idx >= 0 && idx + 1 < args.length) return args[idx + 1];
143
- return undefined;
144
- }
65
+ import { withMilestoneScopeHint } from './src/error-hints.js';
145
66
 
146
67
  let cachedApiKey = null;
147
68
 
@@ -185,19 +106,6 @@ async function createAuthenticatedClient() {
185
106
  return createLinearClient(await getLinearAuth());
186
107
  }
187
108
 
188
- function withMilestoneScopeHint(error) {
189
- const message = String(error?.message || error || 'Unknown error');
190
-
191
- if (/invalid scope/i.test(message) && /write/i.test(message)) {
192
- return new Error(
193
- `${message}\nHint: Milestone create/update/delete require Linear write scope. ` +
194
- `Use API key auth for milestone management: /linear-tools-config --api-key <key>`
195
- );
196
- }
197
-
198
- return error;
199
- }
200
-
201
109
  async function resolveDefaultTeam(projectId) {
202
110
  const settings = await loadSettings();
203
111
 
@@ -502,7 +410,10 @@ function renderMarkdownResult(result, _options, _theme) {
502
410
  }
503
411
 
504
412
  async function registerLinearTools(pi) {
505
- if (typeof pi.registerTool !== 'function') return;
413
+ if (typeof pi.registerTool !== 'function') {
414
+ console.warn('[pi-linear-tools] pi.registerTool not available');
415
+ return;
416
+ }
506
417
 
507
418
  pi.registerTool({
508
419
  name: 'linear_issue',
@@ -628,29 +539,90 @@ async function registerLinearTools(pi) {
628
539
  },
629
540
  renderResult: renderMarkdownResult,
630
541
  async execute(_toolCallId, params) {
631
- const client = await createAuthenticatedClient();
632
-
633
- switch (params.action) {
634
- case 'list':
635
- return executeIssueList(client, params);
636
- case 'view':
637
- return executeIssueView(client, params);
638
- case 'create':
639
- return executeIssueCreate(client, params, { resolveDefaultTeam });
640
- case 'update':
641
- return executeIssueUpdate(client, params);
642
- case 'comment':
643
- return executeIssueComment(client, params);
644
- case 'start':
645
- return executeIssueStart(client, params, {
646
- gitExecutor: async (branchName, fromRef, onBranchExists) => {
647
- return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
648
- },
649
- });
650
- case 'delete':
651
- return executeIssueDelete(client, params);
652
- default:
653
- throw new Error(`Unknown action: ${params.action}`);
542
+ // Pre-check: skip API calls if we know we're rate limited
543
+ const { isRateLimited, resetAt } = checkAndClearRateLimit();
544
+ if (isRateLimited) {
545
+ throw new Error(
546
+ `Linear API rate limit exceeded (cached).\n\n` +
547
+ `The rate limit resets at: ${resetAt.toLocaleTimeString()}\n\n` +
548
+ `Please wait before making more requests.`
549
+ );
550
+ }
551
+
552
+ try {
553
+ const client = await createAuthenticatedClient();
554
+
555
+ switch (params.action) {
556
+ case 'list':
557
+ return await executeIssueList(client, params);
558
+ case 'view':
559
+ return await executeIssueView(client, params);
560
+ case 'create':
561
+ return await executeIssueCreate(client, params, { resolveDefaultTeam });
562
+ case 'update':
563
+ return await executeIssueUpdate(client, params);
564
+ case 'comment':
565
+ return await executeIssueComment(client, params);
566
+ case 'start':
567
+ return await executeIssueStart(client, params, {
568
+ gitExecutor: async (branchName, fromRef, onBranchExists) => {
569
+ return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
570
+ },
571
+ });
572
+ case 'delete':
573
+ return await executeIssueDelete(client, params);
574
+ default:
575
+ throw new Error(`Unknown action: ${params.action}`);
576
+ }
577
+ } catch (error) {
578
+ // Comprehensive error handling - catch ALL errors including SDK's RatelimitedLinearError
579
+ const errorType = error?.type || '';
580
+ const errorMessage = String(error?.message || error || 'Unknown error');
581
+
582
+ // Rate limit error - provide clear reset time and mark globally
583
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
584
+ const resetTimestamp = error?.requestsResetAt || (Date.now() + 3600000);
585
+ const resetTime = new Date(resetTimestamp).toLocaleTimeString();
586
+ markRateLimited(resetTimestamp);
587
+ throw new Error(
588
+ `Linear API rate limit exceeded.\n\n` +
589
+ `The rate limit resets at: ${resetTime}\n\n` +
590
+ `Please wait before making more requests, or reduce the frequency of API calls.`
591
+ );
592
+ }
593
+
594
+ // Authentication/Forbidden errors (handles SDK's ForbiddenLinearError)
595
+ if (errorType === 'Forbidden' || errorType === 'AuthenticationError' ||
596
+ errorMessage.toLowerCase().includes('forbidden') || errorMessage.toLowerCase().includes('unauthorized')) {
597
+ throw new Error(
598
+ `Linear API authentication failed: ${errorMessage}\n\n` +
599
+ `Please check your API key or OAuth token permissions.`
600
+ );
601
+ }
602
+
603
+ // Network errors (handles SDK's NetworkError)
604
+ if (errorType === 'NetworkError' || errorMessage.toLowerCase().includes('network')) {
605
+ throw new Error(
606
+ `Network error communicating with Linear API.\n\n` +
607
+ `Please check your internet connection and try again.`
608
+ );
609
+ }
610
+
611
+ // Internal server errors (handles SDK's InternalError)
612
+ if (errorType === 'InternalError' || (error?.status >= 500 && error?.status < 600)) {
613
+ throw new Error(
614
+ `Linear API server error (${error?.status || 'unknown'}).\n\n` +
615
+ `Linear may be experiencing issues. Please try again later.`
616
+ );
617
+ }
618
+
619
+ // Re-throw if already formatted with "Linear API error:"
620
+ if (errorMessage.includes('Linear API error:')) {
621
+ throw error;
622
+ }
623
+
624
+ // Wrap unexpected errors with context - NEVER let raw errors propagate
625
+ throw new Error(`Linear issue operation failed: ${errorMessage}`);
654
626
  }
655
627
  },
656
628
  });
@@ -673,13 +645,32 @@ async function registerLinearTools(pi) {
673
645
  },
674
646
  renderResult: renderMarkdownResult,
675
647
  async execute(_toolCallId, params) {
676
- const client = await createAuthenticatedClient();
648
+ try {
649
+ const client = await createAuthenticatedClient();
650
+
651
+ switch (params.action) {
652
+ case 'list':
653
+ return await executeProjectList(client);
654
+ default:
655
+ throw new Error(`Unknown action: ${params.action}`);
656
+ }
657
+ } catch (error) {
658
+ // Comprehensive error handling - catch ALL errors
659
+ const errorType = error?.type || '';
660
+ const errorMessage = String(error?.message || error || 'Unknown error');
661
+
662
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
663
+ const resetAt = error?.requestsResetAt
664
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
665
+ : 'approximately 1 hour from now';
666
+ throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
667
+ }
677
668
 
678
- switch (params.action) {
679
- case 'list':
680
- return executeProjectList(client);
681
- default:
682
- throw new Error(`Unknown action: ${params.action}`);
669
+ if (errorMessage.includes('Linear API error:')) {
670
+ throw error;
671
+ }
672
+
673
+ throw new Error(`Linear project operation failed: ${errorMessage}`);
683
674
  }
684
675
  },
685
676
  });
@@ -702,13 +693,32 @@ async function registerLinearTools(pi) {
702
693
  },
703
694
  renderResult: renderMarkdownResult,
704
695
  async execute(_toolCallId, params) {
705
- const client = await createAuthenticatedClient();
696
+ try {
697
+ const client = await createAuthenticatedClient();
698
+
699
+ switch (params.action) {
700
+ case 'list':
701
+ return await executeTeamList(client);
702
+ default:
703
+ throw new Error(`Unknown action: ${params.action}`);
704
+ }
705
+ } catch (error) {
706
+ // Comprehensive error handling - catch ALL errors
707
+ const errorType = error?.type || '';
708
+ const errorMessage = String(error?.message || error || 'Unknown error');
709
+
710
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
711
+ const resetAt = error?.requestsResetAt
712
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
713
+ : 'approximately 1 hour from now';
714
+ throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
715
+ }
706
716
 
707
- switch (params.action) {
708
- case 'list':
709
- return executeTeamList(client);
710
- default:
711
- throw new Error(`Unknown action: ${params.action}`);
717
+ if (errorMessage.includes('Linear API error:')) {
718
+ throw error;
719
+ }
720
+
721
+ throw new Error(`Linear team operation failed: ${errorMessage}`);
712
722
  }
713
723
  },
714
724
  });
@@ -752,9 +762,9 @@ async function registerLinearTools(pi) {
752
762
  },
753
763
  renderResult: renderMarkdownResult,
754
764
  async execute(_toolCallId, params) {
755
- const client = await createAuthenticatedClient();
756
-
757
765
  try {
766
+ const client = await createAuthenticatedClient();
767
+
758
768
  switch (params.action) {
759
769
  case 'list':
760
770
  return await executeMilestoneList(client, params);
@@ -770,7 +780,24 @@ async function registerLinearTools(pi) {
770
780
  throw new Error(`Unknown action: ${params.action}`);
771
781
  }
772
782
  } catch (error) {
773
- throw withMilestoneScopeHint(error);
783
+ // Apply milestone-specific hint, then wrap with operation context
784
+ const hintError = withMilestoneScopeHint(error);
785
+ // Comprehensive error handling - catch ALL errors
786
+ const errorType = error?.type || '';
787
+ const errorMessage = String(hintError?.message || hintError || 'Unknown error');
788
+
789
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
790
+ const resetAt = error?.requestsResetAt
791
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
792
+ : 'approximately 1 hour from now';
793
+ throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
794
+ }
795
+
796
+ if (errorMessage.includes('Linear API error:')) {
797
+ throw hintError;
798
+ }
799
+
800
+ throw new Error(`Linear milestone operation failed: ${errorMessage}`);
774
801
  }
775
802
  },
776
803
  });
@@ -778,6 +805,8 @@ async function registerLinearTools(pi) {
778
805
  }
779
806
 
780
807
  export default async function piLinearToolsExtension(pi) {
808
+ // Safety wrapper: never let extension errors crash pi
809
+ try {
781
810
  pi.registerCommand('linear-tools-config', {
782
811
  description: 'Configure pi-linear-tools settings (API key and default team mappings)',
783
812
  handler: async (argsText, ctx) => {
@@ -915,4 +944,8 @@ export default async function piLinearToolsExtension(pi) {
915
944
  });
916
945
 
917
946
  await registerLinearTools(pi);
947
+ } catch (error) {
948
+ // Safety: never let extension initialization crash pi
949
+ console.error('[pi-linear-tools] Extension initialization failed:', error?.message || error);
950
+ }
918
951
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fink-andreas/pi-linear-tools",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension with Linear SDK tools and configuration commands",
5
5
  "type": "module",
6
6
  "engines": {
@@ -46,6 +46,6 @@
46
46
  "license": "MIT",
47
47
  "dependencies": {
48
48
  "@linear/sdk": "^75.0.0",
49
- "keytar": "^7.9.0"
49
+ "@github/keytar": "^7.10.6"
50
50
  }
51
51
  }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import http from 'node:http';
9
9
  import { URL } from 'node:url';
10
- import { debug, warn, error as logError } from '../logger.js';
10
+ import { debug, error as logError } from '../logger.js';
11
11
 
12
12
  // Default callback server configuration
13
13
  const SERVER_CONFIG = {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * OAuth constants for pi-linear-tools
3
+ */
4
+
5
+ // Linear OAuth application client ID
6
+ export const OAUTH_CLIENT_ID = 'a3e177176c6697611367f1a2405d4a34';
7
+
8
+ // OAuth scopes - minimal required scopes
9
+ export const OAUTH_SCOPES = ['read', 'issues:create', 'comments:create'];
package/src/auth/index.js CHANGED
@@ -6,10 +6,9 @@
6
6
  */
7
7
 
8
8
  import { generatePkceParams } from './pkce.js';
9
- import { buildAuthorizationUrl, exchangeCodeForToken } from './oauth.js';
9
+ import { buildAuthorizationUrl, exchangeCodeForToken, refreshAccessToken, revokeToken } from './oauth.js';
10
10
  import { waitForCallback } from './callback-server.js';
11
- import { storeTokens, getTokens, clearTokens, hasValidTokens } from './token-store.js';
12
- import { getValidAccessToken } from './token-refresh.js';
11
+ import { storeTokens, getTokens, clearTokens } from './token-store.js';
13
12
  import { debug, info, warn, error as logError } from '../logger.js';
14
13
 
15
14
  function parseManualCallbackInput(rawInput) {
@@ -184,10 +183,41 @@ export async function authenticate({
184
183
  /**
185
184
  * Get a valid access token, refreshing if necessary
186
185
  *
187
- * @returns {Promise<string|null>} Valid access token or null if not authenticated
186
+ * @returns {Promise<string|null>} Valid access token, or null if no auth configured or refresh failed
188
187
  */
189
188
  export async function getAccessToken() {
190
- return getValidAccessToken(getTokens);
189
+ const tokens = await getTokens();
190
+
191
+ if (!tokens) {
192
+ return null;
193
+ }
194
+
195
+ const now = Date.now();
196
+ const bufferSeconds = 60;
197
+ const bufferMs = bufferSeconds * 1000;
198
+
199
+ // Check if token is still valid (with buffer)
200
+ if (now < tokens.expiresAt - bufferMs) {
201
+ return tokens.accessToken;
202
+ }
203
+
204
+ // Token needs refresh
205
+ try {
206
+ const tokenResponse = await refreshAccessToken(tokens.refreshToken);
207
+ const expiresAt = Date.now() + tokenResponse.expires_in * 1000;
208
+ const newTokens = {
209
+ accessToken: tokenResponse.access_token,
210
+ refreshToken: tokenResponse.refresh_token,
211
+ expiresAt: expiresAt,
212
+ scope: tokenResponse.scope ? tokenResponse.scope.split(' ') : [],
213
+ tokenType: tokenResponse.token_type || 'Bearer',
214
+ };
215
+ await storeTokens(newTokens);
216
+ return newTokens.accessToken;
217
+ } catch (error) {
218
+ debug('Failed to get valid access token', { error: error.message });
219
+ return null;
220
+ }
191
221
  }
192
222
 
193
223
  /**
@@ -196,7 +226,11 @@ export async function getAccessToken() {
196
226
  * @returns {Promise<boolean>} True if authenticated with valid tokens
197
227
  */
198
228
  export async function isAuthenticated() {
199
- return hasValidTokens();
229
+ const tokens = await getTokens();
230
+ if (!tokens) {
231
+ return false;
232
+ }
233
+ return Date.now() < tokens.expiresAt;
200
234
  }
201
235
 
202
236
  /**
@@ -223,12 +257,21 @@ export async function getAuthStatus() {
223
257
  }
224
258
 
225
259
  /**
226
- * Logout (clear stored tokens)
260
+ * Logout (clear stored tokens and revoke OAuth tokens)
227
261
  *
228
262
  * @returns {Promise<void>}
229
263
  */
230
264
  export async function logout() {
231
265
  info('Logging out...');
266
+ const tokens = await getTokens();
267
+ if (tokens?.accessToken) {
268
+ try {
269
+ await revokeToken(tokens.accessToken);
270
+ debug('OAuth token revoked');
271
+ } catch {
272
+ // Ignore revocation errors - we still want to clear local tokens
273
+ }
274
+ }
232
275
  await clearTokens();
233
276
  info('Logged out successfully');
234
277
  }
package/src/auth/oauth.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { debug, warn, error as logError } from '../logger.js';
9
+ import { OAUTH_CLIENT_ID, OAUTH_SCOPES } from './constants.js';
9
10
 
10
11
  // OAuth configuration
11
12
  const OAUTH_CONFIG = {
@@ -15,11 +16,11 @@ const OAUTH_CONFIG = {
15
16
  revokeUrl: 'https://api.linear.app/oauth/revoke',
16
17
 
17
18
  // Client configuration
18
- clientId: 'a3e177176c6697611367f1a2405d4a34',
19
+ clientId: OAUTH_CLIENT_ID,
19
20
  redirectUri: 'http://localhost:34711/callback',
20
21
 
21
- // OAuth scopes - minimal required scopes
22
- scopes: ['read', 'issues:create', 'comments:create'],
22
+ // OAuth scopes
23
+ scopes: OAUTH_SCOPES,
23
24
 
24
25
  // Prompt consent to allow workspace reselection
25
26
  prompt: 'consent',
@@ -272,10 +273,10 @@ export async function revokeToken(token, tokenTypeHint) {
272
273
  }
273
274
 
274
275
  /**
275
- * Get OAuth configuration
276
+ * Get OAuth configuration (internal)
276
277
  *
277
278
  * @returns {object} OAuth configuration object
278
279
  */
279
- export function getOAuthConfig() {
280
+ function _getOAuthConfig() {
280
281
  return { ...OAUTH_CONFIG };
281
282
  }
package/src/auth/pkce.js CHANGED
@@ -6,106 +6,69 @@
6
6
  */
7
7
 
8
8
  import crypto from 'node:crypto';
9
- import { debug } from '../logger.js';
9
+ import { debug, warn, error as logError } from '../logger.js';
10
10
 
11
11
  /**
12
- * Generate a random code verifier for PKCE
13
- *
14
- * The code verifier must be:
15
- * - High-entropy cryptographically random
16
- * - Between 43 and 128 characters
17
- * - Containing only alphanumeric characters and '-', '.', '_', '~'
18
- *
19
- * @returns {string} Base64URL-encoded code verifier
12
+ * Generate a random code verifier for PKCE (RFC 7636: 43-128 chars, base64url)
13
+ * @returns {string} Code verifier
20
14
  */
21
15
  export function generateCodeVerifier() {
22
- // Generate 32 bytes of cryptographically secure random data
23
- // Base64URL encoding results in ~43 characters
16
+ // 32 bytes = ~43 base64url characters
24
17
  const verifier = crypto.randomBytes(32).toString('base64url');
25
-
26
18
  debug('Generated code verifier', { length: verifier.length });
27
-
28
19
  return verifier;
29
20
  }
30
21
 
31
22
  /**
32
- * Generate a code challenge from the verifier
33
- *
34
- * Uses SHA-256 hash as specified in PKCE with S256 method.
35
- *
36
- * @param {string} verifier - The code verifier generated by generateCodeVerifier()
37
- * @returns {string} Base64URL-encoded code challenge
23
+ * Generate a code challenge from verifier (SHA-256, S256 method)
24
+ * @param {string} verifier - Code verifier
25
+ * @returns {string} Code challenge
38
26
  */
39
27
  export function generateCodeChallenge(verifier) {
40
28
  const challenge = crypto
41
29
  .createHash('sha256')
42
30
  .update(verifier)
43
31
  .digest('base64url');
44
-
45
32
  debug('Generated code challenge', { challengeLength: challenge.length });
46
-
47
33
  return challenge;
48
34
  }
49
35
 
50
36
  /**
51
- * Generate a cryptographically random state parameter
52
- *
53
- * The state parameter is used for CSRF protection during OAuth flow.
54
- * Must be unique per authentication session.
55
- *
56
- * @returns {string} Hex-encoded random state
37
+ * Generate a random state parameter for CSRF protection
38
+ * @returns {string} Hex-encoded state
57
39
  */
58
40
  export function generateState() {
59
- // Generate 16 bytes of random data (32 hex characters)
60
41
  const state = crypto.randomBytes(16).toString('hex');
61
-
62
42
  debug('Generated state parameter', { state });
63
-
64
43
  return state;
65
44
  }
66
45
 
67
46
  /**
68
- * Validate state parameter matches expected value
69
- *
70
- * @param {string} receivedState - State received from OAuth callback
71
- * @param {string} expectedState - State generated at start of flow
72
- * @returns {boolean} True if states match, false otherwise
47
+ * Validate OAuth callback state matches expected
48
+ * @param {string} receivedState - From callback
49
+ * @param {string} expectedState - Generated at flow start
50
+ * @returns {boolean} True if match
73
51
  */
74
52
  export function validateState(receivedState, expectedState) {
75
53
  const isValid = receivedState === expectedState;
76
-
77
54
  if (!isValid) {
78
- debug('State validation failed', {
79
- received: receivedState,
80
- expected: expectedState,
81
- });
82
- } else {
83
- debug('State validation successful');
55
+ debug('State validation failed', { received: receivedState, expected: expectedState });
84
56
  }
85
-
86
57
  return isValid;
87
58
  }
88
59
 
89
60
  /**
90
61
  * Generate all PKCE parameters for OAuth flow
91
- *
92
- * Convenience function that generates all required PKCE parameters.
93
- *
94
- * @returns {object} Object containing verifier, challenge, and state
95
- * @returns {string} returns.verifier - Code verifier
96
- * @returns {string} returns.challenge - Code challenge
97
- * @returns {string} returns.state - State parameter
62
+ * @returns {{verifier: string, challenge: string, state: string}}
98
63
  */
99
64
  export function generatePkceParams() {
100
65
  const verifier = generateCodeVerifier();
101
66
  const challenge = generateCodeChallenge(verifier);
102
67
  const state = generateState();
103
-
104
68
  debug('Generated PKCE parameters', {
105
69
  verifierLength: verifier.length,
106
70
  challengeLength: challenge.length,
107
71
  stateLength: state.length,
108
72
  });
109
-
110
73
  return { verifier, challenge, state };
111
- }
74
+ }