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,512 @@
|
|
|
1
|
+
"""OpenBridge authentication provider.
|
|
2
|
+
|
|
3
|
+
This module implements the OpenBridge authentication provider,
|
|
4
|
+
which manages multiple Amazon Ads identities through OpenBridge's
|
|
5
|
+
remote identity service.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import jwt
|
|
15
|
+
from pydantic import BaseModel, ValidationError
|
|
16
|
+
|
|
17
|
+
from ...models import AuthCredentials, Identity, Token
|
|
18
|
+
from ...utils.http import get_http_client
|
|
19
|
+
from ..base import BaseAmazonAdsProvider, BaseIdentityProvider, ProviderConfig
|
|
20
|
+
from ..registry import register_provider
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OpenbridgeTokenResponse(BaseModel):
|
|
26
|
+
"""OpenBridge token response model.
|
|
27
|
+
|
|
28
|
+
Represents the response from OpenBridge when requesting an
|
|
29
|
+
access token for Amazon Ads API authentication.
|
|
30
|
+
|
|
31
|
+
:param data: Raw response data containing token information
|
|
32
|
+
:type data: Dict[str, Any]
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
data: Dict[str, Any]
|
|
36
|
+
|
|
37
|
+
def get_token(self) -> Optional[str]:
|
|
38
|
+
"""Extract token from response.
|
|
39
|
+
|
|
40
|
+
Extracts the access token from the OpenBridge response data.
|
|
41
|
+
|
|
42
|
+
:return: Access token string if available, None otherwise
|
|
43
|
+
:rtype: Optional[str]
|
|
44
|
+
"""
|
|
45
|
+
# The response has data.access_token directly
|
|
46
|
+
return self.data.get("access_token")
|
|
47
|
+
|
|
48
|
+
def get_client_id(self) -> Optional[str]:
|
|
49
|
+
"""Extract client ID from response.
|
|
50
|
+
|
|
51
|
+
Extracts the client ID from the OpenBridge response data.
|
|
52
|
+
Checks multiple possible field names where the client ID might be stored.
|
|
53
|
+
|
|
54
|
+
:return: Client ID string if available, None otherwise
|
|
55
|
+
:rtype: Optional[str]
|
|
56
|
+
"""
|
|
57
|
+
# Check multiple possible fields for client ID
|
|
58
|
+
# OpenBridge might use different field names
|
|
59
|
+
client_id = (
|
|
60
|
+
self.data.get("client_id")
|
|
61
|
+
or self.data.get("clientId")
|
|
62
|
+
or self.data.get("amazon_advertising_api_client_id")
|
|
63
|
+
or self.data.get("amazonAdvertisingApiClientId")
|
|
64
|
+
)
|
|
65
|
+
return client_id
|
|
66
|
+
|
|
67
|
+
def get_scope(self) -> Optional[str]:
|
|
68
|
+
"""Extract scope/profile ID from response.
|
|
69
|
+
|
|
70
|
+
Extracts the scope (profile ID) from the OpenBridge response data.
|
|
71
|
+
|
|
72
|
+
:return: Scope/Profile ID string if available, None otherwise
|
|
73
|
+
:rtype: Optional[str]
|
|
74
|
+
"""
|
|
75
|
+
# Check for scope or profile_id in the response
|
|
76
|
+
scope = (
|
|
77
|
+
self.data.get("scope")
|
|
78
|
+
or self.data.get("profile_id")
|
|
79
|
+
or self.data.get("profileId")
|
|
80
|
+
or self.data.get("amazon_advertising_api_scope")
|
|
81
|
+
)
|
|
82
|
+
return scope
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@register_provider("openbridge")
|
|
86
|
+
class OpenBridgeProvider(BaseAmazonAdsProvider, BaseIdentityProvider):
|
|
87
|
+
"""OpenBridge authentication provider.
|
|
88
|
+
|
|
89
|
+
Provides authentication and identity management through the OpenBridge
|
|
90
|
+
platform, handling JWT token conversion and remote identity access.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, config: ProviderConfig):
|
|
94
|
+
"""Initialize OpenBridge provider.
|
|
95
|
+
|
|
96
|
+
:param config: Provider configuration
|
|
97
|
+
:type config: ProviderConfig
|
|
98
|
+
"""
|
|
99
|
+
# Refresh token can come from config OR be provided later via Authorization header
|
|
100
|
+
# Don't require it at initialization time
|
|
101
|
+
self.refresh_token = config.get("refresh_token")
|
|
102
|
+
|
|
103
|
+
self._region = config.get("region", "na")
|
|
104
|
+
|
|
105
|
+
# OpenBridge API endpoints - configurable via config or env
|
|
106
|
+
self.auth_base_url = config.get("auth_base_url") or os.environ.get(
|
|
107
|
+
"OPENBRIDGE_AUTH_BASE_URL",
|
|
108
|
+
"https://authentication.api.openbridge.io",
|
|
109
|
+
)
|
|
110
|
+
self.identity_base_url = config.get("identity_base_url") or os.environ.get(
|
|
111
|
+
"OPENBRIDGE_IDENTITY_BASE_URL",
|
|
112
|
+
"https://remote-identity.api.openbridge.io",
|
|
113
|
+
)
|
|
114
|
+
self.service_base_url = config.get("service_base_url") or os.environ.get(
|
|
115
|
+
"OPENBRIDGE_SERVICE_BASE_URL", "https://service.api.openbridge.io"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
self._jwt_token: Optional[Token] = None
|
|
119
|
+
self._identities_cache: Dict[tuple, List[Identity]] = {}
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def provider_type(self) -> str:
|
|
123
|
+
"""Return the provider type identifier."""
|
|
124
|
+
return "openbridge"
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def region(self) -> str:
|
|
128
|
+
"""Get the current region."""
|
|
129
|
+
return self._region
|
|
130
|
+
|
|
131
|
+
async def initialize(self) -> None:
|
|
132
|
+
"""Initialize the provider."""
|
|
133
|
+
logger.info("Initializing OpenBridge provider")
|
|
134
|
+
# Could validate the refresh token here if needed
|
|
135
|
+
|
|
136
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
137
|
+
"""Get shared HTTP client."""
|
|
138
|
+
return await get_http_client()
|
|
139
|
+
|
|
140
|
+
def set_refresh_token(self, refresh_token: str) -> None:
|
|
141
|
+
"""Set the refresh token dynamically.
|
|
142
|
+
|
|
143
|
+
This allows the refresh token to be provided via the Authorization header
|
|
144
|
+
rather than requiring it in the configuration.
|
|
145
|
+
|
|
146
|
+
:param refresh_token: The OpenBridge refresh token
|
|
147
|
+
:type refresh_token: str
|
|
148
|
+
"""
|
|
149
|
+
self.refresh_token = refresh_token
|
|
150
|
+
# Clear cached JWT when refresh token changes
|
|
151
|
+
self._jwt_token = None
|
|
152
|
+
|
|
153
|
+
async def get_token(self) -> Token:
|
|
154
|
+
"""Get current JWT token from OpenBridge."""
|
|
155
|
+
if self._jwt_token and await self.validate_token(self._jwt_token):
|
|
156
|
+
return self._jwt_token
|
|
157
|
+
|
|
158
|
+
if not self.refresh_token:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"No OpenBridge token available. Set OPENBRIDGE_REFRESH_TOKEN (or OPENBRIDGE_API_KEY), or pass it via Authorization header."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return await self._refresh_jwt_token()
|
|
164
|
+
|
|
165
|
+
async def _refresh_jwt_token(self) -> Token:
|
|
166
|
+
"""Convert refresh token to JWT via OpenBridge."""
|
|
167
|
+
if not self.refresh_token:
|
|
168
|
+
raise ValueError("Cannot refresh JWT: No refresh token available")
|
|
169
|
+
|
|
170
|
+
logger.debug("Converting OpenBridge refresh token to JWT")
|
|
171
|
+
|
|
172
|
+
client = await self._get_client()
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
response = await client.post(
|
|
176
|
+
f"{self.auth_base_url}/auth/api/ref",
|
|
177
|
+
json={
|
|
178
|
+
"data": {
|
|
179
|
+
"type": "APIAuth",
|
|
180
|
+
"attributes": {"refresh_token": self.refresh_token},
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
headers={"Content-Type": "application/json"},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if response.status_code not in [200, 202]:
|
|
187
|
+
response.raise_for_status()
|
|
188
|
+
|
|
189
|
+
data = response.json()
|
|
190
|
+
token_value = data.get("data", {}).get("attributes", {}).get("token")
|
|
191
|
+
|
|
192
|
+
if not token_value:
|
|
193
|
+
raise ValueError("No token in OpenBridge response")
|
|
194
|
+
|
|
195
|
+
# Parse the JWT to get expiration
|
|
196
|
+
payload = jwt.decode(token_value, options={"verify_signature": False})
|
|
197
|
+
expires_at_timestamp = payload.get("expires_at", 0)
|
|
198
|
+
expires_at = datetime.fromtimestamp(expires_at_timestamp, tz=timezone.utc)
|
|
199
|
+
|
|
200
|
+
self._jwt_token = Token(
|
|
201
|
+
value=token_value,
|
|
202
|
+
expires_at=expires_at,
|
|
203
|
+
token_type="Bearer",
|
|
204
|
+
metadata={
|
|
205
|
+
"user_id": payload.get("user_id"),
|
|
206
|
+
"account_id": payload.get("account_id"),
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
logger.debug(f"OpenBridge JWT obtained, expires at {expires_at}")
|
|
211
|
+
return self._jwt_token
|
|
212
|
+
|
|
213
|
+
except httpx.HTTPError as e:
|
|
214
|
+
logger.error(f"Failed to get OpenBridge JWT: {e}")
|
|
215
|
+
raise
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error(f"Error processing OpenBridge token: {e}")
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
async def validate_token(self, token: Token) -> bool:
|
|
221
|
+
"""Validate if token is still valid."""
|
|
222
|
+
buffer = timedelta(minutes=5)
|
|
223
|
+
now = datetime.now(timezone.utc)
|
|
224
|
+
expiry = token.expires_at
|
|
225
|
+
# Ensure both datetimes are timezone-aware for comparison
|
|
226
|
+
if expiry.tzinfo is None:
|
|
227
|
+
expiry = expiry.replace(tzinfo=timezone.utc)
|
|
228
|
+
return now < (expiry - buffer)
|
|
229
|
+
|
|
230
|
+
async def list_identities(self, **kwargs) -> List[Identity]:
|
|
231
|
+
"""List all remote identities from OpenBridge.
|
|
232
|
+
|
|
233
|
+
:param kwargs: Optional filters (identity_type, page_size)
|
|
234
|
+
:return: List of identities
|
|
235
|
+
"""
|
|
236
|
+
identity_type = kwargs.get("identity_type", "14") # Default to Amazon Ads
|
|
237
|
+
page_size = kwargs.get("page_size", 100)
|
|
238
|
+
|
|
239
|
+
cache_key = (identity_type, page_size)
|
|
240
|
+
if cache_key in self._identities_cache:
|
|
241
|
+
logger.debug(f"Using cached identities for {cache_key}")
|
|
242
|
+
return self._identities_cache[cache_key]
|
|
243
|
+
|
|
244
|
+
identities = await self._fetch_identities(identity_type, page_size)
|
|
245
|
+
|
|
246
|
+
# Simple cache management
|
|
247
|
+
if len(self._identities_cache) >= 32:
|
|
248
|
+
oldest_key = next(iter(self._identities_cache))
|
|
249
|
+
del self._identities_cache[oldest_key]
|
|
250
|
+
|
|
251
|
+
self._identities_cache[cache_key] = identities
|
|
252
|
+
return identities
|
|
253
|
+
|
|
254
|
+
async def _fetch_identities(
|
|
255
|
+
self, identity_type: str, page_size: int
|
|
256
|
+
) -> List[Identity]:
|
|
257
|
+
"""Fetch identities from OpenBridge API."""
|
|
258
|
+
logger.info(
|
|
259
|
+
f"Fetching remote identities from OpenBridge (type={identity_type})"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
jwt_token = await self.get_token()
|
|
263
|
+
client = await self._get_client()
|
|
264
|
+
identities = []
|
|
265
|
+
page = 1
|
|
266
|
+
has_more = True
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
while has_more:
|
|
270
|
+
logger.debug(f"Fetching page {page} of identities")
|
|
271
|
+
params = {"page": page, "page_size": page_size}
|
|
272
|
+
|
|
273
|
+
if identity_type:
|
|
274
|
+
params["remote_identity_type"] = identity_type
|
|
275
|
+
|
|
276
|
+
response = await client.get(
|
|
277
|
+
f"{self.identity_base_url}/sri",
|
|
278
|
+
params=params,
|
|
279
|
+
headers={
|
|
280
|
+
"Authorization": f"Bearer {jwt_token.value}",
|
|
281
|
+
"x-api-key": self.refresh_token,
|
|
282
|
+
},
|
|
283
|
+
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
284
|
+
)
|
|
285
|
+
response.raise_for_status()
|
|
286
|
+
|
|
287
|
+
data = response.json()
|
|
288
|
+
items = data.get("data", [])
|
|
289
|
+
logger.debug(f"Page {page} has {len(items)} items")
|
|
290
|
+
|
|
291
|
+
for item in items:
|
|
292
|
+
try:
|
|
293
|
+
identity = Identity(**item)
|
|
294
|
+
identities.append(identity)
|
|
295
|
+
except ValidationError as e:
|
|
296
|
+
logger.warning(f"Failed to parse identity: {e}")
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
# Check for more pages
|
|
300
|
+
links = data.get("links", {})
|
|
301
|
+
has_more = bool(links.get("next"))
|
|
302
|
+
|
|
303
|
+
if not items:
|
|
304
|
+
logger.debug(f"No items on page {page}, stopping pagination")
|
|
305
|
+
has_more = False
|
|
306
|
+
|
|
307
|
+
page += 1
|
|
308
|
+
|
|
309
|
+
if page > 100:
|
|
310
|
+
logger.warning("Reached maximum page limit (100)")
|
|
311
|
+
break
|
|
312
|
+
|
|
313
|
+
logger.info(f"Found {len(identities)} remote identities")
|
|
314
|
+
return identities
|
|
315
|
+
|
|
316
|
+
except httpx.HTTPError as e:
|
|
317
|
+
logger.error(f"Failed to list identities: {e}")
|
|
318
|
+
raise
|
|
319
|
+
|
|
320
|
+
async def get_identity(self, identity_id: str) -> Optional[Identity]:
|
|
321
|
+
"""Get specific identity by ID."""
|
|
322
|
+
identities = await self.list_identities()
|
|
323
|
+
for identity in identities:
|
|
324
|
+
if identity.id == identity_id:
|
|
325
|
+
return identity
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
async def get_identity_credentials(self, identity_id: str) -> AuthCredentials:
|
|
329
|
+
"""Get Amazon Ads credentials for specific identity.
|
|
330
|
+
|
|
331
|
+
OpenBridge handles token refresh internally - each call to their
|
|
332
|
+
/service/amzadv/token/<id> endpoint returns a fresh, valid token.
|
|
333
|
+
We parse the expiration if possible to enable client-side caching.
|
|
334
|
+
"""
|
|
335
|
+
logger.info(f"Getting credentials for identity {identity_id}")
|
|
336
|
+
|
|
337
|
+
identity = await self.get_identity(identity_id)
|
|
338
|
+
if not identity:
|
|
339
|
+
raise ValueError(f"Identity {identity_id} not found")
|
|
340
|
+
|
|
341
|
+
jwt_token = await self.get_token()
|
|
342
|
+
client = await self._get_client()
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
response = await client.get(
|
|
346
|
+
f"{self.service_base_url}/service/amzadv/token/{identity_id}",
|
|
347
|
+
headers={
|
|
348
|
+
"Authorization": f"Bearer {jwt_token.value}",
|
|
349
|
+
"x-api-key": self.refresh_token,
|
|
350
|
+
},
|
|
351
|
+
)
|
|
352
|
+
response.raise_for_status()
|
|
353
|
+
|
|
354
|
+
data = response.json()
|
|
355
|
+
# Log sanitized response metadata (without sensitive tokens)
|
|
356
|
+
logger.debug(
|
|
357
|
+
"OpenBridge token response received",
|
|
358
|
+
extra={
|
|
359
|
+
"has_data": "data" in data,
|
|
360
|
+
"data_keys": list(data.get("data", {}).keys()) if "data" in data else [],
|
|
361
|
+
}
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
token_data = OpenbridgeTokenResponse(data=data.get("data", {}))
|
|
365
|
+
amazon_ads_token = token_data.get_token()
|
|
366
|
+
client_id = token_data.get_client_id()
|
|
367
|
+
scope = token_data.get_scope()
|
|
368
|
+
|
|
369
|
+
# Log what we extracted
|
|
370
|
+
logger.info(
|
|
371
|
+
f"Extracted from OpenBridge response - token: {bool(amazon_ads_token)}, client_id: {client_id}, scope: {scope}"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if not amazon_ads_token:
|
|
375
|
+
raise ValueError("No Amazon Ads token in response")
|
|
376
|
+
|
|
377
|
+
# Handle client ID fallback
|
|
378
|
+
if not client_id:
|
|
379
|
+
# Only fall back to env var if OpenBridge didn't provide a client ID
|
|
380
|
+
env_client_id = os.getenv("AMAZON_AD_API_CLIENT_ID")
|
|
381
|
+
if env_client_id:
|
|
382
|
+
logger.info(
|
|
383
|
+
"OpenBridge didn't provide client ID, using AMAZON_AD_API_CLIENT_ID env var"
|
|
384
|
+
)
|
|
385
|
+
client_id = env_client_id
|
|
386
|
+
else:
|
|
387
|
+
raise ValueError(
|
|
388
|
+
"No client ID from OpenBridge and AMAZON_AD_API_CLIENT_ID not set"
|
|
389
|
+
)
|
|
390
|
+
elif client_id.lower() == "openbridge":
|
|
391
|
+
# Legacy: Some older OpenBridge setups might return "openbridge" as placeholder
|
|
392
|
+
logger.warning(
|
|
393
|
+
"OpenBridge returned 'openbridge' as client ID placeholder. "
|
|
394
|
+
"Please update your OpenBridge configuration to provide the real client ID."
|
|
395
|
+
)
|
|
396
|
+
env_client_id = os.getenv("AMAZON_AD_API_CLIENT_ID")
|
|
397
|
+
if env_client_id:
|
|
398
|
+
logger.info("Using AMAZON_AD_API_CLIENT_ID env var as fallback")
|
|
399
|
+
client_id = env_client_id
|
|
400
|
+
else:
|
|
401
|
+
raise ValueError(
|
|
402
|
+
"OpenBridge returned 'openbridge' placeholder and AMAZON_AD_API_CLIENT_ID not set"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Try to parse expiration from the Amazon token if it's a JWT
|
|
406
|
+
expires_at = None
|
|
407
|
+
try:
|
|
408
|
+
# Amazon tokens are usually JWTs we can decode
|
|
409
|
+
payload = jwt.decode(amazon_ads_token, options={"verify_signature": False})
|
|
410
|
+
# Check for standard JWT expiration claim
|
|
411
|
+
if "exp" in payload:
|
|
412
|
+
expires_at = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
|
413
|
+
logger.info(f"Parsed Amazon token expiration: {expires_at}")
|
|
414
|
+
elif "expires_at" in payload:
|
|
415
|
+
expires_at = datetime.fromtimestamp(payload["expires_at"], tz=timezone.utc)
|
|
416
|
+
logger.info(f"Parsed Amazon token expiration: {expires_at}")
|
|
417
|
+
except Exception as e:
|
|
418
|
+
logger.debug(f"Could not parse Amazon token as JWT: {e}")
|
|
419
|
+
|
|
420
|
+
# If we couldn't parse expiration, use a conservative default
|
|
421
|
+
# OpenBridge should always return fresh tokens, but we use a short
|
|
422
|
+
# expiration to ensure frequent refresh checks
|
|
423
|
+
if expires_at is None:
|
|
424
|
+
expires_at = datetime.now(timezone.utc) + timedelta(minutes=55)
|
|
425
|
+
logger.info("Using default 55-minute expiration for Amazon token")
|
|
426
|
+
else:
|
|
427
|
+
# Check if the token is already expired or about to expire
|
|
428
|
+
now = datetime.now(timezone.utc)
|
|
429
|
+
time_until_expiry = expires_at - now
|
|
430
|
+
if time_until_expiry.total_seconds() < 300: # Less than 5 minutes
|
|
431
|
+
logger.warning(
|
|
432
|
+
f"OpenBridge returned token expiring in {time_until_expiry.total_seconds():.0f} seconds!"
|
|
433
|
+
)
|
|
434
|
+
# OpenBridge should not return expired tokens, but log if it happens
|
|
435
|
+
if time_until_expiry.total_seconds() < 0:
|
|
436
|
+
logger.error("OpenBridge returned an EXPIRED token! This should not happen.")
|
|
437
|
+
|
|
438
|
+
# Get the identity's region for the correct endpoint
|
|
439
|
+
identity_region = identity.attributes.get("region", "na").lower()
|
|
440
|
+
logger.info(f"Using identity region: {identity_region}")
|
|
441
|
+
|
|
442
|
+
# Build headers with all available information
|
|
443
|
+
headers = {
|
|
444
|
+
"Authorization": f"Bearer {amazon_ads_token}",
|
|
445
|
+
"Amazon-Advertising-API-ClientId": client_id,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
# Add scope if provided by OpenBridge
|
|
449
|
+
if scope:
|
|
450
|
+
headers["Amazon-Advertising-API-Scope"] = scope
|
|
451
|
+
logger.info(f"Using scope from OpenBridge: {scope}")
|
|
452
|
+
|
|
453
|
+
return AuthCredentials(
|
|
454
|
+
identity_id=identity_id,
|
|
455
|
+
access_token=amazon_ads_token,
|
|
456
|
+
expires_at=expires_at,
|
|
457
|
+
base_url=self.get_region_endpoint(identity_region),
|
|
458
|
+
headers=headers,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
except httpx.HTTPError as e:
|
|
462
|
+
logger.error(f"Failed to get identity credentials: {e}")
|
|
463
|
+
raise
|
|
464
|
+
|
|
465
|
+
async def get_headers(self) -> Dict[str, str]:
|
|
466
|
+
"""Get authentication headers.
|
|
467
|
+
|
|
468
|
+
For OpenBridge, this returns empty headers since all headers
|
|
469
|
+
come from the identity-specific credentials.
|
|
470
|
+
"""
|
|
471
|
+
# OpenBridge headers are identity-specific and come from
|
|
472
|
+
# get_identity_credentials(). Return empty here to avoid
|
|
473
|
+
# overriding with incorrect values.
|
|
474
|
+
return {}
|
|
475
|
+
|
|
476
|
+
def requires_identity_region_routing(self) -> bool:
|
|
477
|
+
"""Check if requests must be routed to identity's region.
|
|
478
|
+
|
|
479
|
+
OpenBridge always routes requests to the region associated
|
|
480
|
+
with the active identity.
|
|
481
|
+
|
|
482
|
+
:return: True - OpenBridge requires identity-based routing
|
|
483
|
+
:rtype: bool
|
|
484
|
+
"""
|
|
485
|
+
return True
|
|
486
|
+
|
|
487
|
+
def headers_are_identity_specific(self) -> bool:
|
|
488
|
+
"""Check if auth headers vary per identity.
|
|
489
|
+
|
|
490
|
+
OpenBridge uses different credentials for each identity,
|
|
491
|
+
so headers cannot be reconstructed from just a cached token.
|
|
492
|
+
|
|
493
|
+
:return: True - OpenBridge headers are identity-specific
|
|
494
|
+
:rtype: bool
|
|
495
|
+
"""
|
|
496
|
+
return True
|
|
497
|
+
|
|
498
|
+
def region_controlled_by_identity(self) -> bool:
|
|
499
|
+
"""Check if region is determined by active identity.
|
|
500
|
+
|
|
501
|
+
In OpenBridge, the region cannot be changed independently;
|
|
502
|
+
it's determined by the active identity's region.
|
|
503
|
+
|
|
504
|
+
:return: True - OpenBridge region is controlled by identity
|
|
505
|
+
:rtype: bool
|
|
506
|
+
"""
|
|
507
|
+
return True
|
|
508
|
+
|
|
509
|
+
async def close(self) -> None:
|
|
510
|
+
"""Clean up provider resources."""
|
|
511
|
+
self._identities_cache.clear()
|
|
512
|
+
self._jwt_token = None
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Manage registration and discovery of authentication providers.
|
|
2
|
+
|
|
3
|
+
Provide a simple registry to register, look up, and create providers
|
|
4
|
+
without modifying core code.
|
|
5
|
+
|
|
6
|
+
Examples
|
|
7
|
+
--------
|
|
8
|
+
.. code-block:: python
|
|
9
|
+
|
|
10
|
+
from amazon_ads_mcp.auth.registry import register_provider, ProviderRegistry
|
|
11
|
+
from amazon_ads_mcp.auth.base import BaseAuthProvider, ProviderConfig
|
|
12
|
+
|
|
13
|
+
@register_provider("example")
|
|
14
|
+
class ExampleProvider(BaseAuthProvider):
|
|
15
|
+
@property
|
|
16
|
+
def provider_type(self) -> str:
|
|
17
|
+
return "example"
|
|
18
|
+
|
|
19
|
+
async def initialize(self) -> None:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
async def get_token(self):
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
async def validate_token(self, token) -> bool:
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
async def close(self) -> None:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
provider = ProviderRegistry.create_provider("example", ProviderConfig())
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
from typing import Dict, Optional, Type
|
|
36
|
+
|
|
37
|
+
from .base import BaseAuthProvider, ProviderConfig
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ProviderRegistry:
|
|
43
|
+
"""Registry for authentication providers.
|
|
44
|
+
|
|
45
|
+
Manage registration, lookup, and instantiation of providers.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
_providers: Dict[str, Type[BaseAuthProvider]] = {}
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def register(
|
|
52
|
+
cls, provider_type: str, provider_class: Type[BaseAuthProvider]
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Register a provider class.
|
|
55
|
+
|
|
56
|
+
:param provider_type: Unique identifier for the provider type.
|
|
57
|
+
:param provider_class: Provider class to register.
|
|
58
|
+
:raises ValueError: If provider type is already registered.
|
|
59
|
+
"""
|
|
60
|
+
if provider_type in cls._providers:
|
|
61
|
+
raise ValueError(f"Provider type '{provider_type}' is already registered")
|
|
62
|
+
|
|
63
|
+
cls._providers[provider_type] = provider_class
|
|
64
|
+
logger.info(
|
|
65
|
+
f"Registered provider: {provider_type} -> {provider_class.__name__}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def unregister(cls, provider_type: str) -> None:
|
|
70
|
+
"""Unregister a provider.
|
|
71
|
+
|
|
72
|
+
Remove a provider from the registry so it cannot be instantiated.
|
|
73
|
+
|
|
74
|
+
:param provider_type: Provider type to unregister.
|
|
75
|
+
"""
|
|
76
|
+
if provider_type in cls._providers:
|
|
77
|
+
del cls._providers[provider_type]
|
|
78
|
+
logger.info(f"Unregistered provider: {provider_type}")
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def get_provider_class(cls, provider_type: str) -> Optional[Type[BaseAuthProvider]]:
|
|
82
|
+
"""Return a registered provider class.
|
|
83
|
+
|
|
84
|
+
:param provider_type: Provider type to retrieve.
|
|
85
|
+
:return: Provider class if registered, otherwise None.
|
|
86
|
+
"""
|
|
87
|
+
return cls._providers.get(provider_type)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def create_provider(
|
|
91
|
+
cls, provider_type: str, config: ProviderConfig
|
|
92
|
+
) -> BaseAuthProvider:
|
|
93
|
+
"""Create a provider instance.
|
|
94
|
+
|
|
95
|
+
:param provider_type: Type of provider to create.
|
|
96
|
+
:param config: Configuration for the provider.
|
|
97
|
+
:return: Provider instance.
|
|
98
|
+
:raises ValueError: If provider type is not registered.
|
|
99
|
+
"""
|
|
100
|
+
provider_class = cls.get_provider_class(provider_type)
|
|
101
|
+
if not provider_class:
|
|
102
|
+
available = ", ".join(cls._providers.keys())
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Unknown provider type: '{provider_type}'. "
|
|
105
|
+
f"Available providers: {available or 'none'}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return provider_class(config)
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def list_providers(cls) -> Dict[str, Type[BaseAuthProvider]]:
|
|
112
|
+
"""List all registered providers.
|
|
113
|
+
|
|
114
|
+
:return: Mapping of provider types to classes.
|
|
115
|
+
"""
|
|
116
|
+
return cls._providers.copy()
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def clear(cls) -> None:
|
|
120
|
+
"""Clear all registered providers.
|
|
121
|
+
|
|
122
|
+
Remove all providers. Useful for tests to ensure clean state.
|
|
123
|
+
"""
|
|
124
|
+
cls._providers.clear()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def register_provider(provider_type: str):
|
|
128
|
+
"""Return a decorator to auto-register a provider class.
|
|
129
|
+
|
|
130
|
+
Usage
|
|
131
|
+
-----
|
|
132
|
+
.. code-block:: python
|
|
133
|
+
|
|
134
|
+
@register_provider("my_provider")
|
|
135
|
+
class MyProvider(BaseAuthProvider):
|
|
136
|
+
...
|
|
137
|
+
|
|
138
|
+
:param provider_type: Type identifier for the provider.
|
|
139
|
+
:return: Decorator function.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def decorator(provider_class: Type[BaseAuthProvider]):
|
|
143
|
+
ProviderRegistry.register(provider_type, provider_class)
|
|
144
|
+
return provider_class
|
|
145
|
+
|
|
146
|
+
return decorator
|