@djangocfg/api 2.1.449 → 2.1.452
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.cjs +147 -25
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +107 -2
- package/dist/auth.d.ts +107 -2
- package/dist/auth.mjs +147 -25
- package/dist/auth.mjs.map +1 -1
- package/package.json +4 -2
- package/src/auth/__tests__/authRedirect.test.ts +101 -0
- package/src/auth/__tests__/base64.test.ts +53 -0
- package/src/auth/__tests__/constants.test.ts +30 -0
- package/src/auth/__tests__/formatAuthError.test.ts +48 -0
- package/src/auth/__tests__/guard.test.ts +144 -0
- package/src/auth/__tests__/jwt.test.ts +119 -0
- package/src/auth/__tests__/onUnauthorized.test.ts +206 -0
- package/src/auth/__tests__/path.test.ts +67 -0
- package/src/auth/__tests__/profileCache.test.ts +138 -0
- package/src/auth/__tests__/sessionBootstrap.test.ts +111 -0
- package/src/auth/__tests__/useAuthForm.2fa.test.ts +28 -18
- package/src/auth/__tests__/useTokenRefresh.dom.test.tsx +167 -0
- package/src/auth/__tests__/useTwoFactorStatus.test.ts +45 -36
- package/src/auth/__tests__/validation.test.ts +54 -0
- package/src/auth/context/AuthContext.tsx +172 -14
- package/src/auth/hooks/useAutoAuth.ts +5 -17
- package/src/auth/hooks/useTokenRefresh.ts +3 -21
- package/src/auth/utils/guard.ts +79 -0
- package/src/auth/utils/index.ts +9 -0
- package/src/auth/utils/jwt.ts +66 -0
- package/src/auth/utils/path.ts +34 -0
|
@@ -24,6 +24,7 @@ import { useCallback, useEffect } from 'react';
|
|
|
24
24
|
import { api as apiAccounts } from '../../';
|
|
25
25
|
import { triggerRefresh } from '../refreshHandler';
|
|
26
26
|
import { authLogger } from '../utils/logger';
|
|
27
|
+
import { isTokenExpiringSoon } from '../utils/jwt';
|
|
27
28
|
|
|
28
29
|
// Configuration
|
|
29
30
|
const TOKEN_REFRESH_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes before expiry
|
|
@@ -38,29 +39,10 @@ interface UseTokenRefreshOptions {
|
|
|
38
39
|
onRefreshError?: (error: Error) => void;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
-
* Decode JWT and get expiry time (ms epoch), or null if undecodable.
|
|
43
|
-
*/
|
|
44
|
-
function getTokenExpiry(token: string): number | null {
|
|
45
|
-
try {
|
|
46
|
-
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
47
|
-
return payload.exp * 1000;
|
|
48
|
-
} catch {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Check if token is expiring within the threshold.
|
|
55
|
-
*/
|
|
56
|
-
function isTokenExpiringSoon(token: string, thresholdMs: number): boolean {
|
|
57
|
-
const expiry = getTokenExpiry(token);
|
|
58
|
-
if (!expiry) return false;
|
|
59
|
-
return expiry - Date.now() < thresholdMs;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
42
|
/**
|
|
63
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.)
|
|
64
46
|
*/
|
|
65
47
|
export function useTokenRefresh(options: UseTokenRefreshOptions = {}) {
|
|
66
48
|
const { enabled = true, onRefresh, onRefreshError } = options;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure auth-guard state resolution.
|
|
3
|
+
*
|
|
4
|
+
* This is the decision that produced the "stuck on Authenticating…" bug: given
|
|
5
|
+
* the auth state, should the UI show a preloader, redirect to login, or render
|
|
6
|
+
* the protected content? Extracted from useAuthGuard (which lives in a package
|
|
7
|
+
* with no test infra) so the branching can be unit-tested in isolation — the
|
|
8
|
+
* hook just wires React state (mounted / isRedirecting) into these functions.
|
|
9
|
+
*
|
|
10
|
+
* Invariant that fixes the bug: once auth is resolved (`authLoading === false`)
|
|
11
|
+
* and the user is unauthenticated, `isLoading` must become false so the guard
|
|
12
|
+
* redirects to the form instead of spinning forever.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface GuardInput {
|
|
16
|
+
/** First client render matches SSR (preloader) until mounted, to avoid hydration mismatch. */
|
|
17
|
+
mounted: boolean;
|
|
18
|
+
/** Whether this route requires authentication. */
|
|
19
|
+
requireAuth: boolean;
|
|
20
|
+
/** AuthContext still resolving initial state. */
|
|
21
|
+
authLoading: boolean;
|
|
22
|
+
/** A redirect to the auth page is in flight. */
|
|
23
|
+
isRedirecting: boolean;
|
|
24
|
+
/** User currently has a (valid-looking) session. */
|
|
25
|
+
isAuthenticated: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True while the guard should render a preloader rather than the protected
|
|
30
|
+
* content. Mirrors useAuthGuard's formula exactly.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveGuardIsLoading(s: GuardInput): boolean {
|
|
33
|
+
if (!s.mounted) return true; // pre-hydration: always preloader
|
|
34
|
+
if (!s.requireAuth) return false; // public route: never blocks
|
|
35
|
+
return s.authLoading || s.isRedirecting || !s.isAuthenticated;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether the guard should trigger a redirect to the auth page. Only once
|
|
40
|
+
* mounted, on a protected route, after auth resolved, when unauthenticated and
|
|
41
|
+
* not already redirecting. This is the branch that must fire for a dead session.
|
|
42
|
+
*/
|
|
43
|
+
export function shouldRedirectToAuth(s: GuardInput): boolean {
|
|
44
|
+
return (
|
|
45
|
+
s.mounted &&
|
|
46
|
+
s.requireAuth &&
|
|
47
|
+
!s.authLoading &&
|
|
48
|
+
!s.isAuthenticated &&
|
|
49
|
+
!s.isRedirecting
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The effective authenticated flag the guard exposes to consumers. */
|
|
54
|
+
export function resolveGuardIsAuthenticated(s: Pick<GuardInput, 'requireAuth' | 'isAuthenticated'>): boolean {
|
|
55
|
+
return !s.requireAuth || s.isAuthenticated;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Delay (ms from `now`) until `isAuthenticated` should next be re-evaluated,
|
|
60
|
+
* given the access/refresh expiry timestamps (ms epoch, or null if absent).
|
|
61
|
+
*
|
|
62
|
+
* `isAuthenticated` is derived from token `exp`, so it can change with no event
|
|
63
|
+
* to trigger a recompute — a tab left open silently crosses an expiry boundary.
|
|
64
|
+
* We schedule a wake-up at the SOONEST future expiry (the next moment the answer
|
|
65
|
+
* could flip), plus a 1s cushion so we re-check strictly after the inclusive
|
|
66
|
+
* boundary. Returns null when nothing live remains to expire (no timer needed).
|
|
67
|
+
* Callers should clamp the result to the platform setTimeout ceiling.
|
|
68
|
+
*/
|
|
69
|
+
export function nextAuthEvaluationDelay(
|
|
70
|
+
accessExp: number | null,
|
|
71
|
+
refreshExp: number | null,
|
|
72
|
+
now: number,
|
|
73
|
+
): number | null {
|
|
74
|
+
const future = [accessExp, refreshExp].filter(
|
|
75
|
+
(d): d is number => d !== null && d > now,
|
|
76
|
+
);
|
|
77
|
+
if (future.length === 0) return null;
|
|
78
|
+
return Math.max(0, Math.min(...future) - now) + 1000;
|
|
79
|
+
}
|
package/src/auth/utils/index.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
export { validateEmail } from './validation';
|
|
2
2
|
export { formatAuthError } from './errors';
|
|
3
|
+
export {
|
|
4
|
+
resolveGuardIsLoading,
|
|
5
|
+
shouldRedirectToAuth,
|
|
6
|
+
resolveGuardIsAuthenticated,
|
|
7
|
+
nextAuthEvaluationDelay,
|
|
8
|
+
type GuardInput,
|
|
9
|
+
} from './guard';
|
|
10
|
+
export { getTokenExpiry, isTokenExpired, isTokenExpiringSoon } from './jwt';
|
|
11
|
+
export { normalizePath, isAllowedAuthPath } from './path';
|
|
3
12
|
export { logger, authLogger } from './logger';
|
|
4
13
|
export {
|
|
5
14
|
Analytics,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path normalization for auth routing.
|
|
3
|
+
*
|
|
4
|
+
* next-intl may keep a 2-letter locale prefix in the URL (e.g. `/ru/auth`), and
|
|
5
|
+
* paths arrive with or without trailing slashes. To match a pathname against a
|
|
6
|
+
* fixed allow-list (like `['/auth']`) reliably, strip the locale segment and
|
|
7
|
+
* trailing slashes first. Extracted from useAutoAuth so it can be unit-tested
|
|
8
|
+
* and reused.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Strip a leading 2-letter locale segment and trailing slashes.
|
|
13
|
+
* '/ru/auth/' → '/auth', '/auth' → '/auth', '/' → '/', '' → ''.
|
|
14
|
+
*/
|
|
15
|
+
export const normalizePath = (path: string | null | undefined): string => {
|
|
16
|
+
if (!path) return '';
|
|
17
|
+
const withoutLocale = path.replace(/^\/[a-z]{2}(?=\/|$)/, '');
|
|
18
|
+
const trimmed = withoutLocale.replace(/\/+$/, '');
|
|
19
|
+
return trimmed || '/';
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when `pathname` (after locale/slash normalization) is one of the allowed
|
|
24
|
+
* paths, or nested under one of them (`/auth` matches `/auth/callback`).
|
|
25
|
+
*/
|
|
26
|
+
export const isAllowedAuthPath = (
|
|
27
|
+
pathname: string | null | undefined,
|
|
28
|
+
allowedPaths: string[],
|
|
29
|
+
): boolean => {
|
|
30
|
+
const normalized = normalizePath(pathname);
|
|
31
|
+
return allowedPaths.some(
|
|
32
|
+
(p) => normalized === p || normalized.startsWith(p + '/'),
|
|
33
|
+
);
|
|
34
|
+
};
|