dshyang32 commited on
Commit
0dd94dc
·
verified ·
1 Parent(s): 5b5fbc3

Notion iframe용 팝업 로그인: 암호화 handoff

Browse files
Files changed (2) hide show
  1. README.md +6 -0
  2. 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
- // Popup flow exists because Notion embeds us in an iframe: huggingface.co's login page
273
- // generally can't run inside a third-party iframe, so login happens in a top-level popup
274
- // and the token is handed back to the iframe with postMessage.
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
- if (window.opener) {
290
- try { window.opener.postMessage(payload, location.origin); } catch (e) {}
291
- $("#popupTitle").textContent = "로그인 완료"; $("#popupMsg").textContent = "이 창은 곧 닫힙니다.";
292
- setTimeout(() => window.close(), 600);
 
 
 
 
 
 
 
293
  } else {
294
  $("#popupTitle").textContent = "로그인은 됐지만 원래 화면으로 돌려보내지 못했습니다";
295
- $("#popupMsg").textContent = "브라우저가 팝업과 원래 창의 연결을 끊었습니다 (window.opener 없음). 이 창을 닫고 원래 화면에서 '이 창에서 로그인'이나 토큰 로그인을 써 주세요. 진단 결과에 이 사실을 적어 주세요.";
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
- $("#loginNote").textContent = IN_IFRAME ? "Notion 등 다른 페이지 안에서는 위쪽 버튼(팝업)이 기본입니다. 안 되면 아래 방법을 차례로 써 보세요." : "";
 
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
- else diag("popup", null, "팝업 열림 · 응답 기다리는 중");
 
 
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() {