@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.d.cts CHANGED
@@ -634,7 +634,9 @@ interface UseAutoAuthOptions {
634
634
  }
635
635
  /**
636
636
  * Hook for automatic authentication from URL query parameters
637
- * Detects OTP from URL and triggers callback
637
+ * Detects OTP from URL and triggers callback.
638
+ * (Path normalization / allow-list matching live in ../utils/path so they can
639
+ * be unit-tested independently of the hook.)
638
640
  */
639
641
  declare const useAutoAuth: (options?: UseAutoAuthOptions) => {
640
642
  isReady: boolean;
@@ -951,6 +953,8 @@ interface UseTokenRefreshOptions {
951
953
  }
952
954
  /**
953
955
  * Hook for proactive automatic token refresh.
956
+ * (JWT expiry helpers live in ../utils/jwt so they can be unit-tested and
957
+ * reused by the auth-bootstrap validation path.)
954
958
  */
955
959
  declare function useTokenRefresh(options?: UseTokenRefreshOptions): {
956
960
  refreshToken: () => Promise<boolean>;
@@ -1002,6 +1006,107 @@ declare const validateEmail: (email: string) => boolean;
1002
1006
  */
1003
1007
  declare const formatAuthError: (error: any) => string;
1004
1008
 
1009
+ /**
1010
+ * Pure auth-guard state resolution.
1011
+ *
1012
+ * This is the decision that produced the "stuck on Authenticating…" bug: given
1013
+ * the auth state, should the UI show a preloader, redirect to login, or render
1014
+ * the protected content? Extracted from useAuthGuard (which lives in a package
1015
+ * with no test infra) so the branching can be unit-tested in isolation — the
1016
+ * hook just wires React state (mounted / isRedirecting) into these functions.
1017
+ *
1018
+ * Invariant that fixes the bug: once auth is resolved (`authLoading === false`)
1019
+ * and the user is unauthenticated, `isLoading` must become false so the guard
1020
+ * redirects to the form instead of spinning forever.
1021
+ */
1022
+ interface GuardInput {
1023
+ /** First client render matches SSR (preloader) until mounted, to avoid hydration mismatch. */
1024
+ mounted: boolean;
1025
+ /** Whether this route requires authentication. */
1026
+ requireAuth: boolean;
1027
+ /** AuthContext still resolving initial state. */
1028
+ authLoading: boolean;
1029
+ /** A redirect to the auth page is in flight. */
1030
+ isRedirecting: boolean;
1031
+ /** User currently has a (valid-looking) session. */
1032
+ isAuthenticated: boolean;
1033
+ }
1034
+ /**
1035
+ * True while the guard should render a preloader rather than the protected
1036
+ * content. Mirrors useAuthGuard's formula exactly.
1037
+ */
1038
+ declare function resolveGuardIsLoading(s: GuardInput): boolean;
1039
+ /**
1040
+ * Whether the guard should trigger a redirect to the auth page. Only once
1041
+ * mounted, on a protected route, after auth resolved, when unauthenticated and
1042
+ * not already redirecting. This is the branch that must fire for a dead session.
1043
+ */
1044
+ declare function shouldRedirectToAuth(s: GuardInput): boolean;
1045
+ /** The effective authenticated flag the guard exposes to consumers. */
1046
+ declare function resolveGuardIsAuthenticated(s: Pick<GuardInput, 'requireAuth' | 'isAuthenticated'>): boolean;
1047
+ /**
1048
+ * Delay (ms from `now`) until `isAuthenticated` should next be re-evaluated,
1049
+ * given the access/refresh expiry timestamps (ms epoch, or null if absent).
1050
+ *
1051
+ * `isAuthenticated` is derived from token `exp`, so it can change with no event
1052
+ * to trigger a recompute — a tab left open silently crosses an expiry boundary.
1053
+ * We schedule a wake-up at the SOONEST future expiry (the next moment the answer
1054
+ * could flip), plus a 1s cushion so we re-check strictly after the inclusive
1055
+ * boundary. Returns null when nothing live remains to expire (no timer needed).
1056
+ * Callers should clamp the result to the platform setTimeout ceiling.
1057
+ */
1058
+ declare function nextAuthEvaluationDelay(accessExp: number | null, refreshExp: number | null, now: number): number | null;
1059
+
1060
+ /**
1061
+ * JWT inspection helpers (browser-safe, dependency-free).
1062
+ *
1063
+ * These decode ONLY the `exp` claim to reason about token freshness on the
1064
+ * client. They do NOT verify the signature — that is the server's job. The
1065
+ * point is purely to avoid firing doomed requests with a token we can already
1066
+ * see is malformed or expired, and to let auth bootstrap collapse a dead
1067
+ * session to the login form instead of hanging on a preloader.
1068
+ *
1069
+ * A JWT that cannot be decoded is treated as EXPIRED/invalid (fail-closed):
1070
+ * better to re-authenticate than to loop on 401s with garbage in storage.
1071
+ */
1072
+ /**
1073
+ * Extract the `exp` claim (epoch ms), or null if the token is undecodable or
1074
+ * has no numeric `exp`.
1075
+ */
1076
+ declare function getTokenExpiry(token: string | null | undefined): number | null;
1077
+ /**
1078
+ * True when the token is missing, malformed, or already past its `exp`.
1079
+ * `skewMs` treats a token expiring within the skew window as already expired
1080
+ * (default 0). Fail-closed: an undecodable token is considered expired.
1081
+ */
1082
+ declare function isTokenExpired(token: string | null | undefined, skewMs?: number, now?: number): boolean;
1083
+ /**
1084
+ * True when a valid token exists but expires within `thresholdMs` (used by the
1085
+ * proactive refresher). A missing/undecodable token returns false here — that
1086
+ * case is handled by the "expired" path, not the "expiring soon" path.
1087
+ */
1088
+ declare function isTokenExpiringSoon(token: string | null | undefined, thresholdMs: number, now?: number): boolean;
1089
+
1090
+ /**
1091
+ * Path normalization for auth routing.
1092
+ *
1093
+ * next-intl may keep a 2-letter locale prefix in the URL (e.g. `/ru/auth`), and
1094
+ * paths arrive with or without trailing slashes. To match a pathname against a
1095
+ * fixed allow-list (like `['/auth']`) reliably, strip the locale segment and
1096
+ * trailing slashes first. Extracted from useAutoAuth so it can be unit-tested
1097
+ * and reused.
1098
+ */
1099
+ /**
1100
+ * Strip a leading 2-letter locale segment and trailing slashes.
1101
+ * '/ru/auth/' → '/auth', '/auth' → '/auth', '/' → '/', '' → ''.
1102
+ */
1103
+ declare const normalizePath: (path: string | null | undefined) => string;
1104
+ /**
1105
+ * True when `pathname` (after locale/slash normalization) is one of the allowed
1106
+ * paths, or nested under one of them (`/auth` matches `/auth/callback`).
1107
+ */
1108
+ declare const isAllowedAuthPath: (pathname: string | null | undefined, allowedPaths: string[]) => boolean;
1109
+
1005
1110
  declare const logger: consola.ConsolaInstance;
1006
1111
  /**
1007
1112
  * Auth-specific logger
@@ -1051,4 +1156,4 @@ declare const Analytics: {
1051
1156
  setUser(userId: string): void;
1052
1157
  };
1053
1158
 
1054
- export { AUTH_CONSTANTS, type AccountsContextValue, AccountsProvider, Analytics, AnalyticsCategory, type AnalyticsCategoryType, AnalyticsEvent, type AnalyticsEventType, type AuthConfig, AuthContext, type AuthContextType, type AuthFormAutoSubmit, type AuthFormReturn, type AuthFormState, type AuthFormStateHandlers, type AuthFormSubmitHandlers, type AuthFormValidation, AuthProvider, type AuthProviderProps, type AuthStep, type DeleteAccountResult, type OTPRequestResult, type PatchedCfgUserUpdateRequest, PatchedCfgUserUpdateRequestSchema, type ProfileCacheOptions, type TwoFactorDevice, type TwoFactorSetupData, type UseAuthFormOptions, type UseAuthFormStateReturn, type UseAutoAuthOptions, type UseDeleteAccountReturn, type UseGithubAuthOptions, type UseGithubAuthReturn, type UseTwoFactorOptions, type UseTwoFactorReturn, type UseTwoFactorSetupOptions, type UseTwoFactorSetupReturn, type UseTwoFactorStatusReturn, type UserProfile, type WebmailLink, authLogger, clearProfileCache, decodeBase64, encodeBase64, formatAuthError, getCacheMetadata, getCachedProfile, hasValidCache, logger, setCachedProfile, useAccountsContext, useAuth, useAuthForm, useAuthFormState, useAuthGuard, useAuthRedirectManager, useAuthValidation, useAutoAuth, useBase64, useCfgRouter, useDeleteAccount, useGithubAuth, useLocalStorage, useQueryParams, useSessionStorage, useTokenRefresh, useTwoFactor, useTwoFactorSetup, useTwoFactorStatus, validateEmail, validateIdentifier };
1159
+ export { AUTH_CONSTANTS, type AccountsContextValue, AccountsProvider, Analytics, AnalyticsCategory, type AnalyticsCategoryType, AnalyticsEvent, type AnalyticsEventType, type AuthConfig, AuthContext, type AuthContextType, type AuthFormAutoSubmit, type AuthFormReturn, type AuthFormState, type AuthFormStateHandlers, type AuthFormSubmitHandlers, type AuthFormValidation, AuthProvider, type AuthProviderProps, type AuthStep, type DeleteAccountResult, type GuardInput, type OTPRequestResult, type PatchedCfgUserUpdateRequest, PatchedCfgUserUpdateRequestSchema, type ProfileCacheOptions, type TwoFactorDevice, type TwoFactorSetupData, type UseAuthFormOptions, type UseAuthFormStateReturn, type UseAutoAuthOptions, type UseDeleteAccountReturn, type UseGithubAuthOptions, type UseGithubAuthReturn, type UseTwoFactorOptions, type UseTwoFactorReturn, type UseTwoFactorSetupOptions, type UseTwoFactorSetupReturn, type UseTwoFactorStatusReturn, type UserProfile, type WebmailLink, authLogger, clearProfileCache, decodeBase64, encodeBase64, formatAuthError, getCacheMetadata, getCachedProfile, getTokenExpiry, hasValidCache, isAllowedAuthPath, isTokenExpired, isTokenExpiringSoon, logger, nextAuthEvaluationDelay, normalizePath, resolveGuardIsAuthenticated, resolveGuardIsLoading, setCachedProfile, shouldRedirectToAuth, useAccountsContext, useAuth, useAuthForm, useAuthFormState, useAuthGuard, useAuthRedirectManager, useAuthValidation, useAutoAuth, useBase64, useCfgRouter, useDeleteAccount, useGithubAuth, useLocalStorage, useQueryParams, useSessionStorage, useTokenRefresh, useTwoFactor, useTwoFactorSetup, useTwoFactorStatus, validateEmail, validateIdentifier };
package/dist/auth.d.ts CHANGED
@@ -634,7 +634,9 @@ interface UseAutoAuthOptions {
634
634
  }
635
635
  /**
636
636
  * Hook for automatic authentication from URL query parameters
637
- * Detects OTP from URL and triggers callback
637
+ * Detects OTP from URL and triggers callback.
638
+ * (Path normalization / allow-list matching live in ../utils/path so they can
639
+ * be unit-tested independently of the hook.)
638
640
  */
639
641
  declare const useAutoAuth: (options?: UseAutoAuthOptions) => {
640
642
  isReady: boolean;
@@ -951,6 +953,8 @@ interface UseTokenRefreshOptions {
951
953
  }
952
954
  /**
953
955
  * Hook for proactive automatic token refresh.
956
+ * (JWT expiry helpers live in ../utils/jwt so they can be unit-tested and
957
+ * reused by the auth-bootstrap validation path.)
954
958
  */
955
959
  declare function useTokenRefresh(options?: UseTokenRefreshOptions): {
956
960
  refreshToken: () => Promise<boolean>;
@@ -1002,6 +1006,107 @@ declare const validateEmail: (email: string) => boolean;
1002
1006
  */
1003
1007
  declare const formatAuthError: (error: any) => string;
1004
1008
 
1009
+ /**
1010
+ * Pure auth-guard state resolution.
1011
+ *
1012
+ * This is the decision that produced the "stuck on Authenticating…" bug: given
1013
+ * the auth state, should the UI show a preloader, redirect to login, or render
1014
+ * the protected content? Extracted from useAuthGuard (which lives in a package
1015
+ * with no test infra) so the branching can be unit-tested in isolation — the
1016
+ * hook just wires React state (mounted / isRedirecting) into these functions.
1017
+ *
1018
+ * Invariant that fixes the bug: once auth is resolved (`authLoading === false`)
1019
+ * and the user is unauthenticated, `isLoading` must become false so the guard
1020
+ * redirects to the form instead of spinning forever.
1021
+ */
1022
+ interface GuardInput {
1023
+ /** First client render matches SSR (preloader) until mounted, to avoid hydration mismatch. */
1024
+ mounted: boolean;
1025
+ /** Whether this route requires authentication. */
1026
+ requireAuth: boolean;
1027
+ /** AuthContext still resolving initial state. */
1028
+ authLoading: boolean;
1029
+ /** A redirect to the auth page is in flight. */
1030
+ isRedirecting: boolean;
1031
+ /** User currently has a (valid-looking) session. */
1032
+ isAuthenticated: boolean;
1033
+ }
1034
+ /**
1035
+ * True while the guard should render a preloader rather than the protected
1036
+ * content. Mirrors useAuthGuard's formula exactly.
1037
+ */
1038
+ declare function resolveGuardIsLoading(s: GuardInput): boolean;
1039
+ /**
1040
+ * Whether the guard should trigger a redirect to the auth page. Only once
1041
+ * mounted, on a protected route, after auth resolved, when unauthenticated and
1042
+ * not already redirecting. This is the branch that must fire for a dead session.
1043
+ */
1044
+ declare function shouldRedirectToAuth(s: GuardInput): boolean;
1045
+ /** The effective authenticated flag the guard exposes to consumers. */
1046
+ declare function resolveGuardIsAuthenticated(s: Pick<GuardInput, 'requireAuth' | 'isAuthenticated'>): boolean;
1047
+ /**
1048
+ * Delay (ms from `now`) until `isAuthenticated` should next be re-evaluated,
1049
+ * given the access/refresh expiry timestamps (ms epoch, or null if absent).
1050
+ *
1051
+ * `isAuthenticated` is derived from token `exp`, so it can change with no event
1052
+ * to trigger a recompute — a tab left open silently crosses an expiry boundary.
1053
+ * We schedule a wake-up at the SOONEST future expiry (the next moment the answer
1054
+ * could flip), plus a 1s cushion so we re-check strictly after the inclusive
1055
+ * boundary. Returns null when nothing live remains to expire (no timer needed).
1056
+ * Callers should clamp the result to the platform setTimeout ceiling.
1057
+ */
1058
+ declare function nextAuthEvaluationDelay(accessExp: number | null, refreshExp: number | null, now: number): number | null;
1059
+
1060
+ /**
1061
+ * JWT inspection helpers (browser-safe, dependency-free).
1062
+ *
1063
+ * These decode ONLY the `exp` claim to reason about token freshness on the
1064
+ * client. They do NOT verify the signature — that is the server's job. The
1065
+ * point is purely to avoid firing doomed requests with a token we can already
1066
+ * see is malformed or expired, and to let auth bootstrap collapse a dead
1067
+ * session to the login form instead of hanging on a preloader.
1068
+ *
1069
+ * A JWT that cannot be decoded is treated as EXPIRED/invalid (fail-closed):
1070
+ * better to re-authenticate than to loop on 401s with garbage in storage.
1071
+ */
1072
+ /**
1073
+ * Extract the `exp` claim (epoch ms), or null if the token is undecodable or
1074
+ * has no numeric `exp`.
1075
+ */
1076
+ declare function getTokenExpiry(token: string | null | undefined): number | null;
1077
+ /**
1078
+ * True when the token is missing, malformed, or already past its `exp`.
1079
+ * `skewMs` treats a token expiring within the skew window as already expired
1080
+ * (default 0). Fail-closed: an undecodable token is considered expired.
1081
+ */
1082
+ declare function isTokenExpired(token: string | null | undefined, skewMs?: number, now?: number): boolean;
1083
+ /**
1084
+ * True when a valid token exists but expires within `thresholdMs` (used by the
1085
+ * proactive refresher). A missing/undecodable token returns false here — that
1086
+ * case is handled by the "expired" path, not the "expiring soon" path.
1087
+ */
1088
+ declare function isTokenExpiringSoon(token: string | null | undefined, thresholdMs: number, now?: number): boolean;
1089
+
1090
+ /**
1091
+ * Path normalization for auth routing.
1092
+ *
1093
+ * next-intl may keep a 2-letter locale prefix in the URL (e.g. `/ru/auth`), and
1094
+ * paths arrive with or without trailing slashes. To match a pathname against a
1095
+ * fixed allow-list (like `['/auth']`) reliably, strip the locale segment and
1096
+ * trailing slashes first. Extracted from useAutoAuth so it can be unit-tested
1097
+ * and reused.
1098
+ */
1099
+ /**
1100
+ * Strip a leading 2-letter locale segment and trailing slashes.
1101
+ * '/ru/auth/' → '/auth', '/auth' → '/auth', '/' → '/', '' → ''.
1102
+ */
1103
+ declare const normalizePath: (path: string | null | undefined) => string;
1104
+ /**
1105
+ * True when `pathname` (after locale/slash normalization) is one of the allowed
1106
+ * paths, or nested under one of them (`/auth` matches `/auth/callback`).
1107
+ */
1108
+ declare const isAllowedAuthPath: (pathname: string | null | undefined, allowedPaths: string[]) => boolean;
1109
+
1005
1110
  declare const logger: consola.ConsolaInstance;
1006
1111
  /**
1007
1112
  * Auth-specific logger
@@ -1051,4 +1156,4 @@ declare const Analytics: {
1051
1156
  setUser(userId: string): void;
1052
1157
  };
1053
1158
 
1054
- export { AUTH_CONSTANTS, type AccountsContextValue, AccountsProvider, Analytics, AnalyticsCategory, type AnalyticsCategoryType, AnalyticsEvent, type AnalyticsEventType, type AuthConfig, AuthContext, type AuthContextType, type AuthFormAutoSubmit, type AuthFormReturn, type AuthFormState, type AuthFormStateHandlers, type AuthFormSubmitHandlers, type AuthFormValidation, AuthProvider, type AuthProviderProps, type AuthStep, type DeleteAccountResult, type OTPRequestResult, type PatchedCfgUserUpdateRequest, PatchedCfgUserUpdateRequestSchema, type ProfileCacheOptions, type TwoFactorDevice, type TwoFactorSetupData, type UseAuthFormOptions, type UseAuthFormStateReturn, type UseAutoAuthOptions, type UseDeleteAccountReturn, type UseGithubAuthOptions, type UseGithubAuthReturn, type UseTwoFactorOptions, type UseTwoFactorReturn, type UseTwoFactorSetupOptions, type UseTwoFactorSetupReturn, type UseTwoFactorStatusReturn, type UserProfile, type WebmailLink, authLogger, clearProfileCache, decodeBase64, encodeBase64, formatAuthError, getCacheMetadata, getCachedProfile, hasValidCache, logger, setCachedProfile, useAccountsContext, useAuth, useAuthForm, useAuthFormState, useAuthGuard, useAuthRedirectManager, useAuthValidation, useAutoAuth, useBase64, useCfgRouter, useDeleteAccount, useGithubAuth, useLocalStorage, useQueryParams, useSessionStorage, useTokenRefresh, useTwoFactor, useTwoFactorSetup, useTwoFactorStatus, validateEmail, validateIdentifier };
1159
+ export { AUTH_CONSTANTS, type AccountsContextValue, AccountsProvider, Analytics, AnalyticsCategory, type AnalyticsCategoryType, AnalyticsEvent, type AnalyticsEventType, type AuthConfig, AuthContext, type AuthContextType, type AuthFormAutoSubmit, type AuthFormReturn, type AuthFormState, type AuthFormStateHandlers, type AuthFormSubmitHandlers, type AuthFormValidation, AuthProvider, type AuthProviderProps, type AuthStep, type DeleteAccountResult, type GuardInput, type OTPRequestResult, type PatchedCfgUserUpdateRequest, PatchedCfgUserUpdateRequestSchema, type ProfileCacheOptions, type TwoFactorDevice, type TwoFactorSetupData, type UseAuthFormOptions, type UseAuthFormStateReturn, type UseAutoAuthOptions, type UseDeleteAccountReturn, type UseGithubAuthOptions, type UseGithubAuthReturn, type UseTwoFactorOptions, type UseTwoFactorReturn, type UseTwoFactorSetupOptions, type UseTwoFactorSetupReturn, type UseTwoFactorStatusReturn, type UserProfile, type WebmailLink, authLogger, clearProfileCache, decodeBase64, encodeBase64, formatAuthError, getCacheMetadata, getCachedProfile, getTokenExpiry, hasValidCache, isAllowedAuthPath, isTokenExpired, isTokenExpiringSoon, logger, nextAuthEvaluationDelay, normalizePath, resolveGuardIsAuthenticated, resolveGuardIsLoading, setCachedProfile, shouldRedirectToAuth, useAccountsContext, useAuth, useAuthForm, useAuthFormState, useAuthGuard, useAuthRedirectManager, useAuthValidation, useAutoAuth, useBase64, useCfgRouter, useDeleteAccount, useGithubAuth, useLocalStorage, useQueryParams, useSessionStorage, useTokenRefresh, useTwoFactor, useTwoFactorSetup, useTwoFactorStatus, validateEmail, validateIdentifier };
package/dist/auth.mjs CHANGED
@@ -286,21 +286,28 @@ var authLogger = logger.withTag("auth");
286
286
  // src/auth/hooks/useAutoAuth.ts
287
287
  import { usePathname as usePathname2 } from "next/navigation";
288
288
  import { useEffect as useEffect3, useRef as useRef3 } from "react";
289
+
290
+ // src/auth/utils/path.ts
289
291
  var normalizePath = /* @__PURE__ */ __name((path) => {
290
292
  if (!path) return "";
291
293
  const withoutLocale = path.replace(/^\/[a-z]{2}(?=\/|$)/, "");
292
294
  const trimmed = withoutLocale.replace(/\/+$/, "");
293
295
  return trimmed || "/";
294
296
  }, "normalizePath");
297
+ var isAllowedAuthPath = /* @__PURE__ */ __name((pathname, allowedPaths) => {
298
+ const normalized = normalizePath(pathname);
299
+ return allowedPaths.some(
300
+ (p) => normalized === p || normalized.startsWith(p + "/")
301
+ );
302
+ }, "isAllowedAuthPath");
303
+
304
+ // src/auth/hooks/useAutoAuth.ts
295
305
  var useAutoAuth = /* @__PURE__ */ __name((options = {}) => {
296
306
  const { onOTPDetected, cleanupUrl = true, allowedPaths = ["/auth"] } = options;
297
307
  const queryParams = useQueryParams();
298
308
  const pathname = usePathname2();
299
309
  const router = useCfgRouter();
300
- const normalizedPath = normalizePath(pathname);
301
- const isAllowedPath = allowedPaths.some(
302
- (path) => normalizedPath === path || normalizedPath.startsWith(path + "/")
303
- );
310
+ const isAllowedPath = isAllowedAuthPath(pathname, allowedPaths);
304
311
  const queryOtp = queryParams.get("otp") || "";
305
312
  const queryEmail = queryParams.get("email") || void 0;
306
313
  const hasOTP = !!queryOtp;
@@ -3802,24 +3809,45 @@ function triggerRefresh() {
3802
3809
  }
3803
3810
  __name(triggerRefresh, "triggerRefresh");
3804
3811
 
3805
- // src/auth/hooks/useTokenRefresh.ts
3806
- var TOKEN_REFRESH_THRESHOLD_MS = 10 * 60 * 1e3;
3807
- var CHECK_INTERVAL_MS = 5 * 60 * 1e3;
3808
- function getTokenExpiry(token) {
3812
+ // src/auth/utils/jwt.ts
3813
+ function decodeSegment(segment) {
3809
3814
  try {
3810
- const payload = JSON.parse(atob(token.split(".")[1]));
3811
- return payload.exp * 1e3;
3815
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
3816
+ if (typeof atob !== "function") return null;
3817
+ const json = atob(base64);
3818
+ return JSON.parse(json);
3812
3819
  } catch {
3813
3820
  return null;
3814
3821
  }
3815
3822
  }
3823
+ __name(decodeSegment, "decodeSegment");
3824
+ function getTokenExpiry(token) {
3825
+ if (!token || typeof token !== "string") return null;
3826
+ const parts = token.split(".");
3827
+ if (parts.length !== 3) return null;
3828
+ const payload = decodeSegment(parts[1]);
3829
+ if (!payload || typeof payload !== "object") return null;
3830
+ const exp = payload.exp;
3831
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
3832
+ return exp * 1e3;
3833
+ }
3816
3834
  __name(getTokenExpiry, "getTokenExpiry");
3817
- function isTokenExpiringSoon(token, thresholdMs) {
3835
+ function isTokenExpired(token, skewMs = 0, now = Date.now()) {
3836
+ const expiry = getTokenExpiry(token);
3837
+ if (expiry === null) return true;
3838
+ return expiry - skewMs <= now;
3839
+ }
3840
+ __name(isTokenExpired, "isTokenExpired");
3841
+ function isTokenExpiringSoon(token, thresholdMs, now = Date.now()) {
3818
3842
  const expiry = getTokenExpiry(token);
3819
- if (!expiry) return false;
3820
- return expiry - Date.now() < thresholdMs;
3843
+ if (expiry === null) return false;
3844
+ return expiry - now < thresholdMs;
3821
3845
  }
3822
3846
  __name(isTokenExpiringSoon, "isTokenExpiringSoon");
3847
+
3848
+ // src/auth/hooks/useTokenRefresh.ts
3849
+ var TOKEN_REFRESH_THRESHOLD_MS = 10 * 60 * 1e3;
3850
+ var CHECK_INTERVAL_MS = 5 * 60 * 1e3;
3823
3851
  function useTokenRefresh(options = {}) {
3824
3852
  const { enabled = true, onRefresh, onRefreshError } = options;
3825
3853
  const refreshToken = useCallback9(async () => {
@@ -3919,6 +3947,30 @@ var useDeleteAccount = /* @__PURE__ */ __name(() => {
3919
3947
  };
3920
3948
  }, "useDeleteAccount");
3921
3949
 
3950
+ // src/auth/utils/guard.ts
3951
+ function resolveGuardIsLoading(s) {
3952
+ if (!s.mounted) return true;
3953
+ if (!s.requireAuth) return false;
3954
+ return s.authLoading || s.isRedirecting || !s.isAuthenticated;
3955
+ }
3956
+ __name(resolveGuardIsLoading, "resolveGuardIsLoading");
3957
+ function shouldRedirectToAuth(s) {
3958
+ return s.mounted && s.requireAuth && !s.authLoading && !s.isAuthenticated && !s.isRedirecting;
3959
+ }
3960
+ __name(shouldRedirectToAuth, "shouldRedirectToAuth");
3961
+ function resolveGuardIsAuthenticated(s) {
3962
+ return !s.requireAuth || s.isAuthenticated;
3963
+ }
3964
+ __name(resolveGuardIsAuthenticated, "resolveGuardIsAuthenticated");
3965
+ function nextAuthEvaluationDelay(accessExp, refreshExp, now) {
3966
+ const future = [accessExp, refreshExp].filter(
3967
+ (d) => d !== null && d > now
3968
+ );
3969
+ if (future.length === 0) return null;
3970
+ return Math.max(0, Math.min(...future) - now) + 1e3;
3971
+ }
3972
+ __name(nextAuthEvaluationDelay, "nextAuthEvaluationDelay");
3973
+
3922
3974
  // src/auth/context/AccountsContext.tsx
3923
3975
  import {
3924
3976
  createContext,
@@ -4683,6 +4735,20 @@ var hasValidTokens = /* @__PURE__ */ __name(() => {
4683
4735
  if (typeof window === "undefined") return false;
4684
4736
  return CfgAccountsApi.isAuthenticated();
4685
4737
  }, "hasValidTokens");
4738
+ var isSessionDeadOnBootstrap = /* @__PURE__ */ __name((accessToken, refreshToken, now = Date.now()) => {
4739
+ const accessDead = isTokenExpired(accessToken, 0, now);
4740
+ if (!accessDead) return false;
4741
+ const refreshDead = isTokenExpired(refreshToken, 0, now);
4742
+ return refreshDead;
4743
+ }, "isSessionDeadOnBootstrap");
4744
+ var isSessionAlive = /* @__PURE__ */ __name((now = Date.now()) => {
4745
+ if (typeof window === "undefined") return false;
4746
+ return !isSessionDeadOnBootstrap(
4747
+ CfgAccountsApi.getToken(),
4748
+ CfgAccountsApi.getRefreshToken(),
4749
+ now
4750
+ );
4751
+ }, "isSessionAlive");
4686
4752
  var AuthProviderInternal = /* @__PURE__ */ __name(({ children, config }) => {
4687
4753
  const accounts = useAccountsContext();
4688
4754
  ensureRefreshHandler();
@@ -4698,19 +4764,24 @@ var AuthProviderInternal = /* @__PURE__ */ __name(({ children, config }) => {
4698
4764
  return true;
4699
4765
  });
4700
4766
  const [initialized, setInitialized] = useState12(false);
4767
+ const [authTick, setAuthTick] = useState12(0);
4768
+ const bumpAuthTick = useCallback12(() => setAuthTick((t) => t + 1), []);
4769
+ useEffect9(() => {
4770
+ if (typeof window === "undefined") return;
4771
+ const delay = nextAuthEvaluationDelay(
4772
+ getTokenExpiry(CfgAccountsApi.getToken()),
4773
+ getTokenExpiry(CfgAccountsApi.getRefreshToken()),
4774
+ Date.now()
4775
+ );
4776
+ if (delay === null) return;
4777
+ const timer = setTimeout(bumpAuthTick, Math.min(delay, 2e9));
4778
+ return () => clearTimeout(timer);
4779
+ }, [authTick, bumpAuthTick]);
4780
+ const onUnauthorizedRef = useRef6(false);
4701
4781
  const router = useCfgRouter();
4702
4782
  const pathname = usePathname3();
4703
4783
  const queryParams = useQueryParams();
4704
4784
  const [storedEmail, setStoredEmail, clearStoredEmail] = useLocalStorage(EMAIL_STORAGE_KEY, null);
4705
- useTokenRefresh({
4706
- enabled: true,
4707
- onRefresh: /* @__PURE__ */ __name((newToken) => {
4708
- authLogger.info("Token auto-refreshed successfully");
4709
- }, "onRefresh"),
4710
- onRefreshError: /* @__PURE__ */ __name((error) => {
4711
- authLogger.warn("Token auto-refresh failed:", error.message);
4712
- }, "onRefreshError")
4713
- });
4714
4785
  const user = accounts.profile;
4715
4786
  const userRef = useRef6(user);
4716
4787
  const configRef = useRef6(config);
@@ -4727,7 +4798,23 @@ var AuthProviderInternal = /* @__PURE__ */ __name(({ children, config }) => {
4727
4798
  clearProfileCache();
4728
4799
  setInitialized(true);
4729
4800
  setIsLoading(false);
4730
- }, []);
4801
+ bumpAuthTick();
4802
+ }, [bumpAuthTick]);
4803
+ const handleProactiveRefreshError = useCallback12((error) => {
4804
+ authLogger.warn("Proactive token refresh failed \u2014 session is dead, redirecting to login:", error.message);
4805
+ if (onUnauthorizedRef.current) return;
4806
+ onUnauthorizedRef.current = true;
4807
+ clearAuthState("proactiveRefresh:failed");
4808
+ const authCallbackUrl = configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
4809
+ router.hardReplace(authCallbackUrl);
4810
+ }, [clearAuthState, router]);
4811
+ useTokenRefresh({
4812
+ enabled: true,
4813
+ onRefresh: /* @__PURE__ */ __name(() => {
4814
+ authLogger.info("Token auto-refreshed successfully");
4815
+ }, "onRefresh"),
4816
+ onRefreshError: handleProactiveRefreshError
4817
+ });
4731
4818
  const handleGlobalAuthError = useCallback12((error, context = "API Request") => {
4732
4819
  const isAuthError = error?.status === 401 || error?.statusCode === 401 || error?.code === "token_not_valid" || error?.code === "authentication_failed";
4733
4820
  if (isAuthError) {
@@ -4740,6 +4827,20 @@ var AuthProviderInternal = /* @__PURE__ */ __name(({ children, config }) => {
4740
4827
  }
4741
4828
  return false;
4742
4829
  }, [clearAuthState]);
4830
+ useEffect9(() => {
4831
+ const handler = /* @__PURE__ */ __name(() => {
4832
+ if (onUnauthorizedRef.current) return;
4833
+ onUnauthorizedRef.current = true;
4834
+ authLogger.warn("Unrecoverable 401 (refresh failed) \u2014 clearing session and redirecting to login");
4835
+ clearAuthState("onUnauthorized:401");
4836
+ const authCallbackUrl = configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
4837
+ router.hardReplace(authCallbackUrl);
4838
+ }, "handler");
4839
+ CfgAccountsApi.onUnauthorized(handler);
4840
+ return () => {
4841
+ CfgAccountsApi.onUnauthorized(null);
4842
+ };
4843
+ }, [clearAuthState, router]);
4743
4844
  const isAutoLoggingOutRef = useRef6(false);
4744
4845
  const swrOnError = useCallback12((error) => {
4745
4846
  const isAuthError = error?.status === 401 || error?.statusCode === 401 || error?.code === "token_not_valid" || error?.code === "authentication_failed";
@@ -4804,6 +4905,11 @@ var AuthProviderInternal = /* @__PURE__ */ __name(({ children, config }) => {
4804
4905
  authLogger.info("Token from API:", token ? `${token.substring(0, 20)}...` : "null");
4805
4906
  authLogger.info("Refresh token from API:", refreshToken2 ? `${refreshToken2.substring(0, 20)}...` : "null");
4806
4907
  authLogger.info("localStorage keys:", Object.keys(localStorage).filter((k) => k.includes("token") || k.includes("auth")));
4908
+ if (!isInIframe && isSessionDeadOnBootstrap(token, refreshToken2)) {
4909
+ authLogger.warn("Bootstrap: dead session in storage (access + refresh both unusable) \u2014 clearing and showing login");
4910
+ clearAuthState("initializeAuth:deadSession");
4911
+ return;
4912
+ }
4807
4913
  const hasTokens = hasValidTokens();
4808
4914
  authLogger.info("Has tokens:", hasTokens);
4809
4915
  if (userRef.current) {
@@ -5060,8 +5166,13 @@ var AuthProviderInternal = /* @__PURE__ */ __name(({ children, config }) => {
5060
5166
  () => ({
5061
5167
  user,
5062
5168
  isLoading,
5063
- // Consider authenticated if we have valid tokens, even without user profile
5064
- isAuthenticated: CfgAccountsApi.isAuthenticated(),
5169
+ // Authenticated iff the session is locally alive (access valid OR a usable
5170
+ // refresh token) — validated by `exp`, NOT by mere token presence. This is
5171
+ // what makes the guard self-sufficient: an expired session reads as
5172
+ // unauthenticated WITHOUT waiting for a server 401 (which CSP / a hung
5173
+ // request / offline can swallow). `authTick` (in deps) forces recompute
5174
+ // after tokens are cleared/set, since token state lives in storage.
5175
+ isAuthenticated: isSessionAlive(),
5065
5176
  isAdminUser,
5066
5177
  loadCurrentProfile,
5067
5178
  checkAuthAndRedirect,
@@ -5086,6 +5197,8 @@ var AuthProviderInternal = /* @__PURE__ */ __name(({ children, config }) => {
5086
5197
  [
5087
5198
  user,
5088
5199
  isLoading,
5200
+ authTick,
5201
+ // recompute isAuthenticated when tokens are cleared/set
5089
5202
  isAdminUser,
5090
5203
  loadCurrentProfile,
5091
5204
  checkAuthAndRedirect,
@@ -5208,9 +5321,18 @@ export {
5208
5321
  formatAuthError,
5209
5322
  getCacheMetadata,
5210
5323
  getCachedProfile,
5324
+ getTokenExpiry,
5211
5325
  hasValidCache,
5326
+ isAllowedAuthPath,
5327
+ isTokenExpired,
5328
+ isTokenExpiringSoon,
5212
5329
  logger,
5330
+ nextAuthEvaluationDelay,
5331
+ normalizePath,
5332
+ resolveGuardIsAuthenticated,
5333
+ resolveGuardIsLoading,
5213
5334
  setCachedProfile,
5335
+ shouldRedirectToAuth,
5214
5336
  useAccountsContext,
5215
5337
  useAuth,
5216
5338
  useAuthForm,