Remove OAuth flow (join/oauth endpoints, pending_members, dead helpers); provision via webhook + own email
This commit is contained in:
1 parent
eaeda98bac
commit
cc39f5ae79
1 file changed
+6
-263
+6
-263
@@ -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):
|
||||||
|
|||||||
Reference in new issue
Block a user