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