ipulse-shared-core-ftredge 20.0.1__py3-none-any.whl → 23.1.1__py3-none-any.whl

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.

Potentially problematic release.


This version of ipulse-shared-core-ftredge might be problematic. Click here for more details.

Files changed (48) hide show
  1. ipulse_shared_core_ftredge/cache/shared_cache.py +1 -2
  2. ipulse_shared_core_ftredge/dependencies/auth_firebase_token_validation.py +60 -23
  3. ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +128 -157
  4. ipulse_shared_core_ftredge/exceptions/base_exceptions.py +35 -4
  5. ipulse_shared_core_ftredge/models/__init__.py +3 -7
  6. ipulse_shared_core_ftredge/models/base_data_model.py +17 -19
  7. ipulse_shared_core_ftredge/models/catalog/__init__.py +10 -0
  8. ipulse_shared_core_ftredge/models/catalog/subscriptionplan.py +274 -0
  9. ipulse_shared_core_ftredge/models/catalog/usertype.py +177 -0
  10. ipulse_shared_core_ftredge/models/user/__init__.py +5 -0
  11. ipulse_shared_core_ftredge/models/user/user_permissions.py +66 -0
  12. ipulse_shared_core_ftredge/models/user/user_subscription.py +348 -0
  13. ipulse_shared_core_ftredge/models/{user_auth.py → user/userauth.py} +19 -10
  14. ipulse_shared_core_ftredge/models/{user_profile.py → user/userprofile.py} +53 -21
  15. ipulse_shared_core_ftredge/models/user/userstatus.py +479 -0
  16. ipulse_shared_core_ftredge/monitoring/__init__.py +0 -2
  17. ipulse_shared_core_ftredge/monitoring/tracemon.py +6 -6
  18. ipulse_shared_core_ftredge/services/__init__.py +11 -13
  19. ipulse_shared_core_ftredge/services/base/__init__.py +3 -1
  20. ipulse_shared_core_ftredge/services/base/base_firestore_service.py +77 -16
  21. ipulse_shared_core_ftredge/services/{cache_aware_firestore_service.py → base/cache_aware_firestore_service.py} +46 -32
  22. ipulse_shared_core_ftredge/services/catalog/__init__.py +14 -0
  23. ipulse_shared_core_ftredge/services/catalog/catalog_subscriptionplan_service.py +277 -0
  24. ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +376 -0
  25. ipulse_shared_core_ftredge/services/charging_processors.py +25 -25
  26. ipulse_shared_core_ftredge/services/user/__init__.py +5 -25
  27. ipulse_shared_core_ftredge/services/user/user_core_service.py +536 -510
  28. ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +796 -0
  29. ipulse_shared_core_ftredge/services/user/user_permissions_operations.py +392 -0
  30. ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +488 -0
  31. ipulse_shared_core_ftredge/services/user/userauth_operations.py +928 -0
  32. ipulse_shared_core_ftredge/services/user/userprofile_operations.py +166 -0
  33. ipulse_shared_core_ftredge/services/user/userstatus_operations.py +476 -0
  34. ipulse_shared_core_ftredge/services/{charging_service.py → user_charging_service.py} +9 -9
  35. {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/METADATA +3 -4
  36. ipulse_shared_core_ftredge-23.1.1.dist-info/RECORD +50 -0
  37. ipulse_shared_core_ftredge/models/subscription.py +0 -190
  38. ipulse_shared_core_ftredge/models/user_status.py +0 -495
  39. ipulse_shared_core_ftredge/monitoring/microservmon.py +0 -526
  40. ipulse_shared_core_ftredge/services/user/iam_management_operations.py +0 -326
  41. ipulse_shared_core_ftredge/services/user/subscription_management_operations.py +0 -384
  42. ipulse_shared_core_ftredge/services/user/user_account_operations.py +0 -479
  43. ipulse_shared_core_ftredge/services/user/user_auth_operations.py +0 -305
  44. ipulse_shared_core_ftredge/services/user/user_holistic_operations.py +0 -436
  45. ipulse_shared_core_ftredge-20.0.1.dist-info/RECORD +0 -42
  46. {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/WHEEL +0 -0
  47. {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/licenses/LICENCE +0 -0
  48. {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,928 @@
1
+ """
2
+ User Auth Operations - Handle Firebase Auth user creation, management, and deletion
3
+ """
4
+ import os
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from typing import Dict, Any, Optional, Union
9
+ from firebase_admin import auth
10
+ from google.cloud import firestore
11
+ from ipulse_shared_base_ftredge.enums import ApprovalStatus
12
+ from ...models import UserAuth
13
+ from ...exceptions import UserAuthError
14
+ from ..base import BaseFirestoreService
15
+
16
+
17
+ class UserauthOperations:
18
+ """
19
+ Handles Firebase Auth operations for user creation, management, and deletion
20
+ """
21
+
22
+ def __init__(self, logger: Optional[logging.Logger] = None, firestore_client: Optional[firestore.Client] = None):
23
+ self.logger = logger or logging.getLogger(__name__)
24
+ self.db = firestore_client
25
+
26
+ # Archival configuration
27
+ self.archive_auth_on_delete = os.getenv('ARCHIVE_AUTH_ON_DELETE', 'true').lower() == 'true'
28
+ self._archive_auth_collection_name = os.getenv(
29
+ 'ARCHIVE_AUTH_COLLECTION_NAME',
30
+ "~archive_core_user_userauths"
31
+ )
32
+
33
+ if self.archive_auth_on_delete and self.db:
34
+ # Use a generic archive service without specific typing for archived documents
35
+ self._archive_db_service = BaseFirestoreService(
36
+ db=self.db,
37
+ collection_name=self._archive_auth_collection_name,
38
+ resource_type="userauth",
39
+ logger=self.logger
40
+ )
41
+
42
+ @property
43
+ def archive_auth_collection_name(self) -> str:
44
+ """Get the archive auth collection name"""
45
+ return self._archive_auth_collection_name
46
+
47
+ # User Auth Operations
48
+
49
+ async def create_userauth(
50
+ self,
51
+ user_auth: UserAuth
52
+ ) -> str:
53
+ """Creates a new Firebase Auth user and returns the UID."""
54
+ self.logger.info(f"Creating Firebase Auth user for email: {user_auth.email}")
55
+ try:
56
+ # Create user synchronously
57
+ loop = asyncio.get_event_loop()
58
+ user_record = await loop.run_in_executor(
59
+ None,
60
+ lambda: auth.create_user(
61
+ email=user_auth.email,
62
+ email_verified=user_auth.email_verified,
63
+ password=user_auth.password,
64
+ phone_number=user_auth.phone_number,
65
+ display_name=user_auth.display_name,
66
+ disabled=user_auth.disabled
67
+ )
68
+ )
69
+
70
+ user_uid = user_record.uid
71
+ self.logger.info(f"Successfully created Firebase Auth user with UID: {user_uid}")
72
+
73
+ # Set custom claims if provided
74
+ if user_auth.custom_claims:
75
+ await self.set_userauth_custom_claims(user_uid, user_auth.custom_claims)
76
+
77
+ return user_uid
78
+
79
+ except auth.EmailAlreadyExistsError as e:
80
+ raise UserAuthError(
81
+ detail=f"User with email {user_auth.email} already exists",
82
+ operation="create_userauth",
83
+ additional_info={"email": str(user_auth.email)}
84
+ ) from e
85
+ except Exception as e:
86
+ self.logger.error(f"Failed to create Firebase Auth user: {e}", exc_info=True)
87
+ raise UserAuthError(
88
+ detail=f"Failed to create Firebase Auth user: {str(e)}",
89
+ operation="create_userauth",
90
+ additional_info={"email": str(user_auth.email)},
91
+ original_error=e
92
+ ) from e
93
+
94
+ # Firebase Auth User Management
95
+
96
+ async def get_userauth(self, user_uid: str, get_model: bool = False) -> Optional[Union[auth.UserRecord, UserAuth]]:
97
+ """
98
+ Retrieves a Firebase Auth user by UID.
99
+
100
+ Args:
101
+ user_uid: The UID of the user to retrieve
102
+ get_model: If True, returns a UserAuth model with data and custom claims merged
103
+
104
+ Returns:
105
+ Firebase Auth UserRecord if get_model=False, UserAuth model if get_model=True, or None if not found
106
+ """
107
+ try:
108
+ loop = asyncio.get_event_loop()
109
+ user_record = await loop.run_in_executor(
110
+ None,
111
+ auth.get_user,
112
+ user_uid
113
+ )
114
+
115
+ if get_model:
116
+ return self._create_userauth_model(user_record)
117
+
118
+ return user_record
119
+ except auth.UserNotFoundError:
120
+ self.logger.warning(f"Firebase Auth user with UID '{user_uid}' not found.")
121
+ return None
122
+ except Exception as e:
123
+ self.logger.error(f"Error retrieving Firebase Auth user {user_uid}: {e}", exc_info=True)
124
+ raise UserAuthError(
125
+ detail=f"Failed to retrieve Firebase Auth user: {str(e)}",
126
+ user_uid=user_uid,
127
+ operation="get_userauth",
128
+ original_error=e
129
+ ) from e
130
+
131
+ async def get_userauth_by_email(self, email: str, get_model: bool = False) -> Optional[Union[auth.UserRecord, UserAuth]]:
132
+ """
133
+ Retrieves a Firebase Auth user by email.
134
+
135
+ Args:
136
+ email: The email of the user to retrieve
137
+ get_model: If True, returns a UserAuth model with data and custom claims merged
138
+
139
+ Returns:
140
+ Firebase Auth UserRecord if get_model=False, UserAuth model if get_model=True, or None if not found
141
+ """
142
+ try:
143
+ loop = asyncio.get_event_loop()
144
+ user_record = await loop.run_in_executor(
145
+ None,
146
+ auth.get_user_by_email,
147
+ email
148
+ )
149
+
150
+ if get_model:
151
+ return self._create_userauth_model(user_record)
152
+
153
+ return user_record
154
+ except auth.UserNotFoundError:
155
+ self.logger.warning(f"Firebase Auth user with email '{email}' not found.")
156
+ return None
157
+ except Exception as e:
158
+ self.logger.error(f"Error retrieving Firebase Auth user by email {email}: {e}", exc_info=True)
159
+ raise UserAuthError(
160
+ detail=f"Failed to retrieve Firebase Auth user by email: {str(e)}",
161
+ operation="get_userauth_by_email",
162
+ original_error=e
163
+ ) from e
164
+
165
+ def _create_userauth_model(self, user_record: auth.UserRecord) -> UserAuth:
166
+ """
167
+ Creates a UserAuth model from a Firebase Auth UserRecord with merged custom claims data.
168
+
169
+ Args:
170
+ user_record: Firebase Auth UserRecord
171
+
172
+ Returns:
173
+ UserAuth model with data and custom claims merged
174
+ """
175
+ from datetime import datetime
176
+
177
+ # Convert Firebase timestamps to datetime objects
178
+ def convert_timestamp(timestamp_str):
179
+ if timestamp_str:
180
+ # Firebase timestamps are in RFC3339 format
181
+ try:
182
+ return datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
183
+ except (ValueError, AttributeError):
184
+ return None
185
+ return None
186
+
187
+ # Extract custom claims
188
+ custom_claims = user_record.custom_claims or {}
189
+
190
+ # Build the UserAuth model
191
+ userauth_data = {
192
+ "email": user_record.email,
193
+ "display_name": user_record.display_name,
194
+ "firebase_uid": user_record.uid,
195
+ "email_verified": user_record.email_verified,
196
+ "disabled": user_record.disabled,
197
+ "phone_number": user_record.phone_number,
198
+ "custom_claims": custom_claims,
199
+ "created_at": convert_timestamp(user_record.user_metadata.creation_timestamp) if user_record.user_metadata else None,
200
+ "last_sign_in": convert_timestamp(user_record.user_metadata.last_sign_in_timestamp) if user_record.user_metadata else None,
201
+ "last_refresh": convert_timestamp(user_record.user_metadata.last_refresh_timestamp) if user_record.user_metadata else None,
202
+ "valid_since": convert_timestamp(user_record.tokens_valid_after_timestamp) if hasattr(user_record, 'tokens_valid_after_timestamp') else None,
203
+ "provider_data": [
204
+ {
205
+ "uid": provider.uid,
206
+ "email": provider.email,
207
+ "display_name": provider.display_name,
208
+ "phone_number": provider.phone_number,
209
+ "provider_id": provider.provider_id
210
+ }
211
+ for provider in (user_record.provider_data or [])
212
+ ]
213
+ }
214
+
215
+ return UserAuth(**userauth_data)
216
+
217
+ async def update_userauth(
218
+ self,
219
+ user_uid: str,
220
+ email: Optional[str] = None,
221
+ password: Optional[str] = None,
222
+ display_name: Optional[str] = None,
223
+ phone_number: Optional[str] = None,
224
+ email_verified: Optional[bool] = None,
225
+ disabled: Optional[bool] = None
226
+ ) -> auth.UserRecord:
227
+ """Updates a Firebase Auth user."""
228
+ self.logger.info(f"Updating Firebase Auth user: {user_uid}")
229
+ try:
230
+ loop = asyncio.get_event_loop()
231
+ user_record = await loop.run_in_executor(
232
+ None,
233
+ lambda: auth.update_user(
234
+ uid=user_uid,
235
+ email=email,
236
+ password=password,
237
+ display_name=display_name,
238
+ phone_number=phone_number,
239
+ email_verified=email_verified,
240
+ disabled=disabled
241
+ )
242
+ )
243
+
244
+ self.logger.info(f"Successfully updated Firebase Auth user: {user_uid}")
245
+ return user_record
246
+
247
+ except auth.UserNotFoundError as e:
248
+ raise UserAuthError(
249
+ detail="Firebase Auth user not found",
250
+ user_uid=user_uid,
251
+ operation="update_userauth"
252
+ ) from e
253
+ except Exception as e:
254
+ self.logger.error(f"Failed to update Firebase Auth user {user_uid}: {e}", exc_info=True)
255
+ raise UserAuthError(
256
+ detail=f"Failed to update Firebase Auth user: {str(e)}",
257
+ user_uid=user_uid,
258
+ operation="update_userauth",
259
+ original_error=e
260
+ ) from e
261
+
262
+ # Firebase Auth Custom Claims
263
+
264
+ async def set_userauth_custom_claims(
265
+ self,
266
+ user_uid: str,
267
+ custom_claims: Dict[str, Any],
268
+ merge_with_existing: bool = False
269
+ ) -> bool:
270
+ """Sets custom claims for a Firebase Auth user with optional merging"""
271
+ try:
272
+ if merge_with_existing:
273
+ # Get existing claims and merge
274
+ user_record = await self.get_userauth(user_uid)
275
+ if user_record and user_record.custom_claims:
276
+ existing_claims = user_record.custom_claims.copy()
277
+ existing_claims.update(custom_claims)
278
+ custom_claims = existing_claims
279
+
280
+ loop = asyncio.get_event_loop()
281
+ await loop.run_in_executor(
282
+ None,
283
+ auth.set_custom_user_claims,
284
+ user_uid,
285
+ custom_claims
286
+ )
287
+
288
+ self.logger.info(f"Successfully set Firebase custom claims for user: {user_uid}")
289
+ return True
290
+
291
+ except auth.UserNotFoundError as e:
292
+ raise UserAuthError(
293
+ detail="Firebase Auth user not found",
294
+ user_uid=user_uid,
295
+ operation="set_userauth_custom_claims"
296
+ ) from e
297
+ except Exception as e:
298
+ self.logger.error(f"Failed to set Firebase custom claims for user {user_uid}: {e}", exc_info=True)
299
+ raise UserAuthError(
300
+ detail=f"Failed to set Firebase custom claims: {str(e)}",
301
+ user_uid=user_uid,
302
+ operation="set_userauth_custom_claims",
303
+ original_error=e
304
+ ) from e
305
+
306
+ async def get_userauth_custom_claims(self, user_uid: str) -> Optional[Dict[str, Any]]:
307
+ """Retrieves custom claims for a Firebase Auth user"""
308
+ try:
309
+ user_record = await self.get_userauth(user_uid)
310
+ return user_record.custom_claims if user_record else None
311
+ except Exception as e:
312
+ self.logger.error(f"Failed to get Firebase custom claims for user {user_uid}: {e}", exc_info=True)
313
+ raise UserAuthError(
314
+ detail=f"Failed to get Firebase custom claims: {str(e)}",
315
+ user_uid=user_uid,
316
+ operation="get_userauth_custom_claims",
317
+ original_error=e
318
+ ) from e
319
+
320
+ # Firebase Auth User Deletion
321
+
322
+ async def delete_userauth(self, user_uid: str, archive: bool = True) -> bool:
323
+ """Deletes a Firebase Auth user with optional archival."""
324
+ if archive and self.archive_auth_on_delete and hasattr(self, '_archive_db_service'):
325
+ try:
326
+ user_record = await self.get_userauth(user_uid)
327
+ if user_record:
328
+ await self._archive_db_service.archive_document(
329
+ document_data=user_record.__dict__,
330
+ doc_id=f"userauth_{user_uid}",
331
+ archive_collection=self._archive_auth_collection_name,
332
+ archived_by="system_deletion"
333
+ )
334
+ except Exception as e:
335
+ self.logger.error(f"Failed to archive Firebase Auth user {user_uid}: {e}", exc_info=True)
336
+ # Do not re-raise, as we want to proceed with deletion anyway
337
+
338
+ try:
339
+ loop = asyncio.get_event_loop()
340
+ await loop.run_in_executor(
341
+ None,
342
+ auth.delete_user,
343
+ user_uid
344
+ )
345
+
346
+ self.logger.info(f"Successfully deleted Firebase Auth user: {user_uid}")
347
+ return True
348
+
349
+ except auth.UserNotFoundError:
350
+ self.logger.warning(f"Firebase Auth user {user_uid} not found during deletion")
351
+ return True # Consider non-existent user as successfully deleted
352
+ except Exception as e:
353
+ self.logger.error(f"Failed to delete Firebase Auth user {user_uid}: {e}", exc_info=True)
354
+ raise UserAuthError(
355
+ detail=f"Failed to delete Firebase Auth user: {str(e)}",
356
+ user_uid=user_uid,
357
+ operation="delete_userauth",
358
+ original_error=e
359
+ ) from e
360
+
361
+ # Token and Security Operations
362
+
363
+ async def create_custom_token(
364
+ self,
365
+ user_uid: str,
366
+ additional_claims: Optional[Dict[str, Any]] = None
367
+ ) -> str:
368
+ """Creates a custom token for a user with optional additional claims"""
369
+ try:
370
+ loop = asyncio.get_event_loop()
371
+ token = await loop.run_in_executor(
372
+ None,
373
+ lambda: auth.create_custom_token(user_uid, additional_claims)
374
+ )
375
+
376
+ self.logger.info(f"Successfully created custom token for user: {user_uid}")
377
+ return token.decode('utf-8') if isinstance(token, bytes) else token
378
+
379
+ except auth.UserNotFoundError as e:
380
+ raise UserAuthError(
381
+ detail="Firebase Auth user not found",
382
+ user_uid=user_uid,
383
+ operation="create_custom_token"
384
+ ) from e
385
+ except Exception as e:
386
+ self.logger.error(f"Failed to create custom token for user {user_uid}: {e}", exc_info=True)
387
+ raise UserAuthError(
388
+ detail=f"Failed to create custom token: {str(e)}",
389
+ user_uid=user_uid,
390
+ operation="create_custom_token",
391
+ original_error=e
392
+ ) from e
393
+
394
+ async def verify_id_token(
395
+ self,
396
+ token: str,
397
+ check_revoked: bool = False
398
+ ) -> Dict[str, Any]:
399
+ """Verifies an ID token and returns the token claims"""
400
+ try:
401
+ loop = asyncio.get_event_loop()
402
+ claims = await loop.run_in_executor(
403
+ None,
404
+ lambda: auth.verify_id_token(token, check_revoked=check_revoked)
405
+ )
406
+
407
+ self.logger.info(f"Successfully verified ID token for user: {claims.get('uid')}")
408
+ return claims
409
+
410
+ except auth.ExpiredIdTokenError as e:
411
+ raise UserAuthError(
412
+ detail="ID token has expired",
413
+ operation="verify_id_token",
414
+ additional_info={"check_revoked": check_revoked}
415
+ ) from e
416
+ except auth.RevokedIdTokenError as e:
417
+ raise UserAuthError(
418
+ detail="ID token has been revoked",
419
+ operation="verify_id_token",
420
+ additional_info={"check_revoked": check_revoked}
421
+ ) from e
422
+ except auth.InvalidIdTokenError as e:
423
+ raise UserAuthError(
424
+ detail="Invalid ID token provided",
425
+ operation="verify_id_token",
426
+ additional_info={"check_revoked": check_revoked}
427
+ ) from e
428
+ except Exception as e:
429
+ self.logger.error(f"Failed to verify ID token: {e}", exc_info=True)
430
+ raise UserAuthError(
431
+ detail=f"Failed to verify ID token: {str(e)}",
432
+ operation="verify_id_token",
433
+ original_error=e
434
+ ) from e
435
+
436
+ async def get_user_auth_token(
437
+ self,
438
+ email: str,
439
+ password: str,
440
+ api_key: str
441
+ ) -> Optional[str]:
442
+ """
443
+ Gets a user authentication token using the Firebase REST API.
444
+
445
+ Note: This method requires the Firebase Web API key and should be used
446
+ for testing or specific admin scenarios. For production authentication,
447
+ prefer using the Firebase client SDKs.
448
+ """
449
+ import urllib.request
450
+ import urllib.parse
451
+ import urllib.error
452
+ import json
453
+
454
+ url = f"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key={api_key}"
455
+ payload = {
456
+ "email": email,
457
+ "password": password,
458
+ "returnSecureToken": True
459
+ }
460
+
461
+ try:
462
+ # Use executor to run blocking HTTP request asynchronously
463
+ loop = asyncio.get_event_loop()
464
+
465
+ def make_request():
466
+ data = json.dumps(payload).encode('utf-8')
467
+ req = urllib.request.Request(
468
+ url,
469
+ data=data,
470
+ headers={'Content-Type': 'application/json'}
471
+ )
472
+
473
+ try:
474
+ with urllib.request.urlopen(req) as response:
475
+ return response.read().decode('utf-8'), response.status
476
+ except urllib.error.HTTPError as e:
477
+ return e.read().decode('utf-8'), e.code
478
+
479
+ response_text, status_code = await loop.run_in_executor(None, make_request)
480
+
481
+ if status_code != 200:
482
+ error_details = response_text
483
+ try:
484
+ error_json = json.loads(response_text)
485
+ if "error" in error_json:
486
+ error_details = f"{error_json['error'].get('message', 'Unknown error')}"
487
+ except Exception:
488
+ pass
489
+
490
+ self.logger.error(f"Auth token request failed ({status_code}): {error_details}")
491
+
492
+ # Handle specific error conditions
493
+ if "EMAIL_NOT_FOUND" in error_details or "INVALID_PASSWORD" in error_details:
494
+ raise UserAuthError(
495
+ detail="Invalid email or password",
496
+ operation="get_user_auth_token",
497
+ additional_info={"email": email}
498
+ )
499
+ elif "USER_DISABLED" in error_details:
500
+ raise UserAuthError(
501
+ detail="User account is disabled",
502
+ operation="get_user_auth_token",
503
+ additional_info={"email": email}
504
+ )
505
+ elif "INVALID_EMAIL" in error_details:
506
+ raise UserAuthError(
507
+ detail="Invalid email format",
508
+ operation="get_user_auth_token",
509
+ additional_info={"email": email}
510
+ )
511
+ else:
512
+ raise UserAuthError(
513
+ detail=f"Authentication failed: {error_details}",
514
+ operation="get_user_auth_token",
515
+ additional_info={"email": email, "status_code": status_code}
516
+ )
517
+
518
+ result = json.loads(response_text)
519
+ token = result.get("idToken")
520
+
521
+ if token:
522
+ self.logger.info(f"Successfully obtained auth token for {email}")
523
+ return token
524
+ else:
525
+ raise UserAuthError(
526
+ detail="No token returned from authentication service",
527
+ operation="get_user_auth_token",
528
+ additional_info={"email": email}
529
+ )
530
+
531
+ except UserAuthError:
532
+ raise # Re-raise our custom errors
533
+ except Exception as e:
534
+ self.logger.error(f"Error getting auth token for {email}: {e}", exc_info=True)
535
+ raise UserAuthError(
536
+ detail=f"Failed to get authentication token: {str(e)}",
537
+ operation="get_user_auth_token",
538
+ additional_info={"email": email},
539
+ original_error=e
540
+ ) from e
541
+
542
+ async def revoke_refresh_tokens(self, user_uid: str) -> bool:
543
+ """Revokes all refresh tokens for a user, forcing re-authentication"""
544
+ try:
545
+ loop = asyncio.get_event_loop()
546
+ await loop.run_in_executor(
547
+ None,
548
+ auth.revoke_refresh_tokens,
549
+ user_uid
550
+ )
551
+
552
+ self.logger.info(f"Successfully revoked refresh tokens for user: {user_uid}")
553
+ return True
554
+
555
+ except auth.UserNotFoundError as e:
556
+ raise UserAuthError(
557
+ detail="Firebase Auth user not found",
558
+ user_uid=user_uid,
559
+ operation="revoke_refresh_tokens"
560
+ ) from e
561
+ except Exception as e:
562
+ self.logger.error(f"Failed to revoke refresh tokens for user {user_uid}: {e}", exc_info=True)
563
+ raise UserAuthError(
564
+ detail=f"Failed to revoke refresh tokens: {str(e)}",
565
+ user_uid=user_uid,
566
+ operation="revoke_refresh_tokens",
567
+ original_error=e
568
+ ) from e
569
+
570
+ async def generate_password_reset_link(
571
+ self,
572
+ email: str,
573
+ action_code_settings: Optional[Dict[str, Any]] = None
574
+ ) -> str:
575
+ """Generates a password reset link for a user"""
576
+ try:
577
+ loop = asyncio.get_event_loop()
578
+ link = await loop.run_in_executor(
579
+ None,
580
+ lambda: auth.generate_password_reset_link(email, action_code_settings)
581
+ )
582
+
583
+ self.logger.info(f"Successfully generated password reset link for email: {email}")
584
+ return link
585
+
586
+ except auth.UserNotFoundError as e:
587
+ raise UserAuthError(
588
+ detail="User not found with the provided email",
589
+ operation="generate_password_reset_link",
590
+ additional_info={"email": email}
591
+ ) from e
592
+ except Exception as e:
593
+ self.logger.error(f"Failed to generate password reset link for {email}: {e}", exc_info=True)
594
+ raise UserAuthError(
595
+ detail=f"Failed to generate password reset link: {str(e)}",
596
+ operation="generate_password_reset_link",
597
+ additional_info={"email": email},
598
+ original_error=e
599
+ ) from e
600
+
601
+ async def generate_email_verification_link(
602
+ self,
603
+ email: str,
604
+ action_code_settings: Optional[Dict[str, Any]] = None
605
+ ) -> str:
606
+ """Generates an email verification link for a user"""
607
+ try:
608
+ loop = asyncio.get_event_loop()
609
+ link = await loop.run_in_executor(
610
+ None,
611
+ lambda: auth.generate_email_verification_link(email, action_code_settings)
612
+ )
613
+
614
+ self.logger.info(f"Successfully generated email verification link for email: {email}")
615
+ return link
616
+
617
+ except auth.UserNotFoundError as e:
618
+ raise UserAuthError(
619
+ detail="User not found with the provided email",
620
+ operation="generate_email_verification_link",
621
+ additional_info={"email": email}
622
+ ) from e
623
+ except Exception as e:
624
+ self.logger.error(f"Failed to generate email verification link for {email}: {e}", exc_info=True)
625
+ raise UserAuthError(
626
+ detail=f"Failed to generate email verification link: {str(e)}",
627
+ operation="generate_email_verification_link",
628
+ additional_info={"email": email},
629
+ original_error=e
630
+ ) from e
631
+
632
+ # Utility Methods
633
+
634
+ async def userauth_exists(self, user_uid: str) -> bool:
635
+ """Check if Firebase Auth user exists"""
636
+ user_record = await self.get_userauth(user_uid)
637
+ return user_record is not None
638
+
639
+ async def validate_userauth_enabled(self, user_uid: str) -> bool:
640
+ """Validate that Firebase Auth user exists and is not disabled"""
641
+ try:
642
+ user_record = await self.get_userauth(user_uid)
643
+ if not user_record:
644
+ return False
645
+ return not user_record.disabled
646
+ except Exception:
647
+ return False
648
+
649
+ async def list_userauths(
650
+ self,
651
+ page_token: Optional[str] = None,
652
+ max_results: int = 1000
653
+ ) -> auth.ListUsersPage:
654
+ """List Firebase Auth users with pagination"""
655
+ try:
656
+ loop = asyncio.get_event_loop()
657
+ page = await loop.run_in_executor(
658
+ None,
659
+ lambda: auth.list_users(page_token=page_token, max_results=max_results)
660
+ )
661
+ return page
662
+ except Exception as e:
663
+ self.logger.error(f"Failed to list Firebase Auth users: {e}", exc_info=True)
664
+ raise UserAuthError(
665
+ detail=f"Failed to list Firebase Auth users: {str(e)}",
666
+ operation="list_userauths",
667
+ original_error=e
668
+ ) from e
669
+
670
+ # User Status Management Operations
671
+
672
+ async def disable_userauth(
673
+ self,
674
+ user_uid: str,
675
+ user_notes: str = "Administrative action - user disabled",
676
+ disabled_by: Optional[str] = None
677
+ ) -> bool:
678
+ """
679
+ Disables a Firebase Auth user and sets user notes in custom claims
680
+
681
+ Args:
682
+ user_uid: UID of the user to disable
683
+ user_notes: Descriptive notes for disabling the user
684
+ disabled_by: Who initiated the disable action
685
+
686
+ Returns:
687
+ True if successfully disabled
688
+ """
689
+ try:
690
+ # Get current user to preserve existing custom claims
691
+ user_record = await self.get_userauth(user_uid)
692
+ if not user_record:
693
+ raise UserAuthError(
694
+ detail="Firebase Auth user not found",
695
+ user_uid=user_uid,
696
+ operation="disable_userauth"
697
+ )
698
+
699
+ # Prepare timestamp and user notes
700
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
701
+
702
+ # Update custom claims with user notes and status
703
+ existing_claims = user_record.custom_claims or {}
704
+ existing_claims["user_notes"] = user_notes
705
+ existing_claims["disabled_at"] = timestamp
706
+ existing_claims["user_notes_updated_at"] = timestamp
707
+
708
+ if disabled_by:
709
+ existing_claims["disabled_by"] = disabled_by
710
+ existing_claims["user_notes_updated_by"] = disabled_by
711
+
712
+ # Set custom claims first
713
+ await self.set_userauth_custom_claims(user_uid, existing_claims)
714
+
715
+ # Then disable the user
716
+ loop = asyncio.get_event_loop()
717
+ await loop.run_in_executor(
718
+ None,
719
+ lambda: auth.update_user(uid=user_uid, disabled=True)
720
+ )
721
+
722
+ self.logger.info(f"Successfully disabled Firebase Auth user: {user_uid} - {user_notes}")
723
+ return True
724
+
725
+ except auth.UserNotFoundError as e:
726
+ raise UserAuthError(
727
+ detail="Firebase Auth user not found",
728
+ user_uid=user_uid,
729
+ operation="disable_userauth"
730
+ ) from e
731
+ except Exception as e:
732
+ self.logger.error(f"Failed to disable Firebase Auth user {user_uid}: {e}", exc_info=True)
733
+ raise UserAuthError(
734
+ detail=f"Failed to disable Firebase Auth user: {str(e)}",
735
+ user_uid=user_uid,
736
+ operation="disable_userauth",
737
+ original_error=e
738
+ ) from e
739
+
740
+ async def enable_userauth(
741
+ self,
742
+ user_uid: str,
743
+ user_notes: str = "Administrative action - user enabled",
744
+ enabled_by: Optional[str] = None
745
+ ) -> bool:
746
+ """
747
+ Re-enables a Firebase Auth user and updates user notes in custom claims
748
+
749
+ Args:
750
+ user_uid: UID of the user to enable
751
+ user_notes: Descriptive notes for enabling the user
752
+ enabled_by: Who initiated the enable action
753
+
754
+ Returns:
755
+ True if successfully enabled
756
+ """
757
+ try:
758
+ # Get current user to preserve existing custom claims
759
+ user_record = await self.get_userauth(user_uid)
760
+ if not user_record:
761
+ raise UserAuthError(
762
+ detail="Firebase Auth user not found",
763
+ user_uid=user_uid,
764
+ operation="enable_userauth"
765
+ )
766
+
767
+ # Prepare timestamp and user notes
768
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
769
+
770
+ # Update custom claims with user notes and status
771
+ existing_claims = user_record.custom_claims or {}
772
+ existing_claims["user_notes"] = user_notes
773
+ existing_claims["enabled_at"] = timestamp
774
+ existing_claims["user_notes_updated_at"] = timestamp
775
+
776
+ if enabled_by:
777
+ existing_claims["enabled_by"] = enabled_by
778
+ existing_claims["user_notes_updated_by"] = enabled_by
779
+
780
+ # Remove disabled-specific claims
781
+ existing_claims.pop("disabled_at", None)
782
+ existing_claims.pop("disabled_by", None)
783
+
784
+ # Set custom claims first
785
+ await self.set_userauth_custom_claims(user_uid, existing_claims)
786
+
787
+ # Then enable the user
788
+ loop = asyncio.get_event_loop()
789
+ await loop.run_in_executor(
790
+ None,
791
+ lambda: auth.update_user(uid=user_uid, disabled=False)
792
+ )
793
+
794
+ self.logger.info(f"Successfully enabled Firebase Auth user: {user_uid} - {user_notes}")
795
+ return True
796
+
797
+ except auth.UserNotFoundError as e:
798
+ raise UserAuthError(
799
+ detail="Firebase Auth user not found",
800
+ user_uid=user_uid,
801
+ operation="enable_userauth"
802
+ ) from e
803
+ except Exception as e:
804
+ self.logger.error(f"Failed to enable Firebase Auth user {user_uid}: {e}", exc_info=True)
805
+ raise UserAuthError(
806
+ detail=f"Failed to enable Firebase Auth user: {str(e)}",
807
+ user_uid=user_uid,
808
+ operation="enable_userauth",
809
+ original_error=e
810
+ ) from e
811
+
812
+ # User Notes Management
813
+
814
+ async def set_user_notes(
815
+ self,
816
+ user_uid: str,
817
+ user_notes: str,
818
+ updated_by: Optional[str] = None
819
+ ) -> bool:
820
+ """
821
+ Sets user notes as a custom claim for a Firebase Auth user
822
+
823
+ Args:
824
+ user_uid: UID of the user
825
+ user_notes: Notes to set for the user
826
+ updated_by: Who updated the notes
827
+
828
+ Returns:
829
+ True if successfully updated
830
+ """
831
+ try:
832
+ # Get current user to preserve existing custom claims
833
+ user_record = await self.get_userauth(user_uid)
834
+ if not user_record:
835
+ raise UserAuthError(
836
+ detail="Firebase Auth user not found",
837
+ user_uid=user_uid,
838
+ operation="set_user_notes"
839
+ )
840
+
841
+ # Update custom claims with user notes
842
+ existing_claims = user_record.custom_claims or {}
843
+ existing_claims["user_notes"] = user_notes
844
+
845
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
846
+ existing_claims["user_notes_updated_at"] = timestamp
847
+
848
+ if updated_by:
849
+ existing_claims["user_notes_updated_by"] = updated_by
850
+
851
+ # Set the updated custom claims
852
+ await self.set_userauth_custom_claims(user_uid, existing_claims)
853
+
854
+ self.logger.info(f"Successfully set user notes for Firebase Auth user: {user_uid}")
855
+ return True
856
+
857
+ except auth.UserNotFoundError as e:
858
+ raise UserAuthError(
859
+ detail="Firebase Auth user not found",
860
+ user_uid=user_uid,
861
+ operation="set_user_notes"
862
+ ) from e
863
+ except Exception as e:
864
+ self.logger.error(f"Failed to set user notes for Firebase Auth user {user_uid}: {e}", exc_info=True)
865
+ raise UserAuthError(
866
+ detail=f"Failed to set user notes: {str(e)}",
867
+ user_uid=user_uid,
868
+ operation="set_user_notes",
869
+ original_error=e
870
+ ) from e
871
+
872
+ async def set_user_approval_status(
873
+ self,
874
+ user_uid: str,
875
+ approval_status: ApprovalStatus,
876
+ updated_by: Optional[str] = None
877
+ ) -> bool:
878
+ """
879
+ Sets user approval status as a custom claim for a Firebase Auth user
880
+
881
+ Args:
882
+ user_uid: UID of the user
883
+ approval_status: ApprovalStatus enum value to set
884
+ updated_by: Who updated the approval status
885
+
886
+ Returns:
887
+ True if successfully updated
888
+ """
889
+ try:
890
+ # Get current user to preserve existing custom claims
891
+ user_record = await self.get_userauth(user_uid)
892
+ if not user_record:
893
+ raise UserAuthError(
894
+ detail="Firebase Auth user not found",
895
+ user_uid=user_uid,
896
+ operation="set_user_approval_status"
897
+ )
898
+
899
+ # Update custom claims with approval status
900
+ existing_claims = user_record.custom_claims or {}
901
+ existing_claims["user_approval_status"] = approval_status.name
902
+
903
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
904
+ existing_claims["user_approval_status_updated_at"] = timestamp
905
+
906
+ if updated_by:
907
+ existing_claims["user_approval_status_updated_by"] = updated_by
908
+
909
+ # Set the updated custom claims
910
+ await self.set_userauth_custom_claims(user_uid, existing_claims)
911
+
912
+ self.logger.info(f"Successfully set user approval status to {approval_status.name} for Firebase Auth user: {user_uid}")
913
+ return True
914
+
915
+ except auth.UserNotFoundError as e:
916
+ raise UserAuthError(
917
+ detail="Firebase Auth user not found",
918
+ user_uid=user_uid,
919
+ operation="set_user_approval_status"
920
+ ) from e
921
+ except Exception as e:
922
+ self.logger.error(f"Failed to set user approval status for Firebase Auth user {user_uid}: {e}", exc_info=True)
923
+ raise UserAuthError(
924
+ detail=f"Failed to set user approval status: {str(e)}",
925
+ user_uid=user_uid,
926
+ operation="set_user_approval_status",
927
+ original_error=e
928
+ ) from e