@barndoor-ai/sdk 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/.eslintignore +8 -0
- package/.eslintrc.cjs +102 -0
- package/.github/CODEOWNERS +4 -0
- package/.github/workflows/ci.yml +57 -0
- package/.prettierignore +6 -0
- package/.prettierrc +13 -0
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/RELEASE.md +203 -0
- package/examples/README.md +92 -0
- package/examples/basic-mcp-client.js +134 -0
- package/examples/openai-integration.js +137 -0
- package/jest.config.js +16 -0
- package/openapi.yaml +681 -0
- package/package.json +87 -0
- package/rollup.config.js +63 -0
- package/scripts/dump-core-files.js +161 -0
- package/scripts/dump-typescript-only.js +150 -0
- package/src/auth/index.ts +26 -0
- package/src/auth/pkce.ts +346 -0
- package/src/auth/store.ts +809 -0
- package/src/client.ts +512 -0
- package/src/config.ts +402 -0
- package/src/exceptions/index.ts +205 -0
- package/src/http/client.ts +272 -0
- package/src/index.ts +92 -0
- package/src/logging.ts +111 -0
- package/src/models/index.ts +156 -0
- package/src/quickstart.ts +358 -0
- package/src/version.ts +41 -0
- package/test/client.test.js +381 -0
- package/test/config.test.js +202 -0
- package/test/exceptions.test.js +142 -0
- package/test/integration.test.js +147 -0
- package/test/models.test.js +177 -0
- package/test/token-management.test.js +81 -0
- package/test/token-validation.test.js +104 -0
- package/tsconfig.json +61 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token storage and management.
|
|
3
|
+
*
|
|
4
|
+
* This module provides token storage that works in both browser and Node.js
|
|
5
|
+
* environments, mirroring the Python SDK's auth_store functionality.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isBrowser, isNode, getStaticConfig } from '../config';
|
|
9
|
+
import { TokenError, TokenExpiredError } from '../exceptions';
|
|
10
|
+
import { createScopedLogger, Logger } from '../logging';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import fs from 'fs/promises';
|
|
14
|
+
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
|
15
|
+
|
|
16
|
+
// Create scoped logger for token management
|
|
17
|
+
const _logger = createScopedLogger('token');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Set a custom logger for the token management module.
|
|
21
|
+
* @deprecated Use setLogger from the main logging module instead
|
|
22
|
+
*/
|
|
23
|
+
export function setTokenLogger(_logger: Logger): void {
|
|
24
|
+
// This is now deprecated - users should use the main setLogger function
|
|
25
|
+
console.warn('setTokenLogger is deprecated. Use setLogger from the main logging module instead.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cross-platform base64url decode function.
|
|
30
|
+
*/
|
|
31
|
+
function base64UrlDecode(str: string): string {
|
|
32
|
+
// Replace URL-safe characters
|
|
33
|
+
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
34
|
+
|
|
35
|
+
if (isNode) {
|
|
36
|
+
return Buffer.from(base64, 'base64').toString('utf8');
|
|
37
|
+
} else if (typeof globalThis !== 'undefined' && globalThis.atob) {
|
|
38
|
+
return globalThis.atob(base64);
|
|
39
|
+
} else {
|
|
40
|
+
throw new Error('No base64 decode function available');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Safely decode JWT payload.
|
|
46
|
+
*/
|
|
47
|
+
function decodeJwtPayload(token: string): { exp?: number; [key: string]: unknown } | null {
|
|
48
|
+
try {
|
|
49
|
+
const parts = token.split('.');
|
|
50
|
+
if (parts.length !== 3) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const payload = JSON.parse(base64UrlDecode(parts[1]!));
|
|
55
|
+
return payload as { exp?: number; [key: string]: unknown };
|
|
56
|
+
} catch (_error) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Add timeout to fetch operations for environments that don't support AbortSignal.timeout.
|
|
63
|
+
*/
|
|
64
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
65
|
+
// Try to use AbortSignal.timeout if available
|
|
66
|
+
if (typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal) {
|
|
67
|
+
return promise;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Fallback timeout implementation
|
|
71
|
+
return Promise.race([
|
|
72
|
+
promise,
|
|
73
|
+
new Promise<never>((_, reject) => {
|
|
74
|
+
setTimeout(() => reject(new Error('Request timeout')), timeoutMs);
|
|
75
|
+
}),
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Token data structure for storage.
|
|
81
|
+
*/
|
|
82
|
+
export interface TokenData {
|
|
83
|
+
/** Access token for API requests */
|
|
84
|
+
access_token: string;
|
|
85
|
+
/** Optional refresh token */
|
|
86
|
+
refresh_token?: string;
|
|
87
|
+
/** Token type (usually 'Bearer') */
|
|
88
|
+
token_type?: string;
|
|
89
|
+
/** Token expiration time in seconds */
|
|
90
|
+
expires_in?: number;
|
|
91
|
+
/** Token scope */
|
|
92
|
+
scope?: string;
|
|
93
|
+
/** Additional token properties */
|
|
94
|
+
[key: string]: unknown;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* JWT verification result.
|
|
99
|
+
*/
|
|
100
|
+
export enum JWTVerificationResult {
|
|
101
|
+
VALID = 'valid',
|
|
102
|
+
EXPIRED = 'expired',
|
|
103
|
+
INVALID = 'invalid',
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Singleton JWKS instances to avoid recreating on every call
|
|
107
|
+
const _jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get or create a JWKS instance for the given domain.
|
|
111
|
+
*/
|
|
112
|
+
function getJWKS(authDomain: string) {
|
|
113
|
+
if (!_jwksCache.has(authDomain)) {
|
|
114
|
+
const jwks = createRemoteJWKSet(new URL(`https://${authDomain}/.well-known/jwks.json`));
|
|
115
|
+
_jwksCache.set(authDomain, jwks);
|
|
116
|
+
}
|
|
117
|
+
return _jwksCache.get(authDomain)!;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Simple file locking context manager for cross-platform compatibility.
|
|
122
|
+
*/
|
|
123
|
+
class _FileLock {
|
|
124
|
+
private readonly lockFile: string;
|
|
125
|
+
private lockAcquired: boolean = false;
|
|
126
|
+
|
|
127
|
+
constructor(filePath: string) {
|
|
128
|
+
this.lockFile = `${filePath}.lock`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Acquire file lock with timeout.
|
|
133
|
+
*/
|
|
134
|
+
async acquire(timeoutMs: number = 5000): Promise<void> {
|
|
135
|
+
if (!isNode) {
|
|
136
|
+
return; // No locking needed in browser
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const startTime = Date.now();
|
|
140
|
+
|
|
141
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
142
|
+
try {
|
|
143
|
+
// Try to create lock file exclusively
|
|
144
|
+
await fs.writeFile(this.lockFile, process.pid.toString(), { flag: 'wx' });
|
|
145
|
+
this.lockAcquired = true;
|
|
146
|
+
return;
|
|
147
|
+
} catch (error: unknown) {
|
|
148
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
|
|
149
|
+
// Lock file exists, check if process is still alive
|
|
150
|
+
try {
|
|
151
|
+
const pidStr = await fs.readFile(this.lockFile, 'utf8');
|
|
152
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
153
|
+
|
|
154
|
+
// Check if process is still running
|
|
155
|
+
try {
|
|
156
|
+
process.kill(pid, 0); // Signal 0 just checks if process exists
|
|
157
|
+
// Process exists, wait and retry
|
|
158
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
159
|
+
continue;
|
|
160
|
+
} catch (error: unknown) {
|
|
161
|
+
// Handle different error types
|
|
162
|
+
if (error && typeof error === 'object' && 'code' in error) {
|
|
163
|
+
const nodeError = error as { code: string };
|
|
164
|
+
if (nodeError.code === 'EPERM') {
|
|
165
|
+
// On Windows, EPERM means process exists but we can't signal it
|
|
166
|
+
// (different user/permissions) - treat as process still running
|
|
167
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
168
|
+
continue;
|
|
169
|
+
} else if (nodeError.code === 'ESRCH') {
|
|
170
|
+
// Process doesn't exist, remove stale lock
|
|
171
|
+
await fs.unlink(this.lockFile).catch(() => {});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// For any other error, assume process doesn't exist
|
|
176
|
+
await fs.unlink(this.lockFile).catch(() => {});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Can't read lock file, try to remove it
|
|
181
|
+
await fs.unlink(this.lockFile).catch(() => {});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
throw new TokenError(`Failed to acquire file lock: ${error}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
throw new TokenError('Failed to acquire file lock: timeout');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Release file lock.
|
|
195
|
+
*/
|
|
196
|
+
async release(): Promise<void> {
|
|
197
|
+
if (!isNode || !this.lockAcquired) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
await fs.unlink(this.lockFile);
|
|
203
|
+
this.lockAcquired = false;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
// eslint-disable-next-line no-console
|
|
206
|
+
console.debug('Failed to release file lock:', error);
|
|
207
|
+
// Don't throw - cleanup is best effort
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Abstract base class for token storage.
|
|
214
|
+
*/
|
|
215
|
+
abstract class TokenStorage {
|
|
216
|
+
/**
|
|
217
|
+
* Load token data from storage.
|
|
218
|
+
* @returns Token data or null if not found
|
|
219
|
+
*/
|
|
220
|
+
abstract loadToken(): Promise<TokenData | null>;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Save token data to storage.
|
|
224
|
+
* @param tokenData - Token data to save
|
|
225
|
+
*/
|
|
226
|
+
abstract saveToken(tokenData: TokenData): Promise<void>;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Clear token data from storage.
|
|
230
|
+
*/
|
|
231
|
+
abstract clearToken(): Promise<void>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Browser-based token storage using localStorage.
|
|
236
|
+
*/
|
|
237
|
+
class BrowserTokenStorage extends TokenStorage {
|
|
238
|
+
/** Storage key for localStorage */
|
|
239
|
+
private readonly storageKey: string;
|
|
240
|
+
|
|
241
|
+
constructor() {
|
|
242
|
+
super();
|
|
243
|
+
this.storageKey = 'barndoor_token';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
public async loadToken(): Promise<TokenData | null> {
|
|
247
|
+
try {
|
|
248
|
+
const tokenData = localStorage.getItem(this.storageKey);
|
|
249
|
+
return tokenData ? (JSON.parse(tokenData) as TokenData) : null;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
_logger.warn('Failed to load token from localStorage:', error);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
public async saveToken(tokenData: TokenData): Promise<void> {
|
|
257
|
+
try {
|
|
258
|
+
localStorage.setItem(this.storageKey, JSON.stringify(tokenData));
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
261
|
+
throw new TokenError(`Failed to save token: ${errorMessage}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
public async clearToken(): Promise<void> {
|
|
266
|
+
try {
|
|
267
|
+
localStorage.removeItem(this.storageKey);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
_logger.warn('Failed to clear token from localStorage:', error);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Node.js-based token storage using file system.
|
|
276
|
+
*/
|
|
277
|
+
class NodeTokenStorage extends TokenStorage {
|
|
278
|
+
/** Path to the token file */
|
|
279
|
+
private readonly tokenFile: string;
|
|
280
|
+
|
|
281
|
+
constructor() {
|
|
282
|
+
super();
|
|
283
|
+
this.tokenFile = this._getTokenFilePath();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private _getTokenFilePath(): string {
|
|
287
|
+
if (isNode) {
|
|
288
|
+
return path.join(os.homedir(), '.barndoor', 'token.json');
|
|
289
|
+
}
|
|
290
|
+
throw new Error('NodeTokenStorage can only be used in Node.js environment');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
public async loadToken(): Promise<TokenData | null> {
|
|
294
|
+
if (!isNode) {
|
|
295
|
+
throw new Error('NodeTokenStorage can only be used in Node.js environment');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const lock = new _FileLock(this.tokenFile);
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
// Acquire file lock to prevent reading during writes
|
|
302
|
+
await lock.acquire();
|
|
303
|
+
|
|
304
|
+
const tokenData = await fs.readFile(this.tokenFile, 'utf8');
|
|
305
|
+
return JSON.parse(tokenData) as TokenData;
|
|
306
|
+
} catch (error: unknown) {
|
|
307
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
308
|
+
return null; // File doesn't exist
|
|
309
|
+
}
|
|
310
|
+
_logger.warn('Failed to load token from file:', error);
|
|
311
|
+
return null;
|
|
312
|
+
} finally {
|
|
313
|
+
await lock.release();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
public async saveToken(tokenData: TokenData): Promise<void> {
|
|
318
|
+
if (!isNode) {
|
|
319
|
+
throw new Error('NodeTokenStorage can only be used in Node.js environment');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const lock = new _FileLock(this.tokenFile);
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
// Acquire file lock to prevent race conditions
|
|
326
|
+
await lock.acquire();
|
|
327
|
+
|
|
328
|
+
// Ensure directory exists
|
|
329
|
+
await fs.mkdir(path.dirname(this.tokenFile), { recursive: true });
|
|
330
|
+
|
|
331
|
+
// Write token file with restrictive permissions
|
|
332
|
+
await fs.writeFile(this.tokenFile, JSON.stringify(tokenData, null, 2));
|
|
333
|
+
await fs.chmod(this.tokenFile, 0o600);
|
|
334
|
+
|
|
335
|
+
_logger.debug('Token saved to storage');
|
|
336
|
+
} catch (error) {
|
|
337
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
338
|
+
throw new TokenError(`Failed to save token: ${errorMessage}`);
|
|
339
|
+
} finally {
|
|
340
|
+
await lock.release();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
public async clearToken(): Promise<void> {
|
|
345
|
+
if (!isNode) {
|
|
346
|
+
throw new Error('NodeTokenStorage can only be used in Node.js environment');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
await fs.unlink(this.tokenFile);
|
|
351
|
+
} catch (error: unknown) {
|
|
352
|
+
if (error && typeof error === 'object' && 'code' in error && error.code !== 'ENOENT') {
|
|
353
|
+
_logger.warn('Failed to clear token file:', error);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Get the appropriate token storage for the current environment.
|
|
361
|
+
* @returns Token storage instance
|
|
362
|
+
*/
|
|
363
|
+
export function getTokenStorage(): TokenStorage {
|
|
364
|
+
if (isBrowser) {
|
|
365
|
+
return new BrowserTokenStorage();
|
|
366
|
+
} else if (isNode) {
|
|
367
|
+
return new NodeTokenStorage();
|
|
368
|
+
} else {
|
|
369
|
+
throw new Error('Unsupported environment for token storage');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Verify JWT locally using JWKS.
|
|
375
|
+
* @param token - JWT token to verify
|
|
376
|
+
* @param authDomain - Auth0 domain
|
|
377
|
+
* @param audience - Expected audience
|
|
378
|
+
* @returns Verification result
|
|
379
|
+
*/
|
|
380
|
+
export async function verifyJWTLocal(
|
|
381
|
+
token: string,
|
|
382
|
+
authDomain: string,
|
|
383
|
+
audience: string
|
|
384
|
+
): Promise<JWTVerificationResult> {
|
|
385
|
+
try {
|
|
386
|
+
const JWKS = getJWKS(authDomain);
|
|
387
|
+
|
|
388
|
+
await jwtVerify(token, JWKS, {
|
|
389
|
+
issuer: `https://${authDomain}/`,
|
|
390
|
+
audience,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
_logger.debug('Token verified locally using JWKS');
|
|
394
|
+
return JWTVerificationResult.VALID;
|
|
395
|
+
} catch (error: unknown) {
|
|
396
|
+
if (error && typeof error === 'object' && 'code' in error) {
|
|
397
|
+
if (error.code === 'ERR_JWT_EXPIRED') {
|
|
398
|
+
_logger.debug('Token expired (verified locally)');
|
|
399
|
+
return JWTVerificationResult.EXPIRED;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
_logger.debug('JWT verification failed:', error);
|
|
403
|
+
return JWTVerificationResult.INVALID;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Simple in-memory mutex for preventing concurrent operations.
|
|
409
|
+
*/
|
|
410
|
+
class Mutex {
|
|
411
|
+
private _locked = false;
|
|
412
|
+
private _waitQueue: Array<() => void> = [];
|
|
413
|
+
|
|
414
|
+
async acquire(): Promise<void> {
|
|
415
|
+
return new Promise<void>(resolve => {
|
|
416
|
+
if (!this._locked) {
|
|
417
|
+
this._locked = true;
|
|
418
|
+
resolve();
|
|
419
|
+
} else {
|
|
420
|
+
this._waitQueue.push(resolve);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
release(): void {
|
|
426
|
+
if (this._waitQueue.length > 0) {
|
|
427
|
+
const next = this._waitQueue.shift();
|
|
428
|
+
if (next) {
|
|
429
|
+
next();
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
this._locked = false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async withLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
437
|
+
await this.acquire();
|
|
438
|
+
try {
|
|
439
|
+
return await fn();
|
|
440
|
+
} finally {
|
|
441
|
+
this.release();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Token manager that handles storage, validation, and refresh.
|
|
448
|
+
*/
|
|
449
|
+
export class TokenManager {
|
|
450
|
+
/** Token storage instance */
|
|
451
|
+
private readonly storage: TokenStorage;
|
|
452
|
+
/** Mutex to prevent concurrent refresh operations */
|
|
453
|
+
private readonly _refreshMutex = new Mutex();
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Create a new TokenManager.
|
|
457
|
+
* @param _apiBaseUrl - Base URL for the API (currently unused)
|
|
458
|
+
*/
|
|
459
|
+
constructor(_apiBaseUrl: string) {
|
|
460
|
+
this.storage = getTokenStorage();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Get a valid token, refreshing if necessary.
|
|
465
|
+
* @returns Valid access token
|
|
466
|
+
*/
|
|
467
|
+
public async getValidToken(): Promise<string> {
|
|
468
|
+
return this._refreshMutex.withLock(async () => {
|
|
469
|
+
// Re-load token inside the lock in case another thread refreshed it
|
|
470
|
+
const tokenData = await this.storage.loadToken();
|
|
471
|
+
|
|
472
|
+
if (!tokenData) {
|
|
473
|
+
throw new TokenError('No token found. Please authenticate.');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
// Check if we should proactively refresh the token
|
|
478
|
+
if (this._shouldRefreshToken(tokenData) && tokenData.refresh_token) {
|
|
479
|
+
_logger.debug('Proactively refreshing token before expiration');
|
|
480
|
+
const newTokenData = await this._refreshToken(tokenData);
|
|
481
|
+
const updatedTokenData = { ...tokenData, ...newTokenData };
|
|
482
|
+
await this.storage.saveToken(updatedTokenData);
|
|
483
|
+
return updatedTokenData.access_token;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Otherwise, validate or refresh as needed
|
|
487
|
+
const validatedTokenData = await this._validateOrRefresh(tokenData);
|
|
488
|
+
await this.storage.saveToken(validatedTokenData);
|
|
489
|
+
return validatedTokenData.access_token;
|
|
490
|
+
} catch (error) {
|
|
491
|
+
_logger.error('Token validation/refresh failed:', error);
|
|
492
|
+
throw new TokenExpiredError('Token expired and refresh failed. Please re-authenticate.');
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Check if token should be refreshed proactively.
|
|
499
|
+
* @private
|
|
500
|
+
*/
|
|
501
|
+
private _shouldRefreshToken(tokenData: TokenData): boolean {
|
|
502
|
+
const payload = decodeJwtPayload(tokenData.access_token);
|
|
503
|
+
if (!payload?.exp) {
|
|
504
|
+
return true; // Refresh if we can't parse the token or no expiration
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Refresh if token expires within 5 minutes (300 seconds)
|
|
508
|
+
const now = Math.floor(Date.now() / 1000);
|
|
509
|
+
return payload.exp - now < 300;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Validate token using fast-path local verification with remote fallback.
|
|
514
|
+
* @private
|
|
515
|
+
*/
|
|
516
|
+
private async _validateOrRefresh(tokenData: TokenData): Promise<TokenData> {
|
|
517
|
+
const accessToken = tokenData.access_token;
|
|
518
|
+
const cfg = getStaticConfig();
|
|
519
|
+
|
|
520
|
+
// Fast path: local JWT verification
|
|
521
|
+
const jwtValid = await verifyJWTLocal(accessToken, cfg.authDomain, cfg.apiAudience);
|
|
522
|
+
|
|
523
|
+
if (jwtValid === JWTVerificationResult.VALID) {
|
|
524
|
+
_logger.debug('Token validated locally');
|
|
525
|
+
return tokenData; // verified locally
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (jwtValid === JWTVerificationResult.INVALID) {
|
|
529
|
+
// Couldn't verify locally, try remote validation
|
|
530
|
+
_logger.debug('Local validation failed, trying remote');
|
|
531
|
+
if (await this._isTokenValidRemote(accessToken)) {
|
|
532
|
+
_logger.debug('Token validated remotely');
|
|
533
|
+
return tokenData;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Token is invalid or expired - attempt refresh
|
|
538
|
+
_logger.info('Token invalid or expired, attempting refresh');
|
|
539
|
+
if (tokenData.refresh_token) {
|
|
540
|
+
const newTokenData = await this._refreshToken(tokenData);
|
|
541
|
+
// Merge the new token data (may include rotated refresh_token) with existing data
|
|
542
|
+
return { ...tokenData, ...newTokenData };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
throw new TokenExpiredError('Token expired and no refresh token available');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Validate token remotely using Auth0's userinfo endpoint.
|
|
550
|
+
* @private
|
|
551
|
+
*/
|
|
552
|
+
private async _isTokenValidRemote(token: string): Promise<boolean> {
|
|
553
|
+
try {
|
|
554
|
+
const cfg = getStaticConfig();
|
|
555
|
+
const fetchPromise = fetch(`https://${cfg.authDomain}/userinfo`, {
|
|
556
|
+
headers: {
|
|
557
|
+
Authorization: `Bearer ${token}`,
|
|
558
|
+
},
|
|
559
|
+
...(typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal
|
|
560
|
+
? { signal: AbortSignal.timeout(5000) }
|
|
561
|
+
: {}),
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const response = await withTimeout(fetchPromise, 5000);
|
|
565
|
+
return response.ok;
|
|
566
|
+
} catch (error) {
|
|
567
|
+
_logger.debug('Remote token validation failed:', error);
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Refresh the access token using the refresh token.
|
|
574
|
+
* @private
|
|
575
|
+
*/
|
|
576
|
+
private async _refreshToken(tokenData: TokenData): Promise<Partial<TokenData>> {
|
|
577
|
+
const refreshToken = tokenData.refresh_token;
|
|
578
|
+
if (!refreshToken) {
|
|
579
|
+
throw new TokenError('No refresh token available');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const cfg = getStaticConfig();
|
|
583
|
+
|
|
584
|
+
// Browser security: don't expose client_secret in browser environments
|
|
585
|
+
if (isBrowser && cfg.clientSecret) {
|
|
586
|
+
throw new TokenError(
|
|
587
|
+
'Refresh flow requires a confidential client; run interactive login again.'
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const payload: Record<string, string> = {
|
|
592
|
+
grant_type: 'refresh_token',
|
|
593
|
+
client_id: cfg.clientId,
|
|
594
|
+
refresh_token: refreshToken,
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// Only add client_secret if we have one and we're not in browser
|
|
598
|
+
if (cfg.clientSecret && !isBrowser) {
|
|
599
|
+
payload['client_secret'] = cfg.clientSecret;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
const fetchPromise = fetch(`https://${cfg.authDomain}/oauth/token`, {
|
|
604
|
+
method: 'POST',
|
|
605
|
+
headers: {
|
|
606
|
+
'Content-Type': 'application/json',
|
|
607
|
+
},
|
|
608
|
+
body: JSON.stringify(payload),
|
|
609
|
+
...(typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal
|
|
610
|
+
? { signal: AbortSignal.timeout(15000) }
|
|
611
|
+
: {}),
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const response = await withTimeout(fetchPromise, 15000);
|
|
615
|
+
|
|
616
|
+
if (response.status === 400) {
|
|
617
|
+
// Bad request - likely invalid refresh token
|
|
618
|
+
let errorDesc = 'Invalid refresh token';
|
|
619
|
+
try {
|
|
620
|
+
const errorData = await response.json();
|
|
621
|
+
errorDesc = errorData.error_description || errorDesc;
|
|
622
|
+
} catch {
|
|
623
|
+
// Ignore JSON parse errors
|
|
624
|
+
}
|
|
625
|
+
_logger.warn(`Refresh token invalid: ${errorDesc}`);
|
|
626
|
+
throw new TokenExpiredError(`Refresh token expired or invalid: ${errorDesc}`);
|
|
627
|
+
} else if (response.status === 429) {
|
|
628
|
+
// Rate limited - should implement backoff
|
|
629
|
+
_logger.warn('Rate limited during token refresh');
|
|
630
|
+
throw new TokenError('Rate limited during token refresh. Please try again later.');
|
|
631
|
+
} else if (response.status >= 500) {
|
|
632
|
+
// Server error - temporary issue
|
|
633
|
+
_logger.warn(`Auth server error during refresh: ${response.status}`);
|
|
634
|
+
throw new TokenError(`Auth server temporarily unavailable (HTTP ${response.status})`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (!response.ok) {
|
|
638
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const newTokenData = await response.json();
|
|
642
|
+
return newTokenData; // may include rotated refresh_token
|
|
643
|
+
} catch (error: unknown) {
|
|
644
|
+
if (error instanceof TokenError || error instanceof TokenExpiredError) {
|
|
645
|
+
// Re-throw our custom exceptions
|
|
646
|
+
throw error;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (error && typeof error === 'object' && 'name' in error) {
|
|
650
|
+
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
|
|
651
|
+
_logger.warn('Timeout during token refresh');
|
|
652
|
+
throw new TokenError('Token refresh timed out. Please check your connection.');
|
|
653
|
+
}
|
|
654
|
+
if (
|
|
655
|
+
error.name === 'TypeError' &&
|
|
656
|
+
'message' in error &&
|
|
657
|
+
typeof error.message === 'string' &&
|
|
658
|
+
error.message.includes('fetch')
|
|
659
|
+
) {
|
|
660
|
+
_logger.warn(`Network error during token refresh: ${error.message}`);
|
|
661
|
+
throw new TokenError('Network error during token refresh. Please check your connection.');
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
_logger.error('Unexpected error during token refresh:', error);
|
|
666
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
667
|
+
throw new TokenError(`Token refresh failed: ${errorMessage}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Legacy functions for backward compatibility
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Load user token from storage.
|
|
676
|
+
* @returns User access token or null if not found
|
|
677
|
+
*/
|
|
678
|
+
export async function loadUserToken(): Promise<string | null> {
|
|
679
|
+
try {
|
|
680
|
+
const storage = getTokenStorage();
|
|
681
|
+
const tokenData = await storage.loadToken();
|
|
682
|
+
return tokenData?.access_token ?? null;
|
|
683
|
+
} catch (_error) {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Save user token to storage.
|
|
690
|
+
* @param token - Token string or token data object
|
|
691
|
+
*/
|
|
692
|
+
export async function saveUserToken(token: string | TokenData): Promise<void> {
|
|
693
|
+
const storage = getTokenStorage();
|
|
694
|
+
|
|
695
|
+
let tokenData: TokenData;
|
|
696
|
+
if (typeof token === 'string') {
|
|
697
|
+
tokenData = { access_token: token };
|
|
698
|
+
} else {
|
|
699
|
+
tokenData = token;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
await storage.saveToken(tokenData);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Clear cached token.
|
|
707
|
+
*/
|
|
708
|
+
export async function clearCachedToken(): Promise<void> {
|
|
709
|
+
const storage = getTokenStorage();
|
|
710
|
+
await storage.clearToken();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Check if cached token is active without attempting refresh.
|
|
715
|
+
* @param apiBaseUrl - Base URL of the API (for compatibility, not used)
|
|
716
|
+
* @returns True if token is active
|
|
717
|
+
*/
|
|
718
|
+
export async function isTokenActive(_apiBaseUrl?: string): Promise<boolean> {
|
|
719
|
+
try {
|
|
720
|
+
const storage = getTokenStorage();
|
|
721
|
+
const tokenData = await storage.loadToken();
|
|
722
|
+
|
|
723
|
+
if (!tokenData?.access_token) {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const cfg = getStaticConfig();
|
|
728
|
+
|
|
729
|
+
// Test the access token against Auth0
|
|
730
|
+
const fetchPromise = fetch(`https://${cfg.authDomain}/userinfo`, {
|
|
731
|
+
headers: {
|
|
732
|
+
Authorization: `Bearer ${tokenData.access_token}`,
|
|
733
|
+
},
|
|
734
|
+
...(typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal
|
|
735
|
+
? { signal: AbortSignal.timeout(10000) }
|
|
736
|
+
: {}),
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const response = await withTimeout(fetchPromise, 10000);
|
|
740
|
+
|
|
741
|
+
return response.ok;
|
|
742
|
+
} catch (_error) {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Check if cached token is active, attempting refresh if needed.
|
|
749
|
+
* @param apiBaseUrl - Base URL of the API (for compatibility, not used)
|
|
750
|
+
* @returns True if token is active or was successfully refreshed
|
|
751
|
+
*/
|
|
752
|
+
export async function isTokenActiveWithRefresh(apiBaseUrl?: string): Promise<boolean> {
|
|
753
|
+
try {
|
|
754
|
+
const tokenManager = new TokenManager(apiBaseUrl || '');
|
|
755
|
+
await tokenManager.getValidToken();
|
|
756
|
+
return true;
|
|
757
|
+
} catch (error) {
|
|
758
|
+
_logger.warn('Token validation/refresh failed:', error);
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Validate a token using fast-path local verification with remote fallback.
|
|
765
|
+
* @param token - The JWT token to validate
|
|
766
|
+
* @param apiBaseUrl - Base URL of the API (for compatibility, not used)
|
|
767
|
+
* @returns Dictionary with 'valid' key indicating if token is valid
|
|
768
|
+
*/
|
|
769
|
+
export async function validateToken(
|
|
770
|
+
token: string,
|
|
771
|
+
_apiBaseUrl?: string
|
|
772
|
+
): Promise<{ valid: boolean }> {
|
|
773
|
+
try {
|
|
774
|
+
const cfg = getStaticConfig();
|
|
775
|
+
|
|
776
|
+
// Fast path: local JWT verification
|
|
777
|
+
const jwtValid = await verifyJWTLocal(token, cfg.authDomain, cfg.apiAudience);
|
|
778
|
+
|
|
779
|
+
if (jwtValid === JWTVerificationResult.VALID) {
|
|
780
|
+
return { valid: true };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (jwtValid === JWTVerificationResult.INVALID) {
|
|
784
|
+
// Couldn't verify locally, try remote validation
|
|
785
|
+
try {
|
|
786
|
+
const fetchPromise = fetch(`https://${cfg.authDomain}/userinfo`, {
|
|
787
|
+
headers: {
|
|
788
|
+
Authorization: `Bearer ${token}`,
|
|
789
|
+
},
|
|
790
|
+
...(typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal
|
|
791
|
+
? { signal: AbortSignal.timeout(5000) }
|
|
792
|
+
: {}),
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const response = await withTimeout(fetchPromise, 5000);
|
|
796
|
+
return { valid: response.ok };
|
|
797
|
+
} catch (error) {
|
|
798
|
+
_logger.warn('Remote token validation failed:', error);
|
|
799
|
+
return { valid: false };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Token is expired or invalid
|
|
804
|
+
return { valid: false };
|
|
805
|
+
} catch (error) {
|
|
806
|
+
_logger.warn('Token validation failed:', error);
|
|
807
|
+
return { valid: false };
|
|
808
|
+
}
|
|
809
|
+
}
|