@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
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
|
|
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/
|
|
3806
|
-
|
|
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
|
|
3811
|
-
|
|
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
|
|
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 (
|
|
3820
|
-
return expiry -
|
|
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
|
-
//
|
|
5064
|
-
|
|
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,
|