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,598 @@
|
|
|
1
|
+
"""OAuth tools for integrated authentication flow."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from fastmcp import Context
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from ..auth.oauth_state_store import get_oauth_state_store
|
|
12
|
+
from ..config.settings import Settings
|
|
13
|
+
from ..utils.region_config import RegionConfig
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OAuthState(BaseModel):
|
|
19
|
+
"""OAuth state tracking."""
|
|
20
|
+
|
|
21
|
+
state: str = Field(description="OAuth state parameter for CSRF protection")
|
|
22
|
+
auth_url: str = Field(description="Full authorization URL")
|
|
23
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
24
|
+
expires_at: datetime = Field(
|
|
25
|
+
default_factory=lambda: datetime.now(timezone.utc) + timedelta(minutes=10)
|
|
26
|
+
)
|
|
27
|
+
completed: bool = Field(default=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OAuthTokens(BaseModel):
|
|
31
|
+
"""OAuth token storage."""
|
|
32
|
+
|
|
33
|
+
access_token: str
|
|
34
|
+
refresh_token: str
|
|
35
|
+
expires_in: int
|
|
36
|
+
obtained_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_expired(self) -> bool:
|
|
40
|
+
"""Check if access token is expired."""
|
|
41
|
+
expiry = self.obtained_at + timedelta(
|
|
42
|
+
seconds=self.expires_in - 60
|
|
43
|
+
) # 60s buffer
|
|
44
|
+
return datetime.now(timezone.utc) > expiry
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OAuthTools:
|
|
48
|
+
"""OAuth authentication tools for Amazon Ads API."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, settings: Settings):
|
|
51
|
+
self.settings = settings
|
|
52
|
+
self.client_id = settings.ad_api_client_id
|
|
53
|
+
self.client_secret = settings.ad_api_client_secret
|
|
54
|
+
self.region = settings.amazon_ads_region
|
|
55
|
+
# Use PORT env var (set at runtime) or settings.mcp_server_port or default to 9080
|
|
56
|
+
import os
|
|
57
|
+
|
|
58
|
+
port = os.getenv("PORT") or getattr(settings, "mcp_server_port", None) or 9080
|
|
59
|
+
self.redirect_uri = f"http://localhost:{port}/auth/callback"
|
|
60
|
+
|
|
61
|
+
async def start_oauth_flow(
|
|
62
|
+
self,
|
|
63
|
+
ctx: Context,
|
|
64
|
+
user_agent: Optional[str] = None,
|
|
65
|
+
ip_address: Optional[str] = None,
|
|
66
|
+
) -> Dict:
|
|
67
|
+
"""
|
|
68
|
+
Start the OAuth authorization flow.
|
|
69
|
+
|
|
70
|
+
Returns the authorization URL for the user to visit.
|
|
71
|
+
"""
|
|
72
|
+
# Get secure state store
|
|
73
|
+
state_store = get_oauth_state_store()
|
|
74
|
+
|
|
75
|
+
# Build base authorization URL
|
|
76
|
+
base_auth_url = (
|
|
77
|
+
f"https://www.amazon.com/ap/oa"
|
|
78
|
+
f"?client_id={self.client_id}"
|
|
79
|
+
f"&scope=cpc_advertising:campaign_management"
|
|
80
|
+
f"&response_type=code"
|
|
81
|
+
f"&redirect_uri={self.redirect_uri}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Generate secure state with HMAC signature
|
|
85
|
+
state = state_store.generate_state(
|
|
86
|
+
auth_url=base_auth_url,
|
|
87
|
+
user_agent=user_agent,
|
|
88
|
+
ip_address=ip_address,
|
|
89
|
+
ttl_minutes=10,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Add state to auth URL
|
|
93
|
+
auth_url = f"{base_auth_url}&state={state}"
|
|
94
|
+
|
|
95
|
+
# Store OAuth state in context for status tracking
|
|
96
|
+
oauth_state = OAuthState(
|
|
97
|
+
state="[REDACTED]", # Security: don't log OAuth state
|
|
98
|
+
auth_url=auth_url,
|
|
99
|
+
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
|
|
100
|
+
)
|
|
101
|
+
ctx.set_state("oauth_state", oauth_state.model_dump())
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"status": "success",
|
|
105
|
+
"auth_url": auth_url,
|
|
106
|
+
"message": "Visit the URL to authorize. The server will automatically handle the callback.",
|
|
107
|
+
"expires_in_minutes": 10,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async def check_oauth_status(self, ctx: Context) -> Dict:
|
|
111
|
+
"""
|
|
112
|
+
Check the current OAuth authentication status.
|
|
113
|
+
|
|
114
|
+
Returns whether authentication is complete and token status.
|
|
115
|
+
"""
|
|
116
|
+
# First check context for tokens
|
|
117
|
+
tokens_data = ctx.get_state("oauth_tokens")
|
|
118
|
+
|
|
119
|
+
# If not in context, check persistent stores
|
|
120
|
+
if not tokens_data:
|
|
121
|
+
# Try secure token store first
|
|
122
|
+
try:
|
|
123
|
+
from ..auth.secure_token_store import get_secure_token_store
|
|
124
|
+
|
|
125
|
+
secure_store = get_secure_token_store()
|
|
126
|
+
|
|
127
|
+
refresh_entry = secure_store.get_token("oauth_refresh_token")
|
|
128
|
+
access_entry = secure_store.get_token("oauth_access_token")
|
|
129
|
+
|
|
130
|
+
if refresh_entry:
|
|
131
|
+
# Found tokens in secure store - reconstruct token object
|
|
132
|
+
tokens_data = {
|
|
133
|
+
"refresh_token": refresh_entry["value"],
|
|
134
|
+
"access_token": (access_entry["value"] if access_entry else ""),
|
|
135
|
+
"expires_in": 3600,
|
|
136
|
+
"obtained_at": refresh_entry.get(
|
|
137
|
+
"created_at", datetime.now(timezone.utc)
|
|
138
|
+
).isoformat(),
|
|
139
|
+
}
|
|
140
|
+
# Cache in context for this request
|
|
141
|
+
ctx.set_state("oauth_tokens", tokens_data)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.debug(f"Could not check secure store: {e}")
|
|
144
|
+
|
|
145
|
+
# If still not found, check auth manager's token store
|
|
146
|
+
if not tokens_data:
|
|
147
|
+
try:
|
|
148
|
+
from ..auth.manager import get_auth_manager
|
|
149
|
+
from ..auth.token_store import TokenKind
|
|
150
|
+
|
|
151
|
+
auth_manager = get_auth_manager()
|
|
152
|
+
if auth_manager:
|
|
153
|
+
token_entry = await auth_manager.get_token(
|
|
154
|
+
provider_type="direct",
|
|
155
|
+
identity_id="direct-auth",
|
|
156
|
+
token_kind=TokenKind.REFRESH,
|
|
157
|
+
)
|
|
158
|
+
if token_entry:
|
|
159
|
+
# Found tokens - create minimal token data
|
|
160
|
+
tokens_data = {
|
|
161
|
+
"refresh_token": token_entry.value,
|
|
162
|
+
"access_token": "",
|
|
163
|
+
"expires_in": 0,
|
|
164
|
+
"obtained_at": datetime.now(timezone.utc).isoformat(),
|
|
165
|
+
}
|
|
166
|
+
# Cache in context
|
|
167
|
+
ctx.set_state("oauth_tokens", tokens_data)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.debug(f"Could not check auth manager: {e}")
|
|
170
|
+
|
|
171
|
+
# Check if callback has been received (legacy path)
|
|
172
|
+
if hasattr(self, "_callback_tokens"):
|
|
173
|
+
tokens = self._callback_tokens
|
|
174
|
+
# Store in context for future use
|
|
175
|
+
oauth_tokens = OAuthTokens(
|
|
176
|
+
access_token=tokens["access_token"],
|
|
177
|
+
refresh_token=tokens["refresh_token"],
|
|
178
|
+
expires_in=tokens["expires_in"],
|
|
179
|
+
)
|
|
180
|
+
ctx.set_state("oauth_tokens", oauth_tokens.model_dump())
|
|
181
|
+
|
|
182
|
+
# Clear the callback tokens after storing
|
|
183
|
+
delattr(self, "_callback_tokens")
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"authenticated": True,
|
|
187
|
+
"status": "callback_received",
|
|
188
|
+
"message": "Successfully authenticated via OAuth callback",
|
|
189
|
+
"has_refresh_token": True,
|
|
190
|
+
"scope": tokens["scope"],
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Check if we found tokens
|
|
194
|
+
if tokens_data:
|
|
195
|
+
# Tokens exist - user is authenticated
|
|
196
|
+
tokens = OAuthTokens(**tokens_data)
|
|
197
|
+
return {
|
|
198
|
+
"authenticated": True,
|
|
199
|
+
"status": "active",
|
|
200
|
+
"has_refresh_token": bool(tokens.refresh_token),
|
|
201
|
+
"access_token_expired": tokens.is_expired,
|
|
202
|
+
"token_age_minutes": int(
|
|
203
|
+
(datetime.now(timezone.utc) - tokens.obtained_at).total_seconds()
|
|
204
|
+
/ 60
|
|
205
|
+
),
|
|
206
|
+
}
|
|
207
|
+
else:
|
|
208
|
+
# No tokens found - check OAuth flow state
|
|
209
|
+
oauth_state = ctx.get_state("oauth_state")
|
|
210
|
+
if oauth_state:
|
|
211
|
+
state_obj = OAuthState(**oauth_state)
|
|
212
|
+
if state_obj.completed:
|
|
213
|
+
return {
|
|
214
|
+
"authenticated": False,
|
|
215
|
+
"status": "error",
|
|
216
|
+
"message": "OAuth flow completed but tokens not stored",
|
|
217
|
+
}
|
|
218
|
+
elif datetime.now(timezone.utc) > state_obj.expires_at:
|
|
219
|
+
return {
|
|
220
|
+
"authenticated": False,
|
|
221
|
+
"status": "expired",
|
|
222
|
+
"message": "OAuth flow expired. Please start again.",
|
|
223
|
+
}
|
|
224
|
+
else:
|
|
225
|
+
return {
|
|
226
|
+
"authenticated": False,
|
|
227
|
+
"status": "pending",
|
|
228
|
+
"message": "Waiting for authorization. Visit the auth URL.",
|
|
229
|
+
"auth_url": state_obj.auth_url,
|
|
230
|
+
}
|
|
231
|
+
else:
|
|
232
|
+
return {
|
|
233
|
+
"authenticated": False,
|
|
234
|
+
"status": "not_started",
|
|
235
|
+
"message": "OAuth flow not started. Use start_oauth_flow first.",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async def refresh_access_token(self, ctx: Context) -> Dict:
|
|
239
|
+
"""
|
|
240
|
+
Manually refresh the access token using the stored refresh token.
|
|
241
|
+
|
|
242
|
+
This is usually handled automatically by middleware.
|
|
243
|
+
"""
|
|
244
|
+
# Try multiple sources for refresh token
|
|
245
|
+
refresh_token = None
|
|
246
|
+
|
|
247
|
+
# 1. Check context state (request-scoped)
|
|
248
|
+
tokens_data = ctx.get_state("oauth_tokens")
|
|
249
|
+
if tokens_data:
|
|
250
|
+
tokens = OAuthTokens(**tokens_data)
|
|
251
|
+
refresh_token = tokens.refresh_token
|
|
252
|
+
|
|
253
|
+
# 2. Check secure token store
|
|
254
|
+
if not refresh_token:
|
|
255
|
+
try:
|
|
256
|
+
from ..auth.secure_token_store import get_secure_token_store
|
|
257
|
+
|
|
258
|
+
secure_store = get_secure_token_store()
|
|
259
|
+
token_entry = secure_store.get_token("oauth_refresh_token")
|
|
260
|
+
if token_entry:
|
|
261
|
+
refresh_token = token_entry["value"]
|
|
262
|
+
except Exception as e:
|
|
263
|
+
logger.debug(f"Could not get from secure store: {e}")
|
|
264
|
+
|
|
265
|
+
# 3. Check callback tokens
|
|
266
|
+
if not refresh_token and hasattr(self, "_callback_tokens"):
|
|
267
|
+
refresh_token = self._callback_tokens.get("refresh_token")
|
|
268
|
+
|
|
269
|
+
# 4. Check auth manager's token store
|
|
270
|
+
if not refresh_token:
|
|
271
|
+
try:
|
|
272
|
+
from ..auth.manager import get_auth_manager
|
|
273
|
+
from ..auth.token_store import TokenKind
|
|
274
|
+
|
|
275
|
+
auth_manager = get_auth_manager()
|
|
276
|
+
if auth_manager:
|
|
277
|
+
token_entry = await auth_manager.get_token(
|
|
278
|
+
provider_type="direct",
|
|
279
|
+
identity_id="direct-auth",
|
|
280
|
+
token_kind=TokenKind.REFRESH,
|
|
281
|
+
)
|
|
282
|
+
if token_entry:
|
|
283
|
+
refresh_token = token_entry.value
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.debug(f"Could not get token from auth manager: {e}")
|
|
286
|
+
|
|
287
|
+
if not refresh_token:
|
|
288
|
+
return {
|
|
289
|
+
"status": "error",
|
|
290
|
+
"message": "No refresh token found. Please complete OAuth flow first.",
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
# Create a temporary tokens object if we didn't have one
|
|
294
|
+
if not tokens_data:
|
|
295
|
+
tokens = OAuthTokens(
|
|
296
|
+
access_token="",
|
|
297
|
+
refresh_token=refresh_token,
|
|
298
|
+
expires_in=0,
|
|
299
|
+
obtained_at=datetime.now(timezone.utc),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Exchange refresh token for new access token
|
|
303
|
+
token_url = RegionConfig.get_oauth_endpoint(self.region)
|
|
304
|
+
token_data = {
|
|
305
|
+
"grant_type": "refresh_token",
|
|
306
|
+
"refresh_token": tokens.refresh_token,
|
|
307
|
+
"client_id": self.client_id,
|
|
308
|
+
"client_secret": self.client_secret,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
# Use explicit timeout for OAuth token refresh
|
|
312
|
+
timeout = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
|
|
313
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
314
|
+
response = await client.post(token_url, data=token_data)
|
|
315
|
+
|
|
316
|
+
if response.status_code == 200:
|
|
317
|
+
token_response = response.json()
|
|
318
|
+
|
|
319
|
+
# Update tokens
|
|
320
|
+
tokens.access_token = token_response["access_token"]
|
|
321
|
+
tokens.expires_in = token_response.get("expires_in", 3600)
|
|
322
|
+
tokens.obtained_at = datetime.now(timezone.utc)
|
|
323
|
+
|
|
324
|
+
# If a new refresh token was provided, update it
|
|
325
|
+
if "refresh_token" in token_response:
|
|
326
|
+
tokens.refresh_token = token_response["refresh_token"]
|
|
327
|
+
|
|
328
|
+
# Store updated tokens in context
|
|
329
|
+
ctx.set_state("oauth_tokens", tokens.model_dump())
|
|
330
|
+
|
|
331
|
+
# Store updated tokens securely
|
|
332
|
+
try:
|
|
333
|
+
from ..auth.secure_token_store import get_secure_token_store
|
|
334
|
+
|
|
335
|
+
secure_store = get_secure_token_store()
|
|
336
|
+
from datetime import timedelta
|
|
337
|
+
|
|
338
|
+
secure_store.store_token(
|
|
339
|
+
token_id="oauth_refresh_token",
|
|
340
|
+
token_value=tokens.refresh_token,
|
|
341
|
+
token_type="refresh",
|
|
342
|
+
expires_at=datetime.now(timezone.utc) + timedelta(days=365),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
logger.info("Updated refresh token in secure store")
|
|
346
|
+
except Exception as e:
|
|
347
|
+
logger.warning(f"Could not update secure store: {e}")
|
|
348
|
+
|
|
349
|
+
# Update auth manager's token store
|
|
350
|
+
try:
|
|
351
|
+
from datetime import timedelta
|
|
352
|
+
|
|
353
|
+
from ..auth.manager import get_auth_manager
|
|
354
|
+
from ..auth.token_store import TokenKind
|
|
355
|
+
|
|
356
|
+
auth_manager = get_auth_manager()
|
|
357
|
+
if auth_manager:
|
|
358
|
+
# Store the new access token
|
|
359
|
+
expires_at = tokens.obtained_at + timedelta(
|
|
360
|
+
seconds=tokens.expires_in
|
|
361
|
+
)
|
|
362
|
+
await auth_manager.set_token(
|
|
363
|
+
provider_type="direct",
|
|
364
|
+
identity_id="direct-auth",
|
|
365
|
+
token_kind=TokenKind.ACCESS,
|
|
366
|
+
token=tokens.access_token,
|
|
367
|
+
expires_at=expires_at,
|
|
368
|
+
metadata={"token_type": "Bearer"},
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Update refresh token if changed
|
|
372
|
+
if "refresh_token" in token_response:
|
|
373
|
+
await auth_manager.set_token(
|
|
374
|
+
provider_type="direct",
|
|
375
|
+
identity_id="direct-auth",
|
|
376
|
+
token_kind=TokenKind.REFRESH,
|
|
377
|
+
token=tokens.refresh_token,
|
|
378
|
+
expires_at=datetime.now(timezone.utc) + timedelta(days=365),
|
|
379
|
+
metadata={},
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
logger.info("Updated tokens in auth manager store")
|
|
383
|
+
except Exception as e:
|
|
384
|
+
logger.warning(f"Could not update auth manager tokens: {e}")
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
"status": "success",
|
|
388
|
+
"message": "Access token refreshed successfully",
|
|
389
|
+
"expires_in_seconds": tokens.expires_in,
|
|
390
|
+
}
|
|
391
|
+
else:
|
|
392
|
+
return {
|
|
393
|
+
"status": "error",
|
|
394
|
+
"message": f"Failed to refresh token: {response.status_code}",
|
|
395
|
+
"error": response.text,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async def clear_oauth_tokens(self, ctx: Context) -> Dict:
|
|
399
|
+
"""
|
|
400
|
+
Clear stored OAuth tokens and state.
|
|
401
|
+
|
|
402
|
+
Use this to reset authentication or switch accounts.
|
|
403
|
+
"""
|
|
404
|
+
ctx.set_state("oauth_tokens", None)
|
|
405
|
+
ctx.set_state("oauth_state", None)
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
"status": "success",
|
|
409
|
+
"message": "OAuth tokens and state cleared. Please run start_oauth_flow to authenticate again.",
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async def handle_oauth_callback(
|
|
413
|
+
self,
|
|
414
|
+
code: str,
|
|
415
|
+
state: str,
|
|
416
|
+
ctx: Context,
|
|
417
|
+
user_agent: Optional[str] = None,
|
|
418
|
+
ip_address: Optional[str] = None,
|
|
419
|
+
) -> Dict:
|
|
420
|
+
"""
|
|
421
|
+
Handle the OAuth callback from Amazon.
|
|
422
|
+
|
|
423
|
+
This is called internally by the server when Amazon redirects back.
|
|
424
|
+
"""
|
|
425
|
+
# Validate state using secure store
|
|
426
|
+
state_store = get_oauth_state_store()
|
|
427
|
+
is_valid, error_message = state_store.validate_state(
|
|
428
|
+
state=state, user_agent=user_agent, ip_address=ip_address
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
if not is_valid:
|
|
432
|
+
logger.warning(f"OAuth state validation failed: {error_message}")
|
|
433
|
+
return {
|
|
434
|
+
"status": "error",
|
|
435
|
+
"message": error_message or "Invalid state parameter",
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
# Exchange code for tokens
|
|
439
|
+
token_url = RegionConfig.get_oauth_endpoint(self.region)
|
|
440
|
+
token_data = {
|
|
441
|
+
"grant_type": "authorization_code",
|
|
442
|
+
"code": code,
|
|
443
|
+
"redirect_uri": self.redirect_uri,
|
|
444
|
+
"client_id": self.client_id,
|
|
445
|
+
"client_secret": self.client_secret,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
# Use explicit timeout for OAuth callback token exchange
|
|
449
|
+
timeout = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
|
|
450
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
451
|
+
response = await client.post(token_url, data=token_data)
|
|
452
|
+
|
|
453
|
+
if response.status_code == 200:
|
|
454
|
+
token_response = response.json()
|
|
455
|
+
|
|
456
|
+
# Store tokens
|
|
457
|
+
tokens = OAuthTokens(
|
|
458
|
+
access_token=token_response["access_token"],
|
|
459
|
+
refresh_token=token_response.get("refresh_token", ""),
|
|
460
|
+
expires_in=token_response.get("expires_in", 3600),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
ctx.set_state("oauth_tokens", tokens.model_dump())
|
|
464
|
+
|
|
465
|
+
# Mark OAuth state as completed
|
|
466
|
+
oauth_state = ctx.get_state("oauth_state")
|
|
467
|
+
if oauth_state:
|
|
468
|
+
oauth_state["completed"] = True
|
|
469
|
+
ctx.set_state("oauth_state", oauth_state)
|
|
470
|
+
|
|
471
|
+
# Store the refresh token securely
|
|
472
|
+
try:
|
|
473
|
+
from ..auth.secure_token_store import get_secure_token_store
|
|
474
|
+
|
|
475
|
+
secure_store = get_secure_token_store()
|
|
476
|
+
from datetime import datetime, timedelta, timezone
|
|
477
|
+
|
|
478
|
+
secure_store.store_token(
|
|
479
|
+
token_id="oauth_refresh_token",
|
|
480
|
+
token_value=tokens.refresh_token,
|
|
481
|
+
token_type="refresh",
|
|
482
|
+
expires_at=datetime.now(timezone.utc) + timedelta(days=365),
|
|
483
|
+
metadata={"scope": token_response.get("scope")},
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
secure_store.store_token(
|
|
487
|
+
token_id="oauth_access_token",
|
|
488
|
+
token_value=tokens.access_token,
|
|
489
|
+
token_type="access",
|
|
490
|
+
expires_at=tokens.obtained_at
|
|
491
|
+
+ timedelta(seconds=tokens.expires_in),
|
|
492
|
+
metadata={"token_type": "Bearer"},
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
logger.info("Stored tokens in secure token store")
|
|
496
|
+
except Exception as e:
|
|
497
|
+
logger.error(f"Failed to store tokens securely: {e}")
|
|
498
|
+
# Continue without raising - tokens are stored in context at minimum
|
|
499
|
+
|
|
500
|
+
# Store tokens in unified token store if auth manager available
|
|
501
|
+
try:
|
|
502
|
+
from datetime import datetime, timedelta, timezone
|
|
503
|
+
|
|
504
|
+
from ..auth.manager import get_auth_manager
|
|
505
|
+
from ..auth.token_store import TokenKind
|
|
506
|
+
|
|
507
|
+
auth_manager = get_auth_manager()
|
|
508
|
+
if auth_manager and hasattr(auth_manager, "set_token"):
|
|
509
|
+
# Store refresh token
|
|
510
|
+
await auth_manager.set_token(
|
|
511
|
+
provider_type="direct",
|
|
512
|
+
identity_id="direct-auth",
|
|
513
|
+
token_kind=TokenKind.REFRESH,
|
|
514
|
+
token=tokens.refresh_token,
|
|
515
|
+
expires_at=datetime.now(timezone.utc)
|
|
516
|
+
+ timedelta(days=365), # Long-lived
|
|
517
|
+
metadata={},
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# Store access token
|
|
521
|
+
expires_at = tokens.obtained_at + timedelta(
|
|
522
|
+
seconds=tokens.expires_in
|
|
523
|
+
)
|
|
524
|
+
await auth_manager.set_token(
|
|
525
|
+
provider_type="direct",
|
|
526
|
+
identity_id="direct-auth",
|
|
527
|
+
token_kind=TokenKind.ACCESS,
|
|
528
|
+
token=tokens.access_token,
|
|
529
|
+
expires_at=expires_at,
|
|
530
|
+
metadata={"token_type": "Bearer"},
|
|
531
|
+
)
|
|
532
|
+
logger.info("Stored OAuth tokens in unified token store")
|
|
533
|
+
|
|
534
|
+
# Update the DirectProvider's refresh token
|
|
535
|
+
if (
|
|
536
|
+
auth_manager.provider
|
|
537
|
+
and auth_manager.provider.provider_type == "direct"
|
|
538
|
+
):
|
|
539
|
+
auth_manager.provider.refresh_token = tokens.refresh_token
|
|
540
|
+
logger.info("Updated DirectProvider with new refresh token")
|
|
541
|
+
except Exception as e:
|
|
542
|
+
logger.error(f"Could not update auth manager: {e}")
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
"status": "success",
|
|
546
|
+
"message": "OAuth completed successfully",
|
|
547
|
+
"has_refresh_token": bool(tokens.refresh_token),
|
|
548
|
+
}
|
|
549
|
+
else:
|
|
550
|
+
return {
|
|
551
|
+
"status": "error",
|
|
552
|
+
"message": f"Failed to exchange code: {response.status_code}",
|
|
553
|
+
"error": response.text,
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def register_oauth_tools(mcp, settings: Settings):
|
|
558
|
+
"""Register OAuth tools with the FastMCP server."""
|
|
559
|
+
oauth = OAuthTools(settings)
|
|
560
|
+
|
|
561
|
+
@mcp.tool
|
|
562
|
+
async def start_oauth_flow(ctx: Context) -> Dict:
|
|
563
|
+
"""
|
|
564
|
+
Start the OAuth authorization flow for Amazon Ads API.
|
|
565
|
+
|
|
566
|
+
Returns an authorization URL that the user should visit to grant access.
|
|
567
|
+
The server will automatically handle the callback.
|
|
568
|
+
"""
|
|
569
|
+
return await oauth.start_oauth_flow(ctx)
|
|
570
|
+
|
|
571
|
+
@mcp.tool
|
|
572
|
+
async def check_oauth_status(ctx: Context) -> Dict:
|
|
573
|
+
"""
|
|
574
|
+
Check the current OAuth authentication status.
|
|
575
|
+
|
|
576
|
+
Returns whether the user is authenticated and token information.
|
|
577
|
+
"""
|
|
578
|
+
return await oauth.check_oauth_status(ctx)
|
|
579
|
+
|
|
580
|
+
@mcp.tool
|
|
581
|
+
async def refresh_oauth_token(ctx: Context) -> Dict:
|
|
582
|
+
"""
|
|
583
|
+
Manually refresh the OAuth access token.
|
|
584
|
+
|
|
585
|
+
This is usually handled automatically, but can be triggered manually if needed.
|
|
586
|
+
"""
|
|
587
|
+
return await oauth.refresh_access_token(ctx)
|
|
588
|
+
|
|
589
|
+
@mcp.tool
|
|
590
|
+
async def clear_oauth_tokens(ctx: Context) -> Dict:
|
|
591
|
+
"""
|
|
592
|
+
Clear all stored OAuth tokens and state.
|
|
593
|
+
|
|
594
|
+
Use this to reset authentication or switch to a different account.
|
|
595
|
+
"""
|
|
596
|
+
return await oauth.clear_oauth_tokens(ctx)
|
|
597
|
+
|
|
598
|
+
return oauth
|