interloper-api 0.11.0__tar.gz → 0.14.0__tar.gz

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 (30) hide show
  1. {interloper_api-0.11.0 → interloper_api-0.14.0}/PKG-INFO +2 -1
  2. {interloper_api-0.11.0 → interloper_api-0.14.0}/pyproject.toml +2 -1
  3. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/__init__.py +2 -0
  4. interloper_api-0.14.0/src/interloper_api/routes/external/google_cloud.py +167 -0
  5. interloper_api-0.14.0/src/interloper_api/routes/oauth.py +184 -0
  6. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/runs.py +8 -4
  7. interloper_api-0.11.0/src/interloper_api/routes/oauth.py +0 -345
  8. {interloper_api-0.11.0 → interloper_api-0.14.0}/README.md +0 -0
  9. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/__init__.py +0 -0
  10. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/app.py +0 -0
  11. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/dependencies.py +0 -0
  12. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/email.py +0 -0
  13. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/__init__.py +0 -0
  14. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/admin.py +0 -0
  15. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/agent.py +0 -0
  16. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/assets.py +0 -0
  17. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/auth.py +0 -0
  18. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/backfills.py +0 -0
  19. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/catalog.py +0 -0
  20. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/destinations.py +0 -0
  21. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/amazon_ads.py +0 -0
  22. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/facebook_ads.py +0 -0
  23. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/google_ads.py +0 -0
  24. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/pinterest_ads.py +0 -0
  25. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/snapchat_ads.py +0 -0
  26. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/jobs.py +0 -0
  27. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/organisations.py +0 -0
  28. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/resources.py +0 -0
  29. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/sources.py +0 -0
  30. {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/ws.py +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.11.0
3
+ Version: 0.14.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
7
7
  Requires-Dist: interloper-core
8
8
  Requires-Dist: interloper-db
9
9
  Requires-Dist: fastapi>=0.115.0
10
+ Requires-Dist: google-auth>=2.0.0
10
11
  Requires-Dist: httpx>=0.28.0
11
12
  Requires-Dist: psycopg2-binary>=2.9.0
12
13
  Requires-Dist: uvicorn[standard]>=0.41.0
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.11.0"
6
+ version = "0.14.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -12,6 +12,7 @@ dependencies = [
12
12
  "interloper-core",
13
13
  "interloper-db",
14
14
  "fastapi>=0.115.0",
15
+ "google-auth>=2.0.0",
15
16
  "httpx>=0.28.0",
16
17
  "psycopg2-binary>=2.9.0",
17
18
  "uvicorn[standard]>=0.41.0",
@@ -38,11 +38,13 @@ def handle_error(error: Exception, context: str) -> None:
38
38
  from interloper_api.routes.external.amazon_ads import sub_router as amazon_ads_router # noqa: E402
39
39
  from interloper_api.routes.external.facebook_ads import sub_router as facebook_ads_router # noqa: E402
40
40
  from interloper_api.routes.external.google_ads import sub_router as google_ads_router # noqa: E402
41
+ from interloper_api.routes.external.google_cloud import sub_router as google_cloud_router # noqa: E402
41
42
  from interloper_api.routes.external.pinterest_ads import sub_router as pinterest_ads_router # noqa: E402
42
43
  from interloper_api.routes.external.snapchat_ads import sub_router as snapchat_ads_router # noqa: E402
43
44
 
44
45
  router.include_router(amazon_ads_router)
45
46
  router.include_router(facebook_ads_router)
46
47
  router.include_router(google_ads_router)
48
+ router.include_router(google_cloud_router)
47
49
  router.include_router(pinterest_ads_router)
48
50
  router.include_router(snapchat_ads_router)
@@ -0,0 +1,167 @@
1
+ """Google Cloud external API routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from fastapi import APIRouter, Depends, HTTPException
11
+ from google.auth import crypt, jwt
12
+ from interloper_db import Profile
13
+ from pydantic import BaseModel, field_validator
14
+
15
+ from interloper_api.dependencies import require_viewer
16
+ from interloper_api.routes.external import handle_error
17
+
18
+ sub_router = APIRouter()
19
+
20
+ _TOKEN_URL = "https://oauth2.googleapis.com/token"
21
+ # BigQuery's own projects.list: returns the projects the credential holds a
22
+ # BigQuery role on -- exactly the candidates for a BigQuery destination --
23
+ # and only requires the BigQuery API, which is necessarily enabled wherever
24
+ # the destination can work (unlike the Cloud Resource Manager API, which is
25
+ # frequently disabled).
26
+ _PROJECTS_URL = "https://bigquery.googleapis.com/bigquery/v2/projects"
27
+ _SCOPE = "https://www.googleapis.com/auth/bigquery.readonly"
28
+
29
+
30
+ class GoogleCloudConnectionRequest(BaseModel):
31
+ """Google Cloud connection credentials (matches GoogleCloudConnection fields)."""
32
+
33
+ service_account_key: str
34
+
35
+ @field_validator("service_account_key", mode="before")
36
+ @classmethod
37
+ def _serialize_key(cls, v: object) -> object:
38
+ if isinstance(v, dict):
39
+ return json.dumps(v)
40
+ return v
41
+
42
+ @property
43
+ def key_info(self) -> dict[str, Any]:
44
+ """The parsed service account key.
45
+
46
+ Returns:
47
+ The key as a dict.
48
+
49
+ Raises:
50
+ HTTPException: If the key is not valid JSON.
51
+ """
52
+ try:
53
+ return json.loads(self.service_account_key)
54
+ except json.JSONDecodeError:
55
+ raise HTTPException(status_code=400, detail="service_account_key is not valid JSON.")
56
+
57
+
58
+ def _make_assertion(key_info: dict[str, Any]) -> str:
59
+ """Build a signed JWT-bearer assertion for the service account.
60
+
61
+ Only the signing comes from google-auth; the token exchange itself goes
62
+ through httpx like every other external route.
63
+
64
+ Args:
65
+ key_info: The parsed service account key.
66
+
67
+ Returns:
68
+ The signed JWT assertion.
69
+ """
70
+ signer = crypt.RSASigner.from_service_account_info(key_info)
71
+ now = int(time.time())
72
+ payload = {
73
+ "iss": key_info["client_email"],
74
+ "scope": _SCOPE,
75
+ "aud": _TOKEN_URL,
76
+ "iat": now,
77
+ "exp": now + 600,
78
+ }
79
+ return jwt.encode(signer, payload).decode()
80
+
81
+
82
+ async def _get_access_token(client: httpx.AsyncClient, key_info: dict[str, Any]) -> str:
83
+ """Exchange a service account JWT assertion for an access token."""
84
+ resp = await client.post(
85
+ _TOKEN_URL,
86
+ data={
87
+ "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
88
+ "assertion": _make_assertion(key_info),
89
+ },
90
+ )
91
+ resp.raise_for_status()
92
+ return resp.json()["access_token"]
93
+
94
+
95
+ async def _list_projects(client: httpx.AsyncClient, access_token: str) -> list[dict[str, str]]:
96
+ """List the projects the credential has BigQuery access to, following pagination.
97
+
98
+ Returns:
99
+ Project options with ``project_id`` and a display ``name``.
100
+ """
101
+ results: list[dict[str, str]] = []
102
+ page_token: str | None = None
103
+ while True:
104
+ params: dict[str, str] = {"maxResults": "500"}
105
+ if page_token:
106
+ params["pageToken"] = page_token
107
+ resp = await client.get(
108
+ _PROJECTS_URL,
109
+ params=params,
110
+ headers={"Authorization": f"Bearer {access_token}"},
111
+ )
112
+ resp.raise_for_status()
113
+ data = resp.json()
114
+ for project in data.get("projects", []):
115
+ project_id = project["id"]
116
+ name = project.get("friendlyName") or project_id
117
+ results.append({"project_id": project_id, "name": f"{name} ({project_id})"})
118
+ page_token = data.get("nextPageToken")
119
+ if not page_token:
120
+ break
121
+ return sorted(results, key=lambda p: p["name"].lower())
122
+
123
+
124
+ def _upstream_detail(exc: httpx.HTTPStatusError) -> str:
125
+ """Extract the human-readable error message from a Google error response.
126
+
127
+ The token endpoint answers ``{"error": ..., "error_description": ...}``;
128
+ the Cloud Resource Manager answers ``{"error": {"message": ...}}``.
129
+
130
+ Returns:
131
+ Google's error message, or the raw body as a fallback.
132
+ """
133
+ try:
134
+ payload = exc.response.json()
135
+ except ValueError:
136
+ return exc.response.text[:200]
137
+ error = payload.get("error")
138
+ if isinstance(error, dict):
139
+ return str(error.get("message") or error)
140
+ description = payload.get("error_description")
141
+ return str(description or error or exc.response.text[:200])
142
+
143
+
144
+ @sub_router.post("/google-cloud/projects")
145
+ async def google_cloud_projects(
146
+ body: GoogleCloudConnectionRequest,
147
+ _user: Profile = Depends(require_viewer),
148
+ ) -> list[dict[str, str]]:
149
+ """Fetch the Google Cloud projects accessible by the connection."""
150
+ key_info = body.key_info
151
+ try:
152
+ async with httpx.AsyncClient(timeout=30) as client:
153
+ access_token = await _get_access_token(client, key_info)
154
+ return await _list_projects(client, access_token)
155
+ except httpx.HTTPStatusError as exc:
156
+ # Surface Google's own message (e.g. "Cloud Resource Manager API has
157
+ # not been used in project ...", "Invalid JWT Signature.") so the
158
+ # form error is actionable, instead of the generic handle_error text.
159
+ status = exc.response.status_code
160
+ detail = _upstream_detail(exc)
161
+ raise HTTPException(
162
+ status_code=status if status in (401, 403, 404) else 502,
163
+ detail=f"Google Cloud error while fetching projects: {detail}",
164
+ )
165
+ except Exception as exc:
166
+ handle_error(exc, "fetching Google Cloud projects")
167
+ return [] # unreachable, but satisfies type checker
@@ -0,0 +1,184 @@
1
+ """OAuth2 token exchange routes.
2
+
3
+ Providers come from the core registry (``interloper.oauth``): each
4
+ ``OAuthProvider`` carries a declarative token-exchange spec (URL, method,
5
+ encoding, parameter names), so the exchange is performed generically —
6
+ adding a provider is an ``interloper.oauth_providers`` entry point, not a
7
+ new route.
8
+
9
+ The connector *app* credentials (``client_id`` / ``client_secret`` /
10
+ ``redirect_uri``) are read from provider-scoped environment variables
11
+ (``<PROVIDER>_CLIENT_ID``, …) and injected into the token response so they
12
+ can be stored alongside the tokens.
13
+
14
+ The ``GET /providers`` endpoint returns metadata for all providers that
15
+ have credentials configured, so the frontend knows which "Sign in with X"
16
+ buttons to render.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import logging
23
+ import os
24
+ from typing import Any
25
+
26
+ import httpx
27
+ from fastapi import APIRouter, HTTPException
28
+ from interloper.oauth import OAuthProvider, providers
29
+ from pydantic import BaseModel
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ router = APIRouter(prefix="/oauth", tags=["oauth"])
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # App credentials (environment)
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ class _ProviderConfig:
42
+ """Connector app credentials resolved from environment variables."""
43
+
44
+ def __init__(self, key: str, *, env_prefix: str | None = None) -> None:
45
+ prefix = (env_prefix or key).upper()
46
+ self.key = key
47
+ self.client_id = os.environ.get(f"{prefix}_CLIENT_ID", "")
48
+ self.client_secret = os.environ.get(f"{prefix}_CLIENT_SECRET", "")
49
+ self.redirect_uri = os.environ.get(f"{prefix}_REDIRECT_URI", "")
50
+
51
+ @property
52
+ def configured(self) -> bool:
53
+ return bool(self.client_id and self.client_secret and self.redirect_uri)
54
+
55
+
56
+ def _load_providers() -> dict[str, _ProviderConfig]:
57
+ return {key: _ProviderConfig(key) for key in providers()}
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Generic token exchange
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ async def _exchange(
66
+ client: httpx.AsyncClient,
67
+ spec: OAuthProvider,
68
+ cfg: _ProviderConfig,
69
+ code: str,
70
+ ) -> dict[str, Any]:
71
+ """Exchange an authorization code for tokens, driven by the provider spec.
72
+
73
+ Args:
74
+ client: The HTTP client to use.
75
+ spec: The provider's token-exchange spec.
76
+ cfg: The connector app credentials.
77
+ code: The authorization code.
78
+
79
+ Returns:
80
+ The token response, with ``client_id`` / ``client_secret`` injected
81
+ so they can be stored alongside the tokens.
82
+ """
83
+ logical_values = {
84
+ "grant_type": "authorization_code",
85
+ "code": code,
86
+ "redirect_uri": cfg.redirect_uri,
87
+ "client_id": cfg.client_id,
88
+ "client_secret": cfg.client_secret,
89
+ }
90
+ params = {wire: logical_values[logical] for logical, wire in spec.token_params.items()}
91
+
92
+ headers: dict[str, str] = {}
93
+ if spec.token_basic_auth:
94
+ creds = base64.b64encode(f"{cfg.client_id}:{cfg.client_secret}".encode()).decode()
95
+ headers["Authorization"] = f"Basic {creds}"
96
+
97
+ if spec.token_method == "get":
98
+ resp = await client.get(spec.token_url, params=params, headers=headers)
99
+ elif spec.token_encoding == "form":
100
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
101
+ resp = await client.post(spec.token_url, data=params, headers=headers)
102
+ else:
103
+ resp = await client.post(spec.token_url, json=params, headers=headers)
104
+ resp.raise_for_status()
105
+
106
+ result = resp.json()
107
+ result["client_id"] = cfg.client_id
108
+ result["client_secret"] = cfg.client_secret
109
+ return result
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Routes
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ class TokenExchangeRequest(BaseModel):
118
+ """Request body for exchanging an authorization code for tokens."""
119
+
120
+ code: str
121
+
122
+
123
+ class ProviderInfo(BaseModel):
124
+ """Public provider metadata (no secrets)."""
125
+
126
+ key: str
127
+ client_id: str
128
+ redirect_uri: str
129
+ auth_url: str = ""
130
+ label: str = ""
131
+ icon: str = ""
132
+
133
+
134
+ @router.get("/providers")
135
+ def list_providers() -> list[ProviderInfo]:
136
+ """Return metadata for all configured OAuth providers.
137
+
138
+ Only registered providers with ``CLIENT_ID``, ``CLIENT_SECRET``, and
139
+ ``REDIRECT_URI`` environment variables set are included. Metadata
140
+ (auth_url, label, icon) comes from the provider registry.
141
+ """
142
+ specs = providers()
143
+ return [
144
+ ProviderInfo(
145
+ key=cfg.key,
146
+ client_id=cfg.client_id,
147
+ redirect_uri=cfg.redirect_uri,
148
+ auth_url=specs[cfg.key].auth_url,
149
+ label=specs[cfg.key].label,
150
+ icon=specs[cfg.key].icon,
151
+ )
152
+ for cfg in _load_providers().values()
153
+ if cfg.configured
154
+ ]
155
+
156
+
157
+ @router.post("/{provider}")
158
+ async def exchange_token(provider: str, body: TokenExchangeRequest) -> dict[str, Any]:
159
+ """Exchange an authorization code for tokens.
160
+
161
+ The response includes ``client_id`` and ``client_secret`` so they
162
+ can be stored alongside the tokens in the connection data.
163
+ """
164
+ spec = providers().get(provider)
165
+ if spec is None:
166
+ raise HTTPException(status_code=400, detail=f"Unknown OAuth provider: {provider}")
167
+
168
+ cfg = _ProviderConfig(provider)
169
+ if not cfg.configured:
170
+ raise HTTPException(status_code=400, detail=f"OAuth provider {provider} is not configured")
171
+
172
+ try:
173
+ logger.info("Exchanging auth code for provider %s", provider)
174
+ async with httpx.AsyncClient(timeout=30) as client:
175
+ result = await _exchange(client, spec, cfg, body.code)
176
+ logger.info("Successfully exchanged auth code for provider %s", provider)
177
+ return result
178
+ except httpx.HTTPStatusError as exc:
179
+ detail = exc.response.text
180
+ logger.error("Token exchange failed for %s: %s %s", provider, exc.response.status_code, detail)
181
+ raise HTTPException(status_code=500, detail=f"Failed to exchange auth code: {detail}")
182
+ except Exception as exc:
183
+ logger.error("Token exchange failed for %s: %s", provider, exc)
184
+ raise HTTPException(status_code=500, detail=f"Failed to exchange auth code: {exc}")
@@ -71,6 +71,7 @@ class EventResponse(BaseModel):
71
71
  error: str | None
72
72
  traceback: str | None
73
73
  message: str | None
74
+ level: str | None
74
75
  timestamp: str
75
76
 
76
77
 
@@ -119,6 +120,7 @@ def _event_to_response(event: Event) -> EventResponse:
119
120
  error=event.error,
120
121
  traceback=event.traceback,
121
122
  message=event.message,
123
+ level=event.level,
122
124
  timestamp=str(event.timestamp),
123
125
  )
124
126
 
@@ -219,20 +221,22 @@ def list_run_events(
219
221
  response: Response,
220
222
  limit: int = 100,
221
223
  offset: int = 0,
224
+ asset_id: UUID | None = None,
222
225
  user: Profile = Depends(require_viewer),
223
226
  store: Store = Depends(get_store),
224
227
  ) -> list[EventResponse]:
225
228
  """List events for a run, oldest first.
226
229
 
227
230
  Events are ordered ``timestamp ASC`` and paged with ``limit``/``offset``.
228
- The total number of events for the run (ignoring ``limit``/``offset``) is
229
- returned in the ``X-Total-Count`` response header so clients can page
231
+ ``asset_id`` narrows the listing to one asset's events. The total number
232
+ of matching events (ignoring ``limit``/``offset``, honouring ``asset_id``)
233
+ is returned in the ``X-Total-Count`` response header so clients can page
230
234
  through every event — including the terminal/outcome events
231
235
  (``asset_completed``, ``asset_failed``, ``run_failed``, …) that sort last.
232
236
  """
233
237
  limit = max(1, min(limit, MAX_EVENTS_PAGE_SIZE))
234
238
  offset = max(0, offset)
235
- total = store.count_events(run_id=run_id)
239
+ total = store.count_events(run_id=run_id, asset_id=asset_id)
236
240
  response.headers["X-Total-Count"] = str(total)
237
- events = store.list_events(run_id=run_id, limit=limit, offset=offset)
241
+ events = store.list_events(run_id=run_id, asset_id=asset_id, limit=limit, offset=offset)
238
242
  return [_event_to_response(e) for e in events]
@@ -1,345 +0,0 @@
1
- """OAuth2 token exchange routes.
2
-
3
- Each provider has an explicit exchange function with its own URL,
4
- HTTP method, body encoding, and parameter names. The ``client_id``
5
- and ``client_secret`` are read from environment variables and injected
6
- into the token response so they can be stored alongside the tokens.
7
-
8
- The ``GET /providers`` endpoint returns metadata for all providers
9
- that have credentials configured, so the frontend knows which
10
- "Sign in with X" buttons to render.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import base64
16
- import logging
17
- import os
18
- from typing import Any
19
-
20
- import httpx
21
- from fastapi import APIRouter, Depends, HTTPException
22
- from pydantic import BaseModel
23
-
24
- from interloper_api.dependencies import get_catalog
25
-
26
- logger = logging.getLogger(__name__)
27
-
28
- router = APIRouter(prefix="/oauth", tags=["oauth"])
29
-
30
-
31
- # ---------------------------------------------------------------------------
32
- # Provider registry
33
- # ---------------------------------------------------------------------------
34
-
35
-
36
- class _ProviderConfig:
37
- """Runtime provider config resolved from environment variables."""
38
-
39
- def __init__(self, key: str, *, env_prefix: str | None = None) -> None:
40
- prefix = (env_prefix or key).upper()
41
- self.key = key
42
- self.client_id = os.environ.get(f"{prefix}_CLIENT_ID", "")
43
- self.client_secret = os.environ.get(f"{prefix}_CLIENT_SECRET", "")
44
- self.redirect_uri = os.environ.get(f"{prefix}_REDIRECT_URI", "")
45
-
46
- @property
47
- def configured(self) -> bool:
48
- return bool(self.client_id and self.client_secret and self.redirect_uri)
49
-
50
-
51
- _PROVIDER_KEYS = [
52
- "amazon",
53
- "criteo",
54
- "facebook",
55
- "google",
56
- "linkedin",
57
- "microsoft",
58
- "pinterest",
59
- "snapchat",
60
- "tiktok",
61
- ]
62
-
63
-
64
- def _load_providers() -> dict[str, _ProviderConfig]:
65
- return {k: _ProviderConfig(k) for k in _PROVIDER_KEYS}
66
-
67
-
68
- # ---------------------------------------------------------------------------
69
- # Token exchange functions (one per provider)
70
- # ---------------------------------------------------------------------------
71
-
72
-
73
- async def _amazon(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
74
- resp = await client.post(
75
- "https://api.amazon.com/auth/o2/token",
76
- json={
77
- "grant_type": "authorization_code",
78
- "code": code,
79
- "redirect_uri": cfg.redirect_uri,
80
- "client_id": cfg.client_id,
81
- "client_secret": cfg.client_secret,
82
- },
83
- )
84
- resp.raise_for_status()
85
- result = resp.json()
86
- result["client_id"] = cfg.client_id
87
- result["client_secret"] = cfg.client_secret
88
- return result
89
-
90
-
91
- async def _criteo(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
92
- resp = await client.post(
93
- "https://api.criteo.com/oauth2/token",
94
- json={
95
- "grant_type": "authorization_code",
96
- "code": code,
97
- "redirect_uri": cfg.redirect_uri,
98
- "client_id": cfg.client_id,
99
- "client_secret": cfg.client_secret,
100
- },
101
- )
102
- resp.raise_for_status()
103
- result = resp.json()
104
- result["client_id"] = cfg.client_id
105
- result["client_secret"] = cfg.client_secret
106
- return result
107
-
108
-
109
- async def _facebook(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
110
- resp = await client.get(
111
- "https://graph.facebook.com/v19.0/oauth/access_token",
112
- params={
113
- "code": code,
114
- "redirect_uri": cfg.redirect_uri,
115
- "client_id": cfg.client_id,
116
- "client_secret": cfg.client_secret,
117
- },
118
- )
119
- resp.raise_for_status()
120
- result = resp.json()
121
- result["client_id"] = cfg.client_id
122
- result["client_secret"] = cfg.client_secret
123
- return result
124
-
125
-
126
- async def _google(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
127
- resp = await client.post(
128
- "https://oauth2.googleapis.com/token",
129
- json={
130
- "grant_type": "authorization_code",
131
- "code": code,
132
- "redirect_uri": cfg.redirect_uri,
133
- "client_id": cfg.client_id,
134
- "client_secret": cfg.client_secret,
135
- },
136
- )
137
- resp.raise_for_status()
138
- result = resp.json()
139
- result["client_id"] = cfg.client_id
140
- result["client_secret"] = cfg.client_secret
141
- return result
142
-
143
-
144
- async def _linkedin(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
145
- resp = await client.post(
146
- "https://www.linkedin.com/oauth/v2/accessToken",
147
- data={
148
- "grant_type": "authorization_code",
149
- "code": code,
150
- "redirect_uri": cfg.redirect_uri,
151
- "client_id": cfg.client_id,
152
- "client_secret": cfg.client_secret,
153
- },
154
- headers={"Content-Type": "application/x-www-form-urlencoded"},
155
- )
156
- resp.raise_for_status()
157
- result = resp.json()
158
- result["client_id"] = cfg.client_id
159
- result["client_secret"] = cfg.client_secret
160
- return result
161
-
162
-
163
- async def _microsoft(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
164
- resp = await client.post(
165
- "https://login.microsoftonline.com/common/oauth2/v2.0/token",
166
- data={
167
- "grant_type": "authorization_code",
168
- "code": code,
169
- "redirect_uri": cfg.redirect_uri,
170
- "client_id": cfg.client_id,
171
- "client_secret": cfg.client_secret,
172
- },
173
- headers={"Content-Type": "application/x-www-form-urlencoded"},
174
- )
175
- resp.raise_for_status()
176
- result = resp.json()
177
- result["client_id"] = cfg.client_id
178
- result["client_secret"] = cfg.client_secret
179
- return result
180
-
181
-
182
- async def _pinterest(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
183
- creds = base64.b64encode(f"{cfg.client_id}:{cfg.client_secret}".encode()).decode()
184
- resp = await client.post(
185
- "https://api.pinterest.com/v5/oauth/token",
186
- data={
187
- "grant_type": "authorization_code",
188
- "code": code,
189
- "redirect_uri": cfg.redirect_uri,
190
- "client_id": cfg.client_id,
191
- "client_secret": cfg.client_secret,
192
- },
193
- headers={
194
- "Content-Type": "application/x-www-form-urlencoded",
195
- "Authorization": f"Basic {creds}",
196
- },
197
- )
198
- resp.raise_for_status()
199
- result = resp.json()
200
- result["client_id"] = cfg.client_id
201
- result["client_secret"] = cfg.client_secret
202
- return result
203
-
204
-
205
- async def _snapchat(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
206
- resp = await client.post(
207
- "https://accounts.snapchat.com/login/oauth2/access_token",
208
- data={
209
- "grant_type": "authorization_code",
210
- "code": code,
211
- "redirect_uri": cfg.redirect_uri,
212
- "client_id": cfg.client_id,
213
- "client_secret": cfg.client_secret,
214
- },
215
- headers={"Content-Type": "application/x-www-form-urlencoded"},
216
- )
217
- resp.raise_for_status()
218
- result = resp.json()
219
- result["client_id"] = cfg.client_id
220
- result["client_secret"] = cfg.client_secret
221
- return result
222
-
223
-
224
- async def _tiktok(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
225
- resp = await client.post(
226
- "https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token",
227
- json={
228
- "auth_code": code,
229
- "app_id": cfg.client_id,
230
- "secret": cfg.client_secret,
231
- },
232
- )
233
- resp.raise_for_status()
234
- result = resp.json()
235
- result["client_id"] = cfg.client_id
236
- result["client_secret"] = cfg.client_secret
237
- return result
238
-
239
-
240
- _EXCHANGE_FNS: dict[str, Any] = {
241
- "amazon": _amazon,
242
- "criteo": _criteo,
243
- "facebook": _facebook,
244
- "google": _google,
245
- "linkedin": _linkedin,
246
- "microsoft": _microsoft,
247
- "pinterest": _pinterest,
248
- "snapchat": _snapchat,
249
- "tiktok": _tiktok,
250
- }
251
-
252
-
253
- # ---------------------------------------------------------------------------
254
- # Routes
255
- # ---------------------------------------------------------------------------
256
-
257
-
258
- class TokenExchangeRequest(BaseModel):
259
- """Request body for exchanging an authorization code for tokens."""
260
-
261
- code: str
262
-
263
-
264
- class ProviderInfo(BaseModel):
265
- """Public provider metadata (no secrets)."""
266
-
267
- key: str
268
- client_id: str
269
- redirect_uri: str
270
- auth_url: str = ""
271
- label: str = ""
272
- icon: str = ""
273
-
274
-
275
- def _extract_oauth_metadata(catalog: dict[str, Any]) -> dict[str, dict[str, str]]:
276
- """Extract OAuth metadata (auth_url, label, icon) from catalog definitions.
277
-
278
- Scans all resource definitions for ``x-oauth`` config schema extensions.
279
- Returns a dict keyed by provider key.
280
- """
281
- metadata: dict[str, dict[str, str]] = {}
282
- for defn in catalog.values():
283
- oauth_ext = defn.get("config_schema", {}).get("x-oauth")
284
- if oauth_ext and isinstance(oauth_ext, dict):
285
- provider = oauth_ext.get("provider", "")
286
- if provider and provider not in metadata:
287
- metadata[provider] = {
288
- "auth_url": oauth_ext.get("auth_url", ""),
289
- "label": oauth_ext.get("label", ""),
290
- "icon": oauth_ext.get("icon", ""),
291
- }
292
- return metadata
293
-
294
-
295
- @router.get("/providers")
296
- def list_providers(catalog: dict[str, Any] = Depends(get_catalog)) -> list[ProviderInfo]:
297
- """Return metadata for all configured OAuth providers.
298
-
299
- Only providers with ``CLIENT_ID``, ``CLIENT_SECRET``, and
300
- ``REDIRECT_URI`` environment variables set are included.
301
- Metadata (auth_url, label, icon) is extracted from the catalog.
302
- """
303
- providers = _load_providers()
304
- oauth_meta = _extract_oauth_metadata(catalog)
305
- return [
306
- ProviderInfo(
307
- key=cfg.key,
308
- client_id=cfg.client_id,
309
- redirect_uri=cfg.redirect_uri,
310
- **(oauth_meta.get(cfg.key, {})),
311
- )
312
- for cfg in providers.values()
313
- if cfg.configured
314
- ]
315
-
316
-
317
- @router.post("/{provider}")
318
- async def exchange_token(provider: str, body: TokenExchangeRequest) -> dict[str, Any]:
319
- """Exchange an authorization code for tokens.
320
-
321
- The response includes ``client_id`` and ``client_secret`` so they
322
- can be stored alongside the tokens in the connection data.
323
- """
324
- exchange = _EXCHANGE_FNS.get(provider)
325
- if exchange is None:
326
- raise HTTPException(status_code=400, detail=f"Unknown OAuth provider: {provider}")
327
-
328
- providers = _load_providers()
329
- cfg = providers.get(provider)
330
- if cfg is None or not cfg.configured:
331
- raise HTTPException(status_code=400, detail=f"OAuth provider {provider} is not configured")
332
-
333
- try:
334
- logger.info("Exchanging auth code for provider %s", provider)
335
- async with httpx.AsyncClient(timeout=30) as client:
336
- result = await exchange(client, cfg, body.code)
337
- logger.info("Successfully exchanged auth code for provider %s", provider)
338
- return result
339
- except httpx.HTTPStatusError as exc:
340
- detail = exc.response.text
341
- logger.error("Token exchange failed for %s: %s %s", provider, exc.response.status_code, detail)
342
- raise HTTPException(status_code=500, detail=f"Failed to exchange auth code: {detail}")
343
- except Exception as exc:
344
- logger.error("Token exchange failed for %s: %s", provider, exc)
345
- raise HTTPException(status_code=500, detail=f"Failed to exchange auth code: {exc}")