@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.
@@ -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
+ }