HannesVonEssen commited on
Commit
7c19c2c
·
verified ·
1 Parent(s): b35e2da

Fix partition-safe native HF login handoff

Browse files
Files changed (1) hide show
  1. space-auth.js +169 -63
space-auth.js CHANGED
@@ -4,11 +4,19 @@ import {
4
  } from "@huggingface/hub";
5
 
6
  const STORAGE_KEY = "hfviewer:space-native-oauth:v1";
 
 
 
 
 
 
 
7
  const authHost = document.getElementById("hfnx-auth");
8
 
9
  let oauthResult = readStoredResult();
10
  let loginUrl = "";
11
  let rendering = false;
 
12
 
13
  function readStoredResult() {
14
  try {
@@ -24,11 +32,16 @@ function writeStoredResult(value) {
24
  if (value) localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
25
  else localStorage.removeItem(STORAGE_KEY);
26
  } catch {
27
- // Storage can be unavailable in privacy-focused browsers. The callback
28
- // tab still remains signed in for the current render.
29
  }
30
  }
31
 
 
 
 
 
 
 
32
  function userFromResult(result) {
33
  const raw = result?.userInfo || result?.userinfo || result?.user_info || result?.user || {};
34
  const username = String(
@@ -50,7 +63,6 @@ function trackAuthEvent(eventName, properties = {}) {
50
  window.hfviewerAnalyticsBridge?.capture?.(eventName, {
51
  product_surface: "hf_space",
52
  space_repo: "embedl/hfviewer",
53
- space_revision: "hf-space-2e4848474064",
54
  auth_provider: "hugging_face",
55
  ...properties,
56
  });
@@ -106,20 +118,14 @@ function makeLoggedOutControl() {
106
  const link = document.createElement("a");
107
  link.dataset.spaceNativeAuth = "login";
108
  link.className = "space-native-auth-login";
109
- link.textContent = "Log in";
110
  link.title = "Sign in with Hugging Face";
111
- link.target = "_blank";
112
- link.rel = "noopener";
113
- link.href = loginUrl || "#";
114
- if (!loginUrl) link.setAttribute("aria-disabled", "true");
115
  link.addEventListener("click", (event) => {
116
- if (!loginUrl) {
117
- event.preventDefault();
118
- return;
119
- }
120
- trackAuthEvent("space_hf_login_clicked", {
121
- component: "space_nav",
122
- });
123
  });
124
  return link;
125
  }
@@ -150,11 +156,7 @@ function makeLoggedInControl(user) {
150
  profile.rel = "noopener";
151
  profile.textContent = "Hugging Face profile";
152
  profile.setAttribute("role", "menuitem");
153
- profile.addEventListener("click", () => {
154
- trackAuthEvent("space_hf_profile_opened", {
155
- component: "space_nav",
156
- });
157
- });
158
 
159
  const logout = document.createElement("button");
160
  logout.type = "button";
@@ -162,9 +164,7 @@ function makeLoggedInControl(user) {
162
  logout.textContent = "Log out";
163
  logout.setAttribute("role", "menuitem");
164
  logout.addEventListener("click", () => {
165
- trackAuthEvent("space_hf_logout", {
166
- component: "space_nav",
167
- });
168
  oauthResult = null;
169
  writeStoredResult(null);
170
  renderAuth();
@@ -194,60 +194,161 @@ function renderAuth() {
194
  queueMicrotask(() => { rendering = false; });
195
  }
196
 
197
- function cleanCallbackQuery() {
198
- try {
199
- const url = new URL(window.location.href);
200
- let changed = false;
201
- for (const key of ["code", "state", "scope", "error", "error_description"]) {
202
- if (url.searchParams.has(key)) {
203
- url.searchParams.delete(key);
204
- changed = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  }
 
 
 
206
  }
207
- if (changed) history.replaceState(null, "", url.toString());
208
- } catch {
209
- // Cosmetic cleanup only.
210
  }
 
211
  }
212
 
213
- async function initialize() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  renderAuth();
215
- identifyAuthenticatedUser(userFromResult(oauthResult), "hf_space_native_oauth_restored");
216
  try {
217
- const callbackResult = await oauthHandleRedirectIfPresent();
218
- if (callbackResult) {
219
- oauthResult = callbackResult;
220
- writeStoredResult(callbackResult);
221
- cleanCallbackQuery();
222
- renderAuth();
223
- const user = userFromResult(callbackResult);
224
- identifyAuthenticatedUser(user, "hf_space_native_oauth_callback");
225
- trackAuthEvent("space_hf_login_completed", {
226
- component: "space_nav",
227
- username_present: !!user?.username,
228
- subject_present: !!user?.subject,
229
- email_present: !!user?.email,
230
- });
231
- }
232
  } catch (error) {
233
- console.warn("Hugging Face Space login callback failed", error);
234
- trackAuthEvent("space_hf_login_failed", {
235
- component: "space_nav",
236
- failure_stage: "oauth_callback",
237
- error_name: String(error?.name || "Error").slice(0, 80),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  });
 
 
 
 
 
 
 
 
239
  }
 
240
 
 
 
 
241
  try {
242
  const scopes = window.huggingface?.variables?.OAUTH_SCOPES;
243
  loginUrl = await oauthLoginUrl(scopes ? { scopes } : undefined);
244
  } catch (error) {
245
- console.warn("Could not prepare Hugging Face Space login", error);
246
- trackAuthEvent("space_hf_login_failed", {
247
- component: "space_nav",
248
- failure_stage: "login_url",
249
- error_name: String(error?.name || "Error").slice(0, 80),
250
- });
251
  }
252
  renderAuth();
253
  }
@@ -265,4 +366,9 @@ window.addEventListener("storage", (event) => {
265
  renderAuth();
266
  });
267
 
268
- initialize();
 
 
 
 
 
 
4
  } from "@huggingface/hub";
5
 
6
  const STORAGE_KEY = "hfviewer:space-native-oauth:v1";
7
+ const HF_NONCE_KEY = "huggingface.co:oauth:nonce";
8
+ const HF_VERIFIER_KEY = "huggingface.co:oauth:code_verifier";
9
+ const POPUP_FLOW_KEY = "hfviewer:space-oauth-popup-flow:v1";
10
+ const COMPLETE_URL = "https://hfviewer.com/api/hf_space_oauth/complete";
11
+ const CONSUME_URL = "https://hfviewer.com/api/hf_space_oauth/consume";
12
+ const POLL_INTERVAL_MS = 800;
13
+ const FLOW_TIMEOUT_MS = 5 * 60 * 1000;
14
  const authHost = document.getElementById("hfnx-auth");
15
 
16
  let oauthResult = readStoredResult();
17
  let loginUrl = "";
18
  let rendering = false;
19
+ let activeFlow = null;
20
 
21
  function readStoredResult() {
22
  try {
 
32
  if (value) localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
33
  else localStorage.removeItem(STORAGE_KEY);
34
  } catch {
35
+ // Keep the current-tab session even when durable storage is unavailable.
 
36
  }
37
  }
38
 
39
+ function randomFlowValue() {
40
+ const bytes = new Uint8Array(32);
41
+ crypto.getRandomValues(bytes);
42
+ return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
43
+ }
44
+
45
  function userFromResult(result) {
46
  const raw = result?.userInfo || result?.userinfo || result?.user_info || result?.user || {};
47
  const username = String(
 
63
  window.hfviewerAnalyticsBridge?.capture?.(eventName, {
64
  product_surface: "hf_space",
65
  space_repo: "embedl/hfviewer",
 
66
  auth_provider: "hugging_face",
67
  ...properties,
68
  });
 
118
  const link = document.createElement("a");
119
  link.dataset.spaceNativeAuth = "login";
120
  link.className = "space-native-auth-login";
121
+ link.textContent = activeFlow ? "Signing in…" : "Log in";
122
  link.title = "Sign in with Hugging Face";
123
+ link.href = "#";
124
+ link.setAttribute("aria-disabled", loginUrl && !activeFlow ? "false" : "true");
 
 
125
  link.addEventListener("click", (event) => {
126
+ event.preventDefault();
127
+ if (!loginUrl || activeFlow) return;
128
+ startLogin();
 
 
 
 
129
  });
130
  return link;
131
  }
 
156
  profile.rel = "noopener";
157
  profile.textContent = "Hugging Face profile";
158
  profile.setAttribute("role", "menuitem");
159
+ profile.addEventListener("click", () => trackAuthEvent("space_hf_profile_opened"));
 
 
 
 
160
 
161
  const logout = document.createElement("button");
162
  logout.type = "button";
 
164
  logout.textContent = "Log out";
165
  logout.setAttribute("role", "menuitem");
166
  logout.addEventListener("click", () => {
167
+ trackAuthEvent("space_hf_logout");
 
 
168
  oauthResult = null;
169
  writeStoredResult(null);
170
  renderAuth();
 
194
  queueMicrotask(() => { rendering = false; });
195
  }
196
 
197
+ function applyOauthResult(callbackResult, source) {
198
+ oauthResult = callbackResult;
199
+ writeStoredResult(callbackResult);
200
+ activeFlow = null;
201
+ renderAuth();
202
+ const user = userFromResult(callbackResult);
203
+ identifyAuthenticatedUser(user, source);
204
+ trackAuthEvent("space_hf_login_completed", {
205
+ component: "space_nav",
206
+ username_present: !!user?.username,
207
+ subject_present: !!user?.subject,
208
+ email_present: !!user?.email,
209
+ });
210
+ }
211
+
212
+ function failLogin(error, failureStage) {
213
+ console.warn("Hugging Face Space login failed", error);
214
+ activeFlow = null;
215
+ renderAuth();
216
+ trackAuthEvent("space_hf_login_failed", {
217
+ component: "space_nav",
218
+ failure_stage: failureStage,
219
+ error_name: String(error?.name || "Error").slice(0, 80),
220
+ });
221
+ }
222
+
223
+ async function postJson(url, body) {
224
+ const response = await fetch(url, {
225
+ method: "POST",
226
+ mode: "cors",
227
+ credentials: "omit",
228
+ headers: { "content-type": "application/json" },
229
+ body: JSON.stringify(body),
230
+ });
231
+ const payload = await response.json().catch(() => ({}));
232
+ if (!response.ok && response.status !== 202) {
233
+ throw new Error(payload?.error || `Request failed (${response.status})`);
234
+ }
235
+ return payload;
236
+ }
237
+
238
+ async function pollForCompletion(flow) {
239
+ while (activeFlow === flow && Date.now() - flow.startedAt < FLOW_TIMEOUT_MS) {
240
+ try {
241
+ const result = await postJson(CONSUME_URL, {
242
+ flow_id: flow.id,
243
+ flow_secret: flow.secret,
244
+ });
245
+ if (result?.status === "complete" && userFromResult(result)) {
246
+ applyOauthResult({ userInfo: result.userInfo }, "hf_space_native_oauth_handoff");
247
+ return;
248
  }
249
+ } catch (error) {
250
+ failLogin(error, "handoff_consume");
251
+ return;
252
  }
253
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
 
 
254
  }
255
+ if (activeFlow === flow) failLogin(new Error("OAuth flow timed out"), "handoff_timeout");
256
  }
257
 
258
+ function startLogin() {
259
+ const nonce = localStorage.getItem(HF_NONCE_KEY);
260
+ const codeVerifier = localStorage.getItem(HF_VERIFIER_KEY);
261
+ if (!nonce || !codeVerifier) {
262
+ failLogin(new Error("OAuth PKCE state is unavailable"), "pkce_prepare");
263
+ return;
264
+ }
265
+ const flow = {
266
+ id: randomFlowValue(),
267
+ secret: randomFlowValue(),
268
+ startedAt: Date.now(),
269
+ };
270
+ const popup = window.open(
271
+ "about:blank",
272
+ "_blank",
273
+ "popup=yes,width=720,height=780,resizable=yes,scrollbars=yes"
274
+ );
275
+ if (!popup) {
276
+ failLogin(new Error("The sign-in popup was blocked"), "popup_blocked");
277
+ return;
278
+ }
279
+ activeFlow = flow;
280
  renderAuth();
281
+ trackAuthEvent("space_hf_login_clicked", { component: "space_nav" });
282
  try {
283
+ // The blank popup inherits this Space origin, but owns top-level (rather
284
+ // than iframe-partitioned) storage. Seed that storage synchronously before
285
+ // navigating to HF. The callback can then finish even if COOP severs opener.
286
+ popup.document.title = "Sign in with Hugging Face";
287
+ popup.document.body.textContent = "Opening Hugging Face sign-in…";
288
+ popup.localStorage.setItem(HF_NONCE_KEY, nonce);
289
+ popup.localStorage.setItem(HF_VERIFIER_KEY, codeVerifier);
290
+ popup.sessionStorage.setItem(POPUP_FLOW_KEY, JSON.stringify({
291
+ flowId: flow.id,
292
+ flowSecret: flow.secret,
293
+ }));
294
+ popup.location.replace(loginUrl);
 
 
 
295
  } catch (error) {
296
+ popup.close();
297
+ failLogin(error, "popup_initialize");
298
+ return;
299
+ }
300
+ void pollForCompletion(flow);
301
+ }
302
+
303
+ function renderPopupStatus(text) {
304
+ if (!authHost) return;
305
+ const status = document.createElement("span");
306
+ status.className = "space-native-auth-login";
307
+ status.textContent = text;
308
+ authHost.replaceChildren(status);
309
+ }
310
+
311
+ async function initializePopup() {
312
+ const callbackPresent = new URL(window.location.href).searchParams.has("code") ||
313
+ new URL(window.location.href).searchParams.has("error");
314
+ if (!callbackPresent) {
315
+ renderPopupStatus("Sign-in state expired. Close this window and try again.");
316
+ return;
317
+ }
318
+
319
+ renderPopupStatus("Completing sign-in…");
320
+ const flow = JSON.parse(sessionStorage.getItem(POPUP_FLOW_KEY) || "null");
321
+ if (!flow?.flowId || !flow?.flowSecret) {
322
+ renderPopupStatus("Sign-in state expired. Close this window and try again.");
323
+ return;
324
+ }
325
+ try {
326
+ const result = await oauthHandleRedirectIfPresent();
327
+ if (!result?.accessToken) throw new Error("Hugging Face did not return an access token");
328
+ await postJson(COMPLETE_URL, {
329
+ flow_id: flow.flowId,
330
+ flow_secret: flow.flowSecret,
331
+ access_token: result.accessToken,
332
  });
333
+ sessionStorage.removeItem(POPUP_FLOW_KEY);
334
+ // The verified identity is relayed through hfviewer; do not retain the token.
335
+ localStorage.removeItem(STORAGE_KEY);
336
+ renderPopupStatus("Signed in. You can close this window.");
337
+ window.close();
338
+ } catch (error) {
339
+ console.warn("Hugging Face Space popup callback failed", error);
340
+ renderPopupStatus("Sign-in failed. Close this window and try again.");
341
  }
342
+ }
343
 
344
+ async function initializeApp() {
345
+ renderAuth();
346
+ identifyAuthenticatedUser(userFromResult(oauthResult), "hf_space_native_oauth_restored");
347
  try {
348
  const scopes = window.huggingface?.variables?.OAUTH_SCOPES;
349
  loginUrl = await oauthLoginUrl(scopes ? { scopes } : undefined);
350
  } catch (error) {
351
+ failLogin(error, "login_url");
 
 
 
 
 
352
  }
353
  renderAuth();
354
  }
 
366
  renderAuth();
367
  });
368
 
369
+ if (new URL(window.location.href).searchParams.has("code") ||
370
+ new URL(window.location.href).searchParams.has("error")) {
371
+ void initializePopup();
372
+ } else {
373
+ void initializeApp();
374
+ }