@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.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * OAuth 2.0 client for Linear authentication
3
+ *
4
+ * Implements OAuth 2.0 Authorization Code flow with PKCE for Linear.
5
+ * Uses public client mode (no client_secret).
6
+ */
7
+
8
+ import { debug, warn, error as logError } from '../logger.js';
9
+
10
+ // OAuth configuration
11
+ const OAUTH_CONFIG = {
12
+ // Linear OAuth endpoints
13
+ authUrl: 'https://linear.app/oauth/authorize',
14
+ tokenUrl: 'https://api.linear.app/oauth/token',
15
+ revokeUrl: 'https://api.linear.app/oauth/revoke',
16
+
17
+ // Client configuration
18
+ clientId: 'a3e177176c6697611367f1a2405d4a34',
19
+ redirectUri: 'http://localhost:34711/callback',
20
+
21
+ // OAuth scopes - minimal required scopes
22
+ scopes: ['read', 'issues:create', 'comments:create'],
23
+
24
+ // Prompt consent to allow workspace reselection
25
+ prompt: 'consent',
26
+ };
27
+
28
+ /**
29
+ * Build the OAuth authorization URL
30
+ *
31
+ * @param {object} params - OAuth parameters
32
+ * @param {string} params.challenge - PKCE code challenge
33
+ * @param {string} params.state - CSRF state parameter
34
+ * @param {string} [params.redirectUri] - Optional override for redirect URI
35
+ * @param {string} [params.scopes] - Optional override for scopes
36
+ * @returns {string} Complete authorization URL
37
+ */
38
+ export function buildAuthorizationUrl({
39
+ challenge,
40
+ state,
41
+ redirectUri = OAUTH_CONFIG.redirectUri,
42
+ scopes = OAUTH_CONFIG.scopes,
43
+ }) {
44
+ const url = new URL(OAUTH_CONFIG.authUrl);
45
+
46
+ // Required OAuth parameters
47
+ url.searchParams.append('client_id', OAUTH_CONFIG.clientId);
48
+ url.searchParams.append('redirect_uri', redirectUri);
49
+ url.searchParams.append('response_type', 'code');
50
+ url.searchParams.append('scope', scopes.join(' '));
51
+
52
+ // PKCE parameters
53
+ url.searchParams.append('code_challenge', challenge);
54
+ url.searchParams.append('code_challenge_method', 'S256');
55
+
56
+ // Security parameters
57
+ url.searchParams.append('state', state);
58
+
59
+ // Force consent screen (allows workspace selection)
60
+ url.searchParams.append('prompt', OAUTH_CONFIG.prompt);
61
+
62
+ debug('Built authorization URL', {
63
+ url: url.toString(),
64
+ clientId: OAUTH_CONFIG.clientId,
65
+ redirectUri,
66
+ scopes: scopes.join(' '),
67
+ });
68
+
69
+ return url.toString();
70
+ }
71
+
72
+ /**
73
+ * Exchange authorization code for access token
74
+ *
75
+ * @param {object} params - Token exchange parameters
76
+ * @param {string} params.code - Authorization code from callback
77
+ * @param {string} params.verifier - PKCE code verifier
78
+ * @param {string} [params.redirectUri] - Optional override for redirect URI
79
+ * @returns {Promise<object>} Token response with access_token, refresh_token, expires_in, scope
80
+ */
81
+ export async function exchangeCodeForToken({
82
+ code,
83
+ verifier,
84
+ redirectUri = OAUTH_CONFIG.redirectUri,
85
+ }) {
86
+ debug('Exchanging code for token', { redirectUri });
87
+
88
+ // Build request body (form-urlencoded as required by OAuth spec)
89
+ const body = new URLSearchParams({
90
+ grant_type: 'authorization_code',
91
+ code: code,
92
+ redirect_uri: redirectUri,
93
+ client_id: OAUTH_CONFIG.clientId,
94
+ code_verifier: verifier,
95
+ });
96
+
97
+ try {
98
+ const response = await fetch(OAUTH_CONFIG.tokenUrl, {
99
+ method: 'POST',
100
+ headers: {
101
+ 'Content-Type': 'application/x-www-form-urlencoded',
102
+ 'Accept': 'application/json',
103
+ },
104
+ body: body.toString(),
105
+ });
106
+
107
+ if (!response.ok) {
108
+ const errorText = await response.text();
109
+ logError('Token exchange failed', {
110
+ status: response.status,
111
+ statusText: response.statusText,
112
+ response: errorText,
113
+ });
114
+
115
+ throw new Error(
116
+ `Token exchange failed: ${response.status} ${response.statusText}`
117
+ );
118
+ }
119
+
120
+ const data = await response.json();
121
+
122
+ debug('Token exchange successful', {
123
+ hasAccessToken: !!data.access_token,
124
+ hasRefreshToken: !!data.refresh_token,
125
+ expiresIn: data.expires_in,
126
+ scope: data.scope,
127
+ });
128
+
129
+ // Validate required fields
130
+ if (!data.access_token) {
131
+ throw new Error('Token response missing access_token');
132
+ }
133
+
134
+ if (!data.refresh_token) {
135
+ throw new Error('Token response missing refresh_token');
136
+ }
137
+
138
+ if (!data.expires_in) {
139
+ throw new Error('Token response missing expires_in');
140
+ }
141
+
142
+ return data;
143
+ } catch (error) {
144
+ logError('Token exchange error', {
145
+ error: error.message,
146
+ stack: error.stack,
147
+ });
148
+ throw error;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Refresh access token using refresh token
154
+ *
155
+ * @param {string} refreshToken - Refresh token from initial token response
156
+ * @returns {Promise<object>} New token response with access_token, refresh_token, expires_in, scope
157
+ */
158
+ export async function refreshAccessToken(refreshToken) {
159
+ debug('Refreshing access token');
160
+
161
+ const body = new URLSearchParams({
162
+ grant_type: 'refresh_token',
163
+ refresh_token: refreshToken,
164
+ client_id: OAUTH_CONFIG.clientId,
165
+ });
166
+
167
+ try {
168
+ const response = await fetch(OAUTH_CONFIG.tokenUrl, {
169
+ method: 'POST',
170
+ headers: {
171
+ 'Content-Type': 'application/x-www-form-urlencoded',
172
+ 'Accept': 'application/json',
173
+ },
174
+ body: body.toString(),
175
+ });
176
+
177
+ if (!response.ok) {
178
+ const errorData = await response.json().catch(() => ({}));
179
+
180
+ debug('Token refresh failed', {
181
+ status: response.status,
182
+ error: errorData.error,
183
+ errorDescription: errorData.error_description,
184
+ });
185
+
186
+ // Handle specific OAuth errors
187
+ if (errorData.error === 'invalid_grant') {
188
+ // Refresh token expired or revoked
189
+ throw new Error(
190
+ 'invalid_grant: Refresh token expired or revoked. Please re-authenticate.'
191
+ );
192
+ }
193
+
194
+ throw new Error(
195
+ `Token refresh failed: ${response.status} ${response.statusText}`
196
+ );
197
+ }
198
+
199
+ const data = await response.json();
200
+
201
+ debug('Token refresh successful', {
202
+ hasAccessToken: !!data.access_token,
203
+ hasRefreshToken: !!data.refresh_token,
204
+ expiresIn: data.expires_in,
205
+ });
206
+
207
+ // Validate required fields
208
+ if (!data.access_token) {
209
+ throw new Error('Refresh response missing access_token');
210
+ }
211
+
212
+ if (!data.refresh_token) {
213
+ throw new Error('Refresh response missing refresh_token');
214
+ }
215
+
216
+ if (!data.expires_in) {
217
+ throw new Error('Refresh response missing expires_in');
218
+ }
219
+
220
+ return data;
221
+ } catch (error) {
222
+ logError('Token refresh error', {
223
+ error: error.message,
224
+ stack: error.stack,
225
+ });
226
+ throw error;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Revoke a token (access or refresh token)
232
+ *
233
+ * @param {string} token - Token to revoke
234
+ * @param {string} [tokenTypeHint] - Optional hint: 'access_token' or 'refresh_token'
235
+ * @returns {Promise<void>}
236
+ */
237
+ export async function revokeToken(token, tokenTypeHint) {
238
+ debug('Revoking token', { hasTokenHint: !!tokenTypeHint });
239
+
240
+ const body = new URLSearchParams({
241
+ token: token,
242
+ client_id: OAUTH_CONFIG.clientId,
243
+ });
244
+
245
+ if (tokenTypeHint) {
246
+ body.append('token_type_hint', tokenTypeHint);
247
+ }
248
+
249
+ try {
250
+ const response = await fetch(OAUTH_CONFIG.revokeUrl, {
251
+ method: 'POST',
252
+ headers: {
253
+ 'Content-Type': 'application/x-www-form-urlencoded',
254
+ },
255
+ body: body.toString(),
256
+ });
257
+
258
+ if (!response.ok) {
259
+ warn('Token revocation failed', {
260
+ status: response.status,
261
+ statusText: response.statusText,
262
+ });
263
+ // Don't throw - revocation is best-effort
264
+ return;
265
+ }
266
+
267
+ debug('Token revoked successfully');
268
+ } catch (error) {
269
+ warn('Token revocation error', { error: error.message });
270
+ // Don't throw - revocation is best-effort
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Get OAuth configuration
276
+ *
277
+ * @returns {object} OAuth configuration object
278
+ */
279
+ export function getOAuthConfig() {
280
+ return { ...OAUTH_CONFIG };
281
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * PKCE (Proof Key for Code Exchange) helpers for OAuth 2.0
3
+ *
4
+ * Implements the PKCE extension as specified in RFC 7636.
5
+ * Used for secure OAuth authentication without client_secret (public client).
6
+ */
7
+
8
+ import crypto from 'node:crypto';
9
+ import { debug } from '../logger.js';
10
+
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
20
+ */
21
+ export function generateCodeVerifier() {
22
+ // Generate 32 bytes of cryptographically secure random data
23
+ // Base64URL encoding results in ~43 characters
24
+ const verifier = crypto.randomBytes(32).toString('base64url');
25
+
26
+ debug('Generated code verifier', { length: verifier.length });
27
+
28
+ return verifier;
29
+ }
30
+
31
+ /**
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
38
+ */
39
+ export function generateCodeChallenge(verifier) {
40
+ const challenge = crypto
41
+ .createHash('sha256')
42
+ .update(verifier)
43
+ .digest('base64url');
44
+
45
+ debug('Generated code challenge', { challengeLength: challenge.length });
46
+
47
+ return challenge;
48
+ }
49
+
50
+ /**
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
57
+ */
58
+ export function generateState() {
59
+ // Generate 16 bytes of random data (32 hex characters)
60
+ const state = crypto.randomBytes(16).toString('hex');
61
+
62
+ debug('Generated state parameter', { state });
63
+
64
+ return state;
65
+ }
66
+
67
+ /**
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
73
+ */
74
+ export function validateState(receivedState, expectedState) {
75
+ const isValid = receivedState === expectedState;
76
+
77
+ if (!isValid) {
78
+ debug('State validation failed', {
79
+ received: receivedState,
80
+ expected: expectedState,
81
+ });
82
+ } else {
83
+ debug('State validation successful');
84
+ }
85
+
86
+ return isValid;
87
+ }
88
+
89
+ /**
90
+ * 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
98
+ */
99
+ export function generatePkceParams() {
100
+ const verifier = generateCodeVerifier();
101
+ const challenge = generateCodeChallenge(verifier);
102
+ const state = generateState();
103
+
104
+ debug('Generated PKCE parameters', {
105
+ verifierLength: verifier.length,
106
+ challengeLength: challenge.length,
107
+ stateLength: state.length,
108
+ });
109
+
110
+ return { verifier, challenge, state };
111
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Token refresh manager with single-flight locking
3
+ *
4
+ * Manages OAuth token refresh with protection against race conditions.
5
+ * Uses a Promise-based lock to ensure only one refresh happens at a time.
6
+ */
7
+
8
+ import { refreshAccessToken } from './oauth.js';
9
+ import { storeTokens, clearTokens } from './token-store.js';
10
+ import { debug, warn, error as logError } from '../logger.js';
11
+
12
+ /**
13
+ * Token refresh manager class
14
+ *
15
+ * Implements single-flight locking to prevent concurrent refresh attempts
16
+ * that could invalidate tokens due to Linear's refresh token rotation.
17
+ */
18
+ class TokenRefreshManager {
19
+ constructor() {
20
+ /** @type {boolean} Is a refresh currently in progress? */
21
+ this.isRefreshing = false;
22
+
23
+ /** @type {Promise<string>|null} The current refresh promise */
24
+ this.refreshPromise = null;
25
+ }
26
+
27
+ /**
28
+ * Refresh the access token using the refresh token
29
+ *
30
+ * Implements single-flight locking: if a refresh is already in progress,
31
+ * this method will wait for the existing refresh to complete and return
32
+ * the new access token.
33
+ *
34
+ * @param {string} refreshToken - The refresh token to use
35
+ * @returns {Promise<string>} New access token
36
+ * @throws {Error} If refresh fails
37
+ */
38
+ async refresh(refreshToken) {
39
+ // If a refresh is already in progress, wait for it
40
+ if (this.isRefreshing && this.refreshPromise) {
41
+ debug('Refresh already in progress, waiting for existing refresh');
42
+ try {
43
+ return await this.refreshPromise;
44
+ } catch (error) {
45
+ // If the existing refresh failed, throw the error
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ // Start a new refresh
51
+ this.isRefreshing = true;
52
+
53
+ this.refreshPromise = (async () => {
54
+ try {
55
+ debug('Starting token refresh');
56
+
57
+ // Call Linear's token endpoint
58
+ const tokenResponse = await refreshAccessToken(refreshToken);
59
+
60
+ // Calculate expiry timestamp
61
+ const expiresAt = Date.now() + tokenResponse.expires_in * 1000;
62
+
63
+ // Store new tokens atomically
64
+ const newTokens = {
65
+ accessToken: tokenResponse.access_token,
66
+ refreshToken: tokenResponse.refresh_token,
67
+ expiresAt: expiresAt,
68
+ scope: tokenResponse.scope ? tokenResponse.scope.split(' ') : [],
69
+ tokenType: tokenResponse.token_type || 'Bearer',
70
+ };
71
+
72
+ await storeTokens(newTokens);
73
+
74
+ debug('Token refresh successful', {
75
+ expiresAt: new Date(expiresAt).toISOString(),
76
+ });
77
+
78
+ return newTokens.accessToken;
79
+ } catch (error) {
80
+ logError('Token refresh failed', {
81
+ error: error.message,
82
+ stack: error.stack,
83
+ });
84
+
85
+ // Handle invalid_grant error - refresh token expired or revoked
86
+ if (error.message.includes('invalid_grant')) {
87
+ warn('Refresh token expired or revoked, clearing tokens');
88
+ await clearTokens();
89
+ }
90
+
91
+ throw error;
92
+ } finally {
93
+ // Reset the lock
94
+ this.isRefreshing = false;
95
+ this.refreshPromise = null;
96
+ }
97
+ })();
98
+
99
+ return this.refreshPromise;
100
+ }
101
+
102
+ /**
103
+ * Check if a refresh is currently in progress
104
+ *
105
+ * @returns {boolean} True if a refresh is in progress
106
+ */
107
+ isRefreshInProgress() {
108
+ return this.isRefreshing;
109
+ }
110
+
111
+ /**
112
+ * Reset the refresh manager state
113
+ *
114
+ * This is mainly for testing purposes.
115
+ */
116
+ reset() {
117
+ debug('Resetting token refresh manager');
118
+ this.isRefreshing = false;
119
+ this.refreshPromise = null;
120
+ }
121
+ }
122
+
123
+ // Create singleton instance
124
+ const refreshManager = new TokenRefreshManager();
125
+
126
+ /**
127
+ * Refresh the access token using the refresh token
128
+ *
129
+ * This function uses a singleton TokenRefreshManager to ensure
130
+ * single-flight locking across all calls.
131
+ *
132
+ * @param {string} refreshToken - The refresh token to use
133
+ * @returns {Promise<string>} New access token
134
+ * @throws {Error} If refresh fails
135
+ */
136
+ export async function refreshTokens(refreshToken) {
137
+ return refreshManager.refresh(refreshToken);
138
+ }
139
+
140
+ /**
141
+ * Check if a token refresh is currently in progress
142
+ *
143
+ * @returns {boolean} True if a refresh is in progress
144
+ */
145
+ export function isRefreshing() {
146
+ return refreshManager.isRefreshInProgress();
147
+ }
148
+
149
+ /**
150
+ * Reset the token refresh manager
151
+ *
152
+ * This is mainly for testing purposes.
153
+ */
154
+ export function resetRefreshManager() {
155
+ refreshManager.reset();
156
+ }
157
+
158
+ /**
159
+ * Get a valid access token, refreshing if necessary
160
+ *
161
+ * This is a convenience function that combines token retrieval
162
+ * and refresh logic.
163
+ *
164
+ * @param {Function} getTokensFn - Function to retrieve current tokens
165
+ * @param {number} [bufferSeconds=60] - Seconds before expiry to trigger refresh
166
+ * @returns {Promise<string|null>} Valid access token or null if not available
167
+ */
168
+ export async function getValidAccessToken(
169
+ getTokensFn,
170
+ bufferSeconds = 60
171
+ ) {
172
+ // Get current tokens
173
+ const tokens = await getTokensFn();
174
+
175
+ if (!tokens) {
176
+ debug('No tokens available');
177
+ return null;
178
+ }
179
+
180
+ const now = Date.now();
181
+ const bufferMs = bufferSeconds * 1000;
182
+ const expiresAt = tokens.expiresAt;
183
+
184
+ // Check if token is still valid (with buffer)
185
+ if (now < expiresAt - bufferMs) {
186
+ debug('Token is still valid', {
187
+ expiresAt: new Date(expiresAt).toISOString(),
188
+ now: new Date(now).toISOString(),
189
+ bufferSeconds,
190
+ });
191
+ return tokens.accessToken;
192
+ }
193
+
194
+ // Token needs refresh
195
+ debug('Token needs refresh', {
196
+ expiresAt: new Date(expiresAt).toISOString(),
197
+ now: new Date(now).toISOString(),
198
+ bufferSeconds,
199
+ });
200
+
201
+ try {
202
+ const newAccessToken = await refreshTokens(tokens.refreshToken);
203
+ return newAccessToken;
204
+ } catch (error) {
205
+ logError('Failed to get valid access token', {
206
+ error: error.message,
207
+ });
208
+ return null;
209
+ }
210
+ }