Connections own their credentials

Migration 0038 moves every stored secret (OAuth tokens, MCP OAuth
tokens and client registrations, API keys) into the connection's
encrypted envelope, links API-key tools to one connection per distinct
credential, allows several accounts per provider, and adds
credential_mode to sources and tools. OAuth MCP tools keep resolving
each member's own token, as they did before.

docsgpt.connectors.service is now the only reader of OAuth tokens:
get_valid_token_info refreshes under a row lock and persists rotated
refresh tokens, and a revoked grant flags the connection, pauses its
sources and notifies the owner. Loaders build from a connection
(BaseConnectorLoader.from_connection), so scheduled sync covers Drive,
SharePoint and Confluence sources with no browser. S3 and Reddit keys
stay on the connection instead of in remote_data.

New endpoints: POST /api/connections, /setup, /reconnect,
/picker-token, /claim, DELETE /api/connections/<id>, per-action
permissions and MCP refresh-tools. Upload, file listing, sync and
validate-session take a connection_id; session tokens keep working for
this release. The tool executor reads credentials from the resolved
connection (owner or member mode) and pauses on a Connect card when a
connection needs signing in. docsgpt connectors reencrypt rewrites
stored credentials after a key rotation.
This commit is contained in:
arc53-machine committed 2026-09-28 17:03:09 +01:00
1 parent 667d4bbab0
commit 3511004f50
54 files changed
+4007 -754

No files matched your search

+345 -1
View File
@@ -11,12 +11,16 @@ from __future__ import annotations
from flask import current_app, jsonify, make_response, request
from flask_restx import Namespace, Resource
import uuid
from docsgpt.api import api
from docsgpt.api.user.authz import ROLE_ADMIN, has_role
from docsgpt.connectors import service
from docsgpt.connectors import catalog, service
from docsgpt.storage.db.repositories.connector_sessions import ConnectorSessionsRepository
from docsgpt.storage.db.session import db_readonly, db_session
_FREQUENCIES = ("never", "daily", "weekly", "monthly")
connections_ns = Namespace("connections", description="Connectors and connections", path="/api")
api.add_namespace(connections_ns)
@@ -52,8 +56,64 @@ class ConnectorCatalog(Resource):
return make_response(jsonify({"success": True, "connectors": entries}), 200)
def _json_body() -> dict:
body = request.get_json(silent=True)
return body if isinstance(body, dict) else {}
def _owned(conn, connection_id: str, user_id: str):
return ConnectorSessionsRepository(conn).get_for_user(connection_id, user_id)
def _error(message: str, status: int, **extra):
return make_response(jsonify({"success": False, "error": message, **extra}), status)
@connections_ns.route("/connections")
class ConnectionsList(Resource):
@api.doc(
description=(
"Create a connection from pasted credentials: "
"{connector_key, credentials, label?}. Same credentials reuse the same connection."
)
)
def post(self):
user_id = _user_id()
if not user_id:
return _unauthorized()
body = _json_body()
definition = catalog.get_definition(body.get("connector_key"))
if definition is None or definition.auth_kind != "api_key":
return _error("This connector does not take pasted credentials", 400)
if definition.missing_settings:
return _error("This connector needs admin setup", 400, code="needs_setup")
credentials = body.get("credentials")
if not isinstance(credentials, dict):
return _error("credentials must be an object", 400)
try:
with db_session() as conn:
row, created = service.create_api_key_connection(
conn, user_id, definition, credentials, label=(body.get("label") or None),
)
except service.EncryptionKeyNotConfigured as err:
return _error(str(err), 400, code="encryption_key_default")
except ValueError as err:
return _error(str(err), 400)
except Exception as err:
current_app.logger.error(f"Error creating connection: {err}", exc_info=True)
return _error("Failed to create connection", 500)
return make_response(
jsonify(
{
"success": True,
"created": created,
"connection": service.serialize_connection(row),
"setup": dict(definition.setup),
}
),
201 if created else 200,
)
@api.doc(description="The caller's connections with status and linked resource counts")
def get(self):
user_id = _user_id()
@@ -86,6 +146,35 @@ class ConnectionDetail(Resource):
return make_response(jsonify({"success": False, "error": "Failed to load connection"}), 500)
return make_response(jsonify({"success": True, "connection": detail}), 200)
@api.doc(
description=(
"Remove a connection: {sources: keep | delete, tools: delete | keep}. "
"Kept sources keep their content and stop syncing."
)
)
def delete(self, connection_id: str):
from docsgpt.api.user.sources.routes import delete_source
user_id = _user_id()
if not user_id:
return _unauthorized()
body = _json_body()
sources_mode = body.get("sources", "keep")
tools_mode = body.get("tools", "delete")
if sources_mode not in ("keep", "delete") or tools_mode not in ("keep", "delete"):
return _error("sources and tools must be keep or delete", 400)
try:
with db_session() as conn:
row = _owned(conn, connection_id, user_id)
if row is None:
return _not_found()
to_delete = service.remove_connection(conn, row, sources=sources_mode, tools=tools_mode)
failed = [str(doc["id"]) for doc in to_delete if not delete_source(user_id, doc)]
except Exception as err:
current_app.logger.error(f"Error removing connection: {err}", exc_info=True)
return _error("Failed to remove connection", 500)
return make_response(jsonify({"success": True, "failed_sources": failed}), 200)
@connections_ns.route("/connections/<string:connection_id>/disconnect")
class ConnectionDisconnect(Resource):
@@ -104,3 +193,258 @@ class ConnectionDisconnect(Resource):
current_app.logger.error(f"Error disconnecting connection: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": "Failed to disconnect"}), 500)
return make_response(jsonify({"success": True, "connection": connection}), 200)
@connections_ns.route("/connections/<string:connection_id>/setup")
class ConnectionSetup(Resource):
@api.doc(
description=(
"Apply the connect wizard's choices: {create_tools, tool_permissions?, "
"sync?: {items, frequency, name?}}. Honours an Idempotency-Key header for the sync."
)
)
def post(self, connection_id: str):
user_id = _user_id()
if not user_id:
return _unauthorized()
body = _json_body()
try:
with db_session() as conn:
row = _owned(conn, connection_id, user_id)
if row is None:
return _not_found()
if service.normalize_status(row) != service.STATUS_CONNECTED:
return _error("Reconnect before setting up", 409, code="reconnect")
tools = []
if body.get("create_tools", True):
tools = service.ensure_connection_tools(
conn, user_id, row, permissions=body.get("tool_permissions") or None,
)
tool_payload = [service.serialize_tool(tool) for tool in tools]
sources = []
if body.get("sync"):
started = _start_sync(user_id, row, body["sync"])
if isinstance(started, tuple):
return _error(*started)
sources.append(started)
except Exception as err:
current_app.logger.error(f"Error setting up connection: {err}", exc_info=True)
return _error("Failed to set up connection", 500)
return make_response(jsonify({"success": True, "tools": tool_payload, "sources": sources}), 200)
def _start_sync(user_id: str, row: dict, sync: dict):
"""Queue the first ingest of a source synced from ``row``.
Returns the source summary, or ``(message, status)`` on a bad request.
"""
from docsgpt.api.user.sources.upload import (
_claim_task_or_get_cached,
_derive_source_id,
_read_idempotency_key,
_scoped_idempotency_key,
)
from docsgpt.api.user.tasks import ingest_connector_task, ingest_remote
definition = catalog.get_definition(catalog.connector_key_for_row(row))
if definition is None or not definition.sync_ingestor:
return ("This connector does not sync content", 400)
items = sync.get("items") or {}
if not isinstance(items, dict):
return ("items must be an object", 400)
frequency = sync.get("frequency") or definition.default_sync_frequency
if frequency not in _FREQUENCIES:
return ("Unknown sync frequency", 400)
name = (sync.get("name") or "").strip() or definition.name
idempotency_key, _ = _read_idempotency_key()
scoped_key = _scoped_idempotency_key(idempotency_key, user_id)
task_id = None
if scoped_key:
task_id, cached = _claim_task_or_get_cached(scoped_key, "connection_setup_sync")
if cached is not None:
return {"id": cached.get("source_id"), "task_id": cached.get("task_id"), "name": name}
source_id = str(_derive_source_id(scoped_key)) if scoped_key else str(uuid.uuid4())
options = {"task_id": task_id} if task_id else {}
if definition.auth_kind == "oauth":
file_ids = [str(i) for i in items.get("file_ids") or [] if i]
folder_ids = [str(i) for i in items.get("folder_ids") or [] if i]
if not file_ids and not folder_ids:
return ("Pick at least one file or folder", 400)
task = ingest_connector_task.apply_async(
kwargs={
"job_name": name,
"user": user_id,
"source_type": definition.sync_ingestor,
"connection_id": str(row["id"]),
"file_ids": file_ids,
"folder_ids": folder_ids,
"recursive": bool(items.get("recursive", True)),
"sync_frequency": frequency,
"idempotency_key": scoped_key,
"source_id": source_id,
},
**options,
)
else:
fields = {f.key for f in definition.setup_fields}
source_data = {k: v for k, v in items.items() if k in fields and v not in (None, "")}
missing = [f.label for f in definition.setup_fields if f.required and f.key not in source_data]
if missing:
return (f"Missing: {', '.join(missing)}", 400)
task = ingest_remote.apply_async(
kwargs={
"source_data": source_data,
"job_name": name,
"user": user_id,
"loader": definition.sync_ingestor,
"connection_id": str(row["id"]),
"sync_frequency": frequency,
"idempotency_key": scoped_key,
"source_id": source_id,
},
**options,
)
return {"id": source_id, "task_id": task_id or task.id, "name": name, "sync_frequency": frequency}
@connections_ns.route("/connections/<string:connection_id>/reconnect")
class ConnectionReconnect(Resource):
@api.doc(
description=(
"OAuth: returns an authorization URL for the same account. "
"API key: accepts {credentials} and replaces the stored ones."
)
)
def post(self, connection_id: str):
from docsgpt.api.connector.routes import build_authorization
user_id = _user_id()
if not user_id:
return _unauthorized()
body = _json_body()
try:
with db_readonly() as conn:
row = _owned(conn, connection_id, user_id)
if row is None:
return _not_found()
key = catalog.connector_key_for_row(row)
definition = catalog.get_definition(key)
auth_kind = row.get("auth_kind") or (definition.auth_kind if definition else None)
if auth_kind == "oauth":
started = build_authorization(row["provider"], user_id, connection_id)
return make_response(jsonify({"success": True, "kind": "oauth", **started}), 200)
if auth_kind == "mcp_oauth":
# The MCP client runs the OAuth dance (dynamic registration,
# PKCE); the frontend starts it through /api/mcp_server/test.
return make_response(
jsonify({"success": True, "kind": "mcp_oauth", "server_url": row.get("server_url")}), 200,
)
credentials = body.get("credentials")
if not isinstance(credentials, dict) or not credentials:
return _error("credentials are required", 400)
service.ensure_can_store_credentials()
with db_session() as conn:
locked = ConnectorSessionsRepository(conn).get_for_update(connection_id)
stored = service.load_secrets(locked) or {}
merged = {**(stored.get("credentials") or {}), **{k: v for k, v in credentials.items() if v}}
service.write_secrets(
conn, locked, {**stored, "credentials": merged},
status=service.STATUS_CONNECTED, last_error=None,
)
service.resume_sources(conn, connection_id)
connection = service.serialize_connection(ConnectorSessionsRepository(conn).get(connection_id))
except service.EncryptionKeyNotConfigured as err:
return _error(str(err), 400, code="encryption_key_default")
except Exception as err:
current_app.logger.error(f"Error reconnecting: {err}", exc_info=True)
return _error("Failed to reconnect", 500)
return make_response(jsonify({"success": True, "kind": "api_key", "connection": connection}), 200)
@connections_ns.route("/connections/<string:connection_id>/picker-token")
class ConnectionPickerToken(Resource):
@api.doc(description="A short-lived access token for a browser-side file picker. Owner only; never a refresh token.")
def post(self, connection_id: str):
user_id = _user_id()
if not user_id:
return _unauthorized()
with db_readonly() as conn:
row = _owned(conn, connection_id, user_id)
if row is None or (row.get("auth_kind") or "oauth") != "oauth":
return _not_found()
try:
token = service.picker_token(connection_id)
except service.ConnectionUnavailable:
return _error("Reconnect to continue", 409, code="reconnect")
except service.TransientConnectionError:
return _error("The provider is not responding. Try again.", 503)
return make_response(jsonify({"success": True, **token}), 200)
@connections_ns.route("/connections/claim")
class ConnectionClaim(Resource):
@api.doc(
description=(
"One-time link of a legacy browser session token ({provider, session_token}) "
"to the caller's connection. Removed next release."
)
)
def post(self):
user_id = _user_id()
if not user_id:
return _unauthorized()
body = _json_body()
provider, token = body.get("provider"), body.get("session_token")
if not provider or not token:
return _error("provider and session_token are required", 400)
with db_readonly() as conn:
row = service.claim_session_token(conn, user_id, str(provider), str(token))
if row is None:
return _not_found()
return make_response(jsonify({"success": True, "connection_id": str(row["id"])}), 200)
@connections_ns.route("/connections/<string:connection_id>/tools/<string:tool_id>/permissions")
class ConnectionToolPermissions(Resource):
@api.doc(description="Set per-action permissions: {permissions: {action: always | ask | off}}")
def put(self, connection_id: str, tool_id: str):
from docsgpt.connectors.permissions import PERMISSIONS
user_id = _user_id()
if not user_id:
return _unauthorized()
permissions = _json_body().get("permissions")
if not isinstance(permissions, dict) or any(p not in PERMISSIONS for p in permissions.values()):
return _error("permissions must map action names to always, ask or off", 400)
with db_session() as conn:
row = _owned(conn, connection_id, user_id)
if row is None:
return _not_found()
tool = service.set_tool_permissions(conn, user_id, tool_id, permissions)
if tool is None or str(tool.get("connection_id")) != connection_id:
return _not_found()
payload = service.serialize_tool(tool)
return make_response(jsonify({"success": True, "tool": payload}), 200)
@connections_ns.route("/connections/<string:connection_id>/refresh-tools")
class ConnectionRefreshTools(Resource):
@api.doc(description="MCP: re-scan the server's actions and return what was added and removed")
def post(self, connection_id: str):
from docsgpt.connectors.mcp import refresh_mcp_tools
user_id = _user_id()
if not user_id:
return _unauthorized()
with db_readonly() as conn:
row = _owned(conn, connection_id, user_id)
if row is None:
return _not_found()
try:
diff = refresh_mcp_tools(user_id, row)
except service.ConnectionUnavailable:
return _error("Reconnect to continue", 409, code="reconnect")
except Exception as err:
current_app.logger.error(f"Error refreshing MCP tools: {err}", exc_info=True)
return _error("Failed to refresh tools", 502)
return make_response(jsonify({"success": True, **diff}), 200)
+108 -109
View File
@@ -1,7 +1,6 @@
import base64
import html
import json
import uuid
from typing import Optional
from urllib.parse import urlencode, urlsplit
@@ -20,11 +19,11 @@ from docsgpt.api import api
from docsgpt.api.user.tasks import (
ingest_connector_task,
)
from docsgpt.connectors import service
from docsgpt.core.settings import settings
from docsgpt.parser.connectors.connector_creator import ConnectorCreator
from docsgpt.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
owns_connector_session,
)
from docsgpt.storage.db.repositories.sources import SourcesRepository
from docsgpt.storage.db.session import db_readonly, db_session
@@ -100,6 +99,7 @@ def _js_literal(value) -> str:
def _render_callback_page(
status: str, message: str, provider_raw: str, session_token: str = "", user_email: str = "",
connection_id: str = "",
):
"""Popup page that reports an OAuth result to the opener on allowed origins only."""
status = status if status in ("success", "error", "cancelled") else "error"
@@ -110,7 +110,14 @@ def _render_callback_page(
)
payload = None
if provider_key and status == "success" and session_token:
payload = {"type": f"{provider_key}_auth_success", "session_token": session_token, "user_email": user_email}
payload = {
"type": f"{provider_key}_auth_success",
# The connection id is what current frontends use; the session
# token is kept for one release for frontends from before it.
"connection_id": connection_id,
"session_token": session_token,
"user_email": user_email,
}
elif provider_key and status == "error":
# The frontend shows its own localized failure message; cancellations are
# reported when the popup closes.
@@ -170,6 +177,27 @@ def _render_callback_page(
def build_authorization(provider: str, user_id: str, connection_id: Optional[str] = None) -> dict:
"""Start an OAuth sign-in for ``provider`` and return its authorization URL.
Raises:
service.EncryptionKeyNotConfigured: See ``ensure_can_store_credentials``.
service.ConnectionUnavailable: ``connection_id`` is not the caller's.
"""
service.ensure_can_store_credentials()
with db_session() as conn:
session_row = service.begin_oauth(conn, user_id, provider, connection_id)
state = base64.urlsafe_b64encode(
json.dumps({"provider": provider, "object_id": str(session_row["id"])}).encode()
).decode()
auth = ConnectorCreator.create_auth(provider)
return {
"authorization_url": auth.get_authorization_url(state=state),
"state": state,
"callback_origin": _origin_of(settings.CONNECTOR_REDIRECT_BASE_URI),
}
@connectors_ns.route("/api/connectors/auth")
class ConnectorAuth(Resource):
@api.doc(description="Get connector OAuth authorization URL", params={"provider": "Connector provider (e.g., google_drive)"})
@@ -186,20 +214,14 @@ class ConnectorAuth(Resource):
if not decoded_token:
return make_response(jsonify({"success": False, "error": "Unauthorized"}), 401)
user_id = decoded_token.get('sub')
with db_session() as conn:
session_row = ConnectorSessionsRepository(conn).upsert(
user_id, provider, status="pending",
try:
started = build_authorization(provider, user_id, request.args.get("connection_id") or None)
except service.EncryptionKeyNotConfigured as err:
return make_response(
jsonify({"success": False, "error": str(err), "code": "encryption_key_default"}), 400,
)
session_pg_id = str(session_row["id"])
state_dict = {
"provider": provider,
"object_id": session_pg_id,
}
state = base64.urlsafe_b64encode(json.dumps(state_dict).encode()).decode()
auth = ConnectorCreator.create_auth(provider)
authorization_url = auth.get_authorization_url(state=state)
except service.ConnectionUnavailable:
return make_response(jsonify({"success": False, "error": "Connection not found"}), 404)
# The popup drops results for origins outside the allowlist, which the
# user only sees as a cancelled sign-in; name the missing origin here.
request_origin = _origin_of(request.headers.get("Origin"))
@@ -208,12 +230,7 @@ class ConnectorAuth(Resource):
f"Connector sign-in requested from {request_origin}, which cannot receive the result; "
"add it to CONNECTOR_ALLOWED_ORIGINS"
)
return make_response(jsonify({
"success": True,
"authorization_url": authorization_url,
"state": state,
"callback_origin": _origin_of(settings.CONNECTOR_REDIRECT_BASE_URI),
}), 200)
return make_response(jsonify({"success": True, **started}), 200)
except Exception as e:
current_app.logger.error(f"Error generating connector auth URL: {e}", exc_info=True)
return make_response(jsonify({"success": False, "error": "Failed to generate authorization URL"}), 500)
@@ -269,13 +286,11 @@ class ConnectorsCallback(Resource):
auth = ConnectorCreator.create_auth(provider)
token_info = auth.exchange_code_for_tokens(authorization_code)
session_token = str(uuid.uuid4())
try:
if provider == "google_drive":
credentials = auth.create_credentials_from_token_info(token_info)
service = auth.build_drive_service(credentials)
user_info = service.about().get(fields="user").execute()
drive_service = auth.build_drive_service(credentials)
user_info = drive_service.about().get(fields="user").execute()
user_email = user_info.get('user', {}).get('emailAddress', 'Connected User')
else:
user_email = token_info.get('user_info', {}).get('email', 'Connected User')
@@ -288,29 +303,23 @@ class ConnectorsCallback(Resource):
# ``object_id`` in the OAuth state is the PG session row
# UUID (new flow) or a legacy Mongo ObjectId (pre-cutover
# issued state). Try UUID update first; fall back to
# legacy id path.
patch = {
"session_token": session_token,
"token_info": sanitized_token_info,
"user_email": user_email,
"status": "authorized",
}
# issued state).
with db_session() as conn:
repo = ConnectorSessionsRepository(conn)
if state_object_id:
value = str(state_object_id)
updated = False
if len(value) == 36 and "-" in value:
updated = repo.update(value, patch)
if not updated:
repo.update_by_legacy_id(value, patch)
value = str(state_object_id or "")
state_row = repo.get(value) or (repo.get_by_legacy_id(value) if value else None)
if state_row is None or state_row.get("provider") != provider:
raise ValueError("OAuth state names no pending connection")
connection = service.complete_oauth(
conn, state_row, provider, sanitized_token_info, user_email,
)
# Render instead of redirecting so the session token never
# lands in a URL (browser history, access logs, Referer).
return _render_callback_page(
"success", "Authentication successful", provider,
session_token=session_token, user_email=user_email,
session_token=connection.get("session_token") or "", user_email=user_email,
connection_id=str(connection["id"]),
)
except Exception as e:
@@ -333,7 +342,8 @@ class ConnectorsCallback(Resource):
class ConnectorFiles(Resource):
@api.expect(api.model("ConnectorFilesModel", {
"provider": fields.String(required=True),
"session_token": fields.String(required=True),
"connection_id": fields.String(required=False),
"session_token": fields.String(required=False, description="Legacy; use connection_id"),
"folder_id": fields.String(required=False),
"limit": fields.Integer(required=False),
"page_token": fields.String(required=False),
@@ -344,26 +354,29 @@ class ConnectorFiles(Resource):
try:
data = request.get_json()
provider = data.get('provider')
session_token = data.get('session_token')
limit = data.get('limit', 10)
if not provider or not session_token:
return make_response(jsonify({"success": False, "error": "provider and session_token are required"}), 400)
if not provider or not (data.get('connection_id') or data.get('session_token')):
return make_response(
jsonify({"success": False, "error": "provider and connection_id are required"}), 400,
)
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False, "error": "Unauthorized"}), 401)
user = decoded_token.get('sub')
with db_readonly() as conn:
session = ConnectorSessionsRepository(conn).get_by_session_token(
session_token,
)
if not owns_connector_session(session, user, provider):
session = service.resolve_request_connection(user, provider, data)
if session is None:
return make_response(jsonify({"success": False, "error": "Invalid or unauthorized session"}), 401)
loader = ConnectorCreator.create_connector(provider, session_token)
try:
loader = ConnectorCreator.create_connector(provider, connection_id=str(session["id"]))
except service.ConnectionUnavailable:
return make_response(
jsonify({"success": False, "error": "Reconnect to continue", "reconnect": True}), 401,
)
generic_keys = {'provider', 'session_token'}
generic_keys = {'provider', 'session_token', 'connection_id'}
input_config = {
k: v for k, v in data.items() if k not in generic_keys
}
@@ -408,65 +421,49 @@ class ConnectorFiles(Resource):
@connectors_ns.route("/api/connectors/validate-session")
class ConnectorValidateSession(Resource):
@api.expect(api.model("ConnectorValidateSessionModel", {"provider": fields.String(required=True), "session_token": fields.String(required=True)}))
@api.doc(description="Validate connector session token and return user info and access token")
@api.expect(api.model("ConnectorValidateSessionModel", {
"provider": fields.String(required=True),
"connection_id": fields.String(required=False),
"session_token": fields.String(required=False, description="Legacy; use connection_id"),
}))
@api.doc(description="Validate a connection and return the account and a short-lived access token")
def post(self):
try:
data = request.get_json()
data = request.get_json() or {}
provider = data.get('provider')
session_token = data.get('session_token')
if not provider or not session_token:
return make_response(jsonify({"success": False, "error": "provider and session_token are required"}), 400)
if not provider or not (data.get('connection_id') or data.get('session_token')):
return make_response(
jsonify({"success": False, "error": "provider and connection_id are required"}), 400,
)
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False, "error": "Unauthorized"}), 401)
user = decoded_token.get('sub')
with db_readonly() as conn:
session = ConnectorSessionsRepository(conn).get_by_session_token(
session_token,
)
if not owns_connector_session(session, user, provider) or not session.get("token_info"):
session = service.resolve_request_connection(user, provider, data)
if session is None:
return make_response(jsonify({"success": False, "error": "Invalid or expired session"}), 401)
token_info = session["token_info"]
auth = ConnectorCreator.create_auth(provider)
is_expired = auth.is_token_expired(token_info)
if is_expired and token_info.get('refresh_token'):
try:
refreshed_token_info = auth.refresh_access_token(token_info.get('refresh_token'))
sanitized_token_info = auth.sanitize_token_info(refreshed_token_info)
with db_session() as conn:
repo = ConnectorSessionsRepository(conn)
row = repo.get_by_session_token(session_token)
if row:
repo.update(str(row["id"]), {"token_info": sanitized_token_info})
token_info = sanitized_token_info
is_expired = False
except Exception as refresh_error:
current_app.logger.error(f"Failed to refresh token: {refresh_error}")
if is_expired:
try:
token = service.picker_token(str(session["id"]))
except service.ConnectionUnavailable:
return make_response(jsonify({
"success": False,
"expired": True,
"error": "Session token has expired. Please reconnect."
}), 401)
except service.TransientConnectionError:
return make_response(
jsonify({"success": False, "error": "The provider is not responding. Try again."}), 503,
)
_base_fields = {"access_token", "refresh_token", "token_uri", "expiry"}
provider_extras = {k: v for k, v in token_info.items() if k not in _base_fields}
response_data = {
return make_response(jsonify({
"success": True,
"expired": False,
"user_email": session.get('user_email', 'Connected User'),
"access_token": token_info.get('access_token'),
**provider_extras,
}
return make_response(jsonify(response_data), 200)
"connection_id": str(session["id"]),
"user_email": session.get('account_label') or session.get('user_email') or 'Connected User',
**token,
}), 200)
except Exception as e:
current_app.logger.error(f"Error validating connector session: {e}", exc_info=True)
return make_response(jsonify({"success": False, "error": "Failed to validate session"}), 500)
@@ -483,15 +480,13 @@ class ConnectorDisconnect(Resource):
try:
data = request.get_json()
provider = data.get('provider')
session_token = data.get('session_token')
if not provider:
return make_response(jsonify({"success": False, "error": "provider is required"}), 400)
if session_token:
session = service.resolve_request_connection(decoded_token.get('sub'), provider, data)
if session is not None:
with db_session() as conn:
ConnectorSessionsRepository(conn).delete_by_session_token(
session_token, decoded_token.get('sub'),
)
service.disconnect(conn, session)
return make_response(jsonify({"success": True}), 200)
except Exception as e:
@@ -506,7 +501,10 @@ class ConnectorSync(Resource):
"ConnectorSyncModel",
{
"source_id": fields.String(required=True, description="Source ID to sync"),
"session_token": fields.String(required=True, description="Authentication token")
"connection_id": fields.String(
required=False, description="Connection to sync with; defaults to the source's own",
),
"session_token": fields.String(required=False, description="Legacy; use connection_id")
},
)
)
@@ -517,16 +515,15 @@ class ConnectorSync(Resource):
return make_response(jsonify({"success": False}), 401)
try:
data = request.get_json()
data = request.get_json() or {}
source_id = data.get('source_id')
session_token = data.get('session_token')
if not all([source_id, session_token]):
if not source_id:
return make_response(
jsonify({
"success": False,
"error": "source_id and session_token are required"
}),
"error": "source_id is required"
}),
400
)
user_id = decoded_token.get('sub')
@@ -562,9 +559,11 @@ class ConnectorSync(Resource):
400
)
with db_readonly() as conn:
session = ConnectorSessionsRepository(conn).get_by_session_token(session_token)
if not owns_connector_session(session, user_id, source_type):
lookup = dict(data)
if not (lookup.get('connection_id') or lookup.get('session_token')) and source.get('connection_id'):
lookup['connection_id'] = str(source['connection_id'])
session = service.resolve_request_connection(user_id, source_type, lookup)
if session is None:
return make_response(
jsonify({"success": False, "error": "Invalid or unauthorized session"}),
401,
@@ -580,7 +579,7 @@ class ConnectorSync(Resource):
job_name=source.get('name'),
user=decoded_token.get('sub'),
source_type=source_type,
session_token=session_token,
connection_id=str(session["id"]),
file_ids=file_ids,
folder_ids=folder_ids,
recursive=recursive,