@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.
- package/dist/auth-server.cjs +1 -2272
- package/dist/auth-server.cjs.map +1 -1
- package/dist/auth-server.d.cts +1 -35
- package/dist/auth-server.d.ts +1 -35
- package/dist/auth-server.mjs +1 -2272
- package/dist/auth-server.mjs.map +1 -1
- package/dist/auth.cjs +207 -128
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +135 -2
- package/dist/auth.d.ts +135 -2
- package/dist/auth.mjs +216 -137
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +35 -0
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.d.cts +147 -2
- package/dist/clients.d.ts +147 -2
- package/dist/clients.mjs +35 -0
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +11 -0
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +11 -0
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +35 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +169 -6
- package/dist/index.d.ts +169 -6
- package/dist/index.mjs +35 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -4
- package/src/_api/generated/_cfg_accounts/api.ts +8 -0
- package/src/_api/generated/_cfg_accounts/openapi.json +84 -5
- package/src/_api/generated/_cfg_accounts/schemas/OAuthTokenResponse.ts +2 -2
- package/src/_api/generated/_cfg_accounts/schemas/OTPRequestResponse.ts +2 -0
- package/src/_api/generated/_cfg_accounts/schemas/WebmailLink.ts +15 -0
- package/src/_api/generated/_cfg_accounts/schemas/WebmailLinkProviderEnum.ts +9 -0
- package/src/_api/generated/_cfg_accounts/schemas/index.ts +2 -0
- package/src/_api/generated/_cfg_centrifugo/api.ts +8 -0
- package/src/_api/generated/_cfg_totp/api.ts +8 -0
- package/src/_api/generated/helpers/auth.ts +12 -0
- package/src/_api/generated/openapi.json +84 -5
- package/src/_api/generated/types.gen.ts +131 -2
- package/src/auth/__tests__/refreshRotation.test.ts +119 -0
- package/src/auth/context/AuthContext.tsx +10 -1
- package/src/auth/hooks/useAuthForm.ts +5 -2
- package/src/auth/hooks/useAuthFormState.ts +4 -1
- package/src/auth/hooks/useTokenRefresh.ts +29 -45
- package/src/auth/middlewares/index.ts +7 -7
- package/src/auth/refreshHandler.ts +79 -0
- package/src/auth/types/form.ts +10 -0
- package/src/auth/types/index.ts +1 -0
- 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<
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
22
|
+
import { useCallback, useEffect } from 'react';
|
|
13
23
|
|
|
14
24
|
import { api as apiAccounts } from '../../';
|
|
15
|
-
import {
|
|
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;
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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('
|
|
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
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
}
|
package/src/auth/types/form.ts
CHANGED
|
@@ -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 {
|
package/src/auth/types/index.ts
CHANGED
|
@@ -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
|
-
}
|