@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
|
@@ -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
|
+
}
|
package/src/auth/pkce.js
ADDED
|
@@ -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
|
+
}
|