feat(superoffice): POC API handshake & auth flow [2ff88f42]
Establishes the initial structure for the SuperOffice connector. Implements the complete, iterative authentication process, culminating in a successful refresh token exchange. Documents the process and the final blocker (API authorization) in the integration plan, awaiting IT action to change the application type to 'Server to server'.
This commit is contained in:
@@ -1 +1 @@
|
||||
{"task_id": "2f988f42-8544-800e-abc1-d1b1c56ade4d", "token": "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", "session_start_time": "2026-02-04T20:42:43.587330"}
|
||||
{"task_id": "2ff88f42-8544-8000-8314-c9013414d1d0", "token": "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", "session_start_time": "2026-02-06T12:51:34.301296"}
|
||||
@@ -95,6 +95,31 @@ Folgende Felder sollten am Objekt `Company` (bzw. `Contact` in SuperOffice-Termi
|
||||
* **Scope:** Der API-User benötigt Lesezugriff auf `Contact` und Schreibzugriff auf die `UDFs`.
|
||||
* **Datenschutz:** Es werden nur Firmendaten (Name, Webseite, Stadt) übertragen. Personenbezogene Ansprechpartner bleiben im CRM und werden nicht an die KI gesendet.
|
||||
|
||||
### 4.1. POC Erkenntnisse & Manueller Setup-Prozess (Feb 2026)
|
||||
|
||||
Während des initialen Proof of Concepts (POC) wurde der Authentifizierungs-Flow für die SOD-Umgebung (SuperOffice Development) erfolgreich etabliert. Dabei wurden folgende wichtige Erkenntnisse gewonnen:
|
||||
|
||||
**Problemstellung:** Ein direkter "Client Credentials Flow" (rein maschinell) ist mit dem Anwendungstyp "empty" nicht möglich. Stattdessen ist ein einmaliger, interaktiver Schritt notwendig, um einen langlebigen `refresh_token` zu generieren, der dann für die automatisierte Kommunikation genutzt werden kann.
|
||||
|
||||
**Durchgeführte Schritte zur Token-Generierung:**
|
||||
|
||||
1. **App-Konfiguration:** Im SuperOffice Developer Portal wurde für die Anwendung "Robo_GTM-Engine" die URL `http://localhost` als "Allowed redirect URL" hinzugefügt.
|
||||
2. **Autorisierungs-URL:** Ein Benutzer hat die folgende, speziell konstruierte URL im Browser geöffnet, um den Autorisierungsprozess zu starten:
|
||||
`https://sod.superoffice.com/login/common/oauth/authorize?client_id=[IHRE_CLIENT_ID]&redirect_uri=http%3A%2F%2Flocalhost&response_type=code`
|
||||
3. **Manuelle Zustimmung:** Der Benutzer hat sich in SuperOffice eingeloggt und der Anwendung die angeforderten Berechtigungen erteilt.
|
||||
4. **Code-Extraktion:** Nach der Zustimmung wurde der Browser auf eine `localhost`-URL umgeleitet. Obwohl die Seite nicht geladen wurde, enthielt die URL den notwendigen `authorization_code` (z.B. `http://localhost/?code=ABC-123...`).
|
||||
5. **Token-Austausch:** Mit einem Skript wurde dieser einmalige `code` an den SuperOffice Token-Endpunkt gesendet. Im Gegenzug wurden ein kurzlebiger `access_token` und der entscheidende, langlebige `refresh_token` empfangen.
|
||||
6. **Sichere Speicherung:** Der `refresh_token` wurde in der `.env`-Datei des Connectors als `SO_REFRESH_TOKEN` hinterlegt.
|
||||
|
||||
**Ergebnis & Aktueller Blocker:**
|
||||
|
||||
* **Erfolg:** Der Authentifizierungs-Handshake ist erfolgreich. Der Connector kann den `SO_REFRESH_TOKEN` nutzen, um jederzeit vollautomatisch neue, gültige `access_token` von SuperOffice zu erhalten.
|
||||
* **Blocker:** Trotz gültigem `access_token` werden alle nachfolgenden API-Aufrufe (z.B. an `/api/v1/User/currentPrincipal`) von SuperOffice auf die Login-Seite umgeleitet (`HTTP 302 Found`). Dies ist ein klares Indiz dafür, dass der Token zwar gültig, aber nicht für den API-Zugriff **autorisiert** ist.
|
||||
|
||||
**Empfehlung für die IT:**
|
||||
|
||||
Die Ursache für die fehlende Autorisierung liegt sehr wahrscheinlich im Anwendungstyp "empty". Um eine echte Server-zu-Server-Integration zu ermöglichen, **muss der Anwendungstyp im SuperOffice Developer Portal auf "Server to server" geändert werden.** Dies wird voraussichtlich erfordern, dass die Schritte 2-6 erneut durchgeführt werden, um neue Tokens mit den korrekten Berechtigungen zu erhalten.
|
||||
|
||||
## 5. Vorbereitung für die IT
|
||||
|
||||
Um den Connector in Betrieb zu nehmen, benötigen wir:
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM python:3.9-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
1
connector-superoffice/__init__.py
Normal file
1
connector-superoffice/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# This file makes the directory a Python package
|
||||
121
connector-superoffice/auth_handler.py
Normal file
121
connector-superoffice/auth_handler.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import os
|
||||
import requests
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuthHandler:
|
||||
"""
|
||||
Handles authentication against the SuperOffice API using an initial Authorization Code
|
||||
exchange and subsequent Refresh Token flow.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.client_id = os.getenv("SO_SOD")
|
||||
self.client_secret = os.getenv("SO_CLIENT_SECRET")
|
||||
self.refresh_token = os.getenv("SO_REFRESH_TOKEN") # Will be set after initial code exchange
|
||||
|
||||
# Token endpoint for both initial code exchange and refresh token flow
|
||||
self.token_endpoint = "https://sod.superoffice.com/login/common/oauth/tokens"
|
||||
|
||||
self._access_token = None
|
||||
self._token_expiry_time = 0
|
||||
|
||||
if not self.client_id or not self.client_secret:
|
||||
raise ValueError("SO_SOD and SO_CLIENT_SECRET must be set in the environment.")
|
||||
|
||||
def _is_token_valid(self):
|
||||
"""Check if the current access token is still valid."""
|
||||
return self._access_token and time.time() < self._token_expiry_time
|
||||
|
||||
def exchange_code_for_tokens(self, auth_code: str, redirect_uri: str):
|
||||
"""
|
||||
Exchanges an Authorization Code for an access token and a refresh token.
|
||||
This is a one-time operation.
|
||||
"""
|
||||
logger.info("Exchanging Authorization Code for tokens...")
|
||||
|
||||
payload = {
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': auth_code,
|
||||
'redirect_uri': redirect_uri
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(self.token_endpoint, data=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
self._access_token = token_data['access_token']
|
||||
self.refresh_token = token_data['refresh_token'] # Store the refresh token
|
||||
self._token_expiry_time = time.time() + token_data['expires_in'] - 60
|
||||
|
||||
logger.info("Successfully exchanged code for tokens.")
|
||||
return token_data
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
logger.error(f"HTTP error during code exchange: {http_err} - {response.text}")
|
||||
raise
|
||||
except requests.exceptions.RequestException as req_err:
|
||||
logger.error(f"Request error during code exchange: {req_err}")
|
||||
raise
|
||||
except KeyError:
|
||||
logger.error(f"Unexpected response format from token endpoint during code exchange: {response.text}")
|
||||
raise ValueError("Could not parse tokens from code exchange response.")
|
||||
|
||||
def get_access_token(self):
|
||||
"""
|
||||
Retrieves an access token. If a valid token exists, it's returned from cache.
|
||||
Otherwise, a new one is requested using the refresh token.
|
||||
"""
|
||||
if self._is_token_valid():
|
||||
logger.info("Returning cached access token.")
|
||||
return self._access_token
|
||||
|
||||
if not self.refresh_token:
|
||||
raise ValueError("Refresh token is not available. Please perform initial code exchange.")
|
||||
|
||||
logger.info(f"Requesting new access token using refresh token from {self.token_endpoint}...")
|
||||
|
||||
payload = {
|
||||
'grant_type': 'refresh_token',
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'refresh_token': self.refresh_token
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(self.token_endpoint, data=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
self._access_token = token_data['access_token']
|
||||
# Update refresh token if it's renewed (optional, but good practice)
|
||||
if 'refresh_token' in token_data:
|
||||
self.refresh_token = token_data['refresh_token']
|
||||
logger.info("Refresh token was renewed.")
|
||||
self._token_expiry_time = time.time() + token_data['expires_in'] - 60
|
||||
|
||||
logger.info("Successfully obtained new access token via refresh token.")
|
||||
return self._access_token
|
||||
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
logger.error(f"HTTP error occurred during refresh token use: {http_err} - {response.text}")
|
||||
raise
|
||||
except requests.exceptions.RequestException as req_err:
|
||||
logger.error(f"Request error occurred during refresh token use: {req_err}")
|
||||
raise
|
||||
except KeyError:
|
||||
logger.error(f"Unexpected response format from token endpoint: {response.text}")
|
||||
raise ValueError("Could not parse access token from refresh token response.")
|
||||
23
connector-superoffice/explorer_client.py
Normal file
23
connector-superoffice/explorer_client.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
This module will be responsible for communicating with the Company Explorer API.
|
||||
- Fetching companies that need enrichment.
|
||||
- Pushing enriched data back.
|
||||
"""
|
||||
|
||||
class ExplorerClient:
|
||||
def __init__(self, api_base_url, api_user, api_password):
|
||||
self.api_base_url = api_base_url
|
||||
self.auth = (api_user, api_password)
|
||||
# TODO: Initialize requests session
|
||||
|
||||
def get_companies_to_sync(self):
|
||||
"""
|
||||
Fetches a list of companies from the Company Explorer that are ready to be synced to SuperOffice.
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_company_details(self, company_id):
|
||||
"""
|
||||
Fetches detailed information for a single company.
|
||||
"""
|
||||
pass
|
||||
@@ -2,20 +2,71 @@ import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Setup basic logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
# Import the new classes
|
||||
from .auth_handler import AuthHandler
|
||||
from .superoffice_client import SuperOfficeClient
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def main():
|
||||
logger.info("Starting SuperOffice Connector...")
|
||||
"""
|
||||
Main function to initialize and run the SuperOffice connector.
|
||||
"""
|
||||
logger.info("Starting SuperOffice Connector Proof of Concept...")
|
||||
|
||||
# TODO: Implement authentication logic here using Gemini CLI
|
||||
# TODO: Implement Polling / Sync logic here
|
||||
# Load environment variables from the root .env file
|
||||
dotenv_path = os.path.join(os.path.dirname(__file__), '..', '.env')
|
||||
logger.info(f"Attempting to load .env file from: {dotenv_path}")
|
||||
|
||||
logger.info("Connector stopped.")
|
||||
if os.path.exists(dotenv_path):
|
||||
load_dotenv(dotenv_path=dotenv_path)
|
||||
logger.info(".env file loaded successfully.")
|
||||
else:
|
||||
logger.error(f".env file not found at the expected location: {dotenv_path}")
|
||||
logger.error("Please ensure the .env file exists in the project root directory.")
|
||||
return
|
||||
|
||||
try:
|
||||
# 1. Initialize AuthHandler
|
||||
auth_handler = AuthHandler()
|
||||
|
||||
# Define the tenant ID
|
||||
TENANT_ID = "Cust26720" # As provided in the initial task description
|
||||
|
||||
# 2. Get a fresh access token (this will also update the refresh token if needed)
|
||||
full_access_token = auth_handler.get_access_token()
|
||||
logger.info(f"Full Access Token for curl: {full_access_token}")
|
||||
|
||||
# 3. Initialize SuperOfficeClient with the tenant ID
|
||||
so_client = SuperOfficeClient(auth_handler, TENANT_ID)
|
||||
|
||||
# 3. Perform test connection
|
||||
logger.info("--- Performing Connection Test ---")
|
||||
connection_result = so_client.test_connection()
|
||||
logger.info("--- Connection Test Finished ---")
|
||||
|
||||
if connection_result:
|
||||
logger.info("POC Succeeded: Connection to SuperOffice API was successful.")
|
||||
else:
|
||||
logger.error("POC Failed: Could not establish a connection to SuperOffice API.")
|
||||
|
||||
except ValueError as ve:
|
||||
logger.error(f"Configuration Error: {ve}")
|
||||
logger.error("POC Failed due to missing configuration.")
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred during the POC execution: {e}", exc_info=True)
|
||||
logger.error("POC Failed due to an unexpected error.")
|
||||
|
||||
|
||||
logger.info("SuperOffice Connector POC finished.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,2 @@
|
||||
requests
|
||||
pydantic
|
||||
python-dotenv
|
||||
tenacity
|
||||
|
||||
55
connector-superoffice/superoffice_client.py
Normal file
55
connector-superoffice/superoffice_client.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import requests
|
||||
import logging
|
||||
from .auth_handler import AuthHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SuperOfficeClient:
|
||||
"""
|
||||
A client for interacting with the SuperOffice API.
|
||||
"""
|
||||
def __init__(self, auth_handler: AuthHandler, tenant_id: str):
|
||||
# Base URL for the SuperOffice REST API, including tenant_id
|
||||
self.base_url = f"https://sod.superoffice.com/{tenant_id}/api"
|
||||
self.auth_handler = auth_handler
|
||||
self.tenant_id = tenant_id
|
||||
self.session = requests.Session()
|
||||
|
||||
def _get_auth_headers(self):
|
||||
"""Returns the authorization headers with a valid token and context."""
|
||||
access_token = self.auth_handler.get_access_token()
|
||||
return {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Accept': 'application/json',
|
||||
'X-SuperOffice-Context': f'TenantId={self.tenant_id}' # Crucial for multi-tenant environments
|
||||
}
|
||||
|
||||
def test_connection(self):
|
||||
"""
|
||||
Performs a simple API call to test the connection and authentication.
|
||||
Fetches the current user principal.
|
||||
"""
|
||||
endpoint = "/v1/User/currentPrincipal"
|
||||
test_url = f"{self.base_url}{endpoint}"
|
||||
|
||||
logger.info(f"Attempting to test connection to: {test_url}")
|
||||
|
||||
try:
|
||||
headers = self._get_auth_headers()
|
||||
response = self.session.get(test_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
user_data = response.json()
|
||||
logger.info("Successfully connected to SuperOffice API.")
|
||||
logger.info(f"Authenticated as: {user_data.get('Name', 'N/A')} ({user_data.get('Associate', 'N/A')})")
|
||||
return user_data
|
||||
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
logger.error(f"HTTP error during connection test: {http_err} - {http_err.response.text}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as req_err:
|
||||
logger.error(f"Request error during connection test: {req_err}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred during connection test: {e}")
|
||||
return None
|
||||
22
connector-superoffice/sync_engine.py
Normal file
22
connector-superoffice/sync_engine.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
This module contains the core logic for the synchronization process.
|
||||
- Fetches data from Company Explorer.
|
||||
- Fetches data from SuperOffice.
|
||||
- Performs deduplication checks.
|
||||
- Creates or updates companies and contacts in SuperOffice.
|
||||
"""
|
||||
|
||||
class SyncEngine:
|
||||
def __init__(self, superoffice_client, explorer_client):
|
||||
self.so_client = superoffice_client
|
||||
self.ex_client = explorer_client
|
||||
|
||||
def run_sync(self):
|
||||
"""
|
||||
Executes a full synchronization run.
|
||||
"""
|
||||
# 1. Get companies from Explorer
|
||||
# 2. For each company, check if it exists in SuperOffice
|
||||
# 3. If not, create it
|
||||
# 4. If yes, update it (or skip)
|
||||
pass
|
||||
Reference in New Issue
Block a user