diff --git a/backend/db.py b/backend/db.py
index 709ebdb..3a4c9a4 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -112,4 +112,11 @@
"results_total": total,
"results_flagged": flagged,
"avg_iq": round(avg_iq, 1) if avg_iq is not None else None,
- }
\ No newline at end of file
+ }
+
+def claim_result(result_id: str, identity: str) -> bool:
+ """Bind identity to an existing result. False if result_id unknown."""
+ with _conn() as c:
+ cur = c.execute("UPDATE results SET identity=? WHERE result_id=?",
+ (identity, result_id))
+ return cur.rowcount > 0
diff --git a/backend/main.py b/backend/main.py
index 1d50c2d..e2eaec3 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -12,7 +12,7 @@
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
-from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
from pydantic import BaseModel, Field
from . import db, oauth, og, scoring, signing
@@ -279,6 +279,23 @@
# --- identity binding (phase 2 scaffolding) -----------------------------------
+def _claim_error_page(result_id: str, provider: str) -> HTMLResponse:
+ """Standalone error page: claim failed, result untouched and still verifiable."""
+ body = (
+ '
'
+ 'VerIQ - claim failed'
+ ''
+ ''
+ '
Claim failed
'
+ '
' + provider + ' did not return a usable profile. Your result is unchanged '
+ 'and still verifiable. You can try again from the result page.
'
+ '
Back to result
'
+ '
'
+ )
+ return HTMLResponse(body, status_code=502)
+
+
@app.get("/auth/x/start")
def auth_x_start(result_id: str):
"""Begin X OAuth binding for a result. 503 until apps registered."""
@@ -299,9 +316,15 @@
flow = oauth.pop_flow(state)
if flow is None or flow["provider"] != "x":
raise HTTPException(400, "invalid or expired state")
- # token exchange + userinfo fetch is implemented in phase 2
- # (needs httpx; kept out of MVP deps)
- raise HTTPException(501, "X token exchange lands in phase 2 — apps not registered yet")
+ tok = oauth.x_exchange(code, flow["verifier"])
+ user = oauth.x_userinfo(tok.get("access_token")) if tok else None
+ if not user or not user.get("username"):
+ return _claim_error_page(flow["result_id"], "X")
+ identity = "x:@" + user["username"]
+ db.claim_result(flow["result_id"], identity)
+ db.log_event("claim", {"result_id": flow["result_id"], "provider": "x",
+ "user_id": user.get("id"), "username": user["username"]})
+ return RedirectResponse("/v/" + flow["result_id"], status_code=303)
@app.get("/auth/ig/start")
@@ -323,7 +346,15 @@
flow = oauth.pop_flow(state)
if flow is None or flow["provider"] != "ig":
raise HTTPException(400, "invalid or expired state")
- raise HTTPException(501, "IG token exchange lands in phase 2 — apps not registered yet")
+ tok = oauth.ig_exchange(code)
+ user = oauth.ig_userinfo(tok.get("access_token")) if tok else None
+ if not user or not user.get("username"):
+ return _claim_error_page(flow["result_id"], "Instagram")
+ identity = "ig:@" + user["username"]
+ db.claim_result(flow["result_id"], identity)
+ db.log_event("claim", {"result_id": flow["result_id"], "provider": "ig",
+ "user_id": user.get("id"), "username": user["username"]})
+ return RedirectResponse("/v/" + flow["result_id"], status_code=303)
# --- startup --------------------------------------------------------------------
diff --git a/backend/oauth.py b/backend/oauth.py
index e7d9ec2..6e77c9b 100644
--- a/backend/oauth.py
+++ b/backend/oauth.py
@@ -108,3 +108,87 @@
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,
+ "code_verifier": verifier,
+ },
+ auth=(cid, csec),
+ 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()
diff --git a/frontend/assets/result.js b/frontend/assets/result.js
index e8d85fc..b09f8d7 100644
--- a/frontend/assets/result.js
+++ b/frontend/assets/result.js
@@ -47,6 +47,13 @@
b3.textContent = d.completed_at;
badges.appendChild(b3);
+ if (d.identity) {
+ $("claimHint").textContent = "This result is already claimed by " +
+ d.identity + ".";
+ $("claimX").style.display = "none";
+ $("claimIg").style.display = "none";
+ }
+
var cats = $("cats");
cats.innerHTML = "";
var order = ["numeric", "matrix", "verbal", "spatial"];
diff --git a/requirements.txt b/requirements.txt
index 23e13e0..c4cec90 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
fastapi>=0.110
uvicorn[standard]>=0.29
pydantic>=2.6
-cryptography>=42.0
\ No newline at end of file
+cryptography>=42.0
+httpx>=0.27