Files
OpenFin/backend/services/saxo_auth.py
2026-07-18 21:14:37 +02:00

161 lines
5.6 KiB
Python

"""
Saxo OpenAPI OAuth2 Authorization Code Grant — SIM or Live, set via SAXO_ENVIRONMENT.
Access tokens last ~20 min, refresh tokens ~40 min (SIM figures — Live may differ), so a
background scheduler (services/saxo_scheduler.py) must proactively refresh well before
either expires — this module only exposes the primitives (build the login URL, exchange
code, refresh, read a currently-valid token).
"""
import os
import secrets
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
import httpx
logger = logging.getLogger(__name__)
SAXO_APP_KEY = os.environ.get("SAXO_APP_KEY", "")
SAXO_APP_SECRET = os.environ.get("SAXO_APP_SECRET", "")
SAXO_REDIRECT_URI = os.environ.get("SAXO_REDIRECT_URI", "https://openfin.open-squared.tech/oauth/saxo/callback")
# SAXO_ENVIRONMENT=sim (default) or live — picks the matching auth/API host pair.
# Explicit SAXO_AUTH_BASE_URL/SAXO_API_BASE_URL env vars still win if set, for edge cases.
SAXO_ENVIRONMENT = os.environ.get("SAXO_ENVIRONMENT", "sim").strip().lower()
_DEFAULT_HOSTS = {
"sim": ("https://sim.logonvalidation.net", "https://gateway.saxobank.com/sim/openapi"),
"live": ("https://live.logonvalidation.net", "https://gateway.saxobank.com/openapi"),
}
_default_auth, _default_api = _DEFAULT_HOSTS.get(SAXO_ENVIRONMENT, _DEFAULT_HOSTS["sim"])
SAXO_AUTH_BASE_URL = os.environ.get("SAXO_AUTH_BASE_URL", _default_auth)
SAXO_API_BASE_URL = os.environ.get("SAXO_API_BASE_URL", _default_api)
# In-memory CSRF state for the OAuth login->callback round trip (single-user desktop app).
_pending_state: Optional[str] = None
def is_configured() -> bool:
return bool(SAXO_APP_KEY and SAXO_APP_SECRET)
def new_login_state() -> str:
global _pending_state
_pending_state = secrets.token_urlsafe(24)
return _pending_state
def consume_and_verify_state(state: str) -> bool:
global _pending_state
ok = bool(_pending_state) and secrets.compare_digest(_pending_state, state or "")
_pending_state = None
return ok
def build_authorize_url(state: str) -> str:
from urllib.parse import urlencode
params = {
"response_type": "code",
"client_id": SAXO_APP_KEY,
"redirect_uri": SAXO_REDIRECT_URI,
"state": state,
}
return f"{SAXO_AUTH_BASE_URL}/authorize?{urlencode(params)}"
def _store_token_response(token_resp: Dict[str, Any]):
from services.database import save_saxo_tokens
now = datetime.now(timezone.utc)
expires_at = (now + timedelta(seconds=int(token_resp["expires_in"]))).isoformat()
refresh_expires_at = (now + timedelta(seconds=int(token_resp["refresh_token_expires_in"]))).isoformat()
save_saxo_tokens(
access_token=token_resp["access_token"],
refresh_token=token_resp["refresh_token"],
expires_at=expires_at,
refresh_expires_at=refresh_expires_at,
environment=SAXO_ENVIRONMENT,
)
def exchange_code_for_tokens(code: str) -> Dict[str, Any]:
resp = httpx.post(
f"{SAXO_AUTH_BASE_URL}/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": SAXO_REDIRECT_URI,
},
auth=(SAXO_APP_KEY, SAXO_APP_SECRET),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=15,
)
resp.raise_for_status()
token_resp = resp.json()
_store_token_response(token_resp)
logger.info("[Saxo] Connected — tokens stored")
return token_resp
def refresh_tokens() -> Optional[Dict[str, Any]]:
from services.database import get_saxo_tokens
stored = get_saxo_tokens()
if not stored or not stored.get("refresh_token"):
return None
resp = httpx.post(
f"{SAXO_AUTH_BASE_URL}/token",
data={
"grant_type": "refresh_token",
"refresh_token": stored["refresh_token"],
"redirect_uri": SAXO_REDIRECT_URI,
},
auth=(SAXO_APP_KEY, SAXO_APP_SECRET),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=15,
)
resp.raise_for_status()
token_resp = resp.json()
_store_token_response(token_resp)
logger.info("[Saxo] Token refreshed")
return token_resp
def get_valid_access_token() -> Optional[str]:
from services.database import get_saxo_tokens
stored = get_saxo_tokens()
if not stored or not stored.get("access_token"):
return None
expires_at = datetime.fromisoformat(stored["expires_at"])
if datetime.now(timezone.utc) < expires_at - timedelta(seconds=60):
return stored["access_token"]
try:
refreshed = refresh_tokens()
except Exception as e:
# Refresh token rejected — clear the dead session instead of leaving a token that
# looks "connected" in /status but gets 401'd on the next real API call.
logger.error(f"[Saxo] Refresh failed on-demand, clearing dead session: {e}")
disconnect()
return None
return refreshed["access_token"] if refreshed else None
def get_status() -> Dict[str, Any]:
from services.database import get_saxo_tokens
stored = get_saxo_tokens()
if not stored or not stored.get("access_token"):
return {"connected": False, "configured": is_configured(), "environment": SAXO_ENVIRONMENT}
return {
"connected": True,
"configured": is_configured(),
"environment": stored.get("environment", "sim"),
"expires_at": stored.get("expires_at"),
"refresh_expires_at": stored.get("refresh_expires_at"),
}
def disconnect():
from services.database import clear_saxo_tokens
clear_saxo_tokens()