Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit fdc13cd

Browse files
authored
Merge pull request #205 from XortexAI/codex-add-notion-drive-connectors
Add Notion and Google Drive connector routes
2 parents 89ac998 + d703470 commit fdc13cd

3 files changed

Lines changed: 355 additions & 0 deletions

File tree

‎src/api/app.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from src.api.routes.auth import router as auth_router
3333
from src.api.routes.billing import router as billing_router
3434
from src.api.routes.code import router as code_router
35+
from src.api.routes.connectors import router as connectors_router
3536
from src.api.routes.enterprise import router as enterprise_router
3637
from src.api.routes.health import router as health_router
3738
from src.api.routes.memory import router as memory_router
@@ -226,6 +227,7 @@ async def lifespan(app: FastAPI):
226227
app.include_router(scanner_router)
227228
app.include_router(auth_router)
228229
app.include_router(api_keys_router)
230+
app.include_router(connectors_router)
229231
app.include_router(billing_router)
230232
app.include_router(enterprise_router)
231233
app.include_router(telemetry_router)

‎src/api/routes/connectors.py‎

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
"""Connector OAuth routes for external knowledge sources."""
2+
3+
from __future__ import annotations
4+
5+
import secrets
6+
import os
7+
from datetime import datetime, timedelta, timezone
8+
from typing import Dict, List, Literal, Optional
9+
from urllib.parse import urlencode
10+
11+
from fastapi import APIRouter, Depends, HTTPException, Query, status
12+
from pydantic import BaseModel, Field
13+
14+
from src.api.dependencies import require_user
15+
router = APIRouter(prefix="/api/connectors", tags=["Connectors"])
16+
17+
ConnectorId = Literal["notion", "google-drive"]
18+
ConnectorState = Literal["connected", "not_connected", "pending"]
19+
20+
STATE_TTL_MINUTES = 10
21+
MAX_PENDING_STATES = 1000
22+
23+
24+
class ConnectorDefinition(BaseModel):
25+
id: ConnectorId
26+
name: str
27+
description: str
28+
auth_url: str
29+
token_url: str
30+
scopes: List[str]
31+
docs_url: str
32+
33+
34+
class ConnectorStatusResponse(BaseModel):
35+
id: ConnectorId
36+
name: str
37+
state: ConnectorState
38+
connected_at: Optional[datetime] = None
39+
scopes: List[str] = Field(default_factory=list)
40+
detail: str
41+
42+
43+
class ConnectorListResponse(BaseModel):
44+
connectors: List[ConnectorStatusResponse]
45+
46+
47+
class ConnectorStartResponse(BaseModel):
48+
connector_id: ConnectorId
49+
authorization_url: str
50+
state: str
51+
expires_at: datetime
52+
53+
54+
class ConnectorDisconnectResponse(BaseModel):
55+
connector_id: ConnectorId
56+
disconnected: bool
57+
58+
59+
class PendingOAuthState(BaseModel):
60+
connector_id: ConnectorId
61+
user_id: str
62+
expires_at: datetime
63+
64+
65+
CONNECTORS: Dict[ConnectorId, ConnectorDefinition] = {
66+
"notion": ConnectorDefinition(
67+
id="notion",
68+
name="Notion",
69+
description="Sync selected Notion pages and workspace notes into XMem memory.",
70+
auth_url="https://api.notion.com/v1/oauth/authorize",
71+
token_url="https://api.notion.com/v1/oauth/token",
72+
scopes=[],
73+
docs_url="https://developers.notion.com/docs/authorization",
74+
),
75+
"google-drive": ConnectorDefinition(
76+
id="google-drive",
77+
name="Google Drive",
78+
description="Bring Google Drive docs and files into XMem as searchable memory.",
79+
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
80+
token_url="https://oauth2.googleapis.com/token",
81+
scopes=[
82+
"https://www.googleapis.com/auth/drive.readonly",
83+
"https://www.googleapis.com/auth/documents.readonly",
84+
],
85+
docs_url="https://developers.google.com/identity/protocols/oauth2",
86+
),
87+
}
88+
89+
_pending_states: Dict[str, PendingOAuthState] = {}
90+
91+
92+
def _now() -> datetime:
93+
return datetime.now(timezone.utc)
94+
95+
96+
def _client_id(connector_id: ConnectorId) -> Optional[str]:
97+
if connector_id == "notion":
98+
return os.getenv("NOTION_CLIENT_ID")
99+
return os.getenv("GOOGLE_DRIVE_CLIENT_ID") or os.getenv("GOOGLE_CLIENT_ID")
100+
101+
102+
def _redirect_uri(connector_id: ConnectorId) -> str:
103+
if connector_id == "notion":
104+
return os.getenv(
105+
"NOTION_REDIRECT_URI",
106+
"http://localhost:8000/api/connectors/notion/oauth/callback",
107+
)
108+
return os.getenv(
109+
"GOOGLE_DRIVE_REDIRECT_URI",
110+
"http://localhost:8000/api/connectors/google-drive/oauth/callback",
111+
)
112+
113+
114+
def _get_connector(connector_id: str) -> ConnectorDefinition:
115+
connector = CONNECTORS.get(connector_id) # type: ignore[arg-type]
116+
if not connector:
117+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown connector")
118+
return connector
119+
120+
121+
def _prune_pending_states(now: Optional[datetime] = None) -> None:
122+
current_time = now or _now()
123+
expired = [
124+
key
125+
for key, pending in _pending_states.items()
126+
if pending.expires_at <= current_time
127+
]
128+
for key in expired:
129+
_pending_states.pop(key, None)
130+
131+
overflow = len(_pending_states) - MAX_PENDING_STATES
132+
if overflow > 0:
133+
oldest = sorted(_pending_states.items(), key=lambda item: item[1].expires_at)
134+
for key, _pending in oldest[:overflow]:
135+
_pending_states.pop(key, None)
136+
137+
138+
def _status_for(user_id: str, connector: ConnectorDefinition) -> ConnectorStatusResponse:
139+
return ConnectorStatusResponse(
140+
id=connector.id,
141+
name=connector.name,
142+
state="not_connected",
143+
scopes=connector.scopes,
144+
detail="OAuth start is available; token exchange and sync storage are not connected yet.",
145+
)
146+
147+
148+
def _build_authorization_url(connector: ConnectorDefinition, state: str) -> str:
149+
client_id = _client_id(connector.id)
150+
if not client_id:
151+
raise HTTPException(
152+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
153+
detail=f"{connector.name} OAuth client ID is not configured",
154+
)
155+
156+
params = {
157+
"client_id": client_id,
158+
"redirect_uri": _redirect_uri(connector.id),
159+
"response_type": "code",
160+
"state": state,
161+
}
162+
if connector.id == "google-drive":
163+
params.update(
164+
{
165+
"access_type": "offline",
166+
"include_granted_scopes": "true",
167+
"prompt": "consent",
168+
"scope": " ".join(connector.scopes),
169+
}
170+
)
171+
if connector.id == "notion":
172+
params["owner"] = "user"
173+
174+
return f"{connector.auth_url}?{urlencode(params)}"
175+
176+
177+
@router.get("", response_model=ConnectorListResponse)
178+
async def list_connectors(current_user: dict = Depends(require_user)) -> ConnectorListResponse:
179+
user_id = str(current_user.get("id"))
180+
return ConnectorListResponse(
181+
connectors=[_status_for(user_id, connector) for connector in CONNECTORS.values()]
182+
)
183+
184+
185+
@router.get("/{connector_id}/status", response_model=ConnectorStatusResponse)
186+
async def connector_status(
187+
connector_id: str,
188+
current_user: dict = Depends(require_user),
189+
) -> ConnectorStatusResponse:
190+
connector = _get_connector(connector_id)
191+
return _status_for(str(current_user.get("id")), connector)
192+
193+
194+
@router.post("/{connector_id}/oauth/start", response_model=ConnectorStartResponse)
195+
async def start_connector_oauth(
196+
connector_id: str,
197+
current_user: dict = Depends(require_user),
198+
) -> ConnectorStartResponse:
199+
connector = _get_connector(connector_id)
200+
_prune_pending_states()
201+
state = secrets.token_urlsafe(32)
202+
expires_at = _now() + timedelta(minutes=STATE_TTL_MINUTES)
203+
authorization_url = _build_authorization_url(connector, state)
204+
_pending_states[state] = PendingOAuthState(
205+
connector_id=connector.id,
206+
user_id=str(current_user.get("id")),
207+
expires_at=expires_at,
208+
)
209+
210+
return ConnectorStartResponse(
211+
connector_id=connector.id,
212+
authorization_url=authorization_url,
213+
state=state,
214+
expires_at=expires_at,
215+
)
216+
217+
218+
@router.get("/{connector_id}/oauth/callback")
219+
async def connector_oauth_callback(
220+
connector_id: str,
221+
state: str = Query(..., min_length=1),
222+
code: Optional[str] = Query(None, min_length=1),
223+
error: Optional[str] = Query(None, min_length=1),
224+
) -> dict:
225+
connector = _get_connector(connector_id)
226+
now = _now()
227+
_prune_pending_states(now)
228+
pending = _pending_states.pop(state, None)
229+
if not pending or pending.connector_id != connector.id or pending.expires_at <= now:
230+
raise HTTPException(
231+
status_code=status.HTTP_400_BAD_REQUEST,
232+
detail="Invalid or expired connector authorization state",
233+
)
234+
if error or not code:
235+
raise HTTPException(
236+
status_code=status.HTTP_400_BAD_REQUEST,
237+
detail=f"Authorization denied: {error or 'no authorization code received'}",
238+
)
239+
240+
# Token exchange, encrypted credential storage, and source ingestion are intentionally
241+
# separate follow-up steps. Do not mark the connector as connected until those exist.
242+
return {
243+
"status": "pending",
244+
"connector_id": connector.id,
245+
"detail": f"{connector.name} authorization received; token exchange is not enabled yet.",
246+
}
247+
248+
249+
@router.post("/{connector_id}/disconnect", response_model=ConnectorDisconnectResponse)
250+
async def disconnect_connector(
251+
connector_id: str,
252+
current_user: dict = Depends(require_user),
253+
) -> ConnectorDisconnectResponse:
254+
connector = _get_connector(connector_id)
255+
disconnected = False
256+
return ConnectorDisconnectResponse(connector_id=connector.id, disconnected=disconnected)

‎tests/api/test_connectors.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
from fastapi import FastAPI
5+
from fastapi.testclient import TestClient
6+
7+
from src.api.dependencies import require_user
8+
from src.api.routes import connectors
9+
10+
11+
@pytest.fixture(autouse=True)
12+
def _reset_connector_state() -> None:
13+
connectors._pending_states.clear()
14+
15+
16+
def _user() -> dict:
17+
return {"id": "user-1", "email": "user@example.com", "username": "user"}
18+
19+
20+
def _client() -> TestClient:
21+
app = FastAPI()
22+
app.dependency_overrides[require_user] = _user
23+
app.include_router(connectors.router)
24+
return TestClient(app)
25+
26+
27+
def test_lists_supported_connectors() -> None:
28+
response = _client().get("/api/connectors")
29+
30+
assert response.status_code == 200
31+
body = response.json()
32+
ids = {item["id"] for item in body["connectors"]}
33+
assert ids == {"notion", "google-drive"}
34+
assert {item["state"] for item in body["connectors"]} == {"not_connected"}
35+
36+
37+
def test_oauth_start_requires_configured_client_id(monkeypatch) -> None:
38+
monkeypatch.delenv("NOTION_CLIENT_ID", raising=False)
39+
40+
response = _client().post("/api/connectors/notion/oauth/start")
41+
42+
assert response.status_code == 503
43+
assert "client ID is not configured" in response.json()["detail"]
44+
45+
46+
def test_oauth_start_builds_authorization_url_without_secret(monkeypatch) -> None:
47+
monkeypatch.setenv("GOOGLE_DRIVE_CLIENT_ID", "drive-client")
48+
monkeypatch.setenv("GOOGLE_DRIVE_CLIENT_SECRET", "do-not-leak")
49+
monkeypatch.setenv(
50+
"GOOGLE_DRIVE_REDIRECT_URI",
51+
"http://localhost:8000/api/connectors/google-drive/oauth/callback",
52+
)
53+
54+
response = _client().post("/api/connectors/google-drive/oauth/start")
55+
56+
assert response.status_code == 200
57+
body = response.json()
58+
assert body["connector_id"] == "google-drive"
59+
assert "accounts.google.com" in body["authorization_url"]
60+
assert "client_id=drive-client" in body["authorization_url"]
61+
assert "do-not-leak" not in body["authorization_url"]
62+
assert body["state"]
63+
64+
65+
def test_callback_validates_state_without_marking_connected(monkeypatch) -> None:
66+
monkeypatch.setenv("NOTION_CLIENT_ID", "notion-client")
67+
client = _client()
68+
69+
started = client.post("/api/connectors/notion/oauth/start")
70+
state = started.json()["state"]
71+
72+
callback = client.get(f"/api/connectors/notion/oauth/callback?code=abc&state={state}")
73+
assert callback.status_code == 200
74+
assert callback.json()["status"] == "pending"
75+
76+
status = client.get("/api/connectors/notion/status")
77+
assert status.status_code == 200
78+
assert status.json()["state"] == "not_connected"
79+
80+
disconnected = client.post("/api/connectors/notion/disconnect")
81+
assert disconnected.status_code == 200
82+
assert disconnected.json() == {"connector_id": "notion", "disconnected": False}
83+
84+
85+
def test_callback_handles_provider_denial_and_consumes_state(monkeypatch) -> None:
86+
monkeypatch.setenv("NOTION_CLIENT_ID", "notion-client")
87+
client = _client()
88+
89+
started = client.post("/api/connectors/notion/oauth/start")
90+
state = started.json()["state"]
91+
92+
callback = client.get(f"/api/connectors/notion/oauth/callback?error=access_denied&state={state}")
93+
94+
assert callback.status_code == 400
95+
assert "access_denied" in callback.json()["detail"]
96+
retry = client.get(f"/api/connectors/notion/oauth/callback?code=abc&state={state}")
97+
assert retry.status_code == 400

0 commit comments

Comments
 (0)