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,277 @@
|
|
|
1
|
+
"""Secure OAuth state store for CSRF protection."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import secrets
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OAuthStateEntry(BaseModel):
|
|
18
|
+
"""OAuth state entry with metadata."""
|
|
19
|
+
|
|
20
|
+
state: str = Field(description="OAuth state parameter")
|
|
21
|
+
nonce: str = Field(description="Random nonce for additional entropy")
|
|
22
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
23
|
+
expires_at: datetime = Field(
|
|
24
|
+
default_factory=lambda: datetime.now(timezone.utc) + timedelta(minutes=10)
|
|
25
|
+
)
|
|
26
|
+
auth_url: str = Field(description="Authorization URL")
|
|
27
|
+
user_agent: Optional[str] = Field(
|
|
28
|
+
default=None, description="User agent for validation"
|
|
29
|
+
)
|
|
30
|
+
ip_address: Optional[str] = Field(
|
|
31
|
+
default=None, description="IP address for validation"
|
|
32
|
+
)
|
|
33
|
+
completed: bool = Field(default=False, description="Whether callback was received")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OAuthStateStore:
|
|
37
|
+
"""
|
|
38
|
+
Secure store for OAuth state validation.
|
|
39
|
+
|
|
40
|
+
This store provides CSRF protection by:
|
|
41
|
+
1. Generating cryptographically secure state tokens
|
|
42
|
+
2. Storing state with expiration and metadata
|
|
43
|
+
3. Validating state on callback with timing checks
|
|
44
|
+
4. Using HMAC signatures for state integrity
|
|
45
|
+
|
|
46
|
+
For production, this should be replaced with Redis or similar.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
secret_key: Optional[str] = None,
|
|
52
|
+
store_path: Optional[Path] = None,
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Initialize OAuth state store.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
secret_key: Secret for HMAC signatures (auto-generated if not provided)
|
|
59
|
+
store_path: Path for persistent storage (memory-only if not provided)
|
|
60
|
+
"""
|
|
61
|
+
self.secret_key = secret_key or secrets.token_hex(32)
|
|
62
|
+
self.store_path = store_path
|
|
63
|
+
self._memory_store: Dict[str, OAuthStateEntry] = {}
|
|
64
|
+
|
|
65
|
+
# Load existing states if using file storage
|
|
66
|
+
if self.store_path:
|
|
67
|
+
self._load_store()
|
|
68
|
+
|
|
69
|
+
def generate_state(
|
|
70
|
+
self,
|
|
71
|
+
auth_url: str,
|
|
72
|
+
user_agent: Optional[str] = None,
|
|
73
|
+
ip_address: Optional[str] = None,
|
|
74
|
+
ttl_minutes: int = 10,
|
|
75
|
+
) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Generate a secure OAuth state token.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
auth_url: The authorization URL
|
|
81
|
+
user_agent: Optional user agent for validation
|
|
82
|
+
ip_address: Optional IP address for validation
|
|
83
|
+
ttl_minutes: Time-to-live in minutes
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Secure state token
|
|
87
|
+
"""
|
|
88
|
+
# Generate random components
|
|
89
|
+
state_base = secrets.token_urlsafe(32)
|
|
90
|
+
nonce = secrets.token_hex(16)
|
|
91
|
+
|
|
92
|
+
# Create HMAC signature
|
|
93
|
+
message = f"{state_base}:{nonce}:{auth_url}"
|
|
94
|
+
signature = hmac.new(
|
|
95
|
+
self.secret_key.encode(), message.encode(), hashlib.sha256
|
|
96
|
+
).hexdigest()[:16] # Use first 16 chars for brevity
|
|
97
|
+
|
|
98
|
+
# Combine into final state
|
|
99
|
+
state = f"{state_base}.{signature}"
|
|
100
|
+
|
|
101
|
+
# Store state entry
|
|
102
|
+
entry = OAuthStateEntry(
|
|
103
|
+
state=state,
|
|
104
|
+
nonce=nonce,
|
|
105
|
+
auth_url=auth_url,
|
|
106
|
+
user_agent=user_agent,
|
|
107
|
+
ip_address=ip_address,
|
|
108
|
+
expires_at=datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self._memory_store[state] = entry
|
|
112
|
+
self._save_store()
|
|
113
|
+
|
|
114
|
+
logger.debug(f"Generated OAuth state with length: {len(state)}")
|
|
115
|
+
return state
|
|
116
|
+
|
|
117
|
+
def validate_state(
|
|
118
|
+
self,
|
|
119
|
+
state: str,
|
|
120
|
+
user_agent: Optional[str] = None,
|
|
121
|
+
ip_address: Optional[str] = None,
|
|
122
|
+
) -> tuple[bool, Optional[str]]:
|
|
123
|
+
"""
|
|
124
|
+
Validate an OAuth state token.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
state: The state token to validate
|
|
128
|
+
user_agent: Optional user agent to verify
|
|
129
|
+
ip_address: Optional IP address to verify
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Tuple of (is_valid, error_message)
|
|
133
|
+
"""
|
|
134
|
+
# Clean expired states first
|
|
135
|
+
self._clean_expired()
|
|
136
|
+
|
|
137
|
+
# Check if state exists
|
|
138
|
+
if state not in self._memory_store:
|
|
139
|
+
return False, "Invalid or expired state"
|
|
140
|
+
|
|
141
|
+
entry = self._memory_store[state]
|
|
142
|
+
|
|
143
|
+
# Check if already used
|
|
144
|
+
if entry.completed:
|
|
145
|
+
logger.warning("Attempted reuse of OAuth state")
|
|
146
|
+
return False, "State already used"
|
|
147
|
+
|
|
148
|
+
# Check expiration
|
|
149
|
+
if datetime.now(timezone.utc) > entry.expires_at:
|
|
150
|
+
return False, "State expired"
|
|
151
|
+
|
|
152
|
+
# Validate HMAC signature
|
|
153
|
+
try:
|
|
154
|
+
state_base, signature = state.rsplit(".", 1)
|
|
155
|
+
message = f"{state_base}:{entry.nonce}:{entry.auth_url}"
|
|
156
|
+
expected_signature = hmac.new(
|
|
157
|
+
self.secret_key.encode(), message.encode(), hashlib.sha256
|
|
158
|
+
).hexdigest()[:16]
|
|
159
|
+
|
|
160
|
+
if not hmac.compare_digest(signature, expected_signature):
|
|
161
|
+
logger.warning("Invalid OAuth state signature")
|
|
162
|
+
return False, "Invalid state signature"
|
|
163
|
+
except (ValueError, KeyError) as e:
|
|
164
|
+
logger.warning(f"Malformed OAuth state: {e}")
|
|
165
|
+
return False, "Malformed state"
|
|
166
|
+
|
|
167
|
+
# Optional: Validate user agent
|
|
168
|
+
if entry.user_agent and user_agent and entry.user_agent != user_agent:
|
|
169
|
+
logger.warning("User agent mismatch in OAuth callback")
|
|
170
|
+
# Don't fail for user agent changes (browser updates, etc)
|
|
171
|
+
|
|
172
|
+
# Optional: Validate IP address
|
|
173
|
+
if entry.ip_address and ip_address and entry.ip_address != ip_address:
|
|
174
|
+
logger.warning("IP address mismatch in OAuth callback")
|
|
175
|
+
# Could be VPN, mobile network change, etc - log but don't fail
|
|
176
|
+
|
|
177
|
+
# Mark as completed
|
|
178
|
+
entry.completed = True
|
|
179
|
+
self._save_store()
|
|
180
|
+
|
|
181
|
+
return True, None
|
|
182
|
+
|
|
183
|
+
def get_auth_url(self, state: str) -> Optional[str]:
|
|
184
|
+
"""Get the auth URL for a given state."""
|
|
185
|
+
entry = self._memory_store.get(state)
|
|
186
|
+
return entry.auth_url if entry else None
|
|
187
|
+
|
|
188
|
+
def _clean_expired(self):
|
|
189
|
+
"""Remove expired state entries."""
|
|
190
|
+
now = datetime.now(timezone.utc)
|
|
191
|
+
expired = [
|
|
192
|
+
state
|
|
193
|
+
for state, entry in self._memory_store.items()
|
|
194
|
+
if now > entry.expires_at + timedelta(hours=1) # Grace period
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
for state in expired:
|
|
198
|
+
del self._memory_store[state]
|
|
199
|
+
logger.debug("Cleaned expired OAuth state")
|
|
200
|
+
|
|
201
|
+
if expired:
|
|
202
|
+
self._save_store()
|
|
203
|
+
|
|
204
|
+
def _load_store(self):
|
|
205
|
+
"""Load state store from file."""
|
|
206
|
+
if not self.store_path or not self.store_path.exists():
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
with open(self.store_path, "r") as f:
|
|
211
|
+
data = json.load(f)
|
|
212
|
+
for state, entry_data in data.items():
|
|
213
|
+
# Parse datetime strings
|
|
214
|
+
entry_data["created_at"] = datetime.fromisoformat(
|
|
215
|
+
entry_data["created_at"]
|
|
216
|
+
)
|
|
217
|
+
entry_data["expires_at"] = datetime.fromisoformat(
|
|
218
|
+
entry_data["expires_at"]
|
|
219
|
+
)
|
|
220
|
+
self._memory_store[state] = OAuthStateEntry(**entry_data)
|
|
221
|
+
logger.debug(
|
|
222
|
+
f"Loaded {len(self._memory_store)} OAuth states from {self.store_path}"
|
|
223
|
+
)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.warning(f"Could not load OAuth state store: {e}")
|
|
226
|
+
|
|
227
|
+
def _save_store(self):
|
|
228
|
+
"""Save state store to file."""
|
|
229
|
+
if not self.store_path:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
# Ensure directory exists
|
|
234
|
+
self.store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
|
|
236
|
+
# Convert to JSON-serializable format
|
|
237
|
+
data = {}
|
|
238
|
+
for state, entry in self._memory_store.items():
|
|
239
|
+
entry_dict = entry.model_dump()
|
|
240
|
+
# Convert datetime to ISO format
|
|
241
|
+
entry_dict["created_at"] = entry_dict["created_at"].isoformat()
|
|
242
|
+
entry_dict["expires_at"] = entry_dict["expires_at"].isoformat()
|
|
243
|
+
data[state] = entry_dict
|
|
244
|
+
|
|
245
|
+
# Write atomically
|
|
246
|
+
tmp_path = self.store_path.with_suffix(".tmp")
|
|
247
|
+
with open(tmp_path, "w") as f:
|
|
248
|
+
json.dump(data, f, indent=2)
|
|
249
|
+
tmp_path.replace(self.store_path)
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
logger.warning(f"Could not save OAuth state store: {e}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# Global instance for the application
|
|
256
|
+
_oauth_state_store: Optional[OAuthStateStore] = None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def get_oauth_state_store() -> OAuthStateStore:
|
|
260
|
+
"""Get or create the global OAuth state store."""
|
|
261
|
+
global _oauth_state_store
|
|
262
|
+
if _oauth_state_store is None:
|
|
263
|
+
# Use environment variable for secret if available
|
|
264
|
+
import os
|
|
265
|
+
|
|
266
|
+
secret_key = os.getenv("OAUTH_STATE_SECRET")
|
|
267
|
+
|
|
268
|
+
# Use file storage in development, memory in production
|
|
269
|
+
store_path = None
|
|
270
|
+
if os.getenv("OAUTH_STATE_PERSIST") == "true":
|
|
271
|
+
store_path = Path.home() / ".amazon_ads_mcp" / "oauth_states.json"
|
|
272
|
+
|
|
273
|
+
_oauth_state_store = OAuthStateStore(
|
|
274
|
+
secret_key=secret_key, store_path=store_path
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return _oauth_state_store
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Authentication providers package.
|
|
2
|
+
|
|
3
|
+
This package contains implementations of various authentication providers.
|
|
4
|
+
Each provider is automatically registered when imported.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Import providers to trigger auto-registration
|
|
8
|
+
from .direct import DirectAmazonAdsProvider
|
|
9
|
+
from .openbridge import OpenBridgeProvider
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DirectAmazonAdsProvider",
|
|
13
|
+
"OpenBridgeProvider",
|
|
14
|
+
]
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Direct Amazon Ads API authentication provider.
|
|
2
|
+
|
|
3
|
+
This module implements direct authentication using Amazon Ads API credentials,
|
|
4
|
+
if you are using your own Amazon Ads API credentials/app.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ...models import AuthCredentials, Identity, Token
|
|
14
|
+
from ...utils.http import get_http_client
|
|
15
|
+
from ..base import BaseAmazonAdsProvider, BaseIdentityProvider, ProviderConfig
|
|
16
|
+
from ..registry import register_provider
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@register_provider("direct")
|
|
22
|
+
class DirectAmazonAdsProvider(BaseAmazonAdsProvider, BaseIdentityProvider):
|
|
23
|
+
"""Direct Amazon Ads API authentication provider.
|
|
24
|
+
|
|
25
|
+
Provides authentication using Amazon Ads API credentials directly,
|
|
26
|
+
implementing the "Bring Your Own API" (BYOA) pathway.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: ProviderConfig):
|
|
30
|
+
"""Initialize direct Amazon Ads provider.
|
|
31
|
+
|
|
32
|
+
:param config: Provider configuration with client_id, client_secret, refresh_token
|
|
33
|
+
:type config: ProviderConfig
|
|
34
|
+
"""
|
|
35
|
+
self.client_id = config.get("client_id")
|
|
36
|
+
self.client_secret = config.get("client_secret")
|
|
37
|
+
self.refresh_token = config.get("refresh_token")
|
|
38
|
+
|
|
39
|
+
# Allow missing refresh_token for OAuth flow bootstrapping
|
|
40
|
+
if not self.client_id or not self.client_secret:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"Direct provider requires 'client_id' and 'client_secret' in config"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if not self.refresh_token:
|
|
46
|
+
logger.warning(
|
|
47
|
+
"No refresh_token configured. Use OAuth tools to obtain one: "
|
|
48
|
+
"start_oauth_flow -> visit URL -> check_oauth_status"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
self.profile_id = config.get("profile_id")
|
|
52
|
+
self._region = config.get("region", "na")
|
|
53
|
+
self._access_token: Optional[Token] = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def provider_type(self) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Return the provider type identifier.
|
|
59
|
+
|
|
60
|
+
:return: Provider type identifier 'direct'
|
|
61
|
+
:rtype: str
|
|
62
|
+
"""
|
|
63
|
+
return "direct"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def region(self) -> str:
|
|
67
|
+
"""
|
|
68
|
+
Get the current region.
|
|
69
|
+
|
|
70
|
+
:return: Region code (na, eu, or fe)
|
|
71
|
+
:rtype: str
|
|
72
|
+
"""
|
|
73
|
+
return self._region
|
|
74
|
+
|
|
75
|
+
async def initialize(self) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Initialize the provider.
|
|
78
|
+
|
|
79
|
+
Performs any asynchronous initialization required for the direct
|
|
80
|
+
authentication provider.
|
|
81
|
+
|
|
82
|
+
:return: None
|
|
83
|
+
:rtype: None
|
|
84
|
+
"""
|
|
85
|
+
logger.info(
|
|
86
|
+
f"Initializing Direct Amazon Ads provider for region {self._region}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
90
|
+
"""
|
|
91
|
+
Get shared HTTP client.
|
|
92
|
+
|
|
93
|
+
Retrieves the shared HTTP client instance for making API requests.
|
|
94
|
+
|
|
95
|
+
:return: Shared HTTP client instance
|
|
96
|
+
:rtype: httpx.AsyncClient
|
|
97
|
+
"""
|
|
98
|
+
return await get_http_client()
|
|
99
|
+
|
|
100
|
+
async def get_token(self) -> Token:
|
|
101
|
+
"""
|
|
102
|
+
Get current access token from Amazon Ads API.
|
|
103
|
+
|
|
104
|
+
Retrieves the current access token, first attempting to retrieve
|
|
105
|
+
from cache, then refreshing if necessary.
|
|
106
|
+
|
|
107
|
+
:return: Valid access token
|
|
108
|
+
:rtype: Token
|
|
109
|
+
:raises ValueError: If no refresh token is available
|
|
110
|
+
"""
|
|
111
|
+
# Try to get from AuthManager's token store first
|
|
112
|
+
auth_manager = None
|
|
113
|
+
try:
|
|
114
|
+
from ..manager import get_auth_manager
|
|
115
|
+
|
|
116
|
+
auth_manager = get_auth_manager()
|
|
117
|
+
|
|
118
|
+
if auth_manager and hasattr(auth_manager, "get_token"):
|
|
119
|
+
from ..token_store import TokenKind
|
|
120
|
+
|
|
121
|
+
token_entry = await auth_manager.get_token(
|
|
122
|
+
provider_type="direct",
|
|
123
|
+
identity_id="direct-auth",
|
|
124
|
+
token_kind=TokenKind.ACCESS,
|
|
125
|
+
region=self._region,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if token_entry and not token_entry.is_expired():
|
|
129
|
+
self._access_token = Token(
|
|
130
|
+
value=token_entry.value,
|
|
131
|
+
expires_at=token_entry.expires_at,
|
|
132
|
+
token_type="Bearer",
|
|
133
|
+
metadata=token_entry.metadata,
|
|
134
|
+
)
|
|
135
|
+
logger.debug("Retrieved access token from unified token store")
|
|
136
|
+
return self._access_token
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.debug(f"Could not get token from store: {e}")
|
|
139
|
+
|
|
140
|
+
# Check local cache
|
|
141
|
+
if self._access_token and await self.validate_token(self._access_token):
|
|
142
|
+
return self._access_token
|
|
143
|
+
|
|
144
|
+
return await self._refresh_access_token()
|
|
145
|
+
|
|
146
|
+
async def _refresh_access_token(self) -> Token:
|
|
147
|
+
"""
|
|
148
|
+
Exchange refresh token for access token via Amazon OAuth2.
|
|
149
|
+
|
|
150
|
+
Performs OAuth2 token refresh to obtain a new access token
|
|
151
|
+
from Amazon's authentication servers.
|
|
152
|
+
|
|
153
|
+
:return: New access token with expiration
|
|
154
|
+
:rtype: Token
|
|
155
|
+
:raises ValueError: If no refresh token is available or token response is invalid
|
|
156
|
+
:raises httpx.HTTPError: If the OAuth2 token request fails
|
|
157
|
+
"""
|
|
158
|
+
# Check if refresh token is available from secure store
|
|
159
|
+
if not self.refresh_token:
|
|
160
|
+
try:
|
|
161
|
+
from ..secure_token_store import get_secure_token_store
|
|
162
|
+
|
|
163
|
+
secure_store = get_secure_token_store()
|
|
164
|
+
token_entry = secure_store.get_token("oauth_refresh_token")
|
|
165
|
+
if token_entry and token_entry.get("value"):
|
|
166
|
+
self.refresh_token = token_entry["value"]
|
|
167
|
+
logger.info("Found refresh token in secure store")
|
|
168
|
+
else:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
"No refresh token available. Use OAuth tools to obtain one: "
|
|
171
|
+
"start_oauth_flow -> visit URL -> check_oauth_status"
|
|
172
|
+
)
|
|
173
|
+
except ImportError:
|
|
174
|
+
logger.warning("Secure token store not available")
|
|
175
|
+
raise ValueError(
|
|
176
|
+
"No refresh token available. Use OAuth tools to obtain one: "
|
|
177
|
+
"start_oauth_flow -> visit URL -> check_oauth_status"
|
|
178
|
+
)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error(f"Error accessing secure token store: {e}")
|
|
181
|
+
raise ValueError(
|
|
182
|
+
"No refresh token available. Use OAuth tools to obtain one: "
|
|
183
|
+
"start_oauth_flow -> visit URL -> check_oauth_status"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
logger.debug("Exchanging refresh token for Amazon Ads access token")
|
|
187
|
+
|
|
188
|
+
client = await self._get_client()
|
|
189
|
+
auth_endpoint = self.get_oauth_endpoint()
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
response = await client.post(
|
|
193
|
+
auth_endpoint,
|
|
194
|
+
data={
|
|
195
|
+
"grant_type": "refresh_token",
|
|
196
|
+
"refresh_token": self.refresh_token,
|
|
197
|
+
"client_id": self.client_id,
|
|
198
|
+
"client_secret": self.client_secret,
|
|
199
|
+
},
|
|
200
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if response.status_code != 200:
|
|
204
|
+
logger.error(
|
|
205
|
+
f"Token refresh failed: {response.status_code} - {response.text}"
|
|
206
|
+
)
|
|
207
|
+
response.raise_for_status()
|
|
208
|
+
|
|
209
|
+
data = response.json()
|
|
210
|
+
access_token = data.get("access_token")
|
|
211
|
+
expires_in = data.get("expires_in", 3600)
|
|
212
|
+
|
|
213
|
+
if not access_token:
|
|
214
|
+
raise ValueError("No access token in Amazon response")
|
|
215
|
+
|
|
216
|
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
217
|
+
|
|
218
|
+
self._access_token = Token(
|
|
219
|
+
value=access_token,
|
|
220
|
+
expires_at=expires_at,
|
|
221
|
+
token_type="Bearer",
|
|
222
|
+
metadata={
|
|
223
|
+
"client_id": self.client_id,
|
|
224
|
+
"region": self._region,
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Store in unified token store
|
|
229
|
+
try:
|
|
230
|
+
from ..manager import get_auth_manager
|
|
231
|
+
|
|
232
|
+
auth_manager = get_auth_manager()
|
|
233
|
+
|
|
234
|
+
if auth_manager and hasattr(auth_manager, "set_token"):
|
|
235
|
+
from ..token_store import TokenKind
|
|
236
|
+
|
|
237
|
+
await auth_manager.set_token(
|
|
238
|
+
provider_type="direct",
|
|
239
|
+
identity_id="direct-auth",
|
|
240
|
+
token_kind=TokenKind.ACCESS,
|
|
241
|
+
token=access_token,
|
|
242
|
+
expires_at=expires_at,
|
|
243
|
+
metadata={
|
|
244
|
+
"client_id": self.client_id,
|
|
245
|
+
"region": self._region,
|
|
246
|
+
"token_type": "Bearer",
|
|
247
|
+
},
|
|
248
|
+
region=self._region,
|
|
249
|
+
)
|
|
250
|
+
logger.debug("Stored access token in unified token store")
|
|
251
|
+
except Exception as e:
|
|
252
|
+
logger.debug(f"Could not store token: {e}")
|
|
253
|
+
|
|
254
|
+
logger.debug(f"Amazon Ads access token obtained, expires at {expires_at}")
|
|
255
|
+
return self._access_token
|
|
256
|
+
|
|
257
|
+
except httpx.HTTPError as e:
|
|
258
|
+
logger.error(f"Failed to refresh Amazon Ads token: {e}")
|
|
259
|
+
raise
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.error(f"Error processing Amazon token response: {e}")
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
async def validate_token(self, token: Token) -> bool:
|
|
265
|
+
"""
|
|
266
|
+
Validate if token is still valid.
|
|
267
|
+
|
|
268
|
+
Checks if the provided token is still valid, considering a 5-minute
|
|
269
|
+
buffer before expiration to ensure safe usage.
|
|
270
|
+
|
|
271
|
+
:param token: Token to validate
|
|
272
|
+
:type token: Token
|
|
273
|
+
:return: True if token is valid, False otherwise
|
|
274
|
+
:rtype: bool
|
|
275
|
+
"""
|
|
276
|
+
buffer = timedelta(minutes=5)
|
|
277
|
+
now = datetime.now(timezone.utc)
|
|
278
|
+
expiry = token.expires_at
|
|
279
|
+
# Ensure both datetimes are timezone-aware for comparison
|
|
280
|
+
if expiry.tzinfo is None:
|
|
281
|
+
expiry = expiry.replace(tzinfo=timezone.utc)
|
|
282
|
+
return now < (expiry - buffer)
|
|
283
|
+
|
|
284
|
+
async def list_identities(self, **kwargs) -> List[Identity]:
|
|
285
|
+
"""
|
|
286
|
+
List identities for direct auth.
|
|
287
|
+
|
|
288
|
+
For direct auth, creates a single synthetic identity
|
|
289
|
+
representing the authenticated account.
|
|
290
|
+
|
|
291
|
+
:param kwargs: Unused filter parameters
|
|
292
|
+
:type kwargs: Any
|
|
293
|
+
:return: List containing the single direct auth identity
|
|
294
|
+
:rtype: List[Identity]
|
|
295
|
+
"""
|
|
296
|
+
identity = Identity(
|
|
297
|
+
id="direct-auth",
|
|
298
|
+
type="amazon_ads_direct",
|
|
299
|
+
attributes={
|
|
300
|
+
"name": "Direct Amazon Ads Account",
|
|
301
|
+
"client_id": self.client_id,
|
|
302
|
+
"region": self._region,
|
|
303
|
+
"profile_id": self.profile_id,
|
|
304
|
+
"auth_method": "direct",
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
return [identity]
|
|
308
|
+
|
|
309
|
+
async def get_identity(self, identity_id: str) -> Optional[Identity]:
|
|
310
|
+
"""
|
|
311
|
+
Get specific identity by ID.
|
|
312
|
+
|
|
313
|
+
Retrieves the direct auth identity if the ID matches.
|
|
314
|
+
|
|
315
|
+
:param identity_id: Identity ID to retrieve
|
|
316
|
+
:type identity_id: str
|
|
317
|
+
:return: Identity if ID matches 'direct-auth', None otherwise
|
|
318
|
+
:rtype: Optional[Identity]
|
|
319
|
+
"""
|
|
320
|
+
if identity_id == "direct-auth":
|
|
321
|
+
identities = await self.list_identities()
|
|
322
|
+
return identities[0]
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
async def get_identity_credentials(self, identity_id: str) -> AuthCredentials:
|
|
326
|
+
"""
|
|
327
|
+
Get Amazon Ads credentials for the direct auth identity.
|
|
328
|
+
|
|
329
|
+
Retrieves authentication credentials including access token and
|
|
330
|
+
required headers for API requests.
|
|
331
|
+
|
|
332
|
+
:param identity_id: Identity ID (must be 'direct-auth')
|
|
333
|
+
:type identity_id: str
|
|
334
|
+
:return: Authentication credentials for Amazon Ads API
|
|
335
|
+
:rtype: AuthCredentials
|
|
336
|
+
:raises ValueError: If identity_id is not 'direct-auth'
|
|
337
|
+
"""
|
|
338
|
+
if identity_id != "direct-auth":
|
|
339
|
+
raise ValueError(f"Unknown identity: {identity_id}")
|
|
340
|
+
|
|
341
|
+
logger.info("Getting credentials for direct Amazon Ads auth")
|
|
342
|
+
|
|
343
|
+
token = await self.get_token()
|
|
344
|
+
headers = await self.get_headers()
|
|
345
|
+
|
|
346
|
+
return AuthCredentials(
|
|
347
|
+
identity_id=identity_id,
|
|
348
|
+
access_token=token.value,
|
|
349
|
+
expires_at=token.expires_at,
|
|
350
|
+
base_url=self.get_region_endpoint(),
|
|
351
|
+
headers=headers,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
async def get_headers(self) -> Dict[str, str]:
|
|
355
|
+
"""
|
|
356
|
+
Get authentication headers for API requests.
|
|
357
|
+
|
|
358
|
+
Generates the required headers for Amazon Ads API requests,
|
|
359
|
+
including Authorization and ClientId headers.
|
|
360
|
+
|
|
361
|
+
:return: Dictionary of authentication headers
|
|
362
|
+
:rtype: Dict[str, str]
|
|
363
|
+
"""
|
|
364
|
+
# If no refresh token, return minimal headers for OAuth flow
|
|
365
|
+
if not self.refresh_token:
|
|
366
|
+
return {
|
|
367
|
+
"Amazon-Advertising-API-ClientId": self.client_id,
|
|
368
|
+
# No Authorization header without token
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
token = await self.get_token()
|
|
372
|
+
|
|
373
|
+
headers = {
|
|
374
|
+
"Authorization": f"Bearer {token.value}",
|
|
375
|
+
"Amazon-Advertising-API-ClientId": self.client_id,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
# Add profile ID if configured
|
|
379
|
+
if self.profile_id:
|
|
380
|
+
headers["Amazon-Advertising-API-Scope"] = self.profile_id
|
|
381
|
+
|
|
382
|
+
return headers
|
|
383
|
+
|
|
384
|
+
async def close(self) -> None:
|
|
385
|
+
"""
|
|
386
|
+
Clean up provider resources.
|
|
387
|
+
|
|
388
|
+
Clears cached tokens and releases any held resources.
|
|
389
|
+
|
|
390
|
+
:return: None
|
|
391
|
+
:rtype: None
|
|
392
|
+
"""
|
|
393
|
+
self._access_token = None
|