From ea104aef6e41635163cfcdd084f598b88e5ec4a2 Mon Sep 17 00:00:00 2001 From: dosangyoon Date: Mon, 23 Mar 2026 10:27:59 +0900 Subject: [PATCH] fix(auth): honor ADMIN_EMAILS with robust email merge and JWT bootstrap for admin checks Made-with: Cursor --- flask_app.py | 30 +++++++++++++++++++++++++----- server.js | 27 +++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/flask_app.py b/flask_app.py index 6094e2e..a6a3f50 100644 --- a/flask_app.py +++ b/flask_app.py @@ -43,6 +43,17 @@ def parse_email_csv(s: str) -> list[str]: return [x.lower() for x in parse_csv(s)] +def email_from_id_token_payload(payload: dict) -> Optional[str]: + """Prefer standard `email`; also accept Auth0-style namespaced claims ending with `/email`.""" + v = payload.get("email") + if isinstance(v, str) and v.strip(): + return v.strip().lower() + for k, val in payload.items(): + if isinstance(k, str) and k.endswith("/email") and isinstance(val, str) and val.strip(): + return val.strip().lower() + return None + + PORT = int(env("PORT", "8023") or "8023") DB_HOST = env("DB_HOST", "").strip() @@ -193,7 +204,8 @@ def verify_user_from_request() -> Tuple[Optional[str], str]: payload = verify_id_token(id_token, issuer=issuer, audience=audience) sub = str(payload.get("sub") or "").strip() - email = (str(payload.get("email")).strip().lower() if payload.get("email") else "") + e = email_from_id_token_payload(payload) + email = e or "" return (sub or None, email) @@ -205,6 +217,8 @@ def is_request_admin() -> Tuple[bool, str]: return (False, "") ensure_user_table() sub, email = verify_user_from_request() + if email and is_bootstrap_admin(email): + return (True, email) if not sub: return (False, email) row = db_one(f"select is_admin from public.{TABLE} where sub = %s", (sub,)) @@ -404,7 +418,7 @@ def api_auth_sync() -> Response: payload = verify_id_token(id_token, issuer=issuer, audience=audience) sub = str(payload.get("sub") or "").strip() - email = (str(payload.get("email")).strip().lower() if payload.get("email") else None) + jwt_email = email_from_id_token_payload(payload) name = (str(payload.get("name")).strip() if payload.get("name") else None) picture = (str(payload.get("picture")).strip() if payload.get("picture") else None) provider = sub.split("|", 1)[0] if "|" in sub else None @@ -412,7 +426,13 @@ def api_auth_sync() -> Response: if not sub: return jsonify({"ok": False, "error": "missing_sub"}), 400 - bootstrap_admin = bool(email and is_bootstrap_admin(email)) + prev_row = db_one(f"select email from public.{TABLE} where sub = %s", (sub,)) + prev_email: Optional[str] = None + if prev_row and prev_row[0]: + pe = str(prev_row[0]).strip().lower() + prev_email = pe or None + effective_email = jwt_email or prev_email + bootstrap_admin = bool(effective_email and is_bootstrap_admin(effective_email)) sql = f""" insert into public.{TABLE} @@ -420,7 +440,7 @@ def api_auth_sync() -> Response: values (%s, %s, %s, %s, %s, now(), now(), %s, %s, now()) on conflict (sub) do update set - email = excluded.email, + email = coalesce(nullif(btrim(excluded.email::text), ''), public.{TABLE}.email), name = excluded.name, picture = excluded.picture, provider = excluded.provider, @@ -432,7 +452,7 @@ def api_auth_sync() -> Response: returning is_admin, can_manage, first_login_at, last_login_at, last_logout_at """ - row = db_one(sql, (sub, email, name, picture, provider, bootstrap_admin, bootstrap_admin, bootstrap_admin, bootstrap_admin)) + row = db_one(sql, (sub, jwt_email, name, picture, provider, bootstrap_admin, bootstrap_admin, bootstrap_admin, bootstrap_admin)) is_admin = bool(row[0]) if row else False can_manage = bool(row[1]) if row else False user = ( diff --git a/server.js b/server.js index 7b1f136..5cf6122 100644 --- a/server.js +++ b/server.js @@ -36,6 +36,20 @@ function parseEmailCsv(s) { return parseCsv(s).map((x) => x.toLowerCase()); } +/** Prefer `email`; also Auth0-style namespaced claims ending with `/email`. */ +function emailFromIdTokenPayload(payload) { + const e = payload?.email; + if (typeof e === "string" && e.trim()) return e.trim().toLowerCase(); + if (!payload || typeof payload !== "object") return null; + for (const k of Object.keys(payload)) { + if (typeof k === "string" && k.endsWith("/email")) { + const v = payload[k]; + if (typeof v === "string" && v.trim()) return v.trim().toLowerCase(); + } + } + return null; +} + const PORT = Number(env("PORT", "8000")) || 8000; const DB_HOST = must("DB_HOST"); const DB_PORT = Number(env("DB_PORT", "5432")) || 5432; @@ -146,14 +160,19 @@ app.post("/api/auth/sync", async (req, res) => { const payload = await verifyIdToken(idToken, { issuer, audience }); const sub = String(payload.sub || "").trim(); - const email = payload.email ? String(payload.email).trim().toLowerCase() : null; + const jwtEmail = emailFromIdTokenPayload(payload); const name = payload.name ? String(payload.name).trim() : null; const picture = payload.picture ? String(payload.picture).trim() : null; const provider = sub.includes("|") ? sub.split("|", 1)[0] : null; if (!sub) return res.status(400).json({ ok: false, error: "missing_sub" }); - const bootstrapAdmin = Boolean(email && ADMIN_EMAILS.has(email)); + const pr = await pool.query(`select email from public.${TABLE} where sub = $1`, [sub]); + const rawPrev = pr.rows?.[0]?.email; + const prevEmail = + rawPrev != null && String(rawPrev).trim() ? String(rawPrev).trim().toLowerCase() : null; + const effectiveEmail = jwtEmail || prevEmail; + const bootstrapAdmin = Boolean(effectiveEmail && ADMIN_EMAILS.has(effectiveEmail)); const q = ` insert into public.${TABLE} @@ -161,7 +180,7 @@ app.post("/api/auth/sync", async (req, res) => { values ($1, $2, $3, $4, $5, now(), now(), $6, $6, now()) on conflict (sub) do update set - email = excluded.email, + email = coalesce(nullif(btrim(excluded.email::text), ''), public.${TABLE}.email), name = excluded.name, picture = excluded.picture, provider = excluded.provider, @@ -172,7 +191,7 @@ app.post("/api/auth/sync", async (req, res) => { updated_at = now() returning is_admin, can_manage, first_login_at, last_login_at, last_logout_at `; - const r = await pool.query(q, [sub, email, name, picture, provider, bootstrapAdmin]); + const r = await pool.query(q, [sub, jwtEmail, name, picture, provider, bootstrapAdmin]); const isAdmin = Boolean(r.rows?.[0]?.is_admin); res.json({ ok: true, canManage: isAdmin, user: r.rows?.[0] || null });