Spaces:
Running
Running
Notion iframe용 팝업 로그인: 암호화 handoff
Browse files- README.md +6 -0
- index.html +80 -13
README.md
CHANGED
|
@@ -25,6 +25,12 @@ hf_oauth_authorized_org: CJLabKIST
|
|
| 25 |
4. 1:1 DM — 대화 ID는 `dm__<작은이름>__<큰이름>`으로 항상 같음
|
| 26 |
5. 메시지를 private Storage Bucket `CJLabKIST/messenger-probe`에 저장, 새로고침 뒤에도 유지
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
저장 방식: 메시지 1개 = 파일 1개 (`messages/<대화ID>/<시각>__<보낸사람>__<id>.json`).
|
| 29 |
읽고-고쳐-쓰기가 없어서 동시에 보내도 서로 덮어쓰지 않습니다.
|
| 30 |
|
|
|
|
| 25 |
4. 1:1 DM — 대화 ID는 `dm__<작은이름>__<큰이름>`으로 항상 같음
|
| 26 |
5. 메시지를 private Storage Bucket `CJLabKIST/messenger-probe`에 저장, 새로고침 뒤에도 유지
|
| 27 |
|
| 28 |
+
Notion 임베드: Notion에는 `https://cjlabkist-messenger-probe.static.hf.space/?embed=1`을 넣습니다.
|
| 29 |
+
`huggingface.co/spaces/...` 주소는 `X-Frame-Options: DENY`라서 임베드되지 않습니다.
|
| 30 |
+
HF 로그인 화면은 iframe 안에서 열리지 않고(`X-Frame-Options: SAMEORIGIN`), HF 페이지의 `COOP: same-origin` 때문에
|
| 31 |
+
팝업이 원래 창과의 연결을 잃습니다. 그래서 팝업 로그인 결과를 **암호화해서 public Bucket `CJLabKIST/messenger-handoff`에 잠깐 두고**
|
| 32 |
+
iframe이 가져간 뒤 지웁니다. 암호 키는 URL `#` 뒤에만 있어 서버로 가지 않습니다.
|
| 33 |
+
|
| 34 |
저장 방식: 메시지 1개 = 파일 1개 (`messages/<대화ID>/<시각>__<보낸사람>__<id>.json`).
|
| 35 |
읽고-고쳐-쓰기가 없어서 동시에 보내도 서로 덮어쓰지 않습니다.
|
| 36 |
|
index.html
CHANGED
|
@@ -206,6 +206,7 @@ import { oauthLoginUrl, oauthHandleRedirectIfPresent, uploadFiles } from "https:
|
|
| 206 |
const CFG = {
|
| 207 |
ORG: "CJLabKIST",
|
| 208 |
BUCKET: "CJLabKIST/messenger-probe",
|
|
|
|
| 209 |
HUB: "https://huggingface.co",
|
| 210 |
POLL_CHAT_MS: 4000,
|
| 211 |
POLL_LIST_MS: 15000,
|
|
@@ -268,10 +269,63 @@ const Bucket = {
|
|
| 268 |
},
|
| 269 |
};
|
| 270 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
// ───────────────────────────── auth
|
| 272 |
-
//
|
| 273 |
-
//
|
| 274 |
-
//
|
| 275 |
async function boot() {
|
| 276 |
const vars = window.huggingface?.variables || {};
|
| 277 |
diag("iframe", true, IN_IFRAME ? "iframe 안 (embed)" + (document.referrer ? " · 부모: " + new URL(document.referrer).host : "") : "최상위 창");
|
|
@@ -286,13 +340,20 @@ async function boot() {
|
|
| 286 |
const payload = { type: "tm-oauth", token: res.accessToken, exp: +new Date(res.accessTokenExpiresAt), orgs: res.userInfo.orgs || [] };
|
| 287 |
if (res.state === "popup") {
|
| 288 |
show("popupView");
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
} else {
|
| 294 |
$("#popupTitle").textContent = "로그인은 됐지만 원래 화면으로 돌려보내지 못했습니다";
|
| 295 |
-
$("#popupMsg").textContent = "
|
| 296 |
}
|
| 297 |
return;
|
| 298 |
}
|
|
@@ -302,6 +363,8 @@ async function boot() {
|
|
| 302 |
// 2) popup opened by the iframe: go straight to HF authorize (must be top-level)
|
| 303 |
if (params.get("popup") === "1") {
|
| 304 |
show("popupView");
|
|
|
|
|
|
|
| 305 |
try { location.href = await oauthLoginUrl({ scopes: vars.OAUTH_SCOPES, state: "popup", redirectUrl: location.origin + location.pathname }); }
|
| 306 |
catch (e) { $("#popupTitle").textContent = "로그인 시작 실패"; $("#popupMsg").textContent = e.message; }
|
| 307 |
return;
|
|
@@ -317,7 +380,8 @@ function showLogin(msg) {
|
|
| 317 |
show("loginView");
|
| 318 |
const has = !!window.huggingface?.variables?.OAUTH_CLIENT_ID;
|
| 319 |
$("#btnPopup").disabled = !has; $("#btnRedirect").disabled = !has;
|
| 320 |
-
$("#
|
|
|
|
| 321 |
if (msg) $("#loginErr").textContent = msg;
|
| 322 |
}
|
| 323 |
window.addEventListener("message", (ev) => {
|
|
@@ -326,10 +390,13 @@ window.addEventListener("message", (ev) => {
|
|
| 326 |
acceptLogin(ev.data.token, ev.data.exp, "oauth-popup", ev.data.orgs);
|
| 327 |
});
|
| 328 |
$("#btnPopup").onclick = () => {
|
|
|
|
| 329 |
// open synchronously inside the click handler so popup blockers allow it
|
| 330 |
-
const w = window.open(location.origin + location.pathname + "?popup=1", "tm-login", "width=520,height=720");
|
| 331 |
-
if (!w) { diag("popup", false, "팝업이 차단됨"); $("#loginErr").textContent = "팝업이 차단됐습니다.
|
| 332 |
-
|
|
|
|
|
|
|
| 333 |
};
|
| 334 |
$("#btnRedirect").onclick = async () => {
|
| 335 |
try { location.href = await oauthLoginUrl({ scopes: window.huggingface.variables.OAUTH_SCOPES, state: "redirect", redirectUrl: location.origin + location.pathname }); }
|
|
@@ -563,7 +630,7 @@ async function send() {
|
|
| 563 |
// ───────────────────────────── Settings · 진단 (feasibility checklist)
|
| 564 |
const CHECKS = [
|
| 565 |
["iframe", "실행 위치"], ["storage", "브라우저 저장소"], ["oauthcfg", "OAuth 설정"], ["popup", "팝업 로그인"],
|
| 566 |
-
["login", "로그인"], ["org", "조직 멤버 확인"], ["members", "팀원 목록"], ["bucketList", "Bucket 읽기"],
|
| 567 |
["bucketWrite", "Bucket 쓰기"], ["persist", "새로고침 후 유지"],
|
| 568 |
];
|
| 569 |
async function checkPersist() {
|
|
|
|
| 206 |
const CFG = {
|
| 207 |
ORG: "CJLabKIST",
|
| 208 |
BUCKET: "CJLabKIST/messenger-probe",
|
| 209 |
+
HANDOFF_BUCKET: "CJLabKIST/messenger-handoff", // public; holds only AES-encrypted, short-lived login handoffs
|
| 210 |
HUB: "https://huggingface.co",
|
| 211 |
POLL_CHAT_MS: 4000,
|
| 212 |
POLL_LIST_MS: 15000,
|
|
|
|
| 269 |
},
|
| 270 |
};
|
| 271 |
|
| 272 |
+
// ───────────────────────────── login handoff (iframe ⇄ popup)
|
| 273 |
+
// HF pages send COOP: same-origin, so a popup opened from the Notion iframe loses window.opener,
|
| 274 |
+
// and partitioned storage means the popup and the iframe share nothing. Bridge them through HF itself:
|
| 275 |
+
// the iframe makes a random key K and passes it in the URL fragment (never sent to any server);
|
| 276 |
+
// the popup logs in, encrypts the token with K and drops it in a public bucket under sha256(K);
|
| 277 |
+
// the iframe polls that path, decrypts, then deletes the file.
|
| 278 |
+
const b64u = {
|
| 279 |
+
enc: (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""),
|
| 280 |
+
dec: (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4)), (c) => c.charCodeAt(0)),
|
| 281 |
+
};
|
| 282 |
+
async function sha256hex(s) { const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return [...new Uint8Array(h)].map((b) => b.toString(16).padStart(2, "0")).join(""); }
|
| 283 |
+
const aesKey = (k) => crypto.subtle.importKey("raw", b64u.dec(k), "AES-GCM", false, ["encrypt", "decrypt"]);
|
| 284 |
+
async function seal(k, obj) {
|
| 285 |
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
| 286 |
+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await aesKey(k), new TextEncoder().encode(JSON.stringify(obj)));
|
| 287 |
+
return { iv: b64u.enc(iv), ct: b64u.enc(ct) };
|
| 288 |
+
}
|
| 289 |
+
async function unseal(k, box) {
|
| 290 |
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64u.dec(box.iv) }, await aesKey(k), b64u.dec(box.ct));
|
| 291 |
+
return JSON.parse(new TextDecoder().decode(pt));
|
| 292 |
+
}
|
| 293 |
+
const handoffPath = async (k) => `handoff/${await sha256hex(k)}.json`;
|
| 294 |
+
async function handoffPut(k, payload) {
|
| 295 |
+
await uploadFiles({
|
| 296 |
+
repo: { type: "bucket", name: CFG.HANDOFF_BUCKET }, accessToken: payload.token, useWebWorkers: false,
|
| 297 |
+
files: [{ path: await handoffPath(k), content: new Blob([JSON.stringify(await seal(k, payload))], { type: "application/json" }) }],
|
| 298 |
+
});
|
| 299 |
+
}
|
| 300 |
+
async function handoffDelete(path, token) {
|
| 301 |
+
await fetch(`${CFG.HUB}/api/buckets/${CFG.HANDOFF_BUCKET}/batch`, {
|
| 302 |
+
method: "POST", headers: { Authorization: "Bearer " + token, "Content-Type": "application/x-ndjson" },
|
| 303 |
+
body: JSON.stringify({ type: "deleteFile", path }) + "\n",
|
| 304 |
+
});
|
| 305 |
+
}
|
| 306 |
+
async function handoffPoll(k) {
|
| 307 |
+
const path = await handoffPath(k), until = Date.now() + 180000;
|
| 308 |
+
while (Date.now() < until && !state.me) {
|
| 309 |
+
await new Promise((r) => setTimeout(r, 1500));
|
| 310 |
+
let r;
|
| 311 |
+
try { r = await fetch(`${CFG.HUB}/buckets/${CFG.HANDOFF_BUCKET}/resolve/${path}?t=${Date.now()}`, { cache: "no-store" }); } catch { continue; }
|
| 312 |
+
if (r.status === 404) continue;
|
| 313 |
+
if (!r.ok) { diag("popup", false, "handoff 읽기 실패: HTTP " + r.status); return; }
|
| 314 |
+
try {
|
| 315 |
+
const p = await unseal(k, await r.json());
|
| 316 |
+
diag("popup", true, "팝업 로그인 → 암호화 handoff(Bucket)로 토큰 받음");
|
| 317 |
+
handoffDelete(path, p.token).then(() => diag("handoffDel", true, "handoff 파일 삭제됨"), (e) => diag("handoffDel", false, e.message));
|
| 318 |
+
await acceptLogin(p.token, p.exp, "oauth-popup", p.orgs);
|
| 319 |
+
} catch (e) { diag("popup", false, "handoff 복호화 실패: " + e.message); }
|
| 320 |
+
return;
|
| 321 |
+
}
|
| 322 |
+
if (!state.me) { diag("popup", false, "3분 안에 팝업 로그인 결과가 오지 않음"); $("#loginErr").textContent = "팝업 로그인 결과를 받지 못했습니다. 다시 시도하거나 토큰으로 로그인해 주세요."; }
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
// ───────────────────────────── auth
|
| 326 |
+
// huggingface.co login pages send X-Frame-Options: SAMEORIGIN, so inside any iframe (Notion, the
|
| 327 |
+
// HF Space page) login must happen in a top-level popup; the token comes back via postMessage when
|
| 328 |
+
// window.opener survives, otherwise via the encrypted bucket handoff above.
|
| 329 |
async function boot() {
|
| 330 |
const vars = window.huggingface?.variables || {};
|
| 331 |
diag("iframe", true, IN_IFRAME ? "iframe 안 (embed)" + (document.referrer ? " · 부모: " + new URL(document.referrer).host : "") : "최상위 창");
|
|
|
|
| 340 |
const payload = { type: "tm-oauth", token: res.accessToken, exp: +new Date(res.accessTokenExpiresAt), orgs: res.userInfo.orgs || [] };
|
| 341 |
if (res.state === "popup") {
|
| 342 |
show("popupView");
|
| 343 |
+
const k = store.get("handoffK"); store.del("handoffK");
|
| 344 |
+
const via = [];
|
| 345 |
+
if (window.opener) { try { window.opener.postMessage(payload, location.origin); via.push("postMessage"); } catch (e) {} }
|
| 346 |
+
if (k) {
|
| 347 |
+
$("#popupMsg").textContent = "원래 화면으로 로그인 정보를 보내는 중…";
|
| 348 |
+
try { await handoffPut(k, payload); via.push("암호화 handoff"); } catch (e) { $("#popupMsg").textContent = "handoff 저장 실패: " + e.message; }
|
| 349 |
+
}
|
| 350 |
+
if (via.length) {
|
| 351 |
+
$("#popupTitle").textContent = "로그인 완료";
|
| 352 |
+
$("#popupMsg").textContent = `원래 화면으로 돌아가세요 (${via.join(" + ")}). 이 창은 곧 닫힙니다.`;
|
| 353 |
+
setTimeout(() => window.close(), 1500);
|
| 354 |
} else {
|
| 355 |
$("#popupTitle").textContent = "로그인은 됐지만 원래 화면으로 돌려보내지 못했습니다";
|
| 356 |
+
$("#popupMsg").textContent = "window.opener도 handoff 키도 없습니다. 이 창을 닫고 토큰 로그인을 써 주세요.";
|
| 357 |
}
|
| 358 |
return;
|
| 359 |
}
|
|
|
|
| 363 |
// 2) popup opened by the iframe: go straight to HF authorize (must be top-level)
|
| 364 |
if (params.get("popup") === "1") {
|
| 365 |
show("popupView");
|
| 366 |
+
const k = new URLSearchParams(location.hash.slice(1)).get("k");
|
| 367 |
+
if (k) store.set("handoffK", k);
|
| 368 |
try { location.href = await oauthLoginUrl({ scopes: vars.OAUTH_SCOPES, state: "popup", redirectUrl: location.origin + location.pathname }); }
|
| 369 |
catch (e) { $("#popupTitle").textContent = "로그인 시작 실패"; $("#popupMsg").textContent = e.message; }
|
| 370 |
return;
|
|
|
|
| 380 |
show("loginView");
|
| 381 |
const has = !!window.huggingface?.variables?.OAUTH_CLIENT_ID;
|
| 382 |
$("#btnPopup").disabled = !has; $("#btnRedirect").disabled = !has;
|
| 383 |
+
$("#btnRedirect").classList.toggle("hidden", IN_IFRAME); // HF login page refuses to render inside iframes (X-Frame-Options)
|
| 384 |
+
$("#loginNote").textContent = IN_IFRAME ? "다른 페이지(Notion, HF Space 화면) 안에서는 로그인이 팝업 창으로 열립니다." : "";
|
| 385 |
if (msg) $("#loginErr").textContent = msg;
|
| 386 |
}
|
| 387 |
window.addEventListener("message", (ev) => {
|
|
|
|
| 390 |
acceptLogin(ev.data.token, ev.data.exp, "oauth-popup", ev.data.orgs);
|
| 391 |
});
|
| 392 |
$("#btnPopup").onclick = () => {
|
| 393 |
+
const k = b64u.enc(crypto.getRandomValues(new Uint8Array(32)));
|
| 394 |
// open synchronously inside the click handler so popup blockers allow it
|
| 395 |
+
const w = window.open(location.origin + location.pathname + "?popup=1#k=" + k, "tm-login", "width=520,height=720");
|
| 396 |
+
if (!w) { diag("popup", false, "팝업이 차단됨"); $("#loginErr").textContent = "팝업이 차단됐습니다. 팝업을 허용하거나 토큰으로 로그인해 주세요."; return; }
|
| 397 |
+
diag("popup", null, "팝업 열림 · 응답 기다리는 중");
|
| 398 |
+
$("#loginErr").textContent = ""; $("#loginNote").textContent = "팝업 창에서 로그인을 마치면 여기로 자동으로 들어옵니다…";
|
| 399 |
+
handoffPoll(k);
|
| 400 |
};
|
| 401 |
$("#btnRedirect").onclick = async () => {
|
| 402 |
try { location.href = await oauthLoginUrl({ scopes: window.huggingface.variables.OAUTH_SCOPES, state: "redirect", redirectUrl: location.origin + location.pathname }); }
|
|
|
|
| 630 |
// ───────────────────────────── Settings · 진단 (feasibility checklist)
|
| 631 |
const CHECKS = [
|
| 632 |
["iframe", "실행 위치"], ["storage", "브라우저 저장소"], ["oauthcfg", "OAuth 설정"], ["popup", "팝업 로그인"],
|
| 633 |
+
["handoffDel", "handoff 정리"], ["login", "로그인"], ["org", "조직 멤버 확인"], ["members", "팀원 목록"], ["bucketList", "Bucket 읽기"],
|
| 634 |
["bucketWrite", "Bucket 쓰기"], ["persist", "새로고침 후 유지"],
|
| 635 |
];
|
| 636 |
async function checkPersist() {
|