amazon-ads-mcp 0.2.7__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.
Files changed (82) hide show
  1. amazon_ads_mcp/__init__.py +11 -0
  2. amazon_ads_mcp/auth/__init__.py +33 -0
  3. amazon_ads_mcp/auth/base.py +211 -0
  4. amazon_ads_mcp/auth/hooks.py +172 -0
  5. amazon_ads_mcp/auth/manager.py +791 -0
  6. amazon_ads_mcp/auth/oauth_state_store.py +277 -0
  7. amazon_ads_mcp/auth/providers/__init__.py +14 -0
  8. amazon_ads_mcp/auth/providers/direct.py +393 -0
  9. amazon_ads_mcp/auth/providers/example_auth0.py.example +216 -0
  10. amazon_ads_mcp/auth/providers/openbridge.py +512 -0
  11. amazon_ads_mcp/auth/registry.py +146 -0
  12. amazon_ads_mcp/auth/secure_token_store.py +297 -0
  13. amazon_ads_mcp/auth/token_store.py +723 -0
  14. amazon_ads_mcp/config/__init__.py +5 -0
  15. amazon_ads_mcp/config/sampling.py +111 -0
  16. amazon_ads_mcp/config/settings.py +366 -0
  17. amazon_ads_mcp/exceptions.py +314 -0
  18. amazon_ads_mcp/middleware/__init__.py +11 -0
  19. amazon_ads_mcp/middleware/authentication.py +1474 -0
  20. amazon_ads_mcp/middleware/caching.py +177 -0
  21. amazon_ads_mcp/middleware/oauth.py +175 -0
  22. amazon_ads_mcp/middleware/sampling.py +112 -0
  23. amazon_ads_mcp/models/__init__.py +320 -0
  24. amazon_ads_mcp/models/amc_models.py +837 -0
  25. amazon_ads_mcp/models/api_responses.py +847 -0
  26. amazon_ads_mcp/models/base_models.py +215 -0
  27. amazon_ads_mcp/models/builtin_responses.py +496 -0
  28. amazon_ads_mcp/models/dsp_models.py +556 -0
  29. amazon_ads_mcp/models/stores_brands.py +610 -0
  30. amazon_ads_mcp/server/__init__.py +6 -0
  31. amazon_ads_mcp/server/__main__.py +6 -0
  32. amazon_ads_mcp/server/builtin_prompts.py +269 -0
  33. amazon_ads_mcp/server/builtin_tools.py +962 -0
  34. amazon_ads_mcp/server/file_routes.py +547 -0
  35. amazon_ads_mcp/server/html_templates.py +149 -0
  36. amazon_ads_mcp/server/mcp_server.py +327 -0
  37. amazon_ads_mcp/server/openapi_utils.py +158 -0
  38. amazon_ads_mcp/server/sampling_handler.py +251 -0
  39. amazon_ads_mcp/server/server_builder.py +751 -0
  40. amazon_ads_mcp/server/sidecar_loader.py +178 -0
  41. amazon_ads_mcp/server/transform_executor.py +827 -0
  42. amazon_ads_mcp/tools/__init__.py +22 -0
  43. amazon_ads_mcp/tools/cache_management.py +105 -0
  44. amazon_ads_mcp/tools/download_tools.py +267 -0
  45. amazon_ads_mcp/tools/identity.py +236 -0
  46. amazon_ads_mcp/tools/oauth.py +598 -0
  47. amazon_ads_mcp/tools/profile.py +150 -0
  48. amazon_ads_mcp/tools/profile_listing.py +285 -0
  49. amazon_ads_mcp/tools/region.py +320 -0
  50. amazon_ads_mcp/tools/region_identity.py +175 -0
  51. amazon_ads_mcp/utils/__init__.py +6 -0
  52. amazon_ads_mcp/utils/async_compat.py +215 -0
  53. amazon_ads_mcp/utils/errors.py +452 -0
  54. amazon_ads_mcp/utils/export_content_type_resolver.py +249 -0
  55. amazon_ads_mcp/utils/export_download_handler.py +579 -0
  56. amazon_ads_mcp/utils/header_resolver.py +81 -0
  57. amazon_ads_mcp/utils/http/__init__.py +56 -0
  58. amazon_ads_mcp/utils/http/circuit_breaker.py +127 -0
  59. amazon_ads_mcp/utils/http/client_manager.py +329 -0
  60. amazon_ads_mcp/utils/http/request.py +207 -0
  61. amazon_ads_mcp/utils/http/resilience.py +512 -0
  62. amazon_ads_mcp/utils/http/resilient_client.py +195 -0
  63. amazon_ads_mcp/utils/http/retry.py +76 -0
  64. amazon_ads_mcp/utils/http_client.py +873 -0
  65. amazon_ads_mcp/utils/media/__init__.py +21 -0
  66. amazon_ads_mcp/utils/media/negotiator.py +243 -0
  67. amazon_ads_mcp/utils/media/types.py +199 -0
  68. amazon_ads_mcp/utils/openapi/__init__.py +16 -0
  69. amazon_ads_mcp/utils/openapi/json.py +55 -0
  70. amazon_ads_mcp/utils/openapi/loader.py +263 -0
  71. amazon_ads_mcp/utils/openapi/refs.py +46 -0
  72. amazon_ads_mcp/utils/region_config.py +200 -0
  73. amazon_ads_mcp/utils/response_wrapper.py +171 -0
  74. amazon_ads_mcp/utils/sampling_helpers.py +156 -0
  75. amazon_ads_mcp/utils/sampling_wrapper.py +173 -0
  76. amazon_ads_mcp/utils/security.py +630 -0
  77. amazon_ads_mcp/utils/tool_naming.py +137 -0
  78. amazon_ads_mcp-0.2.7.dist-info/METADATA +664 -0
  79. amazon_ads_mcp-0.2.7.dist-info/RECORD +82 -0
  80. amazon_ads_mcp-0.2.7.dist-info/WHEEL +4 -0
  81. amazon_ads_mcp-0.2.7.dist-info/entry_points.txt +3 -0
  82. amazon_ads_mcp-0.2.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,791 @@
1
+ """Central authentication management for Amazon Ads MCP.
2
+
3
+ This module provides centralized authentication management using the
4
+ pluggable provider architecture. It handles identity management,
5
+ credential caching, and provider coordination for seamless API access.
6
+
7
+ The module supports:
8
+ - Multiple authentication providers (direct, OpenBridge, etc.)
9
+ - Identity-based credential management
10
+ - Automatic token refresh and caching
11
+ - Profile scope management
12
+ - Region-specific endpoint handling
13
+ """
14
+
15
+ import logging
16
+ import os
17
+ from datetime import datetime, timezone
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from ..config.settings import Settings
21
+ from ..models import AuthCredentials, Identity
22
+
23
+ # Import providers to trigger registration
24
+ from .base import BaseAmazonAdsProvider, BaseIdentityProvider, ProviderConfig
25
+ from .registry import ProviderRegistry
26
+ from .token_store import (
27
+ TokenEntry,
28
+ TokenKey,
29
+ TokenKind,
30
+ TokenStore,
31
+ create_token_store,
32
+ )
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class AuthManager:
38
+ """Central manager for authentication and identity management.
39
+
40
+ This manager uses the provider registry to dynamically load
41
+ authentication providers based on configuration. It implements
42
+ a singleton pattern to ensure consistent state across the
43
+ application.
44
+
45
+ The manager handles:
46
+ - Provider initialization and configuration
47
+ - Identity management and switching
48
+ - Credential caching and refresh
49
+ - Profile scope management
50
+ - Region-specific endpoint handling
51
+ """
52
+
53
+ _instance: Optional["AuthManager"] = None
54
+
55
+ def __new__(cls):
56
+ """Create or return singleton instance.
57
+
58
+ Implements singleton pattern to ensure single auth manager
59
+ instance across the application.
60
+
61
+ :return: Singleton instance of AuthManager
62
+ :rtype: AuthManager
63
+ """
64
+ if cls._instance is None:
65
+ cls._instance = super().__new__(cls)
66
+ return cls._instance
67
+
68
+ def __init__(self):
69
+ """Initialize the authentication manager.
70
+
71
+ Sets up the manager with settings, provider configuration,
72
+ and initial state. Only initializes once due to singleton
73
+ pattern.
74
+
75
+ :return: None
76
+ :rtype: None
77
+ """
78
+ if hasattr(self, "_initialized"):
79
+ return
80
+
81
+ self._initialized = True
82
+ self.settings = Settings()
83
+ self.provider: Optional[BaseAmazonAdsProvider] = None
84
+ self._active_identity: Optional[Identity] = None
85
+ self._active_credentials: Optional[AuthCredentials] = None
86
+
87
+ # Initialize unified token store - persistence disabled by default for security
88
+ # Users can enable with AMAZON_ADS_TOKEN_PERSIST=true if needed
89
+ persist_tokens = (
90
+ os.getenv("AMAZON_ADS_TOKEN_PERSIST", "false").lower() == "true"
91
+ )
92
+ self._token_store: TokenStore = create_token_store(persist=persist_tokens)
93
+
94
+ # Track active profile per identity
95
+ self._active_profiles: Dict[str, str] = {}
96
+ # Standardize on AMAZON_AD_API_PROFILE_ID but support legacy names
97
+ self._default_profile_id: Optional[str] = (
98
+ os.getenv("AMAZON_AD_API_PROFILE_ID") # Primary
99
+ or os.getenv("AD_API_PROFILE_ID") # Legacy
100
+ or os.getenv("AMAZON_ADS_PROFILE_ID") # Legacy
101
+ )
102
+
103
+ # Initialize provider based on settings
104
+ self._setup_provider()
105
+
106
+ def _setup_provider(self):
107
+ """Setup authentication provider using the registry.
108
+
109
+ This method determines which provider to use based on configuration
110
+ and creates it using the provider registry.
111
+
112
+ :return: None
113
+ :rtype: None
114
+ :raises ValueError: If no provider can be initialized
115
+ """
116
+ # Determine auth method
117
+ auth_method = self._determine_auth_method()
118
+
119
+ # Build provider config based on method
120
+ config = self._build_provider_config(auth_method)
121
+
122
+ # Create provider from registry
123
+ try:
124
+ self.provider = ProviderRegistry.create_provider(auth_method, config)
125
+ logger.info(f"Initialized {auth_method} authentication provider")
126
+
127
+ # Set default identity for providers that have one
128
+ if auth_method == "direct":
129
+ self._default_identity_id = "direct-auth"
130
+ elif (
131
+ auth_method == "openbridge"
132
+ and self.settings.openbridge_remote_identity_id
133
+ ):
134
+ self._default_identity_id = self.settings.openbridge_remote_identity_id
135
+
136
+ except ValueError as e:
137
+ # Provide helpful error message
138
+ available = list(ProviderRegistry.list_providers().keys())
139
+ raise ValueError(
140
+ f"Failed to initialize auth provider '{auth_method}': {e}\n"
141
+ f"Available providers: {', '.join(available)}\n"
142
+ f"Make sure you have the required configuration for your chosen provider."
143
+ )
144
+
145
+ def _determine_auth_method(self) -> str:
146
+ """Determine which authentication method to use based on available config.
147
+
148
+ Auto-detects the authentication method from environment variables
149
+ or uses the explicitly configured method.
150
+
151
+ :return: Authentication method name (e.g., 'direct', 'openbridge')
152
+ :rtype: str
153
+ :raises ValueError: If no authentication method is configured
154
+ """
155
+ # Check if explicitly set via env or settings override
156
+ explicit_env_method = os.getenv("AUTH_METHOD") or os.getenv(
157
+ "AMAZON_ADS_AUTH_METHOD"
158
+ )
159
+ if explicit_env_method:
160
+ return explicit_env_method.strip().lower()
161
+
162
+ method_from_settings = (self.settings.auth_method or "").strip().lower()
163
+ # Treat non-default methods as explicit configuration
164
+ if method_from_settings and method_from_settings != "openbridge":
165
+ return method_from_settings
166
+
167
+ # Allow explicit openbridge only when credentials are present
168
+ if (
169
+ method_from_settings == "openbridge"
170
+ and self.settings.openbridge_refresh_token
171
+ ):
172
+ return "openbridge"
173
+
174
+ # Auto-detect based on available credentials
175
+ if all(
176
+ [
177
+ self.settings.effective_client_id,
178
+ self.settings.effective_client_secret,
179
+ self.settings.effective_refresh_token,
180
+ ]
181
+ ):
182
+ logger.info("Auto-detected direct authentication from environment")
183
+ return "direct"
184
+
185
+ if self.settings.openbridge_refresh_token:
186
+ logger.info("Auto-detected OpenBridge authentication from environment")
187
+ return "openbridge"
188
+
189
+ # Check for other provider configs here as needed
190
+ # For example, check for AUTH0_DOMAIN, OKTA_DOMAIN, etc.
191
+
192
+ raise ValueError(
193
+ "No authentication method configured. Please set one of:\n"
194
+ "- For direct auth: AD_API_CLIENT_ID, AD_API_CLIENT_SECRET, AD_API_REFRESH_TOKEN\n"
195
+ "- For OpenBridge: OPENBRIDGE_REFRESH_TOKEN (or OPENBRIDGE_API_KEY)\n"
196
+ "- Or explicitly set AUTH_METHOD environment variable"
197
+ )
198
+
199
+ def _build_provider_config(self, auth_method: str) -> ProviderConfig:
200
+ """Build configuration for the specified authentication provider.
201
+
202
+ Creates a ProviderConfig instance with the appropriate
203
+ configuration data based on the authentication method.
204
+
205
+ :param auth_method: Authentication method to configure
206
+ :type auth_method: str
207
+ :return: Provider configuration object
208
+ :rtype: ProviderConfig
209
+ """
210
+ config_data = {}
211
+
212
+ if auth_method == "direct":
213
+ config_data = {
214
+ "client_id": self.settings.effective_client_id,
215
+ "client_secret": self.settings.effective_client_secret,
216
+ "refresh_token": self.settings.effective_refresh_token,
217
+ "profile_id": self.settings.effective_profile_id,
218
+ "region": self.settings.amazon_ads_region,
219
+ }
220
+
221
+ elif auth_method == "openbridge":
222
+ config_data = {
223
+ "refresh_token": self.settings.openbridge_refresh_token,
224
+ "region": self.settings.amazon_ads_region,
225
+ "auth_base_url": os.getenv("OPENBRIDGE_AUTH_BASE_URL"),
226
+ "identity_base_url": os.getenv("OPENBRIDGE_IDENTITY_BASE_URL"),
227
+ "service_base_url": os.getenv("OPENBRIDGE_SERVICE_BASE_URL"),
228
+ }
229
+
230
+ # Add more provider configs here as needed
231
+ # elif auth_method == "auth0":
232
+ # config_data = {
233
+ # "domain": os.getenv("AUTH0_DOMAIN"),
234
+ # "client_id": os.getenv("AUTH0_CLIENT_ID"),
235
+ # ...
236
+ # }
237
+
238
+ return ProviderConfig(**config_data)
239
+
240
+ async def initialize_provider(self) -> None:
241
+ """Initialize the authentication provider.
242
+
243
+ Performs any asynchronous initialization required by the
244
+ configured provider.
245
+
246
+ :return: None
247
+ :rtype: None
248
+ """
249
+ if self.provider:
250
+ await self.provider.initialize()
251
+
252
+ # Identity management methods (for providers that support multiple identities)
253
+
254
+ async def list_identities(self, **kwargs) -> List[Identity]:
255
+ """List all available identities.
256
+
257
+ Retrieves a list of all available identities from the
258
+ configured provider. For providers that don't support
259
+ multiple identities, returns a synthetic default identity.
260
+
261
+ :param **kwargs: Provider-specific filter parameters
262
+ :type **kwargs: Any
263
+ :return: List of available identities
264
+ :rtype: List[Identity]
265
+ :raises RuntimeError: If no auth provider is configured
266
+ """
267
+ if not self.provider:
268
+ raise RuntimeError("No auth provider configured")
269
+
270
+ if not isinstance(self.provider, BaseIdentityProvider):
271
+ # Provider doesn't support multiple identities
272
+ # Return a synthetic single identity
273
+ return [
274
+ Identity(
275
+ id="default",
276
+ type=self.provider.provider_type,
277
+ attributes={
278
+ "name": f"Default {self.provider.provider_type} identity"
279
+ },
280
+ )
281
+ ]
282
+
283
+ return await self.provider.list_identities(**kwargs)
284
+
285
+ async def get_identity(self, identity_id: str) -> Optional[Identity]:
286
+ """Get a specific identity by ID.
287
+
288
+ Retrieves a specific identity from the configured provider.
289
+ For providers that don't support multiple identities,
290
+ returns the default identity if the ID matches.
291
+
292
+ :param identity_id: Unique identifier for the identity
293
+ :type identity_id: str
294
+ :return: Identity if found, None otherwise
295
+ :rtype: Optional[Identity]
296
+ :raises RuntimeError: If no auth provider is configured
297
+ """
298
+ if not self.provider:
299
+ raise RuntimeError("No auth provider configured")
300
+
301
+ if not isinstance(self.provider, BaseIdentityProvider):
302
+ # Return synthetic identity if it matches
303
+ if identity_id == "default":
304
+ identities = await self.list_identities()
305
+ return identities[0] if identities else None
306
+ return None
307
+
308
+ return await self.provider.get_identity(identity_id)
309
+
310
+ async def set_active_identity(self, identity_id: str) -> Identity:
311
+ """Set the active identity for API operations.
312
+
313
+ Sets the specified identity as the active identity for
314
+ subsequent API operations. Clears cached credentials
315
+ for the previous identity.
316
+
317
+ :param identity_id: ID of the identity to activate
318
+ :type identity_id: str
319
+ :return: The activated identity
320
+ :rtype: Identity
321
+ :raises ValueError: If the specified identity is not found
322
+ """
323
+ logger.info(f"Setting active identity: {identity_id}")
324
+
325
+ identity = await self.get_identity(identity_id)
326
+ if not identity:
327
+ raise ValueError(f"Identity {identity_id} not found")
328
+
329
+ self._active_identity = identity
330
+
331
+ # Clear cached credentials for previous identity
332
+ if (
333
+ self._active_credentials
334
+ and self._active_credentials.identity_id != identity_id
335
+ ):
336
+ logger.info("Clearing cached credentials for previous identity")
337
+ self._active_credentials = None
338
+
339
+ logger.info(f"Active identity set to: {identity_id}")
340
+ return identity
341
+
342
+ async def ensure_default_identity(self) -> None:
343
+ """Ensure the default identity is loaded if configured.
344
+
345
+ Attempts to set the default identity if no active identity
346
+ is currently set and a default is configured.
347
+
348
+ :return: None
349
+ :rtype: None
350
+ """
351
+ if not self._active_identity and hasattr(self, "_default_identity_id"):
352
+ try:
353
+ await self.set_active_identity(self._default_identity_id)
354
+ except Exception as e:
355
+ logger.debug(f"Could not set default identity: {e}")
356
+
357
+ def get_active_identity(self) -> Optional[Identity]:
358
+ """Get the current active identity.
359
+
360
+ Returns the currently active identity for API operations.
361
+
362
+ :return: Active identity or None if none set
363
+ :rtype: Optional[Identity]
364
+ """
365
+ return self._active_identity
366
+
367
+ async def get_active_credentials(self) -> AuthCredentials:
368
+ """Get credentials for the active identity.
369
+
370
+ Retrieves valid credentials for the currently active identity,
371
+ refreshing them if necessary. For single-identity providers,
372
+ creates synthetic credentials.
373
+
374
+ :return: Valid credentials for the active identity
375
+ :rtype: AuthCredentials
376
+ :raises RuntimeError: If no auth provider is configured or no active identity
377
+ """
378
+ if not self.provider:
379
+ raise RuntimeError("No auth provider configured")
380
+
381
+ # For providers that support multiple identities
382
+ if isinstance(self.provider, BaseIdentityProvider):
383
+ if not self._active_identity:
384
+ # Try to use configured default
385
+ await self.ensure_default_identity()
386
+ if not self._active_identity:
387
+ logger.error(
388
+ f"No active identity set for {self.provider.provider_type}. "
389
+ f"Need to call set_active_identity() or configure default identity"
390
+ )
391
+ raise RuntimeError(
392
+ "No active identity set. Use set_active_identity() first."
393
+ )
394
+
395
+ identity_id = self._active_identity.id
396
+ logger.info(f"Getting credentials for active identity: {identity_id}")
397
+
398
+ # Try to get cached credentials from token store
399
+ cached_access = await self.get_token(
400
+ provider_type=self.provider.provider_type,
401
+ identity_id=identity_id,
402
+ token_kind=TokenKind.ACCESS,
403
+ region=self._active_identity.attributes.get("region"),
404
+ )
405
+
406
+ if cached_access and not cached_access.is_expired():
407
+ # Check if provider requires identity-specific headers
408
+ if (
409
+ hasattr(self.provider, "headers_are_identity_specific")
410
+ and self.provider.headers_are_identity_specific()
411
+ ):
412
+ logger.info(
413
+ f"{self.provider.provider_type}: Need full credentials, not just cached token"
414
+ )
415
+ # Fall through to fetch fresh credentials
416
+ else:
417
+ # Reconstruct credentials from cached token (for providers without identity-specific headers)
418
+ creds = AuthCredentials(
419
+ identity_id=identity_id,
420
+ access_token=cached_access.value,
421
+ expires_at=cached_access.expires_at,
422
+ base_url=(
423
+ self.provider.get_region_endpoint()
424
+ if hasattr(self.provider, "get_region_endpoint")
425
+ else None
426
+ ),
427
+ headers=(
428
+ await self.provider.get_headers()
429
+ if hasattr(self.provider, "get_headers")
430
+ else {}
431
+ ),
432
+ )
433
+ self._active_credentials = creds
434
+ return creds
435
+ elif cached_access:
436
+ logger.info(f"Credentials for {identity_id} expired, refreshing")
437
+
438
+ # Get new credentials
439
+ logger.info(
440
+ f"Fetching fresh credentials from {self.provider.provider_type} for identity {identity_id}"
441
+ )
442
+ credentials = await self.provider.get_identity_credentials(identity_id)
443
+ logger.info(
444
+ f"Got credentials with headers: {list(credentials.headers.keys())}"
445
+ )
446
+ logger.info(
447
+ f"Client ID in credentials: {credentials.headers.get('Amazon-Advertising-API-ClientId')}"
448
+ )
449
+
450
+ # Store access token in unified store
451
+ await self.set_token(
452
+ provider_type=self.provider.provider_type,
453
+ identity_id=identity_id,
454
+ token_kind=TokenKind.ACCESS,
455
+ token=credentials.access_token,
456
+ expires_at=credentials.expires_at,
457
+ metadata={"base_url": credentials.base_url},
458
+ region=self._active_identity.attributes.get("region"),
459
+ )
460
+
461
+ self._active_credentials = credentials
462
+
463
+ logger.info(
464
+ f"Got credentials for {identity_id}, expires at {credentials.expires_at}"
465
+ )
466
+ return credentials
467
+
468
+ # For single-identity providers, create synthetic credentials
469
+ else:
470
+ # Check if we have cached credentials
471
+ if (
472
+ self._active_credentials
473
+ and datetime.now(timezone.utc) < self._active_credentials.expires_at
474
+ ):
475
+ return self._active_credentials
476
+
477
+ identity_id = "default"
478
+
479
+ # Get token and headers from provider
480
+ token = await self.provider.get_token()
481
+ headers = await self.provider.get_headers()
482
+
483
+ credentials = AuthCredentials(
484
+ identity_id=identity_id,
485
+ access_token=token.value,
486
+ expires_at=token.expires_at,
487
+ base_url=(
488
+ self.provider.get_region_endpoint()
489
+ if hasattr(self.provider, "get_region_endpoint")
490
+ else ""
491
+ ),
492
+ headers=headers,
493
+ )
494
+
495
+ self._active_credentials = credentials
496
+ logger.info(
497
+ f"Cached credentials for identity {identity_id} with client ID: {credentials.headers.get('Amazon-Advertising-API-ClientId')}"
498
+ )
499
+ return credentials
500
+
501
+ async def get_headers(self) -> Dict[str, str]:
502
+ """Get authentication headers for API requests.
503
+
504
+ Retrieves authentication headers including the profile scope
505
+ if an active profile is set.
506
+
507
+ :return: Dictionary of authentication headers
508
+ :rtype: Dict[str, str]
509
+ """
510
+ credentials = await self.get_active_credentials()
511
+ headers = credentials.headers.copy()
512
+
513
+ # Debug logging to trace the issue
514
+ logger.info(f"Auth headers from credentials: {list(headers.keys())}")
515
+
516
+ # Add Authorization header from access token
517
+ if credentials.access_token:
518
+ headers["Authorization"] = f"Bearer {credentials.access_token}"
519
+
520
+ # Add profile ID to Scope header if we have one
521
+ profile_id = self.get_active_profile_id()
522
+ if profile_id:
523
+ headers["Amazon-Advertising-API-Scope"] = profile_id
524
+
525
+ return headers
526
+
527
+ # Profile management
528
+
529
+ def set_active_profile_id(self, profile_id: str) -> None:
530
+ """Set the active profile ID for the current identity.
531
+
532
+ Sets the Amazon Ads profile ID to use for API operations.
533
+ If no active identity is set, stores as the default profile.
534
+
535
+ :param profile_id: The Amazon Ads profile ID to use
536
+ :type profile_id: str
537
+ :return: None
538
+ :rtype: None
539
+ """
540
+ if not self._active_identity:
541
+ # Store as default
542
+ self._default_profile_id = profile_id
543
+ else:
544
+ identity_id = self._active_identity.id
545
+ self._active_profiles[identity_id] = profile_id
546
+
547
+ logger.info(f"Set active profile {profile_id}")
548
+
549
+ def get_active_profile_id(self) -> Optional[str]:
550
+ """Get the active profile ID for the current identity.
551
+
552
+ Returns the profile ID for the currently active identity,
553
+ or the default profile ID if no identity is active.
554
+
555
+ :return: Active profile ID or None if none set
556
+ :rtype: Optional[str]
557
+ """
558
+ if not self._active_identity:
559
+ return self._default_profile_id
560
+
561
+ identity_id = self._active_identity.id
562
+ return self._active_profiles.get(identity_id, self._default_profile_id)
563
+
564
+ def clear_active_profile_id(self) -> None:
565
+ """Clear the active profile ID for the current identity.
566
+
567
+ Removes the profile ID association for the currently
568
+ active identity.
569
+
570
+ :return: None
571
+ :rtype: None
572
+ """
573
+ if self._active_identity:
574
+ identity_id = self._active_identity.id
575
+ if identity_id in self._active_profiles:
576
+ del self._active_profiles[identity_id]
577
+ logger.info(f"Cleared active profile for identity {identity_id}")
578
+
579
+ def get_active_region(self) -> Optional[str]:
580
+ """Get the normalized region for the active identity.
581
+
582
+ Returns the normalized region code for the currently
583
+ active identity or provider.
584
+
585
+ :return: Normalized region code (na, eu, fe) or None
586
+ :rtype: Optional[str]
587
+ """
588
+ if self.provider and hasattr(self.provider, "region"):
589
+ return self.provider.region
590
+
591
+ # Try to get from identity attributes
592
+ if self._active_identity:
593
+ try:
594
+ attrs = getattr(self._active_identity, "attributes", {})
595
+ region = attrs.get("region")
596
+ if region and region in {"na", "eu", "fe"}:
597
+ return region
598
+ except (AttributeError, TypeError, KeyError):
599
+ pass
600
+
601
+ return None
602
+
603
+ def get_profile_source(self) -> str:
604
+ """Get the source of the active profile ID.
605
+
606
+ Returns whether the active profile ID was explicitly set
607
+ for the current identity or is using the default.
608
+
609
+ :return: 'explicit' if set for current identity, 'default' otherwise
610
+ :rtype: str
611
+ """
612
+ if self._active_identity and self._active_identity.id in self._active_profiles:
613
+ return "explicit"
614
+ return "default"
615
+
616
+ def get_active_identity_id(self) -> Optional[str]:
617
+ """Get the ID of the active identity.
618
+
619
+ Returns the ID of the currently active identity, if any.
620
+
621
+ :return: Active identity ID or None
622
+ :rtype: Optional[str]
623
+ """
624
+ if self._active_identity:
625
+ return self._active_identity.id
626
+ return None
627
+
628
+ # Token Store interface methods
629
+
630
+ @property
631
+ def token_store(self) -> TokenStore:
632
+ """Get the unified token store instance.
633
+
634
+ :return: Token store instance
635
+ :rtype: TokenStore
636
+ """
637
+ return self._token_store
638
+
639
+ async def get_token(
640
+ self,
641
+ provider_type: str,
642
+ identity_id: str,
643
+ token_kind: TokenKind,
644
+ region: Optional[str] = None,
645
+ profile_id: Optional[str] = None,
646
+ ) -> Optional[TokenEntry]:
647
+ """
648
+ Get a token from the unified store.
649
+
650
+ Retrieves a token from the token store based on the provided
651
+ provider type, identity, and token kind. Returns None if
652
+ the token is not found or has expired.
653
+
654
+ :param provider_type: Provider type (e.g., 'direct', 'openbridge')
655
+ :type provider_type: str
656
+ :param identity_id: Identity identifier
657
+ :type identity_id: str
658
+ :param token_kind: Type of token
659
+ :type token_kind: TokenKind
660
+ :param region: Optional region
661
+ :type region: Optional[str]
662
+ :param profile_id: Optional profile ID
663
+ :type profile_id: Optional[str]
664
+ :return: Token entry if found and not expired
665
+ :rtype: Optional[TokenEntry]
666
+ """
667
+ key = TokenKey(
668
+ provider_type=provider_type,
669
+ identity_id=identity_id,
670
+ token_kind=token_kind,
671
+ region=region,
672
+ profile_id=profile_id,
673
+ )
674
+ return await self._token_store.get(key)
675
+
676
+ async def set_token(
677
+ self,
678
+ provider_type: str,
679
+ identity_id: str,
680
+ token_kind: TokenKind,
681
+ token: str,
682
+ expires_at: datetime,
683
+ metadata: Optional[Dict[str, Any]] = None,
684
+ region: Optional[str] = None,
685
+ profile_id: Optional[str] = None,
686
+ ) -> None:
687
+ """Store a token in the unified store.
688
+
689
+ :param provider_type: Provider type (e.g., 'direct', 'openbridge')
690
+ :type provider_type: str
691
+ :param identity_id: Identity identifier
692
+ :type identity_id: str
693
+ :param token_kind: Type of token
694
+ :type token_kind: TokenKind
695
+ :param token: The token value
696
+ :type token: str
697
+ :param expires_at: When the token expires
698
+ :type expires_at: datetime
699
+ :param metadata: Optional metadata
700
+ :type metadata: Optional[Dict[str, Any]]
701
+ :param region: Optional region
702
+ :type region: Optional[str]
703
+ :param profile_id: Optional profile ID
704
+ :type profile_id: Optional[str]
705
+ """
706
+ key = TokenKey(
707
+ provider_type=provider_type,
708
+ identity_id=identity_id,
709
+ token_kind=token_kind,
710
+ region=region,
711
+ profile_id=profile_id,
712
+ )
713
+ entry = TokenEntry(value=token, expires_at=expires_at, metadata=metadata or {})
714
+ await self._token_store.set(key, entry)
715
+
716
+ async def invalidate_token(
717
+ self,
718
+ provider_type: str,
719
+ identity_id: str,
720
+ token_kind: TokenKind,
721
+ region: Optional[str] = None,
722
+ profile_id: Optional[str] = None,
723
+ ) -> None:
724
+ """Invalidate a specific token.
725
+
726
+ :param provider_type: Provider type
727
+ :type provider_type: str
728
+ :param identity_id: Identity identifier
729
+ :type identity_id: str
730
+ :param token_kind: Type of token
731
+ :type token_kind: TokenKind
732
+ :param region: Optional region
733
+ :type region: Optional[str]
734
+ :param profile_id: Optional profile ID
735
+ :type profile_id: Optional[str]
736
+ """
737
+ key = TokenKey(
738
+ provider_type=provider_type,
739
+ identity_id=identity_id,
740
+ token_kind=token_kind,
741
+ region=region,
742
+ profile_id=profile_id,
743
+ )
744
+ await self._token_store.invalidate(key)
745
+
746
+ async def close(self):
747
+ """Clean up resources.
748
+
749
+ Closes the authentication provider and clears cached
750
+ credentials to free up resources.
751
+
752
+ :return: None
753
+ :rtype: None
754
+ """
755
+ if self.provider:
756
+ await self.provider.close()
757
+ await self._token_store.clear()
758
+
759
+ @classmethod
760
+ def reset(cls):
761
+ """Reset singleton instance.
762
+
763
+ Resets the singleton instance, primarily used for
764
+ testing purposes to ensure clean state.
765
+
766
+ :return: None
767
+ :rtype: None
768
+ """
769
+ global _auth_manager
770
+ if cls._instance:
771
+ cls._instance = None
772
+ _auth_manager = None
773
+
774
+
775
+ # Global auth manager instance
776
+ _auth_manager: Optional[AuthManager] = None
777
+
778
+
779
+ def get_auth_manager() -> AuthManager:
780
+ """Get or create the global authentication manager instance.
781
+
782
+ Returns the global singleton instance of AuthManager,
783
+ creating it if it doesn't exist.
784
+
785
+ :return: Global authentication manager instance
786
+ :rtype: AuthManager
787
+ """
788
+ global _auth_manager
789
+ if _auth_manager is None:
790
+ _auth_manager = AuthManager()
791
+ return _auth_manager