From 52670aac735fec20113aa4552903ca2857c81c56 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 29 May 2026 14:54:18 +0530 Subject: [PATCH 1/6] Add Notion and Google Drive connector routes --- src/api/app.py | 2 + src/api/routes/connectors.py | 254 +++++++++++++++++++++++++++++++++++ src/config/settings.py | 24 ++++ tests/api/test_connectors.py | 78 +++++++++++ 4 files changed, 358 insertions(+) create mode 100644 src/api/routes/connectors.py create mode 100644 tests/api/test_connectors.py diff --git a/src/api/app.py b/src/api/app.py index df2be06..01f5106 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -32,6 +32,7 @@ from src.api.routes.auth import router as auth_router from src.api.routes.billing import router as billing_router from src.api.routes.code import router as code_router +from src.api.routes.connectors import router as connectors_router from src.api.routes.enterprise import router as enterprise_router from src.api.routes.health import router as health_router from src.api.routes.memory import router as memory_router @@ -226,6 +227,7 @@ async def lifespan(app: FastAPI): app.include_router(scanner_router) app.include_router(auth_router) app.include_router(api_keys_router) + app.include_router(connectors_router) app.include_router(billing_router) app.include_router(enterprise_router) app.include_router(telemetry_router) diff --git a/src/api/routes/connectors.py b/src/api/routes/connectors.py new file mode 100644 index 0000000..170f2a0 --- /dev/null +++ b/src/api/routes/connectors.py @@ -0,0 +1,254 @@ +"""Connector OAuth routes for external knowledge sources.""" + +from __future__ import annotations + +import secrets +from datetime import datetime, timedelta, timezone +from typing import Dict, List, Literal, Optional +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field + +from src.api.dependencies import require_user +from src.config import settings + + +router = APIRouter(prefix="/api/connectors", tags=["Connectors"]) + +ConnectorId = Literal["notion", "google-drive"] +ConnectorState = Literal["connected", "not_connected", "pending"] + +STATE_TTL_MINUTES = 10 + + +class ConnectorDefinition(BaseModel): + id: ConnectorId + name: str + description: str + auth_url: str + token_url: str + scopes: List[str] + docs_url: str + + +class ConnectorStatusResponse(BaseModel): + id: ConnectorId + name: str + state: ConnectorState + connected_at: Optional[datetime] = None + scopes: List[str] = Field(default_factory=list) + detail: str + + +class ConnectorListResponse(BaseModel): + connectors: List[ConnectorStatusResponse] + + +class ConnectorStartResponse(BaseModel): + connector_id: ConnectorId + authorization_url: str + state: str + expires_at: datetime + + +class ConnectorDisconnectResponse(BaseModel): + connector_id: ConnectorId + disconnected: bool + + +class PendingOAuthState(BaseModel): + connector_id: ConnectorId + user_id: str + expires_at: datetime + + +class StoredConnection(BaseModel): + connector_id: ConnectorId + user_id: str + connected_at: datetime + scopes: List[str] + + +CONNECTORS: Dict[ConnectorId, ConnectorDefinition] = { + "notion": ConnectorDefinition( + id="notion", + name="Notion", + description="Sync selected Notion pages and workspace notes into XMem memory.", + auth_url="https://api.notion.com/v1/oauth/authorize", + token_url="https://api.notion.com/v1/oauth/token", + scopes=[], + docs_url="https://developers.notion.com/docs/authorization", + ), + "google-drive": ConnectorDefinition( + id="google-drive", + name="Google Drive", + description="Bring Google Drive docs and files into XMem as searchable memory.", + auth_url="https://accounts.google.com/o/oauth2/v2/auth", + token_url="https://oauth2.googleapis.com/token", + scopes=[ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/documents.readonly", + ], + docs_url="https://developers.google.com/identity/protocols/oauth2", + ), +} + +_pending_states: Dict[str, PendingOAuthState] = {} +_connections: Dict[str, StoredConnection] = {} + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _connection_key(user_id: str, connector_id: ConnectorId) -> str: + return f"{user_id}:{connector_id}" + + +def _client_id(connector_id: ConnectorId) -> Optional[str]: + if connector_id == "notion": + return settings.notion_client_id + return settings.google_drive_client_id + + +def _redirect_uri(connector_id: ConnectorId) -> str: + if connector_id == "notion": + return settings.notion_redirect_uri + return settings.google_drive_redirect_uri + + +def _get_connector(connector_id: str) -> ConnectorDefinition: + connector = CONNECTORS.get(connector_id) # type: ignore[arg-type] + if not connector: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown connector") + return connector + + +def _status_for(user_id: str, connector: ConnectorDefinition) -> ConnectorStatusResponse: + connection = _connections.get(_connection_key(user_id, connector.id)) + if connection: + return ConnectorStatusResponse( + id=connector.id, + name=connector.name, + state="connected", + connected_at=connection.connected_at, + scopes=connection.scopes, + detail="Connected", + ) + + return ConnectorStatusResponse( + id=connector.id, + name=connector.name, + state="not_connected", + scopes=connector.scopes, + detail="Not connected", + ) + + +def _build_authorization_url(connector: ConnectorDefinition, state: str) -> str: + client_id = _client_id(connector.id) + if not client_id: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"{connector.name} OAuth client ID is not configured", + ) + + params = { + "client_id": client_id, + "redirect_uri": _redirect_uri(connector.id), + "response_type": "code", + "state": state, + } + if connector.id == "google-drive": + params.update( + { + "access_type": "offline", + "include_granted_scopes": "true", + "prompt": "consent", + "scope": " ".join(connector.scopes), + } + ) + if connector.id == "notion": + params["owner"] = "user" + + return f"{connector.auth_url}?{urlencode(params)}" + + +@router.get("", response_model=ConnectorListResponse) +async def list_connectors(current_user: dict = Depends(require_user)) -> ConnectorListResponse: + user_id = str(current_user.get("id")) + return ConnectorListResponse( + connectors=[_status_for(user_id, connector) for connector in CONNECTORS.values()] + ) + + +@router.get("/{connector_id}/status", response_model=ConnectorStatusResponse) +async def connector_status( + connector_id: str, + current_user: dict = Depends(require_user), +) -> ConnectorStatusResponse: + connector = _get_connector(connector_id) + return _status_for(str(current_user.get("id")), connector) + + +@router.post("/{connector_id}/oauth/start", response_model=ConnectorStartResponse) +async def start_connector_oauth( + connector_id: str, + current_user: dict = Depends(require_user), +) -> ConnectorStartResponse: + connector = _get_connector(connector_id) + state = secrets.token_urlsafe(32) + expires_at = _now() + timedelta(minutes=STATE_TTL_MINUTES) + _pending_states[state] = PendingOAuthState( + connector_id=connector.id, + user_id=str(current_user.get("id")), + expires_at=expires_at, + ) + + return ConnectorStartResponse( + connector_id=connector.id, + authorization_url=_build_authorization_url(connector, state), + state=state, + expires_at=expires_at, + ) + + +@router.get("/{connector_id}/oauth/callback") +async def connector_oauth_callback( + connector_id: str, + code: str = Query(..., min_length=1), + state: str = Query(..., min_length=1), +) -> dict: + connector = _get_connector(connector_id) + pending = _pending_states.pop(state, None) + if not pending or pending.connector_id != connector.id or pending.expires_at <= _now(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired connector authorization state", + ) + + # Token exchange and source ingestion are intentionally separate follow-up steps. + # This callback validates the flow and records a pending connection marker only. + _connections[_connection_key(pending.user_id, connector.id)] = StoredConnection( + connector_id=connector.id, + user_id=pending.user_id, + connected_at=_now(), + scopes=connector.scopes, + ) + return { + "status": "connected", + "connector_id": connector.id, + "detail": f"{connector.name} authorization received", + } + + +@router.post("/{connector_id}/disconnect", response_model=ConnectorDisconnectResponse) +async def disconnect_connector( + connector_id: str, + current_user: dict = Depends(require_user), +) -> ConnectorDisconnectResponse: + connector = _get_connector(connector_id) + key = _connection_key(str(current_user.get("id")), connector.id) + disconnected = _connections.pop(key, None) is not None + return ConnectorDisconnectResponse(connector_id=connector.id, disconnected=disconnected) diff --git a/src/config/settings.py b/src/config/settings.py index 075843f..25ab7f3 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -401,6 +401,30 @@ class Settings(BaseSettings): default="http://localhost:8000/auth/callback", description="Google OAuth redirect URI" ) + notion_client_id: Optional[str] = Field( + default=None, + description="Notion OAuth client ID for the Notion connector" + ) + notion_client_secret: Optional[str] = Field( + default=None, + description="Notion OAuth client secret for the Notion connector" + ) + notion_redirect_uri: str = Field( + default="http://localhost:8000/api/connectors/notion/oauth/callback", + description="Notion OAuth redirect URI" + ) + google_drive_client_id: Optional[str] = Field( + default=None, + description="Google OAuth client ID for the Google Drive connector" + ) + google_drive_client_secret: Optional[str] = Field( + default=None, + description="Google OAuth client secret for the Google Drive connector" + ) + google_drive_redirect_uri: str = Field( + default="http://localhost:8000/api/connectors/google-drive/oauth/callback", + description="Google Drive OAuth redirect URI" + ) jwt_secret_key: str = Field( default="your-secret-key-change-in-production", description="Secret key for JWT token signing (change in production!)" diff --git a/tests/api/test_connectors.py b/tests/api/test_connectors.py new file mode 100644 index 0000000..a97ffff --- /dev/null +++ b/tests/api/test_connectors.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.dependencies import require_user +from src.api.routes import connectors + + +def _user() -> dict: + return {"id": "user-1", "email": "user@example.com", "username": "user"} + + +def _client() -> TestClient: + app = FastAPI() + app.dependency_overrides[require_user] = _user + app.include_router(connectors.router) + return TestClient(app) + + +def test_lists_supported_connectors() -> None: + response = _client().get("/api/connectors") + + assert response.status_code == 200 + body = response.json() + ids = {item["id"] for item in body["connectors"]} + assert ids == {"notion", "google-drive"} + assert {item["state"] for item in body["connectors"]} == {"not_connected"} + + +def test_oauth_start_requires_configured_client_id(monkeypatch) -> None: + monkeypatch.setattr(connectors.settings, "notion_client_id", None, raising=False) + + response = _client().post("/api/connectors/notion/oauth/start") + + assert response.status_code == 503 + assert "client ID is not configured" in response.json()["detail"] + + +def test_oauth_start_builds_authorization_url_without_secret(monkeypatch) -> None: + monkeypatch.setattr(connectors.settings, "google_drive_client_id", "drive-client", raising=False) + monkeypatch.setattr(connectors.settings, "google_drive_client_secret", "do-not-leak", raising=False) + monkeypatch.setattr( + connectors.settings, + "google_drive_redirect_uri", + "http://localhost:8000/api/connectors/google-drive/oauth/callback", + raising=False, + ) + + response = _client().post("/api/connectors/google-drive/oauth/start") + + assert response.status_code == 200 + body = response.json() + assert body["connector_id"] == "google-drive" + assert "accounts.google.com" in body["authorization_url"] + assert "client_id=drive-client" in body["authorization_url"] + assert "do-not-leak" not in body["authorization_url"] + assert body["state"] + + +def test_callback_marks_connection_then_disconnects(monkeypatch) -> None: + monkeypatch.setattr(connectors.settings, "notion_client_id", "notion-client", raising=False) + client = _client() + + started = client.post("/api/connectors/notion/oauth/start") + state = started.json()["state"] + + callback = client.get(f"/api/connectors/notion/oauth/callback?code=abc&state={state}") + assert callback.status_code == 200 + assert callback.json()["status"] == "connected" + + connected = client.get("/api/connectors/notion/status") + assert connected.status_code == 200 + assert connected.json()["state"] == "connected" + + disconnected = client.post("/api/connectors/notion/disconnect") + assert disconnected.status_code == 200 + assert disconnected.json() == {"connector_id": "notion", "disconnected": True} From 5760df636f8f4957ec039d0df763da04babdc44a Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 29 May 2026 15:00:53 +0530 Subject: [PATCH 2/6] Avoid connector settings file changes --- src/api/routes/connectors.py | 18 +++++++++++------- src/config/settings.py | 24 ------------------------ tests/api/test_connectors.py | 14 ++++++-------- 3 files changed, 17 insertions(+), 39 deletions(-) diff --git a/src/api/routes/connectors.py b/src/api/routes/connectors.py index 170f2a0..c3ffbe3 100644 --- a/src/api/routes/connectors.py +++ b/src/api/routes/connectors.py @@ -3,6 +3,7 @@ from __future__ import annotations import secrets +import os from datetime import datetime, timedelta, timezone from typing import Dict, List, Literal, Optional from urllib.parse import urlencode @@ -11,9 +12,6 @@ from pydantic import BaseModel, Field from src.api.dependencies import require_user -from src.config import settings - - router = APIRouter(prefix="/api/connectors", tags=["Connectors"]) ConnectorId = Literal["notion", "google-drive"] @@ -108,14 +106,20 @@ def _connection_key(user_id: str, connector_id: ConnectorId) -> str: def _client_id(connector_id: ConnectorId) -> Optional[str]: if connector_id == "notion": - return settings.notion_client_id - return settings.google_drive_client_id + return os.getenv("NOTION_CLIENT_ID") + return os.getenv("GOOGLE_DRIVE_CLIENT_ID") or os.getenv("GOOGLE_CLIENT_ID") def _redirect_uri(connector_id: ConnectorId) -> str: if connector_id == "notion": - return settings.notion_redirect_uri - return settings.google_drive_redirect_uri + return os.getenv( + "NOTION_REDIRECT_URI", + "http://localhost:8000/api/connectors/notion/oauth/callback", + ) + return os.getenv( + "GOOGLE_DRIVE_REDIRECT_URI", + "http://localhost:8000/api/connectors/google-drive/oauth/callback", + ) def _get_connector(connector_id: str) -> ConnectorDefinition: diff --git a/src/config/settings.py b/src/config/settings.py index 25ab7f3..075843f 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -401,30 +401,6 @@ class Settings(BaseSettings): default="http://localhost:8000/auth/callback", description="Google OAuth redirect URI" ) - notion_client_id: Optional[str] = Field( - default=None, - description="Notion OAuth client ID for the Notion connector" - ) - notion_client_secret: Optional[str] = Field( - default=None, - description="Notion OAuth client secret for the Notion connector" - ) - notion_redirect_uri: str = Field( - default="http://localhost:8000/api/connectors/notion/oauth/callback", - description="Notion OAuth redirect URI" - ) - google_drive_client_id: Optional[str] = Field( - default=None, - description="Google OAuth client ID for the Google Drive connector" - ) - google_drive_client_secret: Optional[str] = Field( - default=None, - description="Google OAuth client secret for the Google Drive connector" - ) - google_drive_redirect_uri: str = Field( - default="http://localhost:8000/api/connectors/google-drive/oauth/callback", - description="Google Drive OAuth redirect URI" - ) jwt_secret_key: str = Field( default="your-secret-key-change-in-production", description="Secret key for JWT token signing (change in production!)" diff --git a/tests/api/test_connectors.py b/tests/api/test_connectors.py index a97ffff..a266b2c 100644 --- a/tests/api/test_connectors.py +++ b/tests/api/test_connectors.py @@ -29,7 +29,7 @@ def test_lists_supported_connectors() -> None: def test_oauth_start_requires_configured_client_id(monkeypatch) -> None: - monkeypatch.setattr(connectors.settings, "notion_client_id", None, raising=False) + monkeypatch.delenv("NOTION_CLIENT_ID", raising=False) response = _client().post("/api/connectors/notion/oauth/start") @@ -38,13 +38,11 @@ def test_oauth_start_requires_configured_client_id(monkeypatch) -> None: def test_oauth_start_builds_authorization_url_without_secret(monkeypatch) -> None: - monkeypatch.setattr(connectors.settings, "google_drive_client_id", "drive-client", raising=False) - monkeypatch.setattr(connectors.settings, "google_drive_client_secret", "do-not-leak", raising=False) - monkeypatch.setattr( - connectors.settings, - "google_drive_redirect_uri", + monkeypatch.setenv("GOOGLE_DRIVE_CLIENT_ID", "drive-client") + monkeypatch.setenv("GOOGLE_DRIVE_CLIENT_SECRET", "do-not-leak") + monkeypatch.setenv( + "GOOGLE_DRIVE_REDIRECT_URI", "http://localhost:8000/api/connectors/google-drive/oauth/callback", - raising=False, ) response = _client().post("/api/connectors/google-drive/oauth/start") @@ -59,7 +57,7 @@ def test_oauth_start_builds_authorization_url_without_secret(monkeypatch) -> Non def test_callback_marks_connection_then_disconnects(monkeypatch) -> None: - monkeypatch.setattr(connectors.settings, "notion_client_id", "notion-client", raising=False) + monkeypatch.setenv("NOTION_CLIENT_ID", "notion-client") client = _client() started = client.post("/api/connectors/notion/oauth/start") From e95d4e5796aa9dac14efe2c6ce7788d7419df952 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 29 May 2026 15:04:47 +0530 Subject: [PATCH 3/6] Harden connector OAuth callback state --- src/api/routes/connectors.py | 65 ++++++++++++++++-------------------- tests/api/test_connectors.py | 12 +++---- 2 files changed, 34 insertions(+), 43 deletions(-) diff --git a/src/api/routes/connectors.py b/src/api/routes/connectors.py index c3ffbe3..b9e7ee3 100644 --- a/src/api/routes/connectors.py +++ b/src/api/routes/connectors.py @@ -18,6 +18,7 @@ ConnectorState = Literal["connected", "not_connected", "pending"] STATE_TTL_MINUTES = 10 +MAX_PENDING_STATES = 1000 class ConnectorDefinition(BaseModel): @@ -61,13 +62,6 @@ class PendingOAuthState(BaseModel): expires_at: datetime -class StoredConnection(BaseModel): - connector_id: ConnectorId - user_id: str - connected_at: datetime - scopes: List[str] - - CONNECTORS: Dict[ConnectorId, ConnectorDefinition] = { "notion": ConnectorDefinition( id="notion", @@ -93,17 +87,12 @@ class StoredConnection(BaseModel): } _pending_states: Dict[str, PendingOAuthState] = {} -_connections: Dict[str, StoredConnection] = {} def _now() -> datetime: return datetime.now(timezone.utc) -def _connection_key(user_id: str, connector_id: ConnectorId) -> str: - return f"{user_id}:{connector_id}" - - def _client_id(connector_id: ConnectorId) -> Optional[str]: if connector_id == "notion": return os.getenv("NOTION_CLIENT_ID") @@ -129,24 +118,30 @@ def _get_connector(connector_id: str) -> ConnectorDefinition: return connector -def _status_for(user_id: str, connector: ConnectorDefinition) -> ConnectorStatusResponse: - connection = _connections.get(_connection_key(user_id, connector.id)) - if connection: - return ConnectorStatusResponse( - id=connector.id, - name=connector.name, - state="connected", - connected_at=connection.connected_at, - scopes=connection.scopes, - detail="Connected", - ) +def _prune_pending_states(now: Optional[datetime] = None) -> None: + current_time = now or _now() + expired = [ + key + for key, pending in _pending_states.items() + if pending.expires_at <= current_time + ] + for key in expired: + _pending_states.pop(key, None) + + overflow = len(_pending_states) - MAX_PENDING_STATES + if overflow > 0: + oldest = sorted(_pending_states.items(), key=lambda item: item[1].expires_at) + for key, _pending in oldest[:overflow]: + _pending_states.pop(key, None) + +def _status_for(user_id: str, connector: ConnectorDefinition) -> ConnectorStatusResponse: return ConnectorStatusResponse( id=connector.id, name=connector.name, state="not_connected", scopes=connector.scopes, - detail="Not connected", + detail="OAuth start is available; token exchange and sync storage are not connected yet.", ) @@ -202,6 +197,7 @@ async def start_connector_oauth( current_user: dict = Depends(require_user), ) -> ConnectorStartResponse: connector = _get_connector(connector_id) + _prune_pending_states() state = secrets.token_urlsafe(32) expires_at = _now() + timedelta(minutes=STATE_TTL_MINUTES) _pending_states[state] = PendingOAuthState( @@ -225,25 +221,21 @@ async def connector_oauth_callback( state: str = Query(..., min_length=1), ) -> dict: connector = _get_connector(connector_id) + now = _now() + _prune_pending_states(now) pending = _pending_states.pop(state, None) - if not pending or pending.connector_id != connector.id or pending.expires_at <= _now(): + if not pending or pending.connector_id != connector.id or pending.expires_at <= now: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired connector authorization state", ) - # Token exchange and source ingestion are intentionally separate follow-up steps. - # This callback validates the flow and records a pending connection marker only. - _connections[_connection_key(pending.user_id, connector.id)] = StoredConnection( - connector_id=connector.id, - user_id=pending.user_id, - connected_at=_now(), - scopes=connector.scopes, - ) + # Token exchange, encrypted credential storage, and source ingestion are intentionally + # separate follow-up steps. Do not mark the connector as connected until those exist. return { - "status": "connected", + "status": "pending", "connector_id": connector.id, - "detail": f"{connector.name} authorization received", + "detail": f"{connector.name} authorization received; token exchange is not enabled yet.", } @@ -253,6 +245,5 @@ async def disconnect_connector( current_user: dict = Depends(require_user), ) -> ConnectorDisconnectResponse: connector = _get_connector(connector_id) - key = _connection_key(str(current_user.get("id")), connector.id) - disconnected = _connections.pop(key, None) is not None + disconnected = False return ConnectorDisconnectResponse(connector_id=connector.id, disconnected=disconnected) diff --git a/tests/api/test_connectors.py b/tests/api/test_connectors.py index a266b2c..7ec79de 100644 --- a/tests/api/test_connectors.py +++ b/tests/api/test_connectors.py @@ -56,7 +56,7 @@ def test_oauth_start_builds_authorization_url_without_secret(monkeypatch) -> Non assert body["state"] -def test_callback_marks_connection_then_disconnects(monkeypatch) -> None: +def test_callback_validates_state_without_marking_connected(monkeypatch) -> None: monkeypatch.setenv("NOTION_CLIENT_ID", "notion-client") client = _client() @@ -65,12 +65,12 @@ def test_callback_marks_connection_then_disconnects(monkeypatch) -> None: callback = client.get(f"/api/connectors/notion/oauth/callback?code=abc&state={state}") assert callback.status_code == 200 - assert callback.json()["status"] == "connected" + assert callback.json()["status"] == "pending" - connected = client.get("/api/connectors/notion/status") - assert connected.status_code == 200 - assert connected.json()["state"] == "connected" + status = client.get("/api/connectors/notion/status") + assert status.status_code == 200 + assert status.json()["state"] == "not_connected" disconnected = client.post("/api/connectors/notion/disconnect") assert disconnected.status_code == 200 - assert disconnected.json() == {"connector_id": "notion", "disconnected": True} + assert disconnected.json() == {"connector_id": "notion", "disconnected": False} From 7a01bbb184cc1668d76108551f87afa8ee85aa68 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 29 May 2026 15:11:19 +0530 Subject: [PATCH 4/6] Handle connector OAuth denial callbacks --- src/api/routes/connectors.py | 8 +++++++- tests/api/test_connectors.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/api/routes/connectors.py b/src/api/routes/connectors.py index b9e7ee3..51d9f55 100644 --- a/src/api/routes/connectors.py +++ b/src/api/routes/connectors.py @@ -217,8 +217,9 @@ async def start_connector_oauth( @router.get("/{connector_id}/oauth/callback") async def connector_oauth_callback( connector_id: str, - code: str = Query(..., min_length=1), state: str = Query(..., min_length=1), + code: Optional[str] = Query(None, min_length=1), + error: Optional[str] = Query(None, min_length=1), ) -> dict: connector = _get_connector(connector_id) now = _now() @@ -229,6 +230,11 @@ async def connector_oauth_callback( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired connector authorization state", ) + if error or not code: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Authorization denied: {error or 'no authorization code received'}", + ) # Token exchange, encrypted credential storage, and source ingestion are intentionally # separate follow-up steps. Do not mark the connector as connected until those exist. diff --git a/tests/api/test_connectors.py b/tests/api/test_connectors.py index 7ec79de..5855bea 100644 --- a/tests/api/test_connectors.py +++ b/tests/api/test_connectors.py @@ -7,6 +7,10 @@ from src.api.routes import connectors +def setup_function() -> None: + connectors._pending_states.clear() + + def _user() -> dict: return {"id": "user-1", "email": "user@example.com", "username": "user"} @@ -74,3 +78,18 @@ def test_callback_validates_state_without_marking_connected(monkeypatch) -> None disconnected = client.post("/api/connectors/notion/disconnect") assert disconnected.status_code == 200 assert disconnected.json() == {"connector_id": "notion", "disconnected": False} + + +def test_callback_handles_provider_denial_and_consumes_state(monkeypatch) -> None: + monkeypatch.setenv("NOTION_CLIENT_ID", "notion-client") + client = _client() + + started = client.post("/api/connectors/notion/oauth/start") + state = started.json()["state"] + + callback = client.get(f"/api/connectors/notion/oauth/callback?error=access_denied&state={state}") + + assert callback.status_code == 400 + assert "access_denied" in callback.json()["detail"] + retry = client.get(f"/api/connectors/notion/oauth/callback?code=abc&state={state}") + assert retry.status_code == 400 From 71f75c2bae2125796ca0901b9747ff003275ac6e Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 29 May 2026 15:13:41 +0530 Subject: [PATCH 5/6] Isolate connector route tests --- tests/api/test_connectors.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/api/test_connectors.py b/tests/api/test_connectors.py index 5855bea..ea8ab5b 100644 --- a/tests/api/test_connectors.py +++ b/tests/api/test_connectors.py @@ -1,5 +1,6 @@ from __future__ import annotations +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -7,7 +8,8 @@ from src.api.routes import connectors -def setup_function() -> None: +@pytest.fixture(autouse=True) +def _reset_connector_state() -> None: connectors._pending_states.clear() From d703470f166b4148aa6c26f1be947345f4313961 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 29 May 2026 15:27:02 +0530 Subject: [PATCH 6/6] Avoid stale pending connector OAuth states --- src/api/routes/connectors.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/routes/connectors.py b/src/api/routes/connectors.py index 51d9f55..46cb7fc 100644 --- a/src/api/routes/connectors.py +++ b/src/api/routes/connectors.py @@ -200,6 +200,7 @@ async def start_connector_oauth( _prune_pending_states() state = secrets.token_urlsafe(32) expires_at = _now() + timedelta(minutes=STATE_TTL_MINUTES) + authorization_url = _build_authorization_url(connector, state) _pending_states[state] = PendingOAuthState( connector_id=connector.id, user_id=str(current_user.get("id")), @@ -208,7 +209,7 @@ async def start_connector_oauth( return ConnectorStartResponse( connector_id=connector.id, - authorization_url=_build_authorization_url(connector, state), + authorization_url=authorization_url, state=state, expires_at=expires_at, )