""" 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()