Remove OAuth flow (join/oauth endpoints, pending_members, dead helpers); provision via webhook + own email

This commit is contained in:
inference-bot committed 2026-09-11 14:17:08 -06:00
1 parent eaeda98bac
commit cc39f5ae79
1 file changed
+6 -263
+6 -263
View File
@@ -56,15 +56,6 @@ OWUI_JWT_SECRET = os.environ.get("OWUI_JWT_SECRET", "")
# is the standard way to authenticate them. # is the standard way to authenticate them.
WEBHOOK_TOKEN = os.environ.get("WEBHOOK_TOKEN", "") WEBHOOK_TOKEN = os.environ.get("WEBHOOK_TOKEN", "")
# Open Collective OAuth app credentials (for the "connect your account" flow,
# which is how we obtain a member's email — the webhook strips it for privacy).
OC_OAUTH_CLIENT_ID = os.environ.get("OC_OAUTH_CLIENT_ID", "")
OC_OAUTH_CLIENT_SECRET = os.environ.get("OC_OAUTH_CLIENT_SECRET", "")
OC_OAUTH_REDIRECT_URI = os.environ.get(
"OC_OAUTH_REDIRECT_URI", "https://portal.inference.coop/oauth/callback"
)
OC_AUTHORIZE_URL = "https://opencollective.com/oauth/authorize"
OC_TOKEN_URL = "https://opencollective.com/oauth/token"
OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2" OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2"
OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative") OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative")
@@ -138,13 +129,6 @@ def get_db() -> sqlite3.Connection:
"active INTEGER DEFAULT 1" "active INTEGER DEFAULT 1"
")" ")"
) )
conn.execute(
"CREATE TABLE IF NOT EXISTS pending_members ("
"slug TEXT PRIMARY KEY, "
"name TEXT, "
"created_at TEXT DEFAULT (datetime('now'))"
")"
)
# Migration: add slug column if the members table predates it. # Migration: add slug column if the members table predates it.
cols = [r[1] for r in conn.execute("PRAGMA table_info(members)").fetchall()] cols = [r[1] for r in conn.execute("PRAGMA table_info(members)").fetchall()]
if "slug" not in cols: if "slug" not in cols:
@@ -152,81 +136,6 @@ def get_db() -> sqlite3.Connection:
return conn return conn
def store_pending_member(slug: str, name: str) -> None:
conn = get_db()
conn.execute(
"INSERT INTO pending_members (slug, name) VALUES (?, ?) "
"ON CONFLICT(slug) DO UPDATE SET name=excluded.name",
(slug, name),
)
conn.commit()
conn.close()
def is_pending_member(slug: str) -> bool:
"""True if this slug has a pending membership (i.e. the webhook saw a
contribution from them). Used to gate the OAuth flow so only paying
members can provision an account."""
if not slug:
return False
conn = get_db()
row = conn.execute(
"SELECT 1 FROM pending_members WHERE slug = ?", (slug,)
).fetchone()
conn.close()
return row is not None
async def fetch_member_emails() -> dict[str, str]:
"""Return a map of email -> role for active financial contributors.
Uses the bot's personal token (admin) so the GraphQL API exposes member
emails, which are hidden from anonymous access. This is the authoritative
source of truth for "who is a member" — and it works for guest
contributors (who have no OC account) because their email is still in the
list.
"""
if not OC_PERSONAL_TOKEN:
logger.warning("OC_PERSONAL_TOKEN not set; cannot fetch member emails")
return {}
query = (
'{ collective(slug: "%s") { members(limit: 100) { nodes { role account { email } } } } }'
% OC_COLLECTIVE_SLUG
)
async with httpx.AsyncClient() as client:
r = await client.post(
OC_GRAPHQL_URL,
headers={"Personal-Token": OC_PERSONAL_TOKEN},
json={"query": query},
)
if r.status_code != 200:
logger.warning("OC member fetch failed: %s", r.status_code)
return {}
nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", [])
result: dict[str, str] = {}
for n in nodes:
role = n.get("role", "")
acct = n.get("account", {}) or {}
email = (acct.get("email") or "").strip().lower()
if email and role in ("BACKER", "ADMIN"):
result[email] = role
return result
async def is_active_member(email: str) -> bool:
"""Check whether this email is an active financial contributor.
Matches on email (not slug) so guest contributors — who have no OC account
and thus no usable slug — are still recognized. The email comes from the
OAuth `me` query (with the user's consent); we match it against the
admin-visible member list.
"""
if not email:
return False
members = await fetch_member_emails()
return email.strip().lower() in members
async def fetch_members() -> list[dict]: async def fetch_members() -> list[dict]:
"""Return the full active-member list: email, name, slug, role. """Return the full active-member list: email, name, slug, role.
@@ -380,11 +289,8 @@ def welcome_email(name: str, setup_url: str) -> tuple[str, str, str]:
async def get_active_member_emails() -> list[str]: async def get_active_member_emails() -> list[str]:
"""Return the list of active member emails from the portal's own database. """Return the list of active member emails from the portal's own database.
We use the local `members` table (populated during OAuth, which captures the The local `members` table is the authoritative source of member emails
member's email) rather than Open Collective's GraphQL, because OC returns (populated during provisioning). Used for the Loomio sync.
`email: null` for privacy — the email field is only exposed via OAuth with
the user's consent. The local table is the authoritative source of member
emails.
""" """
conn = get_db() conn = get_db()
rows = conn.execute( rows = conn.execute(
@@ -541,17 +447,6 @@ async def cloudron_set_active(user_id: str, active: bool) -> None:
r.raise_for_status() r.raise_for_status()
async def cloudron_send_invite(user_id: str, email: str) -> None:
"""Send the account-setup invite email to the member."""
async with httpx.AsyncClient() as client:
r = await client.post(
f"{CLOUDRON_API}/api/v1/users/{user_id}/send_invite_email",
headers=cloudron_headers(),
json={"email": email},
)
r.raise_for_status()
async def cloudron_get_invite_link(user_id: str) -> str: async def cloudron_get_invite_link(user_id: str) -> str:
"""Return the account-setup link WITHOUT sending Cloudron's own email. """Return the account-setup link WITHOUT sending Cloudron's own email.
@@ -674,8 +569,7 @@ async def opencollective_webhook(request: Request, token: str):
data = payload.get("data", {}) data = payload.get("data", {})
# The webhook does NOT include email (Open Collective strips it for # The webhook does NOT include email (Open Collective strips it for
# privacy). We get name + slug, store a pending member, and obtain the # privacy). We get name + slug, then look up the email via the admin token.
# email later via the OAuth "connect your account" flow.
# #
# Slug/name live in different places depending on the event type: # Slug/name live in different places depending on the event type:
# - order.processed / transaction.created → data.fromCollective.{slug,name} # - order.processed / transaction.created → data.fromCollective.{slug,name}
@@ -819,157 +713,6 @@ async def inject_key(request: Request, path: str):
) )
# --- C. OAuth "connect your account" flow ---
@app.get("/join")
async def join_page():
"""The 'finish your setup' page — where a new member connects their
Open Collective account so we can obtain their email (with consent)."""
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Finish your setup — Inference Cooperative</title>
<style>
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background: #faf8f5; color: #2d3327; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 24px; }
.card { background: #fff; border: 1px solid #e8e2d8; border-radius: 12px; padding: 40px; max-width: 480px; text-align: center; }
h1 { font-size: 24px; margin: 0 0 12px; }
p { color: #6b7a62; line-height: 1.5; margin: 0 0 20px; }
.btn { display: inline-block; background: #5b8c5a; color: #fff; text-decoration: none; padding: 14px 32px; border-radius: 10px; font-weight: 600; }
.btn:hover { opacity: 0.9; }
.note { font-size: 13px; color: #94a08c; margin-top: 20px; }
.note a { color: #6b7a62; }
</style>
</head>
<body>
<div class="card">
<h1>Finish your setup</h1>
<p>Thanks for joining the Inference Cooperative! To set up your account, connect your Open Collective account so we can verify your membership.</p>
<a class="btn" href="/oauth/start">Connect Open Collective</a>
<p class="note">You'll receive an account-setup email shortly after connecting.<br>Didn't get it? Contact <a href="mailto:info@inference.coop">info@inference.coop</a>.</p>
<p class="note">Contributed as a guest? <a href="https://opencollective.com/signin">Create an Open Collective account</a> using the same email you used to contribute, then return here and connect it.</p>
</div>
</body>
</html>"""
return Response(content=html, media_type="text/html")
@app.get("/oauth/start")
async def oauth_start():
"""Redirect the user to Open Collective's consent screen."""
state = secrets.token_urlsafe(16)
params = {
"client_id": OC_OAUTH_CLIENT_ID,
"response_type": "code",
"redirect_uri": OC_OAUTH_REDIRECT_URI,
"scope": "email",
"state": state,
}
qs = "&".join(f"{k}={v}" for k, v in params.items())
return Response(
status_code=302,
headers={"Location": f"{OC_AUTHORIZE_URL}?{qs}"},
)
@app.get("/oauth/callback")
async def oauth_callback(request: Request):
"""Exchange the OAuth code for a token, fetch the email, and provision."""
code = request.query_params.get("code", "")
if not code:
raise HTTPException(400, "Missing code")
# Exchange code for access token
async with httpx.AsyncClient() as client:
r = await client.post(
OC_TOKEN_URL,
data={
"grant_type": "authorization_code",
"client_id": OC_OAUTH_CLIENT_ID,
"client_secret": OC_OAUTH_CLIENT_SECRET,
"code": code,
"redirect_uri": OC_OAUTH_REDIRECT_URI,
},
)
r.raise_for_status()
token = r.json().get("access_token", "")
if not token:
raise HTTPException(500, "No access token returned")
# Fetch the user's email + slug (slug lets us map webhook events back)
r = await client.post(
OC_GRAPHQL_URL,
headers={"Authorization": f"Bearer {token}"},
json={"query": "{ me { id name email slug } }"},
)
r.raise_for_status()
me = r.json().get("data", {}).get("me", {})
email = me.get("email", "")
name = me.get("name", "Member")
slug = me.get("slug", "")
if not email:
raise HTTPException(400, "No email returned — did you grant the email scope?")
# Gate: only provision if this person is an active financial contributor.
# We check the LIVE Open Collective membership list (authoritative), not
# the webhook's pending table (which only fires on new events). Matching is
# by email so guest contributors (no OC account) are still recognized.
if not await is_active_member(email):
logger.warning("Rejected non-member %s (email not an active contributor)", email)
html = """<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Not a member yet — Inference Cooperative</title>
<style>
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background: #faf8f5; color: #2d3327; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 24px; }
.card { background: #fff; border: 1px solid #e8e2d8; border-radius: 12px; padding: 40px; max-width: 480px; text-align: center; }
h1 { font-size: 24px; margin: 0 0 12px; }
p { color: #6b7a62; line-height: 1.5; margin: 0 0 20px; }
.note { font-size: 13px; color: #94a08c; margin-top: 20px; }
.note a { color: #6b7a62; }
</style>
</head>
<body>
<div class="card">
<h1>Not a member yet</h1>
<p>We couldn't find an active membership for your account. To join, contribute on our Open Collective page first, then return here to finish setup.</p>
<p class="note">Contributed as a guest? Make sure you've <a href="https://opencollective.com/signin">created an Open Collective account</a> with the same email you used to contribute, then try again.</p>
<p class="note">Questions? Contact <a href="mailto:info@inference.coop">info@inference.coop</a>.</p>
</div>
</body>
</html>"""
return Response(content=html, media_type="text/html", status_code=403)
# Provision the member (sends our own welcome email with the setup link).
await provision_member(email, name, slug)
html = """<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>You're in — Inference Cooperative</title>
<style>
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background: #faf8f5; color: #2d3327; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 24px; }
.card { background: #fff; border: 1px solid #e8e2d8; border-radius: 12px; padding: 40px; max-width: 480px; text-align: center; }
h1 { font-size: 24px; margin: 0 0 12px; }
p { color: #6b7a62; line-height: 1.5; margin: 0 0 20px; }
.note { font-size: 13px; color: #94a08c; margin-top: 20px; }
.note a { color: #6b7a62; }
</style>
</head>
<body>
<div class="card">
<h1>You're in!</h1>
<p>Your account is being set up. Check your email for a link to create your account and start chatting.</p>
<p class="note">Didn't get it? Contact <a href="mailto:info@inference.coop">info@inference.coop</a>.</p>
</div>
</body>
</html>"""
return Response(content=html, media_type="text/html")
# --- D. Admin / health --- # --- D. Admin / health ---
@app.get("/health") @app.get("/health")
@@ -997,9 +740,9 @@ async def admin_sync_loomio(token: str):
async def admin_provision(token: str, request: Request): async def admin_provision(token: str, request: Request):
"""Manually provision a member by email (full pipeline). """Manually provision a member by email (full pipeline).
Protected by the same secret token as the webhook. Runs the same Protected by the same secret token as the webhook. Runs the full
provisioning as the OAuth callback: Cloudron user + members group + LiteLLM provisioning pipeline: Cloudron user + members group + LiteLLM key + our
key + invite email + Loomio sync. Body: {"email": "...", "name": "..."}. welcome email + Loomio sync. Body: {"email": "...", "name": "..."}.
Idempotent — safe to call repeatedly. Idempotent — safe to call repeatedly.
""" """
if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN): if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN):