Newer
Older
veriq / backend / oauth.py
from typing import Dict, Optional, Tuple
"""VerIQ — identity binding via OAuth (phase 2 scaffolding).

X (Twitter): OAuth 2.0 Authorization Code with PKCE.
  - authorize: https://twitter.com/i/oauth2/authorize
  - token:     https://api.twitter.com/2/oauth2/token
  - userinfo:  https://api.twitter.com/2/users/me (user.fields=id,name,username,profile_image_url)
  - scopes:    users.read tweet.read (read-only; we never post)

Instagram: Facebook Login for Instagram (business/creator accounts).
  - authorize: https://www.facebook.com/v19.0/dialog/oauth
  - token:     https://graph.facebook.com/v19.0/oauth/access_token
  - userinfo:  https://graph.facebook.com/me?fields=id,username,name,picture

Both are wired but return 503 until the owner registers the apps and fills
.env (X_CLIENT_ID/X_CLIENT_SECRET, IG_APP_ID/IG_APP_SECRET).
"""
import base64
import hashlib
import os
import secrets
import time
from urllib.parse import urlencode

X_AUTHORIZE = "https://twitter.com/i/oauth2/authorize"
X_TOKEN = "https://api.twitter.com/2/oauth2/token"
X_USERINFO = "https://api.twitter.com/2/users/me"

IG_AUTHORIZE = "https://www.facebook.com/v19.0/dialog/oauth"
IG_TOKEN = "https://graph.facebook.com/v19.0/oauth/access_token"
IG_USERINFO = "https://graph.facebook.com/me"

# in-memory PKCE/state store (single-process MVP; fine for v1)
FLOWS: Dict[str, dict] = {}
FLOW_TTL_S = 600


def _env(name: str) -> Optional[str]:
    v = os.environ.get(name, "").strip()
    return v or None


def x_configured() -> bool:
    return bool(_env("X_CLIENT_ID") and _env("X_CLIENT_SECRET"))


def ig_configured() -> bool:
    return bool(_env("IG_APP_ID") and _env("IG_APP_SECRET"))


def public_base_url() -> str:
    return _env("VERIQ_PUBLIC_BASE_URL") or "http://127.0.0.1:8801"


def _new_flow(provider: str, result_id: str) -> dict:
    verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip("=")
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
    state = secrets.token_urlsafe(24)
    FLOWS[state] = {
        "provider": provider,
        "result_id": result_id,
        "verifier": verifier,
        "created_at": time.time(),
    }
    # prune
    now = time.time()
    for k in [k for k, v in FLOWS.items() if now - v["created_at"] > FLOW_TTL_S]:
        FLOWS.pop(k, None)
    return {"state": state, "challenge": challenge, "verifier": verifier}


def x_authorize_url(result_id: str) -> Tuple[str, Optional[str]]:
    """Returns (authorize_url, state) or None if not configured."""
    cid = _env("X_CLIENT_ID")
    if not cid:
        return None
    flow = _new_flow("x", result_id)
    params = {
        "response_type": "code",
        "client_id": cid,
        "redirect_uri": public_base_url() + "/auth/x/callback",
        "scope": "users.read tweet.read",
        "state": flow["state"],
        "code_challenge": flow["challenge"],
        "code_challenge_method": "S256",
    }
    return X_AUTHORIZE + "?" + urlencode(params), flow["state"]


def ig_authorize_url(result_id: str) -> Tuple[str, Optional[str]]:
    app_id = _env("IG_APP_ID")
    if not app_id:
        return None
    flow = _new_flow("ig", result_id)
    params = {
        "client_id": app_id,
        "redirect_uri": public_base_url() + "/auth/ig/callback",
        "state": flow["state"],
        "scope": "instagram_basic,instagram_graph_user_profile",
        "response_type": "code",
    }
    return IG_AUTHORIZE + "?" + urlencode(params), flow["state"]


def pop_flow(state: str) -> Optional[dict]:
    flow = FLOWS.pop(state, None)
    if flow and time.time() - flow["created_at"] <= FLOW_TTL_S:
        return flow
    return None


# --- phase 2: token exchange + userinfo (httpx) --------------------------------


def x_exchange(code: str, verifier: str) -> Optional[dict]:
    """OAuth 2.0 PKCE: authorization code -> access token. None on any failure."""
    cid, csec = _env("X_CLIENT_ID"), _env("X_CLIENT_SECRET")
    if not (cid and csec):
        return None
    import httpx
    try:
        r = httpx.post(
            X_TOKEN,
            data={
                "grant_type": "authorization_code",
                "code": code,
                "redirect_uri": public_base_url() + "/auth/x/callback",
                "client_id": cid,
                "client_type": "third_party_app",
                "client_secret": csec,
                "code_verifier": verifier,
            },
            timeout=15,
        )
    except Exception:
        return None
    if r.status_code != 200:
        return None
    return r.json()


def x_userinfo(access_token: str) -> Optional[dict]:
    import httpx
    try:
        r = httpx.get(
            X_USERINFO,
            params={"user.fields": "id,name,username,profile_image_url"},
            headers={"Authorization": "Bearer " + access_token},
            timeout=15,
        )
    except Exception:
        return None
    if r.status_code != 200:
        return None
    return (r.json() or {}).get("data")


def ig_exchange(code: str) -> Optional[dict]:
    app_id, app_secret = _env("IG_APP_ID"), _env("IG_APP_SECRET")
    if not (app_id and app_secret):
        return None
    import httpx
    try:
        r = httpx.get(
            IG_TOKEN,
            params={
                "client_id": app_id,
                "client_secret": app_secret,
                "redirect_uri": public_base_url() + "/auth/ig/callback",
                "code": code,
            },
            timeout=15,
        )
    except Exception:
        return None
    if r.status_code != 200:
        return None
    return r.json()


def ig_userinfo(access_token: str) -> Optional[dict]:
    import httpx
    try:
        r = httpx.get(
            IG_USERINFO,
            params={"fields": "id,username,name,picture",
                    "access_token": access_token},
            timeout=15,
        )
    except Exception:
        return None
    if r.status_code != 200:
        return None
    return r.json()