@umituz/react-native-auth 4.2.15 → 4.2.17
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/package.json +1 -1
- package/src/infrastructure/services/AnonymousModeService.ts +14 -2
- package/src/infrastructure/services/AuthService.ts +25 -5
- package/src/infrastructure/utils/AuthValidation.ts +6 -3
- package/src/infrastructure/utils/authStateHandler.ts +15 -2
- package/src/infrastructure/utils/listener/authStateHandler.ts +15 -2
- package/src/infrastructure/utils/listener/listenerState.util.ts +10 -2
- package/src/infrastructure/utils/listener/setupListener.ts +2 -2
- package/src/infrastructure/utils/validation/sanitization.ts +10 -1
- package/src/presentation/components/LoginForm.tsx +1 -1
- package/src/presentation/components/RegisterForm.tsx +1 -1
- package/src/presentation/hooks/useLoginForm.ts +1 -1
- package/src/presentation/screens/PasswordPromptScreen.tsx +2 -1
- package/src/presentation/stores/authModalStore.ts +14 -2
- package/src/presentation/stores/authStore.ts +7 -1
- package/src/presentation/stores/initializeAuthListener.ts +4 -2
- package/src/types/auth-store.types.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-auth",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.17",
|
|
4
4
|
"description": "Authentication service for React Native apps - Secure, type-safe, and production-ready. Provider-agnostic design with dependency injection, configurable validation, and comprehensive error handling.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -21,6 +21,8 @@ export class AnonymousModeService {
|
|
|
21
21
|
this.isAnonymousMode = value === "true";
|
|
22
22
|
return this.isAnonymousMode;
|
|
23
23
|
} catch {
|
|
24
|
+
// On error, reset to false to maintain consistency
|
|
25
|
+
this.isAnonymousMode = false;
|
|
24
26
|
return false;
|
|
25
27
|
}
|
|
26
28
|
}
|
|
@@ -40,14 +42,24 @@ export class AnonymousModeService {
|
|
|
40
42
|
this.isAnonymousMode = false;
|
|
41
43
|
return true;
|
|
42
44
|
} catch {
|
|
43
|
-
|
|
45
|
+
// Don't update memory state if storage operation failed
|
|
46
|
+
// This maintains consistency between storage and memory
|
|
44
47
|
return false;
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
async enable(storageProvider: IStorageProvider): Promise<void> {
|
|
52
|
+
// Save to storage first, then update memory to maintain consistency
|
|
53
|
+
const previousState = this.isAnonymousMode;
|
|
49
54
|
this.isAnonymousMode = true;
|
|
50
|
-
await this.save(storageProvider);
|
|
55
|
+
const saveSuccess = await this.save(storageProvider);
|
|
56
|
+
|
|
57
|
+
if (!saveSuccess) {
|
|
58
|
+
// Rollback on failure
|
|
59
|
+
this.isAnonymousMode = previousState;
|
|
60
|
+
throw new Error('Failed to save anonymous mode state');
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
emitAnonymousModeEnabled();
|
|
52
64
|
}
|
|
53
65
|
|
|
@@ -16,6 +16,7 @@ export class AuthService {
|
|
|
16
16
|
private anonymousModeService: AnonymousModeService;
|
|
17
17
|
private storageProvider?: IStorageProvider;
|
|
18
18
|
private initialized: boolean = false;
|
|
19
|
+
private initializationPromise: Promise<void> | null = null;
|
|
19
20
|
private config: AuthConfig;
|
|
20
21
|
|
|
21
22
|
constructor(config: Partial<AuthConfig> = {}, storageProvider?: IStorageProvider) {
|
|
@@ -30,14 +31,28 @@ export class AuthService {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
async initialize(): Promise<void> {
|
|
34
|
+
// Return existing promise if initialization is in progress
|
|
35
|
+
if (this.initializationPromise) {
|
|
36
|
+
return this.initializationPromise;
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
if (this.initialized) return;
|
|
34
40
|
|
|
35
|
-
|
|
41
|
+
// Create and store initialization promise to prevent concurrent initialization
|
|
42
|
+
this.initializationPromise = (async () => {
|
|
43
|
+
this.repository = new AuthRepository(this.config);
|
|
44
|
+
|
|
45
|
+
if (this.storageProvider) {
|
|
46
|
+
await this.anonymousModeService.load(this.storageProvider);
|
|
47
|
+
}
|
|
48
|
+
this.initialized = true;
|
|
49
|
+
})();
|
|
36
50
|
|
|
37
|
-
|
|
38
|
-
await this.
|
|
51
|
+
try {
|
|
52
|
+
await this.initializationPromise;
|
|
53
|
+
} finally {
|
|
54
|
+
this.initializationPromise = null;
|
|
39
55
|
}
|
|
40
|
-
this.initialized = true;
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
isInitialized(): boolean {
|
|
@@ -65,7 +80,12 @@ export class AuthService {
|
|
|
65
80
|
|
|
66
81
|
private async clearAnonymousModeIfNeeded(): Promise<void> {
|
|
67
82
|
if (this.anonymousModeService.getIsAnonymousMode() && this.storageProvider) {
|
|
68
|
-
await this.anonymousModeService.clear(this.storageProvider);
|
|
83
|
+
const success = await this.anonymousModeService.clear(this.storageProvider);
|
|
84
|
+
if (!success) {
|
|
85
|
+
console.warn('[AuthService] Failed to clear anonymous mode from storage');
|
|
86
|
+
// Force clear in memory to maintain consistency
|
|
87
|
+
this.anonymousModeService.setAnonymousMode(false);
|
|
88
|
+
}
|
|
69
89
|
}
|
|
70
90
|
}
|
|
71
91
|
|
|
@@ -34,7 +34,8 @@ export function validateEmail(
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function validatePasswordForLogin(password: string): ValidationResult {
|
|
37
|
-
|
|
37
|
+
// Don't trim passwords - whitespace may be intentional
|
|
38
|
+
if (!password || password.length === 0) return { isValid: false, error: "auth.validation.passwordRequired" };
|
|
38
39
|
return { isValid: true };
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -42,7 +43,8 @@ export function validatePasswordForRegister(
|
|
|
42
43
|
password: string,
|
|
43
44
|
config: PasswordConfig,
|
|
44
45
|
): PasswordStrengthResult {
|
|
45
|
-
|
|
46
|
+
// Don't trim passwords - whitespace may be intentional
|
|
47
|
+
if (!password || password.length === 0) {
|
|
46
48
|
return { isValid: false, error: "auth.validation.passwordRequired", requirements: { hasMinLength: false } };
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -56,7 +58,8 @@ export function validatePasswordForRegister(
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export function validatePasswordConfirmation(password: string, confirm: string): ValidationResult {
|
|
59
|
-
|
|
61
|
+
// Don't trim passwords - whitespace may be intentional
|
|
62
|
+
if (!confirm || confirm.length === 0) return { isValid: false, error: "auth.validation.confirmPasswordRequired" };
|
|
60
63
|
if (password !== confirm) return { isValid: false, error: "auth.validation.passwordsDoNotMatch" };
|
|
61
64
|
return { isValid: true };
|
|
62
65
|
}
|
|
@@ -45,13 +45,26 @@ export function createAuthStateHandler(
|
|
|
45
45
|
? { previousAnonymousUserId: state.current.previousUserId }
|
|
46
46
|
: undefined;
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
try {
|
|
49
|
+
await ensureUserDocument(user, extras);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('[AuthStateHandler] Failed to ensure user document:', error);
|
|
52
|
+
// Continue execution - don't let user document creation failure block auth flow
|
|
53
|
+
}
|
|
49
54
|
|
|
50
55
|
state.current = {
|
|
51
56
|
previousUserId: currentUserId,
|
|
52
57
|
wasAnonymous: isCurrentlyAnonymous,
|
|
53
58
|
};
|
|
54
59
|
|
|
55
|
-
|
|
60
|
+
// Call user callback with error handling
|
|
61
|
+
if (onAuthStateChange) {
|
|
62
|
+
try {
|
|
63
|
+
await onAuthStateChange(user);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error('[AuthStateHandler] User callback error:', error);
|
|
66
|
+
// Don't propagate user callback errors
|
|
67
|
+
}
|
|
68
|
+
}
|
|
56
69
|
};
|
|
57
70
|
}
|
|
@@ -18,7 +18,7 @@ export function handleAuthStateChange(
|
|
|
18
18
|
store: Store,
|
|
19
19
|
auth: Auth,
|
|
20
20
|
autoAnonymousSignIn: boolean,
|
|
21
|
-
onAuthStateChange?: (user: User | null) => void
|
|
21
|
+
onAuthStateChange?: (user: User | null) => void | Promise<void>
|
|
22
22
|
): void {
|
|
23
23
|
try {
|
|
24
24
|
if (!user && autoAnonymousSignIn) {
|
|
@@ -37,7 +37,20 @@ export function handleAuthStateChange(
|
|
|
37
37
|
store.setIsAnonymous(false);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// Call user callback with proper error handling for async callbacks
|
|
41
|
+
if (onAuthStateChange) {
|
|
42
|
+
try {
|
|
43
|
+
const result = onAuthStateChange(user);
|
|
44
|
+
// If callback returns a promise, catch rejections
|
|
45
|
+
if (result && typeof result.then === 'function') {
|
|
46
|
+
result.catch((error) => {
|
|
47
|
+
console.error("[AuthListener] User callback promise rejected:", error);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error("[AuthListener] User callback error:", error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
41
54
|
} catch (error) {
|
|
42
55
|
console.error("[AuthListener] Error handling auth state change:", error);
|
|
43
56
|
// Ensure we don't leave the app in a bad state
|
|
@@ -95,7 +95,11 @@ export function incrementRefCount(): number {
|
|
|
95
95
|
* Uses cleanupInProgress flag to prevent concurrent cleanup attempts
|
|
96
96
|
*/
|
|
97
97
|
export function decrementRefCount(): { shouldCleanup: boolean; count: number } {
|
|
98
|
-
|
|
98
|
+
// Prevent refCount from going negative
|
|
99
|
+
if (state.refCount > 0) {
|
|
100
|
+
state.refCount--;
|
|
101
|
+
}
|
|
102
|
+
|
|
99
103
|
const shouldCleanup =
|
|
100
104
|
state.refCount <= 0 &&
|
|
101
105
|
state.unsubscribe !== null &&
|
|
@@ -132,7 +136,11 @@ export function completeAnonymousSignIn(): void {
|
|
|
132
136
|
*/
|
|
133
137
|
export function resetListenerState(): void {
|
|
134
138
|
if (state.unsubscribe) {
|
|
135
|
-
|
|
139
|
+
try {
|
|
140
|
+
state.unsubscribe();
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error('[ListenerState] Error during unsubscribe:', error);
|
|
143
|
+
}
|
|
136
144
|
}
|
|
137
145
|
state.initialized = false;
|
|
138
146
|
state.refCount = 0;
|
|
@@ -19,7 +19,7 @@ export function setupAuthListener(
|
|
|
19
19
|
auth: Auth,
|
|
20
20
|
store: Store,
|
|
21
21
|
autoAnonymousSignIn: boolean,
|
|
22
|
-
onAuthStateChange?: (user: User | null) => void
|
|
22
|
+
onAuthStateChange?: (user: User | null) => void | Promise<void>
|
|
23
23
|
): void {
|
|
24
24
|
const service = getAuthService();
|
|
25
25
|
|
|
@@ -58,6 +58,6 @@ export function setupAuthListener(
|
|
|
58
58
|
store.setLoading(false);
|
|
59
59
|
store.setInitialized(true);
|
|
60
60
|
store.setError("Failed to initialize authentication listener");
|
|
61
|
-
throw
|
|
61
|
+
// Don't re-throw - app state is already cleaned up and consistent
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -14,31 +14,39 @@ export const SECURITY_LIMITS = {
|
|
|
14
14
|
export type SecurityLimitKey = keyof typeof SECURITY_LIMITS;
|
|
15
15
|
|
|
16
16
|
export const sanitizeWhitespace = (input: string): string => {
|
|
17
|
+
if (!input) return '';
|
|
17
18
|
return input.trim().replace(/\s+/g, ' ');
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export const sanitizeEmail = (email: string): string => {
|
|
22
|
+
if (!email) return '';
|
|
21
23
|
const trimmed = email.trim().toLowerCase();
|
|
22
24
|
return trimmed.substring(0, SECURITY_LIMITS.EMAIL_MAX_LENGTH);
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
export const sanitizePassword = (password: string): string => {
|
|
26
|
-
|
|
28
|
+
// Don't trim passwords - leading/trailing whitespace may be intentional
|
|
29
|
+
// Only enforce max length for security
|
|
30
|
+
if (!password) return '';
|
|
31
|
+
return password.substring(0, SECURITY_LIMITS.PASSWORD_MAX_LENGTH);
|
|
27
32
|
};
|
|
28
33
|
|
|
29
34
|
export const sanitizeName = (name: string): string => {
|
|
35
|
+
if (!name) return '';
|
|
30
36
|
const trimmed = sanitizeWhitespace(name);
|
|
31
37
|
const noTags = trimmed.replace(/<[^>]*>/g, '');
|
|
32
38
|
return noTags.substring(0, SECURITY_LIMITS.NAME_MAX_LENGTH);
|
|
33
39
|
};
|
|
34
40
|
|
|
35
41
|
export const sanitizeText = (text: string): string => {
|
|
42
|
+
if (!text) return '';
|
|
36
43
|
const trimmed = sanitizeWhitespace(text);
|
|
37
44
|
const noTags = trimmed.replace(/<[^>]*>/g, '');
|
|
38
45
|
return noTags.substring(0, SECURITY_LIMITS.GENERAL_TEXT_MAX_LENGTH);
|
|
39
46
|
};
|
|
40
47
|
|
|
41
48
|
export const containsDangerousChars = (input: string): boolean => {
|
|
49
|
+
if (!input) return false;
|
|
42
50
|
const dangerousPatterns = [
|
|
43
51
|
/<script/i,
|
|
44
52
|
/javascript:/i,
|
|
@@ -54,6 +62,7 @@ export const isWithinLengthLimit = (
|
|
|
54
62
|
maxLength: number,
|
|
55
63
|
minLength = 0
|
|
56
64
|
): boolean => {
|
|
65
|
+
if (!input) return minLength === 0;
|
|
57
66
|
const length = input.trim().length;
|
|
58
67
|
return length >= minLength && length <= maxLength;
|
|
59
68
|
};
|
|
@@ -68,7 +68,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({
|
|
|
68
68
|
<AtomicButton
|
|
69
69
|
variant="primary"
|
|
70
70
|
onPress={() => { void handleSignIn(); }}
|
|
71
|
-
disabled={loading || !email.trim() || !password
|
|
71
|
+
disabled={loading || !email.trim() || !password}
|
|
72
72
|
fullWidth
|
|
73
73
|
style={styles.signInButton}
|
|
74
74
|
>
|
|
@@ -128,7 +128,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|
|
128
128
|
<AtomicButton
|
|
129
129
|
variant="primary"
|
|
130
130
|
onPress={() => { void handleSignUp(); }}
|
|
131
|
-
disabled={loading || !email.trim() || !password
|
|
131
|
+
disabled={loading || !email.trim() || !password || !confirmPassword}
|
|
132
132
|
fullWidth
|
|
133
133
|
style={styles.signUpButton}
|
|
134
134
|
>
|
|
@@ -106,7 +106,7 @@ export function useLoginForm(config?: UseLoginFormConfig): UseLoginFormResult {
|
|
|
106
106
|
const localizationKey = getAuthErrorLocalizationKey(err);
|
|
107
107
|
setLocalError(getErrorMessage(localizationKey));
|
|
108
108
|
}
|
|
109
|
-
}, [fields, signIn, translations, getErrorMessage, clearErrors
|
|
109
|
+
}, [fields, signIn, translations, getErrorMessage, clearErrors]);
|
|
110
110
|
|
|
111
111
|
const handleContinueAnonymously = useCallback(async () => {
|
|
112
112
|
try {
|
|
@@ -49,7 +49,8 @@ export const PasswordPromptScreen: React.FC<PasswordPromptScreenProps> = ({
|
|
|
49
49
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
50
50
|
console.log("[PasswordPromptScreen] handleConfirm called, password length:", password.length);
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
// Don't trim password - whitespace may be intentional
|
|
53
|
+
if (!password) {
|
|
53
54
|
setError('Password is required');
|
|
54
55
|
return;
|
|
55
56
|
}
|
|
@@ -61,7 +61,8 @@ export const useAuthModalStore = createStore<AuthModalState, AuthModalActions>({
|
|
|
61
61
|
},
|
|
62
62
|
|
|
63
63
|
hideAuthModal: () => {
|
|
64
|
-
|
|
64
|
+
// Clear pending callback to prevent memory leaks
|
|
65
|
+
set({ isVisible: false, pendingCallback: null });
|
|
65
66
|
},
|
|
66
67
|
|
|
67
68
|
setMode: (mode: AuthModalMode) => {
|
|
@@ -71,7 +72,18 @@ export const useAuthModalStore = createStore<AuthModalState, AuthModalActions>({
|
|
|
71
72
|
executePendingCallback: () => {
|
|
72
73
|
const state = get();
|
|
73
74
|
if (state.pendingCallback) {
|
|
74
|
-
|
|
75
|
+
// Wrap in try-catch to handle promise rejections
|
|
76
|
+
try {
|
|
77
|
+
const result = state.pendingCallback();
|
|
78
|
+
// If it's a promise, catch rejections
|
|
79
|
+
if (result && typeof result.then === 'function') {
|
|
80
|
+
result.catch((error) => {
|
|
81
|
+
console.error('[AuthModalStore] Pending callback error:', error);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error('[AuthModalStore] Pending callback error:', error);
|
|
86
|
+
}
|
|
75
87
|
set({ pendingCallback: null });
|
|
76
88
|
}
|
|
77
89
|
},
|
|
@@ -48,7 +48,13 @@ export const useAuthStore = createStore<AuthState, AuthActions>({
|
|
|
48
48
|
initialized: state.initialized ?? false,
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
-
|
|
51
|
+
// Only restore persisted fields (isAnonymous, initialized)
|
|
52
|
+
// Never restore runtime state (user, firebaseUser, loading, error)
|
|
53
|
+
return {
|
|
54
|
+
...initialAuthState,
|
|
55
|
+
isAnonymous: state.isAnonymous ?? false,
|
|
56
|
+
initialized: state.initialized ?? false,
|
|
57
|
+
};
|
|
52
58
|
},
|
|
53
59
|
actions: (set, get) => ({
|
|
54
60
|
setFirebaseUser: (firebaseUser) => {
|
|
@@ -34,14 +34,16 @@ export function initializeAuthListener(
|
|
|
34
34
|
if (!startInitialization()) {
|
|
35
35
|
// Either already initializing or initialized - handle accordingly
|
|
36
36
|
if (isListenerInitialized()) {
|
|
37
|
-
|
|
37
|
+
const unsubscribe = handleExistingInitialization();
|
|
38
|
+
return unsubscribe || handleInitializationInProgress();
|
|
38
39
|
}
|
|
39
40
|
return handleInitializationInProgress();
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
// If already initialized, increment ref count and return unsubscribe
|
|
43
44
|
if (isListenerInitialized()) {
|
|
44
|
-
|
|
45
|
+
const unsubscribe = handleExistingInitialization();
|
|
46
|
+
return unsubscribe || (() => {});
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
const auth = getFirebaseAuth();
|
|
@@ -65,6 +65,6 @@ export const initialAuthState: AuthState = {
|
|
|
65
65
|
export interface AuthListenerOptions {
|
|
66
66
|
/** Enable auto anonymous sign-in when no user is logged in */
|
|
67
67
|
autoAnonymousSignIn?: boolean;
|
|
68
|
-
/** Callback when auth state changes */
|
|
69
|
-
onAuthStateChange?: (user: User | null) => void
|
|
68
|
+
/** Callback when auth state changes (can be async) */
|
|
69
|
+
onAuthStateChange?: (user: User | null) => void | Promise<void>;
|
|
70
70
|
}
|