154 lines
5.2 KiB
Python
154 lines
5.2 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"]
|
|
|
|
refreshed = refresh_tokens()
|
|
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()
|