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.
- amazon_ads_mcp/__init__.py +11 -0
- amazon_ads_mcp/auth/__init__.py +33 -0
- amazon_ads_mcp/auth/base.py +211 -0
- amazon_ads_mcp/auth/hooks.py +172 -0
- amazon_ads_mcp/auth/manager.py +791 -0
- amazon_ads_mcp/auth/oauth_state_store.py +277 -0
- amazon_ads_mcp/auth/providers/__init__.py +14 -0
- amazon_ads_mcp/auth/providers/direct.py +393 -0
- amazon_ads_mcp/auth/providers/example_auth0.py.example +216 -0
- amazon_ads_mcp/auth/providers/openbridge.py +512 -0
- amazon_ads_mcp/auth/registry.py +146 -0
- amazon_ads_mcp/auth/secure_token_store.py +297 -0
- amazon_ads_mcp/auth/token_store.py +723 -0
- amazon_ads_mcp/config/__init__.py +5 -0
- amazon_ads_mcp/config/sampling.py +111 -0
- amazon_ads_mcp/config/settings.py +366 -0
- amazon_ads_mcp/exceptions.py +314 -0
- amazon_ads_mcp/middleware/__init__.py +11 -0
- amazon_ads_mcp/middleware/authentication.py +1474 -0
- amazon_ads_mcp/middleware/caching.py +177 -0
- amazon_ads_mcp/middleware/oauth.py +175 -0
- amazon_ads_mcp/middleware/sampling.py +112 -0
- amazon_ads_mcp/models/__init__.py +320 -0
- amazon_ads_mcp/models/amc_models.py +837 -0
- amazon_ads_mcp/models/api_responses.py +847 -0
- amazon_ads_mcp/models/base_models.py +215 -0
- amazon_ads_mcp/models/builtin_responses.py +496 -0
- amazon_ads_mcp/models/dsp_models.py +556 -0
- amazon_ads_mcp/models/stores_brands.py +610 -0
- amazon_ads_mcp/server/__init__.py +6 -0
- amazon_ads_mcp/server/__main__.py +6 -0
- amazon_ads_mcp/server/builtin_prompts.py +269 -0
- amazon_ads_mcp/server/builtin_tools.py +962 -0
- amazon_ads_mcp/server/file_routes.py +547 -0
- amazon_ads_mcp/server/html_templates.py +149 -0
- amazon_ads_mcp/server/mcp_server.py +327 -0
- amazon_ads_mcp/server/openapi_utils.py +158 -0
- amazon_ads_mcp/server/sampling_handler.py +251 -0
- amazon_ads_mcp/server/server_builder.py +751 -0
- amazon_ads_mcp/server/sidecar_loader.py +178 -0
- amazon_ads_mcp/server/transform_executor.py +827 -0
- amazon_ads_mcp/tools/__init__.py +22 -0
- amazon_ads_mcp/tools/cache_management.py +105 -0
- amazon_ads_mcp/tools/download_tools.py +267 -0
- amazon_ads_mcp/tools/identity.py +236 -0
- amazon_ads_mcp/tools/oauth.py +598 -0
- amazon_ads_mcp/tools/profile.py +150 -0
- amazon_ads_mcp/tools/profile_listing.py +285 -0
- amazon_ads_mcp/tools/region.py +320 -0
- amazon_ads_mcp/tools/region_identity.py +175 -0
- amazon_ads_mcp/utils/__init__.py +6 -0
- amazon_ads_mcp/utils/async_compat.py +215 -0
- amazon_ads_mcp/utils/errors.py +452 -0
- amazon_ads_mcp/utils/export_content_type_resolver.py +249 -0
- amazon_ads_mcp/utils/export_download_handler.py +579 -0
- amazon_ads_mcp/utils/header_resolver.py +81 -0
- amazon_ads_mcp/utils/http/__init__.py +56 -0
- amazon_ads_mcp/utils/http/circuit_breaker.py +127 -0
- amazon_ads_mcp/utils/http/client_manager.py +329 -0
- amazon_ads_mcp/utils/http/request.py +207 -0
- amazon_ads_mcp/utils/http/resilience.py +512 -0
- amazon_ads_mcp/utils/http/resilient_client.py +195 -0
- amazon_ads_mcp/utils/http/retry.py +76 -0
- amazon_ads_mcp/utils/http_client.py +873 -0
- amazon_ads_mcp/utils/media/__init__.py +21 -0
- amazon_ads_mcp/utils/media/negotiator.py +243 -0
- amazon_ads_mcp/utils/media/types.py +199 -0
- amazon_ads_mcp/utils/openapi/__init__.py +16 -0
- amazon_ads_mcp/utils/openapi/json.py +55 -0
- amazon_ads_mcp/utils/openapi/loader.py +263 -0
- amazon_ads_mcp/utils/openapi/refs.py +46 -0
- amazon_ads_mcp/utils/region_config.py +200 -0
- amazon_ads_mcp/utils/response_wrapper.py +171 -0
- amazon_ads_mcp/utils/sampling_helpers.py +156 -0
- amazon_ads_mcp/utils/sampling_wrapper.py +173 -0
- amazon_ads_mcp/utils/security.py +630 -0
- amazon_ads_mcp/utils/tool_naming.py +137 -0
- amazon_ads_mcp-0.2.7.dist-info/METADATA +664 -0
- amazon_ads_mcp-0.2.7.dist-info/RECORD +82 -0
- amazon_ads_mcp-0.2.7.dist-info/WHEEL +4 -0
- amazon_ads_mcp-0.2.7.dist-info/entry_points.txt +3 -0
- 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
|