amazon-ads-mcp 0.2.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. amazon_ads_mcp/__init__.py +11 -0
  2. amazon_ads_mcp/auth/__init__.py +33 -0
  3. amazon_ads_mcp/auth/base.py +211 -0
  4. amazon_ads_mcp/auth/hooks.py +172 -0
  5. amazon_ads_mcp/auth/manager.py +791 -0
  6. amazon_ads_mcp/auth/oauth_state_store.py +277 -0
  7. amazon_ads_mcp/auth/providers/__init__.py +14 -0
  8. amazon_ads_mcp/auth/providers/direct.py +393 -0
  9. amazon_ads_mcp/auth/providers/example_auth0.py.example +216 -0
  10. amazon_ads_mcp/auth/providers/openbridge.py +512 -0
  11. amazon_ads_mcp/auth/registry.py +146 -0
  12. amazon_ads_mcp/auth/secure_token_store.py +297 -0
  13. amazon_ads_mcp/auth/token_store.py +723 -0
  14. amazon_ads_mcp/config/__init__.py +5 -0
  15. amazon_ads_mcp/config/sampling.py +111 -0
  16. amazon_ads_mcp/config/settings.py +366 -0
  17. amazon_ads_mcp/exceptions.py +314 -0
  18. amazon_ads_mcp/middleware/__init__.py +11 -0
  19. amazon_ads_mcp/middleware/authentication.py +1474 -0
  20. amazon_ads_mcp/middleware/caching.py +177 -0
  21. amazon_ads_mcp/middleware/oauth.py +175 -0
  22. amazon_ads_mcp/middleware/sampling.py +112 -0
  23. amazon_ads_mcp/models/__init__.py +320 -0
  24. amazon_ads_mcp/models/amc_models.py +837 -0
  25. amazon_ads_mcp/models/api_responses.py +847 -0
  26. amazon_ads_mcp/models/base_models.py +215 -0
  27. amazon_ads_mcp/models/builtin_responses.py +496 -0
  28. amazon_ads_mcp/models/dsp_models.py +556 -0
  29. amazon_ads_mcp/models/stores_brands.py +610 -0
  30. amazon_ads_mcp/server/__init__.py +6 -0
  31. amazon_ads_mcp/server/__main__.py +6 -0
  32. amazon_ads_mcp/server/builtin_prompts.py +269 -0
  33. amazon_ads_mcp/server/builtin_tools.py +962 -0
  34. amazon_ads_mcp/server/file_routes.py +547 -0
  35. amazon_ads_mcp/server/html_templates.py +149 -0
  36. amazon_ads_mcp/server/mcp_server.py +327 -0
  37. amazon_ads_mcp/server/openapi_utils.py +158 -0
  38. amazon_ads_mcp/server/sampling_handler.py +251 -0
  39. amazon_ads_mcp/server/server_builder.py +751 -0
  40. amazon_ads_mcp/server/sidecar_loader.py +178 -0
  41. amazon_ads_mcp/server/transform_executor.py +827 -0
  42. amazon_ads_mcp/tools/__init__.py +22 -0
  43. amazon_ads_mcp/tools/cache_management.py +105 -0
  44. amazon_ads_mcp/tools/download_tools.py +267 -0
  45. amazon_ads_mcp/tools/identity.py +236 -0
  46. amazon_ads_mcp/tools/oauth.py +598 -0
  47. amazon_ads_mcp/tools/profile.py +150 -0
  48. amazon_ads_mcp/tools/profile_listing.py +285 -0
  49. amazon_ads_mcp/tools/region.py +320 -0
  50. amazon_ads_mcp/tools/region_identity.py +175 -0
  51. amazon_ads_mcp/utils/__init__.py +6 -0
  52. amazon_ads_mcp/utils/async_compat.py +215 -0
  53. amazon_ads_mcp/utils/errors.py +452 -0
  54. amazon_ads_mcp/utils/export_content_type_resolver.py +249 -0
  55. amazon_ads_mcp/utils/export_download_handler.py +579 -0
  56. amazon_ads_mcp/utils/header_resolver.py +81 -0
  57. amazon_ads_mcp/utils/http/__init__.py +56 -0
  58. amazon_ads_mcp/utils/http/circuit_breaker.py +127 -0
  59. amazon_ads_mcp/utils/http/client_manager.py +329 -0
  60. amazon_ads_mcp/utils/http/request.py +207 -0
  61. amazon_ads_mcp/utils/http/resilience.py +512 -0
  62. amazon_ads_mcp/utils/http/resilient_client.py +195 -0
  63. amazon_ads_mcp/utils/http/retry.py +76 -0
  64. amazon_ads_mcp/utils/http_client.py +873 -0
  65. amazon_ads_mcp/utils/media/__init__.py +21 -0
  66. amazon_ads_mcp/utils/media/negotiator.py +243 -0
  67. amazon_ads_mcp/utils/media/types.py +199 -0
  68. amazon_ads_mcp/utils/openapi/__init__.py +16 -0
  69. amazon_ads_mcp/utils/openapi/json.py +55 -0
  70. amazon_ads_mcp/utils/openapi/loader.py +263 -0
  71. amazon_ads_mcp/utils/openapi/refs.py +46 -0
  72. amazon_ads_mcp/utils/region_config.py +200 -0
  73. amazon_ads_mcp/utils/response_wrapper.py +171 -0
  74. amazon_ads_mcp/utils/sampling_helpers.py +156 -0
  75. amazon_ads_mcp/utils/sampling_wrapper.py +173 -0
  76. amazon_ads_mcp/utils/security.py +630 -0
  77. amazon_ads_mcp/utils/tool_naming.py +137 -0
  78. amazon_ads_mcp-0.2.7.dist-info/METADATA +664 -0
  79. amazon_ads_mcp-0.2.7.dist-info/RECORD +82 -0
  80. amazon_ads_mcp-0.2.7.dist-info/WHEEL +4 -0
  81. amazon_ads_mcp-0.2.7.dist-info/entry_points.txt +3 -0
  82. amazon_ads_mcp-0.2.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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