feat(connector-superoffice): implement OAuth 2.0 flow and S2S architecture
Completed POC for SuperOffice integration with the following key achievements: - Switched from RSA/SOAP to OAuth 2.0 (Refresh Token Flow) for better compatibility with SOD environment. - Implemented robust token refreshing and caching mechanism in . - Solved 'Wrong Subdomain' issue by enforcing for tenant . - Created for REST API interaction (Search, Create, Update UDFs). - Added helper scripts: , , . - Documented usage and configuration in . - Updated configuration requirements. [2ff88f42]
This commit is contained in:
@@ -1,121 +1,60 @@
|
||||
import os
|
||||
import requests
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
|
||||
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")
|
||||
# Load configuration from environment
|
||||
self.client_id = os.getenv("SO_CLIENT_ID") or 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.refresh_token = os.getenv("SO_REFRESH_TOKEN")
|
||||
self.tenant_id = os.getenv("SO_CONTEXT_IDENTIFIER") # e.g., Cust55774
|
||||
|
||||
# OAuth Token Endpoint for SOD
|
||||
self.token_url = "https://sod.superoffice.com/login/common/oauth/tokens"
|
||||
|
||||
self._access_token = None
|
||||
self._token_expiry_time = 0
|
||||
self._webapi_url = None
|
||||
self._expiry = 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 get_ticket(self):
|
||||
if self._access_token and time.time() < self._expiry:
|
||||
return self._access_token, self._webapi_url
|
||||
return self.refresh_access_token()
|
||||
|
||||
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}...")
|
||||
def refresh_access_token(self):
|
||||
logger.info(f"Refreshing Access Token for Client ID: {self.client_id[:5]}...")
|
||||
|
||||
payload = {
|
||||
'grant_type': 'refresh_token',
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'refresh_token': self.refresh_token
|
||||
"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'
|
||||
"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()
|
||||
resp = requests.post(self.token_url, data=payload, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
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
|
||||
self._access_token = data.get("access_token")
|
||||
# Based on user's browser URL
|
||||
self._webapi_url = f"https://app-sod.superoffice.com/{self.tenant_id}"
|
||||
|
||||
logger.info("Successfully obtained new access token via refresh token.")
|
||||
return self._access_token
|
||||
self._expiry = time.time() + int(data.get("expires_in", 3600)) - 60
|
||||
logger.info("Successfully refreshed Access Token.")
|
||||
return self._access_token, self._webapi_url
|
||||
|
||||
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.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing access token: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
logger.error(f"Response: {e.response.text}")
|
||||
raise
|
||||
Reference in New Issue
Block a user