@djangocfg/api 2.1.448 → 2.1.450
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 +285 -146
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +137 -2
- package/dist/auth.d.ts +137 -2
- package/dist/auth.mjs +294 -155
- 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__/jwt.test.ts +119 -0
- package/src/auth/__tests__/onUnauthorized.test.ts +206 -0
- package/src/auth/__tests__/refreshRotation.test.ts +119 -0
- package/src/auth/__tests__/sessionBootstrap.test.ts +76 -0
- package/src/auth/context/AuthContext.tsx +131 -14
- package/src/auth/hooks/useAuthForm.ts +5 -2
- package/src/auth/hooks/useAuthFormState.ts +4 -1
- package/src/auth/hooks/useTokenRefresh.ts +28 -62
- 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/utils/jwt.ts +66 -0
- package/src/auth/middlewares/tokenRefresh.ts +0 -161
|
@@ -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,19 +1,30 @@
|
|
|
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';
|
|
27
|
+
import { isTokenExpiringSoon } from '../utils/jwt';
|
|
17
28
|
|
|
18
29
|
// Configuration
|
|
19
30
|
const TOKEN_REFRESH_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes before expiry
|
|
@@ -29,83 +40,42 @@ interface UseTokenRefreshOptions {
|
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
/**
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
37
|
-
return payload.exp * 1000; // Convert to milliseconds
|
|
38
|
-
} catch {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Check if token is expiring soon
|
|
45
|
-
*/
|
|
46
|
-
function isTokenExpiringSoon(token: string, thresholdMs: number): boolean {
|
|
47
|
-
const expiry = getTokenExpiry(token);
|
|
48
|
-
if (!expiry) return false;
|
|
49
|
-
|
|
50
|
-
const timeUntilExpiry = expiry - Date.now();
|
|
51
|
-
return timeUntilExpiry < thresholdMs;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Hook for automatic token refresh
|
|
43
|
+
* Hook for proactive automatic token refresh.
|
|
44
|
+
* (JWT expiry helpers live in ../utils/jwt so they can be unit-tested and
|
|
45
|
+
* reused by the auth-bootstrap validation path.)
|
|
56
46
|
*/
|
|
57
47
|
export function useTokenRefresh(options: UseTokenRefreshOptions = {}) {
|
|
58
48
|
const { enabled = true, onRefresh, onRefreshError } = options;
|
|
59
|
-
const isRefreshingRef = useRef(false);
|
|
60
49
|
|
|
61
50
|
/**
|
|
62
|
-
* Refresh the token
|
|
51
|
+
* Refresh the token via the canonical single-flight path. Returns true on
|
|
52
|
+
* success. No local in-flight guard needed — `triggerRefresh` shares the
|
|
53
|
+
* store's single-flight promise, so overlapping calls coalesce.
|
|
63
54
|
*/
|
|
64
55
|
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) {
|
|
56
|
+
if (!apiAccounts.getRefreshToken()) {
|
|
72
57
|
authLogger.warn('No refresh token available');
|
|
73
58
|
return false;
|
|
74
59
|
}
|
|
75
60
|
|
|
76
|
-
isRefreshingRef.current = true;
|
|
77
61
|
authLogger.info('Refreshing token...');
|
|
78
|
-
|
|
79
62
|
try {
|
|
80
|
-
|
|
81
|
-
const result = await CfgAccountsAuth.cfgAccountsTokenRefreshCreate({
|
|
82
|
-
body: { refresh: refreshTokenValue },
|
|
83
|
-
throwOnError: true,
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
const newAccessToken = result.data.access;
|
|
87
|
-
|
|
63
|
+
const newAccessToken = await triggerRefresh();
|
|
88
64
|
if (!newAccessToken) {
|
|
89
|
-
throw new Error('
|
|
65
|
+
throw new Error('Token refresh failed');
|
|
90
66
|
}
|
|
91
|
-
|
|
92
|
-
apiAccounts.setToken(newAccessToken);
|
|
93
|
-
apiAccounts.setRefreshToken(refreshTokenValue);
|
|
94
67
|
authLogger.info('Token refreshed successfully');
|
|
95
|
-
|
|
96
68
|
onRefresh?.(newAccessToken);
|
|
97
69
|
return true;
|
|
98
70
|
} catch (error) {
|
|
99
71
|
authLogger.error('Token refresh error:', error);
|
|
100
72
|
onRefreshError?.(error instanceof Error ? error : new Error(String(error)));
|
|
101
73
|
return false;
|
|
102
|
-
} finally {
|
|
103
|
-
isRefreshingRef.current = false;
|
|
104
74
|
}
|
|
105
75
|
}, [onRefresh, onRefreshError]);
|
|
106
76
|
|
|
107
77
|
/**
|
|
108
|
-
* Check and refresh if
|
|
78
|
+
* Check and refresh if the current token is expiring soon.
|
|
109
79
|
*/
|
|
110
80
|
const checkAndRefresh = useCallback(async () => {
|
|
111
81
|
const token = apiAccounts.getToken();
|
|
@@ -121,12 +91,8 @@ export function useTokenRefresh(options: UseTokenRefreshOptions = {}) {
|
|
|
121
91
|
useEffect(() => {
|
|
122
92
|
if (!enabled) return;
|
|
123
93
|
|
|
124
|
-
// Check immediately
|
|
125
94
|
checkAndRefresh();
|
|
126
|
-
|
|
127
|
-
// Set up interval
|
|
128
95
|
const intervalId = setInterval(checkAndRefresh, CHECK_INTERVAL_MS);
|
|
129
|
-
|
|
130
96
|
return () => clearInterval(intervalId);
|
|
131
97
|
}, [enabled, checkAndRefresh]);
|
|
132
98
|
|
|
@@ -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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT inspection helpers (browser-safe, dependency-free).
|
|
3
|
+
*
|
|
4
|
+
* These decode ONLY the `exp` claim to reason about token freshness on the
|
|
5
|
+
* client. They do NOT verify the signature — that is the server's job. The
|
|
6
|
+
* point is purely to avoid firing doomed requests with a token we can already
|
|
7
|
+
* see is malformed or expired, and to let auth bootstrap collapse a dead
|
|
8
|
+
* session to the login form instead of hanging on a preloader.
|
|
9
|
+
*
|
|
10
|
+
* A JWT that cannot be decoded is treated as EXPIRED/invalid (fail-closed):
|
|
11
|
+
* better to re-authenticate than to loop on 401s with garbage in storage.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Base64url-decode a JWT segment. Returns null on any malformation. */
|
|
15
|
+
function decodeSegment(segment: string): unknown {
|
|
16
|
+
try {
|
|
17
|
+
const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
|
|
18
|
+
// atob is defined in browsers and modern Node (global). Guard anyway.
|
|
19
|
+
if (typeof atob !== 'function') return null;
|
|
20
|
+
const json = atob(base64);
|
|
21
|
+
return JSON.parse(json);
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract the `exp` claim (epoch ms), or null if the token is undecodable or
|
|
29
|
+
* has no numeric `exp`.
|
|
30
|
+
*/
|
|
31
|
+
export function getTokenExpiry(token: string | null | undefined): number | null {
|
|
32
|
+
if (!token || typeof token !== 'string') return null;
|
|
33
|
+
const parts = token.split('.');
|
|
34
|
+
if (parts.length !== 3) return null; // not a well-formed JWT
|
|
35
|
+
const payload = decodeSegment(parts[1]);
|
|
36
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
37
|
+
const exp = (payload as { exp?: unknown }).exp;
|
|
38
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp)) return null;
|
|
39
|
+
return exp * 1000;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* True when the token is missing, malformed, or already past its `exp`.
|
|
44
|
+
* `skewMs` treats a token expiring within the skew window as already expired
|
|
45
|
+
* (default 0). Fail-closed: an undecodable token is considered expired.
|
|
46
|
+
*/
|
|
47
|
+
export function isTokenExpired(token: string | null | undefined, skewMs = 0, now = Date.now()): boolean {
|
|
48
|
+
const expiry = getTokenExpiry(token);
|
|
49
|
+
if (expiry === null) return true; // undecodable / no exp → treat as dead
|
|
50
|
+
return expiry - skewMs <= now;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* True when a valid token exists but expires within `thresholdMs` (used by the
|
|
55
|
+
* proactive refresher). A missing/undecodable token returns false here — that
|
|
56
|
+
* case is handled by the "expired" path, not the "expiring soon" path.
|
|
57
|
+
*/
|
|
58
|
+
export function isTokenExpiringSoon(
|
|
59
|
+
token: string | null | undefined,
|
|
60
|
+
thresholdMs: number,
|
|
61
|
+
now = Date.now(),
|
|
62
|
+
): boolean {
|
|
63
|
+
const expiry = getTokenExpiry(token);
|
|
64
|
+
if (expiry === null) return false;
|
|
65
|
+
return expiry - now < thresholdMs;
|
|
66
|
+
}
|
|
@@ -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
|
-
}
|