fix(auth): honor ADMIN_EMAILS with robust email merge and JWT bootstrap for admin checks
Made-with: Cursor
This commit is contained in:
30
flask_app.py
30
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 = (
|
||||
|
||||
27
server.js
27
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 });
|
||||
|
||||
Reference in New Issue
Block a user