fix(auth): honor ADMIN_EMAILS with robust email merge and JWT bootstrap for admin checks

Made-with: Cursor
This commit is contained in:
dosangyoon
2026-03-23 10:27:59 +09:00
parent aff4b7d961
commit ea104aef6e
2 changed files with 48 additions and 9 deletions

View File

@@ -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 });