@djangocfg/api 2.1.448 → 2.1.449

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.
Files changed (51) hide show
  1. package/dist/auth-server.cjs +1 -2272
  2. package/dist/auth-server.cjs.map +1 -1
  3. package/dist/auth-server.d.cts +1 -35
  4. package/dist/auth-server.d.ts +1 -35
  5. package/dist/auth-server.mjs +1 -2272
  6. package/dist/auth-server.mjs.map +1 -1
  7. package/dist/auth.cjs +207 -128
  8. package/dist/auth.cjs.map +1 -1
  9. package/dist/auth.d.cts +135 -2
  10. package/dist/auth.d.ts +135 -2
  11. package/dist/auth.mjs +216 -137
  12. package/dist/auth.mjs.map +1 -1
  13. package/dist/clients.cjs +35 -0
  14. package/dist/clients.cjs.map +1 -1
  15. package/dist/clients.d.cts +147 -2
  16. package/dist/clients.d.ts +147 -2
  17. package/dist/clients.mjs +35 -0
  18. package/dist/clients.mjs.map +1 -1
  19. package/dist/hooks.cjs +11 -0
  20. package/dist/hooks.cjs.map +1 -1
  21. package/dist/hooks.mjs +11 -0
  22. package/dist/hooks.mjs.map +1 -1
  23. package/dist/index.cjs +35 -0
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +169 -6
  26. package/dist/index.d.ts +169 -6
  27. package/dist/index.mjs +35 -0
  28. package/dist/index.mjs.map +1 -1
  29. package/package.json +7 -4
  30. package/src/_api/generated/_cfg_accounts/api.ts +8 -0
  31. package/src/_api/generated/_cfg_accounts/openapi.json +84 -5
  32. package/src/_api/generated/_cfg_accounts/schemas/OAuthTokenResponse.ts +2 -2
  33. package/src/_api/generated/_cfg_accounts/schemas/OTPRequestResponse.ts +2 -0
  34. package/src/_api/generated/_cfg_accounts/schemas/WebmailLink.ts +15 -0
  35. package/src/_api/generated/_cfg_accounts/schemas/WebmailLinkProviderEnum.ts +9 -0
  36. package/src/_api/generated/_cfg_accounts/schemas/index.ts +2 -0
  37. package/src/_api/generated/_cfg_centrifugo/api.ts +8 -0
  38. package/src/_api/generated/_cfg_totp/api.ts +8 -0
  39. package/src/_api/generated/helpers/auth.ts +12 -0
  40. package/src/_api/generated/openapi.json +84 -5
  41. package/src/_api/generated/types.gen.ts +131 -2
  42. package/src/auth/__tests__/refreshRotation.test.ts +119 -0
  43. package/src/auth/context/AuthContext.tsx +10 -1
  44. package/src/auth/hooks/useAuthForm.ts +5 -2
  45. package/src/auth/hooks/useAuthFormState.ts +4 -1
  46. package/src/auth/hooks/useTokenRefresh.ts +29 -45
  47. package/src/auth/middlewares/index.ts +7 -7
  48. package/src/auth/refreshHandler.ts +79 -0
  49. package/src/auth/types/form.ts +10 -0
  50. package/src/auth/types/index.ts +1 -0
  51. package/src/auth/middlewares/tokenRefresh.ts +0 -161
@@ -16,11 +16,13 @@ import { APIError } from '../../_api/generated/helpers';
16
16
  import { clearProfileCache, getCachedProfile } from '../hooks/useProfileCache';
17
17
  import { useAuthRedirectManager } from '../hooks/useAuthRedirect';
18
18
  import { useTokenRefresh } from '../hooks/useTokenRefresh';
19
+ import { ensureRefreshHandler } from '../refreshHandler';
19
20
  import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
20
21
  import { authLogger } from '../utils/logger';
21
22
  import { AccountsProvider, useAccountsContext } from './AccountsContext';
22
23
 
23
24
  import type { AuthConfig, AuthContextType, AuthProviderProps, UserProfile } from './types';
25
+ import type { OTPRequestResult } from '../types';
24
26
 
25
27
  // Default routes
26
28
  const defaultRoutes = {
@@ -43,6 +45,12 @@ const hasValidTokens = (): boolean => {
43
45
  const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config }) => {
44
46
  const accounts = useAccountsContext();
45
47
 
48
+ // Register the canonical refresh strategy exactly once, synchronously on the
49
+ // first render, BEFORE any request can 401. This lights up the generated
50
+ // client's built-in 401 recovery (single-flight + retry + rotated-token
51
+ // persistence). Idempotent — safe across remounts / multiple providers.
52
+ ensureRefreshHandler();
53
+
46
54
  // Redirect URL manager for saving URL before auth redirect
47
55
  const redirectManager = useAuthRedirectManager({
48
56
  fallbackUrl: config?.routes?.defaultCallback || defaultRoutes.defaultCallback,
@@ -353,7 +361,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
353
361
 
354
362
  // OTP methods - email only - now uses AccountsContext
355
363
  const requestOTP = useCallback(
356
- async (identifier: string, sourceUrl?: string): Promise<{ success: boolean; message: string; statusCode?: number; retryAfter?: number }> => {
364
+ async (identifier: string, sourceUrl?: string): Promise<OTPRequestResult> => {
357
365
  // Clear tokens before requesting OTP
358
366
  apiAccounts.clearToken();
359
367
 
@@ -372,6 +380,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
372
380
  return {
373
381
  success: true,
374
382
  message: result.message || `OTP code sent to your email address`,
383
+ webmail: result.webmail ?? null,
375
384
  };
376
385
  } catch (error) {
377
386
  authLogger.error('Request OTP error:', error);
@@ -66,6 +66,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
66
66
  setTwoFactorCode,
67
67
  setUseBackupCode,
68
68
  startRateLimitCountdown,
69
+ setWebmail,
69
70
  } = formState;
70
71
 
71
72
  // 2FA verification hook - skip redirect so we can show success screen
@@ -142,6 +143,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
142
143
 
143
144
  if (result.success) {
144
145
  saveIdentifierToStorage(identifier);
146
+ setWebmail(result.webmail ?? null);
145
147
  setStep('otp');
146
148
  onIdentifierSuccess?.(identifier);
147
149
  } else {
@@ -163,7 +165,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
163
165
  }, [
164
166
  identifier, acceptedTerms, requireTermsAcceptance,
165
167
  validateIdentifier, requestOTP, saveIdentifierToStorage,
166
- setError, setIsLoading, setStep, clearError,
168
+ setError, setIsLoading, setStep, clearError, setWebmail,
167
169
  startRateLimitCountdown, onIdentifierSuccess, onError, sourceUrl,
168
170
  ]);
169
171
 
@@ -235,6 +237,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
235
237
 
236
238
  if (result.success) {
237
239
  saveIdentifierToStorage(identifier);
240
+ setWebmail(result.webmail ?? null);
238
241
  setOtp('');
239
242
  } else {
240
243
  if (result.retryAfter) {
@@ -252,7 +255,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
252
255
  } finally {
253
256
  setIsLoading(false);
254
257
  }
255
- }, [identifier, requestOTP, saveIdentifierToStorage, setOtp, setError, setIsLoading, clearError, startRateLimitCountdown, onError, sourceUrl]);
258
+ }, [identifier, requestOTP, saveIdentifierToStorage, setOtp, setError, setIsLoading, clearError, startRateLimitCountdown, setWebmail, onError, sourceUrl]);
256
259
 
257
260
  const handleBackToIdentifier = useCallback(() => {
258
261
  setStep('identifier');
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
 
5
- import type { AuthFormState, AuthFormStateHandlers, AuthStep } from '../types';
5
+ import type { AuthFormState, AuthFormStateHandlers, AuthStep, WebmailLink } from '../types';
6
6
 
7
7
  const formatCountdown = (s: number): string => {
8
8
  if (s <= 0) return '';
@@ -25,6 +25,7 @@ export const useAuthFormState = (
25
25
  const [acceptedTerms, setAcceptedTerms] = useState(true);
26
26
  const [step, setStep] = useState<AuthStep>('identifier');
27
27
  const [error, setError] = useState('');
28
+ const [webmail, setWebmail] = useState<WebmailLink | null>(null);
28
29
 
29
30
  // 2FA state
30
31
  const [twoFactorSessionId, setTwoFactorSessionId] = useState<string | null>(null);
@@ -72,6 +73,7 @@ export const useAuthFormState = (
72
73
  rateLimitSeconds,
73
74
  isRateLimited: rateLimitSeconds > 0,
74
75
  rateLimitLabel: formatCountdown(rateLimitSeconds),
76
+ webmail,
75
77
  // Handlers
76
78
  setIdentifier,
77
79
  setOtp,
@@ -85,5 +87,6 @@ export const useAuthFormState = (
85
87
  setTwoFactorCode,
86
88
  setUseBackupCode,
87
89
  startRateLimitCountdown,
90
+ setWebmail,
88
91
  };
89
92
  };
@@ -1,18 +1,28 @@
1
1
  'use client';
2
2
 
3
3
  /**
4
- * Token Refresh Hook
4
+ * Token Refresh Hook (proactive trigger only)
5
5
  *
6
- * Provides automatic token refresh functionality:
7
- * - Proactively refreshes token before expiry
8
- * - Refreshes on window focus
9
- * - Refreshes on network reconnect
6
+ * Refreshes the access token BEFORE it expires, and on window focus / network
7
+ * reconnect. This hook deliberately owns NO refresh logic of its own — it only
8
+ * decides WHEN to refresh and delegates HOW to the canonical path
9
+ * (`triggerRefresh` `api.refreshNow` the store's single-flight
10
+ * `tryRefresh`). That path handles token rotation, concurrent de-duplication,
11
+ * and persisting the rotated refresh token.
12
+ *
13
+ * Historical note: this hook (and a now-removed fetch middleware) used to run
14
+ * their own refresh calls and persisted the OLD refresh token instead of the
15
+ * rotated one the server returned. With Django's default
16
+ * ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION that produced a
17
+ * "Token is blacklisted" 401 on the following refresh and logged users out.
18
+ * Funnelling every refresh through one implementation removes that whole class
19
+ * of bug — see auth/refreshHandler.ts.
10
20
  */
11
21
 
12
- import { useCallback, useEffect, useRef } from 'react';
22
+ import { useCallback, useEffect } from 'react';
13
23
 
14
24
  import { api as apiAccounts } from '../../';
15
- import { CfgAccountsAuth } from '../../_api/generated/sdk.gen';
25
+ import { triggerRefresh } from '../refreshHandler';
16
26
  import { authLogger } from '../utils/logger';
17
27
 
18
28
  // Configuration
@@ -29,83 +39,61 @@ interface UseTokenRefreshOptions {
29
39
  }
30
40
 
31
41
  /**
32
- * Decode JWT and get expiry time
42
+ * Decode JWT and get expiry time (ms epoch), or null if undecodable.
33
43
  */
34
44
  function getTokenExpiry(token: string): number | null {
35
45
  try {
36
46
  const payload = JSON.parse(atob(token.split('.')[1]));
37
- return payload.exp * 1000; // Convert to milliseconds
47
+ return payload.exp * 1000;
38
48
  } catch {
39
49
  return null;
40
50
  }
41
51
  }
42
52
 
43
53
  /**
44
- * Check if token is expiring soon
54
+ * Check if token is expiring within the threshold.
45
55
  */
46
56
  function isTokenExpiringSoon(token: string, thresholdMs: number): boolean {
47
57
  const expiry = getTokenExpiry(token);
48
58
  if (!expiry) return false;
49
-
50
- const timeUntilExpiry = expiry - Date.now();
51
- return timeUntilExpiry < thresholdMs;
59
+ return expiry - Date.now() < thresholdMs;
52
60
  }
53
61
 
54
62
  /**
55
- * Hook for automatic token refresh
63
+ * Hook for proactive automatic token refresh.
56
64
  */
57
65
  export function useTokenRefresh(options: UseTokenRefreshOptions = {}) {
58
66
  const { enabled = true, onRefresh, onRefreshError } = options;
59
- const isRefreshingRef = useRef(false);
60
67
 
61
68
  /**
62
- * Refresh the token
69
+ * Refresh the token via the canonical single-flight path. Returns true on
70
+ * success. No local in-flight guard needed — `triggerRefresh` shares the
71
+ * store's single-flight promise, so overlapping calls coalesce.
63
72
  */
64
73
  const refreshToken = useCallback(async (): Promise<boolean> => {
65
- if (isRefreshingRef.current) {
66
- authLogger.debug('Token refresh already in progress');
67
- return false;
68
- }
69
-
70
- const refreshTokenValue = apiAccounts.getRefreshToken();
71
- if (!refreshTokenValue) {
74
+ if (!apiAccounts.getRefreshToken()) {
72
75
  authLogger.warn('No refresh token available');
73
76
  return false;
74
77
  }
75
78
 
76
- isRefreshingRef.current = true;
77
79
  authLogger.info('Refreshing token...');
78
-
79
80
  try {
80
- // Use generated API client with correct URL (/cfg/accounts/token/refresh/)
81
- const result = await CfgAccountsAuth.cfgAccountsTokenRefreshCreate({
82
- body: { refresh: refreshTokenValue },
83
- throwOnError: true,
84
- });
85
-
86
- const newAccessToken = result.data.access;
87
-
81
+ const newAccessToken = await triggerRefresh();
88
82
  if (!newAccessToken) {
89
- throw new Error('No access token in refresh response');
83
+ throw new Error('Token refresh failed');
90
84
  }
91
-
92
- apiAccounts.setToken(newAccessToken);
93
- apiAccounts.setRefreshToken(refreshTokenValue);
94
85
  authLogger.info('Token refreshed successfully');
95
-
96
86
  onRefresh?.(newAccessToken);
97
87
  return true;
98
88
  } catch (error) {
99
89
  authLogger.error('Token refresh error:', error);
100
90
  onRefreshError?.(error instanceof Error ? error : new Error(String(error)));
101
91
  return false;
102
- } finally {
103
- isRefreshingRef.current = false;
104
92
  }
105
93
  }, [onRefresh, onRefreshError]);
106
94
 
107
95
  /**
108
- * Check and refresh if needed
96
+ * Check and refresh if the current token is expiring soon.
109
97
  */
110
98
  const checkAndRefresh = useCallback(async () => {
111
99
  const token = apiAccounts.getToken();
@@ -121,12 +109,8 @@ export function useTokenRefresh(options: UseTokenRefreshOptions = {}) {
121
109
  useEffect(() => {
122
110
  if (!enabled) return;
123
111
 
124
- // Check immediately
125
112
  checkAndRefresh();
126
-
127
- // Set up interval
128
113
  const intervalId = setInterval(checkAndRefresh, CHECK_INTERVAL_MS);
129
-
130
114
  return () => clearInterval(intervalId);
131
115
  }, [enabled, checkAndRefresh]);
132
116
 
@@ -1,8 +1,8 @@
1
1
  export { proxyMiddleware, proxyMiddlewareConfig } from './proxy';
2
- export {
3
- refreshAccessToken,
4
- createAutoRefreshFetch,
5
- isTokenExpiringSoon,
6
- refreshIfExpiringSoon,
7
- isAuthenticationError,
8
- } from './tokenRefresh';
2
+
3
+ // NOTE: The former `tokenRefresh` middleware (createAutoRefreshFetch /
4
+ // refreshAccessToken / refreshIfExpiringSoon) was removed. Automatic 401
5
+ // recovery and proactive refresh now live in a single implementation: the
6
+ // generated client interceptor (helpers/auth.ts `installAuthOnClient`) plus the
7
+ // canonical strategy in `auth/refreshHandler.ts`. Do not reintroduce a parallel
8
+ // refresh path here — see auth/refreshHandler.ts for the rationale.
@@ -0,0 +1,79 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Canonical refresh strategy — registered ONCE, used by every refresh path.
5
+ *
6
+ * The generated client (`installAuthOnClient` in helpers/auth.ts) already owns
7
+ * the hard parts of token refresh: single-flight de-duplication of concurrent
8
+ * 401s, one automatic retry of the failed request, and — crucially —
9
+ * persisting the ROTATED refresh token the server returns. It only needs a
10
+ * strategy telling it HOW to call the refresh endpoint. That strategy is here.
11
+ *
12
+ * Why this matters: Django (SimpleJWT via django-cfg) ships with
13
+ * `ROTATE_REFRESH_TOKENS=True` + `BLACKLIST_AFTER_ROTATION=True` by default.
14
+ * Every successful refresh returns a NEW refresh token and blacklists the old
15
+ * one. The handler MUST return `{ access, refresh }` so the store persists the
16
+ * rotated token; reusing the old one yields a "Token is blacklisted" 401 on the
17
+ * next refresh. The canonical store (auth.tryRefresh) does the persistence — we
18
+ * only surface the rotated token to it.
19
+ *
20
+ * This is the single source of truth. Proactive refresh (useTokenRefresh) and
21
+ * reactive 401 recovery (the response interceptor) both funnel through it via
22
+ * `api.refreshNow()` / `tryRefresh()`. Do NOT re-implement refresh anywhere
23
+ * else.
24
+ */
25
+
26
+ import { api as apiAccounts } from '../';
27
+ import { CfgAccountsAuth } from '../_api/generated/sdk.gen';
28
+ import { authLogger } from './utils/logger';
29
+
30
+ let _registered = false;
31
+
32
+ /**
33
+ * Register the canonical refresh handler on the shared auth store. Idempotent:
34
+ * safe to call from every AuthProvider mount; only the first call wires it.
35
+ */
36
+ export function ensureRefreshHandler(): void {
37
+ if (_registered) return;
38
+ _registered = true;
39
+
40
+ apiAccounts.setRefreshHandler(async (refresh: string) => {
41
+ const result = await CfgAccountsAuth.cfgAccountsTokenRefreshCreate({
42
+ body: { refresh },
43
+ throwOnError: true,
44
+ });
45
+
46
+ const access = result.data?.access;
47
+ if (!access) {
48
+ authLogger.error('Refresh response missing access token');
49
+ return null;
50
+ }
51
+
52
+ // Return the ROTATED refresh token so the store persists it. Fall back to
53
+ // the current one only if the server omits it (rotation disabled).
54
+ return { access, refresh: result.data?.refresh ?? refresh };
55
+ });
56
+
57
+ authLogger.debug('Canonical refresh handler registered');
58
+ }
59
+
60
+ /**
61
+ * Proactively run the canonical refresh now, sharing the SAME single-flight as
62
+ * 401 recovery (rotation + dedup + rotated-token persistence handled by the
63
+ * store). Ensures the handler is registered first. Returns the fresh access
64
+ * token, or null if refresh failed / no refresh token / no handler.
65
+ *
66
+ * Use this for expiry-timer / focus / reconnect triggers — never re-implement
67
+ * the refresh call itself.
68
+ */
69
+ export function triggerRefresh(): Promise<string | null> {
70
+ ensureRefreshHandler();
71
+ // `refreshNow` is provided by the generated auth facade (helpers/auth.ts +
72
+ // per-group api.ts). Guarded so this compiles even before regeneration.
73
+ const maybe = apiAccounts as unknown as { refreshNow?: () => Promise<string | null> };
74
+ if (typeof maybe.refreshNow === 'function') {
75
+ return maybe.refreshNow();
76
+ }
77
+ authLogger.warn('api.refreshNow() unavailable — run `make generate` to regenerate the client');
78
+ return Promise.resolve(null);
79
+ }
@@ -7,6 +7,10 @@
7
7
 
8
8
  import type { MutableRefObject } from 'react';
9
9
 
10
+ import type { WebmailLink } from '../../_api/generated/types.gen';
11
+
12
+ export type { WebmailLink };
13
+
10
14
  // ─────────────────────────────────────────────────────────────────────────────
11
15
  // Step Types
12
16
  // ─────────────────────────────────────────────────────────────────────────────
@@ -22,6 +26,8 @@ export interface OTPRequestResult {
22
26
  message: string;
23
27
  statusCode?: number;
24
28
  retryAfter?: number;
29
+ /** Webmail deep-link for the recipient's provider, or null if unknown. */
30
+ webmail?: WebmailLink | null;
25
31
  }
26
32
 
27
33
  // ─────────────────────────────────────────────────────────────────────────────
@@ -55,6 +61,8 @@ export interface AuthFormState {
55
61
  isRateLimited: boolean;
56
62
  /** Formatted countdown label, e.g. "19:00" or "42s" */
57
63
  rateLimitLabel: string;
64
+ /** Webmail deep-link for the current identifier's provider (null = no button). */
65
+ webmail: WebmailLink | null;
58
66
  }
59
67
 
60
68
  // ─────────────────────────────────────────────────────────────────────────────
@@ -75,6 +83,8 @@ export interface AuthFormStateHandlers {
75
83
  setUseBackupCode: (useBackup: boolean) => void;
76
84
  /** Start a countdown timer that disables submit for `seconds` seconds */
77
85
  startRateLimitCountdown: (seconds: number) => void;
86
+ /** Set (or clear) the webmail deep-link for the current identifier */
87
+ setWebmail: (webmail: WebmailLink | null) => void;
78
88
  }
79
89
 
80
90
  export interface AuthFormSubmitHandlers {
@@ -13,6 +13,7 @@ export type {
13
13
  AuthFormReturn,
14
14
  UseAuthFormOptions,
15
15
  OTPRequestResult,
16
+ WebmailLink,
16
17
  } from './form';
17
18
 
18
19
  // Re-export context types
@@ -1,161 +0,0 @@
1
- /**
2
- * Token Auto-Refresh Middleware
3
- *
4
- * Automatically refreshes access token when receiving 401 responses.
5
- * Implements request queuing to prevent multiple simultaneous refresh attempts.
6
- */
7
-
8
- import { api as apiAccounts } from '../../';
9
- import { CfgAccountsAuth } from '../../_api/generated/sdk.gen';
10
- import { authLogger } from '../utils/logger';
11
-
12
- // Refresh state management
13
- let isRefreshing = false;
14
- let refreshSubscribers: Array<(token: string | null) => void> = [];
15
-
16
- /**
17
- * Subscribe to token refresh completion
18
- */
19
- function subscribeTokenRefresh(callback: (token: string | null) => void): void {
20
- refreshSubscribers.push(callback);
21
- }
22
-
23
- /**
24
- * Notify all subscribers when token is refreshed
25
- */
26
- function onTokenRefreshed(token: string | null): void {
27
- refreshSubscribers.forEach(callback => callback(token));
28
- refreshSubscribers = [];
29
- }
30
-
31
- /**
32
- * Attempt to refresh the access token using the refresh token
33
- * @returns New access token or null if refresh failed
34
- */
35
- export async function refreshAccessToken(): Promise<string | null> {
36
- // If already refreshing, wait for completion
37
- if (isRefreshing) {
38
- return new Promise(resolve => {
39
- subscribeTokenRefresh(token => resolve(token));
40
- });
41
- }
42
-
43
- isRefreshing = true;
44
- authLogger.info('Starting token refresh...');
45
-
46
- try {
47
- const refreshToken = apiAccounts.getRefreshToken();
48
- if (!refreshToken) {
49
- authLogger.warn('No refresh token available for refresh');
50
- onTokenRefreshed(null);
51
- return null;
52
- }
53
-
54
- // Use generated API client with correct URL (/cfg/accounts/token/refresh/)
55
- const result = await CfgAccountsAuth.cfgAccountsTokenRefreshCreate({
56
- body: { refresh: refreshToken },
57
- throwOnError: true,
58
- });
59
-
60
- const newAccessToken = result.data.access;
61
-
62
- if (!newAccessToken) {
63
- authLogger.error('Token refresh response missing access token');
64
- onTokenRefreshed(null);
65
- return null;
66
- }
67
-
68
- // Update tokens in storage
69
- apiAccounts.setToken(newAccessToken);
70
- apiAccounts.setRefreshToken(refreshToken);
71
- authLogger.info('Token refreshed successfully');
72
-
73
- onTokenRefreshed(newAccessToken);
74
- return newAccessToken;
75
- } catch (error) {
76
- authLogger.error('Token refresh error:', error);
77
- onTokenRefreshed(null);
78
- return null;
79
- } finally {
80
- isRefreshing = false;
81
- }
82
- }
83
-
84
- /**
85
- * Check if a response indicates an authentication error
86
- */
87
- export function isAuthenticationError(response: Response): boolean {
88
- return response.status === 401;
89
- }
90
-
91
- /**
92
- * Create a fetch wrapper that automatically refreshes tokens on 401
93
- *
94
- * @param originalFetch - The original fetch function to wrap
95
- * @returns Wrapped fetch function with auto-refresh capability
96
- */
97
- export function createAutoRefreshFetch(originalFetch: typeof fetch): typeof fetch {
98
- return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
99
- // Make the initial request
100
- let response = await originalFetch(input, init);
101
-
102
- // If 401 and we have a refresh token, try to refresh
103
- if (isAuthenticationError(response) && apiAccounts.getRefreshToken()) {
104
- authLogger.info('Received 401, attempting token refresh...');
105
-
106
- const newToken = await refreshAccessToken();
107
-
108
- if (newToken) {
109
- // Retry the request with new token
110
- const newInit: RequestInit = {
111
- ...init,
112
- headers: {
113
- ...init?.headers,
114
- Authorization: `Bearer ${newToken}`,
115
- },
116
- };
117
-
118
- authLogger.info('Retrying request with new token...');
119
- response = await originalFetch(input, newInit);
120
- } else {
121
- authLogger.warn('Token refresh failed, returning original 401 response');
122
- }
123
- }
124
-
125
- return response;
126
- };
127
- }
128
-
129
- /**
130
- * Check if token is about to expire (within threshold)
131
- * @param thresholdMs - Time in ms before expiry to consider "expiring soon"
132
- * @returns true if token expires within threshold
133
- */
134
- export function isTokenExpiringSoon(thresholdMs: number = 5 * 60 * 1000): boolean {
135
- const token = apiAccounts.getToken();
136
- if (!token) return false;
137
-
138
- try {
139
- // Decode JWT payload (base64)
140
- const payload = JSON.parse(atob(token.split('.')[1]));
141
- const expiresAt = payload.exp * 1000; // Convert to milliseconds
142
- const now = Date.now();
143
- const timeUntilExpiry = expiresAt - now;
144
-
145
- return timeUntilExpiry < thresholdMs;
146
- } catch (error) {
147
- authLogger.error('Error checking token expiry:', error);
148
- return false;
149
- }
150
- }
151
-
152
- /**
153
- * Proactively refresh token if it's expiring soon
154
- * Call this periodically or before important operations
155
- */
156
- export async function refreshIfExpiringSoon(thresholdMs: number = 5 * 60 * 1000): Promise<void> {
157
- if (isTokenExpiringSoon(thresholdMs)) {
158
- authLogger.info('Token expiring soon, proactively refreshing...');
159
- await refreshAccessToken();
160
- }
161
- }