meta-ads-mcp-python 1.0.79__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.
@@ -0,0 +1,567 @@
1
+ """Authentication related functionality for Meta Ads API."""
2
+
3
+ from typing import Any, Dict, Optional
4
+ import time
5
+ import platform
6
+ import pathlib
7
+ import os
8
+ import webbrowser
9
+ import asyncio
10
+ import json
11
+ from .utils import logger
12
+ import requests
13
+
14
+ # Import from the new callback server module
15
+ from .callback_server import (
16
+ start_callback_server,
17
+ shutdown_callback_server,
18
+ token_container,
19
+ callback_server_port
20
+ )
21
+
22
+ # Import the new Pipeboard authentication
23
+ from .pipeboard_auth import pipeboard_auth_manager
24
+
25
+ # Auth constants
26
+ # Scope includes pages_show_list and pages_read_engagement to fix issue #16
27
+ # where get_account_pages failed for regular users due to missing page permissions
28
+ AUTH_SCOPE = "business_management,public_profile,pages_show_list,pages_read_engagement"
29
+ AUTH_REDIRECT_URI = "http://localhost:8888/callback"
30
+ AUTH_RESPONSE_TYPE = "token"
31
+
32
+ # Log important configuration information
33
+ logger.info("Authentication module initialized")
34
+ logger.info(f"Auth scope: {AUTH_SCOPE}")
35
+ logger.info(f"Default redirect URI: {AUTH_REDIRECT_URI}")
36
+
37
+ # Global flag for authentication state
38
+ needs_authentication = False
39
+
40
+ # Meta configuration singleton
41
+ class MetaConfig:
42
+ _instance = None
43
+
44
+ def __new__(cls):
45
+ if cls._instance is None:
46
+ logger.debug("Creating new MetaConfig instance")
47
+ cls._instance = super(MetaConfig, cls).__new__(cls)
48
+ cls._instance.app_id = os.environ.get("META_APP_ID", "779761636818489")
49
+ logger.info(f"MetaConfig initialized with app_id from env/default: {cls._instance.app_id}")
50
+ return cls._instance
51
+
52
+ def set_app_id(self, app_id):
53
+ """Set the Meta App ID for API calls"""
54
+ logger.info(f"Setting Meta App ID: {app_id}")
55
+ self.app_id = app_id
56
+ # Also update environment variable for modules that might read directly from it
57
+ os.environ["META_APP_ID"] = app_id
58
+ logger.debug(f"Updated META_APP_ID environment variable: {os.environ.get('META_APP_ID')}")
59
+
60
+ def get_app_id(self):
61
+ """Get the current Meta App ID"""
62
+ # Check if we have one set
63
+ if hasattr(self, 'app_id') and self.app_id:
64
+ logger.debug(f"Using app_id from instance: {self.app_id}")
65
+ return self.app_id
66
+
67
+ # If not, try environment variable
68
+ env_app_id = os.environ.get("META_APP_ID", "")
69
+ if env_app_id:
70
+ logger.debug(f"Using app_id from environment: {env_app_id}")
71
+ # Update our instance for future use
72
+ self.app_id = env_app_id
73
+ return env_app_id
74
+
75
+ logger.warning("No app_id found in instance or environment variables")
76
+ return ""
77
+
78
+ def is_configured(self):
79
+ """Check if the Meta configuration is complete"""
80
+ app_id = self.get_app_id()
81
+ configured = bool(app_id)
82
+ logger.debug(f"MetaConfig.is_configured() = {configured} (app_id: {app_id})")
83
+ return configured
84
+
85
+ # Create singleton instance
86
+ meta_config = MetaConfig()
87
+
88
+ class TokenInfo:
89
+ """Stores token information including expiration"""
90
+ def __init__(self, access_token: str, expires_in: Optional[int] = None, user_id: Optional[str] = None):
91
+ self.access_token = access_token
92
+ self.expires_in = expires_in
93
+ self.user_id = user_id
94
+ self.created_at = int(time.time())
95
+ logger.debug(f"TokenInfo created. Expires in: {expires_in if expires_in else 'Not specified'}")
96
+
97
+ def is_expired(self) -> bool:
98
+ """Check if the token is expired"""
99
+ if not self.expires_in:
100
+ return False # If no expiration is set, assume it's not expired
101
+
102
+ current_time = int(time.time())
103
+ return current_time > (self.created_at + self.expires_in)
104
+
105
+ def serialize(self) -> Dict[str, Any]:
106
+ """Convert to a dictionary for storage"""
107
+ return {
108
+ "access_token": self.access_token,
109
+ "expires_in": self.expires_in,
110
+ "user_id": self.user_id,
111
+ "created_at": self.created_at
112
+ }
113
+
114
+ @classmethod
115
+ def deserialize(cls, data: Dict[str, Any]) -> 'TokenInfo':
116
+ """Create from a stored dictionary"""
117
+ token = cls(
118
+ access_token=data.get("access_token", ""),
119
+ expires_in=data.get("expires_in"),
120
+ user_id=data.get("user_id")
121
+ )
122
+ token.created_at = data.get("created_at", int(time.time()))
123
+ return token
124
+
125
+
126
+ class AuthManager:
127
+ """Manages authentication with Meta APIs"""
128
+ def __init__(self, app_id: str, redirect_uri: str = AUTH_REDIRECT_URI):
129
+ self.app_id = app_id
130
+ self.redirect_uri = redirect_uri
131
+ self.token_info = None
132
+ # Check for Pipeboard token first
133
+ self.use_pipeboard = bool(os.environ.get("PIPEBOARD_API_TOKEN", ""))
134
+ if not self.use_pipeboard:
135
+ self._load_cached_token()
136
+
137
+ def _get_token_cache_path(self) -> pathlib.Path:
138
+ """Get the platform-specific path for token cache file"""
139
+ if platform.system() == "Windows":
140
+ base_path = pathlib.Path(os.environ.get("APPDATA", ""))
141
+ elif platform.system() == "Darwin": # macOS
142
+ base_path = pathlib.Path.home() / "Library" / "Application Support"
143
+ else: # Assume Linux/Unix
144
+ base_path = pathlib.Path.home() / ".config"
145
+
146
+ # Create directory if it doesn't exist
147
+ cache_dir = base_path / "meta-ads-mcp"
148
+ cache_dir.mkdir(parents=True, exist_ok=True)
149
+
150
+ return cache_dir / "token_cache.json"
151
+
152
+ def _load_cached_token(self) -> bool:
153
+ """Load token from cache if available"""
154
+ cache_path = self._get_token_cache_path()
155
+
156
+ if not cache_path.exists():
157
+ return False
158
+
159
+ try:
160
+ with open(cache_path, "r") as f:
161
+ data = json.load(f)
162
+
163
+ # Validate the cached data structure
164
+ required_fields = ["access_token", "created_at"]
165
+ if not all(field in data for field in required_fields):
166
+ logger.warning("Cached token data is missing required fields")
167
+ return False
168
+
169
+ # Check if the token looks valid (basic format check)
170
+ if not data.get("access_token") or len(data["access_token"]) < 20:
171
+ logger.warning("Cached token appears malformed")
172
+ return False
173
+
174
+ self.token_info = TokenInfo.deserialize(data)
175
+
176
+ # Check if token is expired
177
+ if self.token_info.is_expired():
178
+ logger.info("Cached token is expired, removing cache file")
179
+ # Remove the expired cache file
180
+ try:
181
+ cache_path.unlink()
182
+ logger.info(f"Removed expired token cache: {cache_path}")
183
+ except Exception as e:
184
+ logger.warning(f"Could not remove expired cache file: {e}")
185
+ self.token_info = None
186
+ return False
187
+
188
+ # Additional validation: check if token is too old (more than 60 days)
189
+ current_time = int(time.time())
190
+ if self.token_info.created_at and (current_time - self.token_info.created_at) > (60 * 24 * 3600):
191
+ logger.warning("Cached token is too old (more than 60 days), removing cache file")
192
+ try:
193
+ cache_path.unlink()
194
+ logger.info(f"Removed old token cache: {cache_path}")
195
+ except Exception as e:
196
+ logger.warning(f"Could not remove old cache file: {e}")
197
+ self.token_info = None
198
+ return False
199
+
200
+ logger.info(f"Loaded cached token (expires in {(self.token_info.created_at + self.token_info.expires_in) - int(time.time())} seconds)")
201
+ return True
202
+ except Exception as e:
203
+ logger.error(f"Error loading cached token: {e}")
204
+ # If there's any error reading the cache, try to remove the corrupted file
205
+ try:
206
+ cache_path.unlink()
207
+ logger.info(f"Removed corrupted token cache: {cache_path}")
208
+ except Exception as cleanup_error:
209
+ logger.warning(f"Could not remove corrupted cache file: {cleanup_error}")
210
+ return False
211
+
212
+ def _save_token_to_cache(self) -> None:
213
+ """Save token to cache file"""
214
+ if not self.token_info:
215
+ return
216
+
217
+ cache_path = self._get_token_cache_path()
218
+
219
+ try:
220
+ with open(cache_path, "w") as f:
221
+ json.dump(self.token_info.serialize(), f)
222
+ logger.info(f"Token cached at: {cache_path}")
223
+ except Exception as e:
224
+ logger.error(f"Error saving token to cache: {e}")
225
+
226
+ def get_auth_url(self) -> str:
227
+ """Generate the Facebook OAuth URL for desktop app flow"""
228
+ return (
229
+ f"https://www.facebook.com/v24.0/dialog/oauth?"
230
+ f"client_id={self.app_id}&"
231
+ f"redirect_uri={self.redirect_uri}&"
232
+ f"scope={AUTH_SCOPE}&"
233
+ f"response_type={AUTH_RESPONSE_TYPE}"
234
+ )
235
+
236
+ def authenticate(self, force_refresh: bool = False) -> Optional[str]:
237
+ """
238
+ Authenticate with Meta APIs
239
+
240
+ Args:
241
+ force_refresh: Force token refresh even if cached token exists
242
+
243
+ Returns:
244
+ Access token if successful, None otherwise
245
+ """
246
+ # If Pipeboard auth is available, use that instead
247
+ if self.use_pipeboard:
248
+ logger.info("Using Pipeboard authentication")
249
+ return pipeboard_auth_manager.get_access_token(force_refresh=force_refresh)
250
+
251
+ # Otherwise, use the original OAuth flow
252
+ # Check if we already have a valid token
253
+ if not force_refresh and self.token_info and not self.token_info.is_expired():
254
+ return self.token_info.access_token
255
+
256
+ # Start the callback server if not already running
257
+ try:
258
+ port = start_callback_server()
259
+
260
+ # Update redirect URI with the actual port
261
+ self.redirect_uri = f"http://localhost:{port}/callback"
262
+
263
+ # Generate the auth URL
264
+ auth_url = self.get_auth_url()
265
+
266
+ # Open browser with auth URL
267
+ logger.info(f"Opening browser with URL: {auth_url}")
268
+ webbrowser.open(auth_url)
269
+
270
+ # We don't wait for the token here anymore
271
+ # The token will be processed by the callback server
272
+ # Just return None to indicate we've started the flow
273
+ return None
274
+ except Exception as e:
275
+ logger.error(f"Failed to start callback server: {e}")
276
+ logger.info("Callback server disabled. OAuth authentication flow cannot be used.")
277
+ return None
278
+
279
+ def get_access_token(self) -> Optional[str]:
280
+ """
281
+ Get the current access token, refreshing if necessary
282
+
283
+ Returns:
284
+ Access token if available, None otherwise
285
+ """
286
+ # If using Pipeboard, always delegate to the Pipeboard auth manager
287
+ if self.use_pipeboard:
288
+ return pipeboard_auth_manager.get_access_token()
289
+
290
+ if not self.token_info or self.token_info.is_expired():
291
+ return None
292
+
293
+ return self.token_info.access_token
294
+
295
+ def invalidate_token(self) -> None:
296
+ """Invalidate the current token, usually because it has expired or is invalid"""
297
+ # If using Pipeboard, delegate to the Pipeboard auth manager
298
+ if self.use_pipeboard:
299
+ pipeboard_auth_manager.invalidate_token()
300
+ return
301
+
302
+ if self.token_info:
303
+ logger.info(f"Invalidating token: {self.token_info.access_token[:10]}...")
304
+ self.token_info = None
305
+
306
+ # Signal that authentication is needed
307
+ global needs_authentication
308
+ needs_authentication = True
309
+
310
+ # Remove the cached token file
311
+ try:
312
+ cache_path = self._get_token_cache_path()
313
+ if cache_path.exists():
314
+ os.remove(cache_path)
315
+ logger.info(f"Removed cached token file: {cache_path}")
316
+ except Exception as e:
317
+ logger.error(f"Error removing cached token file: {e}")
318
+
319
+ def clear_token(self) -> None:
320
+ """Alias for invalidate_token for consistency with other APIs"""
321
+ self.invalidate_token()
322
+
323
+
324
+ def process_token_response(token_container):
325
+ """Process the token response from Facebook."""
326
+ global needs_authentication, auth_manager
327
+
328
+ if token_container and token_container.get('token'):
329
+ logger.info("Processing token response from Facebook OAuth")
330
+
331
+ # Exchange the short-lived token for a long-lived token
332
+ short_lived_token = token_container['token']
333
+ long_lived_token_info = exchange_token_for_long_lived(short_lived_token)
334
+
335
+ if long_lived_token_info:
336
+ logger.info(f"Successfully exchanged for long-lived token (expires in {long_lived_token_info.expires_in} seconds)")
337
+
338
+ try:
339
+ auth_manager.token_info = long_lived_token_info
340
+ logger.info(f"Long-lived token info set in auth_manager, expires in {long_lived_token_info.expires_in} seconds")
341
+ except NameError:
342
+ logger.error("auth_manager not defined when trying to process token")
343
+
344
+ try:
345
+ logger.info("Attempting to save long-lived token to cache")
346
+ auth_manager._save_token_to_cache()
347
+ logger.info(f"Long-lived token successfully saved to cache at {auth_manager._get_token_cache_path()}")
348
+ except Exception as e:
349
+ logger.error(f"Error saving token to cache: {e}")
350
+
351
+ needs_authentication = False
352
+ return True
353
+ else:
354
+ # Fall back to the short-lived token if exchange fails
355
+ logger.warning("Failed to exchange for long-lived token, using short-lived token instead")
356
+ token_info = TokenInfo(
357
+ access_token=short_lived_token,
358
+ expires_in=token_container.get('expires_in', 0)
359
+ )
360
+
361
+ try:
362
+ auth_manager.token_info = token_info
363
+ logger.info(f"Short-lived token info set in auth_manager, expires in {token_info.expires_in} seconds")
364
+ except NameError:
365
+ logger.error("auth_manager not defined when trying to process token")
366
+
367
+ try:
368
+ logger.info("Attempting to save token to cache")
369
+ auth_manager._save_token_to_cache()
370
+ logger.info(f"Token successfully saved to cache at {auth_manager._get_token_cache_path()}")
371
+ except Exception as e:
372
+ logger.error(f"Error saving token to cache: {e}")
373
+
374
+ needs_authentication = False
375
+ return True
376
+ else:
377
+ logger.warning("Received empty token in process_token_response")
378
+ needs_authentication = True
379
+ return False
380
+
381
+
382
+ def exchange_token_for_long_lived(short_lived_token):
383
+ """
384
+ Exchange a short-lived token for a long-lived token (60 days validity).
385
+
386
+ Args:
387
+ short_lived_token: The short-lived access token received from OAuth flow
388
+
389
+ Returns:
390
+ TokenInfo object with the long-lived token, or None if exchange failed
391
+ """
392
+ logger.info("Attempting to exchange short-lived token for long-lived token")
393
+
394
+ try:
395
+ # Get the app ID from the configuration
396
+ app_id = meta_config.get_app_id()
397
+
398
+ # Get the app secret - this should be securely stored
399
+ app_secret = os.environ.get("META_APP_SECRET", "")
400
+
401
+ if not app_id or not app_secret:
402
+ logger.error("Missing app_id or app_secret for token exchange")
403
+ return None
404
+
405
+ # Make the API request to exchange the token
406
+ url = "https://graph.facebook.com/v24.0/oauth/access_token"
407
+ params = {
408
+ "grant_type": "fb_exchange_token",
409
+ "client_id": app_id,
410
+ "client_secret": app_secret,
411
+ "fb_exchange_token": short_lived_token
412
+ }
413
+
414
+ logger.debug(f"Making token exchange request to {url}")
415
+ response = requests.get(url, params=params)
416
+
417
+ if response.status_code == 200:
418
+ data = response.json()
419
+ logger.debug(f"Token exchange response: {data}")
420
+
421
+ # Create TokenInfo from the response
422
+ # The response includes access_token and expires_in (in seconds)
423
+ new_token = data.get("access_token")
424
+ expires_in = data.get("expires_in")
425
+
426
+ if new_token:
427
+ logger.info(f"Received long-lived token, expires in {expires_in} seconds (~{expires_in//86400} days)")
428
+ return TokenInfo(
429
+ access_token=new_token,
430
+ expires_in=expires_in
431
+ )
432
+ else:
433
+ logger.error("No access_token in exchange response")
434
+ return None
435
+ else:
436
+ logger.error(f"Token exchange failed with status {response.status_code}: {response.text}")
437
+ return None
438
+ except Exception as e:
439
+ logger.error(f"Error exchanging token: {e}")
440
+ return None
441
+
442
+
443
+ async def get_current_access_token() -> Optional[str]:
444
+ """Get the current access token from auth manager"""
445
+ # Check for environment variable first - this takes highest precedence
446
+ env_token = os.environ.get("META_ACCESS_TOKEN")
447
+ if env_token:
448
+ logger.debug("Using access token from META_ACCESS_TOKEN environment variable")
449
+ # Basic validation
450
+ if len(env_token) < 20: # Most Meta tokens are much longer
451
+ logger.error(f"TOKEN VALIDATION FAILED: Token from environment variable appears malformed (length: {len(env_token)})")
452
+ return None
453
+ return env_token
454
+
455
+ # Use the singleton auth manager
456
+ global auth_manager
457
+
458
+ # Log the function call and current app ID
459
+ logger.debug("get_current_access_token() called")
460
+ app_id = meta_config.get_app_id()
461
+ logger.debug(f"Current app_id: {app_id}")
462
+
463
+ # Check if using Pipeboard authentication
464
+ using_pipeboard = auth_manager.use_pipeboard
465
+
466
+ # Check if app_id is valid - but only if not using Pipeboard authentication
467
+ if not app_id and not using_pipeboard:
468
+ logger.error("TOKEN VALIDATION FAILED: No valid app_id configured")
469
+ logger.error("Please set META_APP_ID environment variable or configure via meta_config.set_app_id()")
470
+ return None
471
+
472
+ # Attempt to get access token
473
+ try:
474
+ token = auth_manager.get_access_token()
475
+
476
+ if token:
477
+ # Add basic token validation - check if it looks like a valid token
478
+ if len(token) < 20: # Most Meta tokens are much longer
479
+ logger.error(f"TOKEN VALIDATION FAILED: Token appears malformed (length: {len(token)})")
480
+ auth_manager.invalidate_token()
481
+ return None
482
+
483
+ logger.debug(f"Access token found in auth_manager (starts with: {token[:10]}...)")
484
+ return token
485
+ else:
486
+ logger.warning("No valid access token available in auth_manager")
487
+
488
+ # Check why token might be missing
489
+ if hasattr(auth_manager, 'token_info') and auth_manager.token_info:
490
+ if auth_manager.token_info.is_expired():
491
+ logger.error("TOKEN VALIDATION FAILED: Token is expired")
492
+ # Add expiration details
493
+ if hasattr(auth_manager.token_info, 'expires_in') and auth_manager.token_info.expires_in:
494
+ expiry_time = auth_manager.token_info.created_at + auth_manager.token_info.expires_in
495
+ current_time = int(time.time())
496
+ expired_seconds_ago = current_time - expiry_time
497
+ logger.error(f"Token expired {expired_seconds_ago} seconds ago")
498
+ elif not auth_manager.token_info.access_token:
499
+ logger.error("TOKEN VALIDATION FAILED: Token object exists but access_token is empty")
500
+ else:
501
+ logger.error("TOKEN VALIDATION FAILED: Token exists but was rejected for unknown reason")
502
+ else:
503
+ logger.error("TOKEN VALIDATION FAILED: No token information available")
504
+
505
+ # Suggest next steps for troubleshooting
506
+ logger.error("To fix: Try re-authenticating or check if your token has been revoked")
507
+ return None
508
+ except Exception as e:
509
+ logger.error(f"Error getting access token: {str(e)}")
510
+ import traceback
511
+ logger.error(f"Token validation stacktrace: {traceback.format_exc()}")
512
+ return None
513
+
514
+
515
+ def login():
516
+ """
517
+ Start the login flow to authenticate with Meta
518
+ """
519
+ print("Starting Meta Ads authentication flow...")
520
+
521
+ try:
522
+ # Start the callback server first
523
+ try:
524
+ port = start_callback_server()
525
+ except Exception as callback_error:
526
+ print(f"Error: {callback_error}")
527
+ print("Callback server is disabled. Please use alternative authentication methods:")
528
+ print("- Set PIPEBOARD_API_TOKEN environment variable for Pipeboard authentication")
529
+ print("- Or provide a direct META_ACCESS_TOKEN environment variable")
530
+ return
531
+
532
+ # Get the auth URL and open the browser
533
+ auth_url = auth_manager.get_auth_url()
534
+ print(f"Opening browser with URL: {auth_url}")
535
+ webbrowser.open(auth_url)
536
+
537
+ # Wait for token to be received
538
+ print("Waiting for authentication to complete...")
539
+ max_wait = 300 # 5 minutes
540
+ wait_interval = 2 # 2 seconds
541
+
542
+ for _ in range(max_wait // wait_interval):
543
+ if token_container["token"]:
544
+ token = token_container["token"]
545
+ print("Authentication successful!")
546
+ # Verify token works by getting basic user info
547
+ try:
548
+ from .api import make_api_request
549
+ result = asyncio.run(make_api_request("me", token, {}))
550
+ print(f"Authenticated as: {result.get('name', 'Unknown')} (ID: {result.get('id', 'Unknown')})")
551
+ return
552
+ except Exception as e:
553
+ print(f"Warning: Could not verify token: {e}")
554
+ return
555
+ time.sleep(wait_interval)
556
+
557
+ print("Authentication timed out. Please try again.")
558
+ except Exception as e:
559
+ print(f"Error during authentication: {e}")
560
+ print(f"Direct authentication URL: {auth_manager.get_auth_url()}")
561
+ print("You can manually open this URL in your browser to complete authentication.")
562
+
563
+ # Initialize auth manager with a placeholder - will be updated at runtime
564
+ META_APP_ID = os.environ.get("META_APP_ID", "YOUR_META_APP_ID")
565
+
566
+ # Create the auth manager
567
+ auth_manager = AuthManager(META_APP_ID)