Add social quick login and user sync API
Add quick provider login buttons (Auth0 connections), an API to upsert users into Postgres and gate admin via can_manage, plus schema and Node server. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
32
README.md
32
README.md
@@ -16,6 +16,36 @@ python3 -m http.server 8000
|
|||||||
|
|
||||||
그 후 브라우저에서 `http://localhost:8000`으로 접속합니다.
|
그 후 브라우저에서 `http://localhost:8000`으로 접속합니다.
|
||||||
|
|
||||||
|
## 서버(Node) + PostgreSQL 사용자 저장
|
||||||
|
|
||||||
|
로그인 후 사용자 정보를 `ncue_user`에 저장하고(Upsert), `can_manage`로 관리 권한을 DB에서 제어하려면 Node 서버로 실행하세요.
|
||||||
|
|
||||||
|
1) 의존성 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2) 테이블 생성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f db/schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
3) 서버 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 권한 부여
|
||||||
|
|
||||||
|
최초 로그인 사용자는 DB에 저장되지만 `can_manage=false`입니다. 관리 권한을 주려면:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
update ncue_user set can_manage = true where email = 'me@example.com';
|
||||||
|
```
|
||||||
|
|
||||||
## 로그인(관리 기능 잠금)
|
## 로그인(관리 기능 잠금)
|
||||||
|
|
||||||
이 프로젝트는 **정적 사이트**에서 동작하도록, 관리 기능(추가/편집/삭제/가져오기)을 **로그인 후(허용 이메일)** 에만 활성화할 수 있습니다.
|
이 프로젝트는 **정적 사이트**에서 동작하도록, 관리 기능(추가/편집/삭제/가져오기)을 **로그인 후(허용 이메일)** 에만 활성화할 수 있습니다.
|
||||||
@@ -36,6 +66,8 @@ python3 -m http.server 8000
|
|||||||
|
|
||||||
- 서버에 바로 반영하기 전 테스트가 필요하면, 페이지 상단의 **로그인**을 누르면 뜨는 **로그인 설정 모달**에서
|
- 서버에 바로 반영하기 전 테스트가 필요하면, 페이지 상단의 **로그인**을 누르면 뜨는 **로그인 설정 모달**에서
|
||||||
`domain/clientId/allowedEmails`를 입력하면 브라우저에 저장되어 즉시 테스트할 수 있습니다.
|
`domain/clientId/allowedEmails`를 입력하면 브라우저에 저장되어 즉시 테스트할 수 있습니다.
|
||||||
|
- Auth0에서 각 소셜 로그인 연결(connection)을 만들었다면, 모달의 connection 이름(예: `google-oauth2`, `kakao`, `naver`)을 입력하면
|
||||||
|
상단에 **구글/카카오/네이버** 간편 로그인 버튼이 표시됩니다.
|
||||||
|
|
||||||
## 데이터 저장
|
## 데이터 저장
|
||||||
|
|
||||||
|
|||||||
27
db/schema.sql
Normal file
27
db/schema.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- NCUE user table for admin gating / auditing
|
||||||
|
-- Run: psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f db/schema.sql
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'ncue_user'
|
||||||
|
) THEN
|
||||||
|
CREATE TABLE public.ncue_user (
|
||||||
|
sub text PRIMARY KEY,
|
||||||
|
email text,
|
||||||
|
name text,
|
||||||
|
picture text,
|
||||||
|
provider text,
|
||||||
|
last_login_at timestamptz,
|
||||||
|
can_manage boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ncue_user_email ON public.ncue_user (email);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
11
index.html
11
index.html
@@ -71,6 +71,9 @@
|
|||||||
<span class="user-dot" aria-hidden="true"></span>
|
<span class="user-dot" aria-hidden="true"></span>
|
||||||
<span class="user-text" id="userText">로그인 필요</span>
|
<span class="user-text" id="userText">로그인 필요</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn" id="btnGoogle" type="button" hidden>구글</button>
|
||||||
|
<button class="btn" id="btnKakao" type="button" hidden>카카오</button>
|
||||||
|
<button class="btn" id="btnNaver" type="button" hidden>네이버</button>
|
||||||
<button class="btn" id="btnLogin" type="button">로그인</button>
|
<button class="btn" id="btnLogin" type="button">로그인</button>
|
||||||
<button class="btn" id="btnLogout" type="button" hidden>로그아웃</button>
|
<button class="btn" id="btnLogout" type="button" hidden>로그아웃</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,6 +206,14 @@
|
|||||||
<div class="hint">쉼표로 구분합니다. 비워두면 “로그인한 모든 계정”이 관리 가능해집니다.</div>
|
<div class="hint">쉼표로 구분합니다. 비워두면 “로그인한 모든 계정”이 관리 가능해집니다.</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Connection 이름(선택)</span>
|
||||||
|
<input id="authConnGoogle" class="input" type="text" placeholder="Google 예: google-oauth2" />
|
||||||
|
<input id="authConnKakao" class="input" type="text" placeholder="Kakao 예: kakao" />
|
||||||
|
<input id="authConnNaver" class="input" type="text" placeholder="Naver 예: naver" />
|
||||||
|
<div class="hint">Auth0에서 설정한 connection 이름입니다. 비우면 “로그인” 버튼으로 통합 로그인 화면을 띄웁니다.</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<button class="btn btn-ghost" type="button" id="btnAuthReset">초기화</button>
|
<button class="btn btn-ghost" type="button" id="btnAuthReset">초기화</button>
|
||||||
<button class="btn btn-primary" type="submit" id="btnAuthSave">저장</button>
|
<button class="btn btn-primary" type="submit" id="btnAuthSave">저장</button>
|
||||||
|
|||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "ncue-links-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"helmet": "^7.2.0",
|
||||||
|
"jose": "^5.9.6",
|
||||||
|
"pg": "^8.13.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
99
script.js
99
script.js
@@ -22,12 +22,18 @@
|
|||||||
userText: document.getElementById("userText"),
|
userText: document.getElementById("userText"),
|
||||||
btnLogin: document.getElementById("btnLogin"),
|
btnLogin: document.getElementById("btnLogin"),
|
||||||
btnLogout: document.getElementById("btnLogout"),
|
btnLogout: document.getElementById("btnLogout"),
|
||||||
|
btnGoogle: document.getElementById("btnGoogle"),
|
||||||
|
btnKakao: document.getElementById("btnKakao"),
|
||||||
|
btnNaver: document.getElementById("btnNaver"),
|
||||||
authModal: document.getElementById("authModal"),
|
authModal: document.getElementById("authModal"),
|
||||||
btnAuthClose: document.getElementById("btnAuthClose"),
|
btnAuthClose: document.getElementById("btnAuthClose"),
|
||||||
authForm: document.getElementById("authForm"),
|
authForm: document.getElementById("authForm"),
|
||||||
authDomain: document.getElementById("authDomain"),
|
authDomain: document.getElementById("authDomain"),
|
||||||
authClientId: document.getElementById("authClientId"),
|
authClientId: document.getElementById("authClientId"),
|
||||||
authAllowedEmails: document.getElementById("authAllowedEmails"),
|
authAllowedEmails: document.getElementById("authAllowedEmails"),
|
||||||
|
authConnGoogle: document.getElementById("authConnGoogle"),
|
||||||
|
authConnKakao: document.getElementById("authConnKakao"),
|
||||||
|
authConnNaver: document.getElementById("authConnNaver"),
|
||||||
btnAuthReset: document.getElementById("btnAuthReset"),
|
btnAuthReset: document.getElementById("btnAuthReset"),
|
||||||
modal: document.getElementById("modal"),
|
modal: document.getElementById("modal"),
|
||||||
btnClose: document.getElementById("btnClose"),
|
btnClose: document.getElementById("btnClose"),
|
||||||
@@ -65,6 +71,7 @@
|
|||||||
authorized: false,
|
authorized: false,
|
||||||
ready: false,
|
ready: false,
|
||||||
mode: "disabled", // enabled | misconfigured | sdk_missing | disabled
|
mode: "disabled", // enabled | misconfigured | sdk_missing | disabled
|
||||||
|
serverCanManage: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
@@ -358,6 +365,14 @@
|
|||||||
domain: String(auth0.domain || "").trim(),
|
domain: String(auth0.domain || "").trim(),
|
||||||
clientId: String(auth0.clientId || "").trim(),
|
clientId: String(auth0.clientId || "").trim(),
|
||||||
},
|
},
|
||||||
|
connections:
|
||||||
|
data.connections && typeof data.connections === "object"
|
||||||
|
? {
|
||||||
|
google: String(data.connections.google || "").trim(),
|
||||||
|
kakao: String(data.connections.kakao || "").trim(),
|
||||||
|
naver: String(data.connections.naver || "").trim(),
|
||||||
|
}
|
||||||
|
: { google: "", kakao: "", naver: "" },
|
||||||
allowedEmails: allowedEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
|
allowedEmails: allowedEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -379,6 +394,11 @@
|
|||||||
domain: String(auth0.domain || "").trim(),
|
domain: String(auth0.domain || "").trim(),
|
||||||
clientId: String(auth0.clientId || "").trim(),
|
clientId: String(auth0.clientId || "").trim(),
|
||||||
},
|
},
|
||||||
|
connections: {
|
||||||
|
google: "",
|
||||||
|
kakao: "",
|
||||||
|
naver: "",
|
||||||
|
},
|
||||||
allowedEmails: allowedEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
|
allowedEmails: allowedEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
|
||||||
};
|
};
|
||||||
const override = loadAuthOverride();
|
const override = loadAuthOverride();
|
||||||
@@ -389,6 +409,11 @@
|
|||||||
domain: override.auth0.domain || base.auth0.domain,
|
domain: override.auth0.domain || base.auth0.domain,
|
||||||
clientId: override.auth0.clientId || base.auth0.clientId,
|
clientId: override.auth0.clientId || base.auth0.clientId,
|
||||||
},
|
},
|
||||||
|
connections: {
|
||||||
|
google: override.connections?.google || "",
|
||||||
|
kakao: override.connections?.kakao || "",
|
||||||
|
naver: override.connections?.naver || "",
|
||||||
|
},
|
||||||
allowedEmails: override.allowedEmails.length ? override.allowedEmails : base.allowedEmails,
|
allowedEmails: override.allowedEmails.length ? override.allowedEmails : base.allowedEmails,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -427,6 +452,16 @@
|
|||||||
// 설정/SDK 문제 상태에서도 버튼은 "클릭 가능"하게 두고, 클릭 시 토스트로 안내합니다.
|
// 설정/SDK 문제 상태에서도 버튼은 "클릭 가능"하게 두고, 클릭 시 토스트로 안내합니다.
|
||||||
el.btnLogin.disabled = false;
|
el.btnLogin.disabled = false;
|
||||||
el.btnLogout.disabled = false;
|
el.btnLogout.disabled = false;
|
||||||
|
|
||||||
|
// 간편 로그인 버튼 노출 (connection이 설정되어 있고, 미로그인 상태)
|
||||||
|
const cfg = getAuthConfig();
|
||||||
|
const showQuick = enabled && !auth.user;
|
||||||
|
const g = showQuick && Boolean(cfg.connections.google);
|
||||||
|
const k = showQuick && Boolean(cfg.connections.kakao);
|
||||||
|
const n = showQuick && Boolean(cfg.connections.naver);
|
||||||
|
if (el.btnGoogle) el.btnGoogle.hidden = !g;
|
||||||
|
if (el.btnKakao) el.btnKakao.hidden = !k;
|
||||||
|
if (el.btnNaver) el.btnNaver.hidden = !n;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyManageLock() {
|
function applyManageLock() {
|
||||||
@@ -519,6 +554,35 @@
|
|||||||
auth.user = isAuthed ? await auth.client.getUser() : null;
|
auth.user = isAuthed ? await auth.client.getUser() : null;
|
||||||
const email = auth.user && auth.user.email ? auth.user.email : "";
|
const email = auth.user && auth.user.email ? auth.user.email : "";
|
||||||
auth.authorized = Boolean(auth.user) && isAllowedEmail(email);
|
auth.authorized = Boolean(auth.user) && isAllowedEmail(email);
|
||||||
|
auth.serverCanManage = null;
|
||||||
|
|
||||||
|
// 로그인되었으면 서버에 사용자 upsert 및 can_manage 동기화(서버가 있을 때만)
|
||||||
|
if (auth.user) {
|
||||||
|
try {
|
||||||
|
const claims = await auth.client.getIdTokenClaims();
|
||||||
|
const raw = claims && claims.__raw ? String(claims.__raw) : "";
|
||||||
|
if (raw) {
|
||||||
|
const cfg = getAuthConfig();
|
||||||
|
const r = await fetch("/api/auth/sync", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${raw}`,
|
||||||
|
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
|
||||||
|
"X-Auth0-ClientId": cfg.auth0.clientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
const data = await r.json();
|
||||||
|
if (data && data.ok) {
|
||||||
|
auth.serverCanManage = Boolean(data.canManage);
|
||||||
|
auth.authorized = auth.serverCanManage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore: server not running or blocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (auth.user && !auth.authorized) {
|
if (auth.user && !auth.authorized) {
|
||||||
toastOnce("deny", "로그인은 되었지만 허용 이메일이 아니라서 관리 기능이 잠금 상태입니다.");
|
toastOnce("deny", "로그인은 되었지만 허용 이메일이 아니라서 관리 기능이 잠금 상태입니다.");
|
||||||
@@ -538,6 +602,16 @@
|
|||||||
await auth.client.loginWithRedirect();
|
await auth.client.loginWithRedirect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loginWithConnection(connection) {
|
||||||
|
if (auth.mode !== "enabled" || !auth.client) {
|
||||||
|
openAuthModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await auth.client.loginWithRedirect({
|
||||||
|
authorizationParams: { connection },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
if (auth.mode !== "enabled" || !auth.client) return;
|
if (auth.mode !== "enabled" || !auth.client) return;
|
||||||
auth.user = null;
|
auth.user = null;
|
||||||
@@ -561,6 +635,9 @@
|
|||||||
el.authDomain.value = cfg.auth0.domain || "";
|
el.authDomain.value = cfg.auth0.domain || "";
|
||||||
el.authClientId.value = cfg.auth0.clientId || "";
|
el.authClientId.value = cfg.auth0.clientId || "";
|
||||||
el.authAllowedEmails.value = (cfg.allowedEmails || []).join(", ");
|
el.authAllowedEmails.value = (cfg.allowedEmails || []).join(", ");
|
||||||
|
if (el.authConnGoogle) el.authConnGoogle.value = cfg.connections.google || "";
|
||||||
|
if (el.authConnKakao) el.authConnKakao.value = cfg.connections.kakao || "";
|
||||||
|
if (el.authConnNaver) el.authConnNaver.value = cfg.connections.naver || "";
|
||||||
|
|
||||||
el.authModal.hidden = false;
|
el.authModal.hidden = false;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
@@ -883,6 +960,24 @@
|
|||||||
|
|
||||||
if (el.btnLogin) el.btnLogin.addEventListener("click", () => login().catch(() => toast("로그인에 실패했습니다.")));
|
if (el.btnLogin) el.btnLogin.addEventListener("click", () => login().catch(() => toast("로그인에 실패했습니다.")));
|
||||||
if (el.btnLogout) el.btnLogout.addEventListener("click", () => logout().catch(() => toast("로그아웃에 실패했습니다.")));
|
if (el.btnLogout) el.btnLogout.addEventListener("click", () => logout().catch(() => toast("로그아웃에 실패했습니다.")));
|
||||||
|
if (el.btnGoogle)
|
||||||
|
el.btnGoogle.addEventListener("click", () => {
|
||||||
|
const c = getAuthConfig().connections.google;
|
||||||
|
if (!c) return openAuthModal();
|
||||||
|
return loginWithConnection(c).catch(() => toast("로그인에 실패했습니다."));
|
||||||
|
});
|
||||||
|
if (el.btnKakao)
|
||||||
|
el.btnKakao.addEventListener("click", () => {
|
||||||
|
const c = getAuthConfig().connections.kakao;
|
||||||
|
if (!c) return openAuthModal();
|
||||||
|
return loginWithConnection(c).catch(() => toast("로그인에 실패했습니다."));
|
||||||
|
});
|
||||||
|
if (el.btnNaver)
|
||||||
|
el.btnNaver.addEventListener("click", () => {
|
||||||
|
const c = getAuthConfig().connections.naver;
|
||||||
|
if (!c) return openAuthModal();
|
||||||
|
return loginWithConnection(c).catch(() => toast("로그인에 실패했습니다."));
|
||||||
|
});
|
||||||
|
|
||||||
if (el.btnAuthClose) el.btnAuthClose.addEventListener("click", closeAuthModal);
|
if (el.btnAuthClose) el.btnAuthClose.addEventListener("click", closeAuthModal);
|
||||||
if (el.authModal) {
|
if (el.authModal) {
|
||||||
@@ -914,6 +1009,9 @@
|
|||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => s.trim().toLowerCase())
|
.map((s) => s.trim().toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
const connGoogle = el.authConnGoogle ? String(el.authConnGoogle.value || "").trim() : "";
|
||||||
|
const connKakao = el.authConnKakao ? String(el.authConnKakao.value || "").trim() : "";
|
||||||
|
const connNaver = el.authConnNaver ? String(el.authConnNaver.value || "").trim() : "";
|
||||||
|
|
||||||
if (!domain || !clientId) {
|
if (!domain || !clientId) {
|
||||||
toast("Domain과 Client ID를 입력하세요.");
|
toast("Domain과 Client ID를 입력하세요.");
|
||||||
@@ -922,6 +1020,7 @@
|
|||||||
|
|
||||||
saveAuthOverride({
|
saveAuthOverride({
|
||||||
auth0: { domain, clientId },
|
auth0: { domain, clientId },
|
||||||
|
connections: { google: connGoogle, kakao: connKakao, naver: connNaver },
|
||||||
allowedEmails: emails,
|
allowedEmails: emails,
|
||||||
});
|
});
|
||||||
toast("저장했습니다. 페이지를 새로고침합니다.");
|
toast("저장했습니다. 페이지를 새로고침합니다.");
|
||||||
|
|||||||
128
server.js
Normal file
128
server.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import express from "express";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import pg from "pg";
|
||||||
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
function env(name, fallback = "") {
|
||||||
|
return (process.env[name] ?? fallback).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function must(name) {
|
||||||
|
const v = env(name).trim();
|
||||||
|
if (!v) throw new Error(`Missing env: ${name}`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeIdent(s) {
|
||||||
|
const v = String(s || "").trim();
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(v)) throw new Error("Invalid TABLE identifier");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = Number(env("PORT", "8000")) || 8000;
|
||||||
|
const DB_HOST = must("DB_HOST");
|
||||||
|
const DB_PORT = Number(env("DB_PORT", "5432")) || 5432;
|
||||||
|
const DB_NAME = must("DB_NAME");
|
||||||
|
const DB_USER = must("DB_USER");
|
||||||
|
const DB_PASSWORD = must("DB_PASSWORD");
|
||||||
|
const TABLE = safeIdent(env("TABLE", "ncue_user") || "ncue_user");
|
||||||
|
|
||||||
|
const pool = new pg.Pool({
|
||||||
|
host: DB_HOST,
|
||||||
|
port: DB_PORT,
|
||||||
|
database: DB_NAME,
|
||||||
|
user: DB_USER,
|
||||||
|
password: DB_PASSWORD,
|
||||||
|
ssl: false,
|
||||||
|
max: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: false, // keep simple for static + Auth0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(express.json({ limit: "256kb" }));
|
||||||
|
|
||||||
|
app.get("/healthz", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query("select 1 as ok");
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getBearer(req) {
|
||||||
|
const h = req.headers.authorization || "";
|
||||||
|
const m = /^Bearer\s+(.+)$/i.exec(h);
|
||||||
|
return m ? m[1].trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyIdToken(idToken, { issuer, audience }) {
|
||||||
|
const jwks = createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`));
|
||||||
|
const { payload } = await jwtVerify(idToken, jwks, {
|
||||||
|
issuer,
|
||||||
|
audience,
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("/api/auth/sync", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const idToken = getBearer(req);
|
||||||
|
if (!idToken) return res.status(401).json({ ok: false, error: "missing_token" });
|
||||||
|
|
||||||
|
const issuer = String(req.headers["x-auth0-issuer"] || "").trim();
|
||||||
|
const audience = String(req.headers["x-auth0-clientid"] || "").trim();
|
||||||
|
if (!issuer || !audience) return res.status(400).json({ ok: false, error: "missing_auth0_headers" });
|
||||||
|
|
||||||
|
const payload = await verifyIdToken(idToken, { issuer, audience });
|
||||||
|
|
||||||
|
const sub = String(payload.sub || "").trim();
|
||||||
|
const email = payload.email ? String(payload.email).trim().toLowerCase() : null;
|
||||||
|
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 q = `
|
||||||
|
insert into public.${TABLE}
|
||||||
|
(sub, email, name, picture, provider, last_login_at, updated_at)
|
||||||
|
values
|
||||||
|
($1, $2, $3, $4, $5, now(), now())
|
||||||
|
on conflict (sub) do update set
|
||||||
|
email = excluded.email,
|
||||||
|
name = excluded.name,
|
||||||
|
picture = excluded.picture,
|
||||||
|
provider = excluded.provider,
|
||||||
|
last_login_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
returning can_manage
|
||||||
|
`;
|
||||||
|
const r = await pool.query(q, [sub, email, name, picture, provider]);
|
||||||
|
const canManage = Boolean(r.rows?.[0]?.can_manage);
|
||||||
|
|
||||||
|
res.json({ ok: true, canManage });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(401).json({ ok: false, error: "verify_failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static site
|
||||||
|
app.use(express.static(__dirname, { extensions: ["html"] }));
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`listening on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user