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