UniLat3D commited on
Commit
2266d6e
·
verified ·
1 Parent(s): 7c968bd

Revalidate the current HF account before displaying private history

Browse files

Perform fresh PKCE/OIDC checks before entry and after leaving the page, bind completed checks to partitioned cookies, clear old account content, and reject stale-tab metadata requests after account changes.

hf_space/history_bridge.py CHANGED
@@ -211,6 +211,12 @@ class AppSessions:
211
  self._revoked = {key: expiry for key, expiry in self._revoked.items() if expiry > now}
212
  self._revoked[claims['sid']] = claims.get('exp', now)
213
 
 
 
 
 
 
 
214
 
215
  class BootstrapGuards:
216
  """First-party proof that browser metadata approved the popup navigation."""
@@ -264,6 +270,7 @@ class AppSessionMiddleware:
264
  if cookie is not None:
265
  identity, claims = self.sessions.decode(cookie) if len(raw_values) <= 1 else (None, None)
266
  scope['supra_history_app_identity'] = identity
 
267
  logout = scope.get('path', '').rstrip('/') == '/logout'
268
  if logout:
269
  self.sessions.revoke(claims)
 
211
  self._revoked = {key: expiry for key, expiry in self._revoked.items() if expiry > now}
212
  self._revoked[claims['sid']] = claims.get('exp', now)
213
 
214
+ @staticmethod
215
+ def clear_cookie(response):
216
+ for suffix in ('; Partitioned', ''):
217
+ value = f'{COOKIE_NAME}=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=None{suffix}'
218
+ response.headers.append('set-cookie', value)
219
+
220
 
221
  class BootstrapGuards:
222
  """First-party proof that browser metadata approved the popup navigation."""
 
270
  if cookie is not None:
271
  identity, claims = self.sessions.decode(cookie) if len(raw_values) <= 1 else (None, None)
272
  scope['supra_history_app_identity'] = identity
273
+ scope['supra_history_app_claims'] = claims if identity else None
274
  logout = scope.get('path', '').rstrip('/') == '/logout'
275
  if logout:
276
  self.sessions.revoke(claims)
hf_space/history_oauth.py CHANGED
@@ -6,12 +6,13 @@ from dataclasses import dataclass
6
  import hmac
7
  import json
8
  import os
 
9
  import secrets
10
  import time
11
  from urllib.parse import urlsplit
12
 
13
  from fastapi import FastAPI, HTTPException, Request
14
- from itsdangerous import BadSignature, TimestampSigner
15
  from starlette.middleware.sessions import SessionMiddleware
16
  from starlette.requests import HTTPConnection
17
  from starlette.responses import HTMLResponse, JSONResponse
@@ -25,6 +26,7 @@ AUTH_PATH = '/api/history/auth'
25
  FLOW_KEY = '_supra_history_oauth_flow'
26
  ATTEMPT_KEY = '_supra_history_oauth_silent_at'
27
  FLOW_LIFETIME = 600
 
28
 
29
 
30
  @dataclass(frozen=True)
@@ -127,11 +129,14 @@ def _completion(settings: OAuthSettings, request: Request, error: str | None = N
127
  <style nonce="{nonce}">body{{background:#101010;color:#ddd;font:16px Arial,sans-serif;padding:32px}}a{{color:#f2cd70}}</style></head>
128
  <body><p>{'Account connected. You can close this window and return to Supra-Tex.' if authenticated else 'You can close this window and return to Supra-Tex.'}</p>
129
  <script nonce="{nonce}">(() => {{const message={payload};const origin={origin};if(window.parent!==window)window.parent.postMessage(message,origin);if(window.opener)window.opener.postMessage(message,origin);window.close();}})();</script></body></html>'''
130
- return HTMLResponse(html, headers={
131
  'Cache-Control': 'private, no-store', 'Pragma': 'no-cache',
132
  'Content-Security-Policy': f"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; base-uri 'none'; form-action 'none'; frame-ancestors 'self' https://huggingface.co",
133
  'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff',
134
  })
 
 
 
135
 
136
 
137
  def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None, oauth_client=None,
@@ -153,12 +158,13 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
153
  install_session_write_guard(app)
154
  bridges = bridges if bridges is not None else Bridges()
155
  sessions = sessions if sessions is not None else AppSessions(settings.client_secret)
 
156
  guards = BootstrapGuards(settings.client_secret)
157
  app.add_middleware(AppSessionMiddleware, sessions=sessions)
158
  app.state.supra_history_bridges = bridges
159
  app.state.supra_history_app_sessions = sessions
160
 
161
- async def authorize(request: Request, silent=False, bridge_id=None):
162
  # Older Gradio sessions include a full ID token/profile. Once invalid,
163
  # retaining them alongside PKCE state can overflow the browser's 4 KiB
164
  # cookie limit and lose the new authorization state.
@@ -169,7 +175,7 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
169
  previous_attempt = float(request.session.get(ATTEMPT_KEY, 0))
170
  except (ValueError, TypeError):
171
  previous_attempt = now
172
- if now - previous_attempt < 30:
173
  return _completion(settings, request, 'unavailable')
174
  request.session[ATTEMPT_KEY] = now
175
  state = secrets.token_urlsafe(32)
@@ -178,6 +184,8 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
178
  if name.startswith('_state_supra_history_'):
179
  request.session.pop(name, None)
180
  request.session[FLOW_KEY] = {'state': state, 'created_at': now, 'silent': silent,
 
 
181
  **({'bridge_id': bridge_id} if bridge_id else {})}
182
  params = {'state': state, 'nonce': nonce}
183
  if silent:
@@ -195,10 +203,31 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
195
  return _completion(settings, request, 'unavailable')
196
 
197
  @app.get(AUTH_PATH + '/start')
198
- async def start(request: Request, silent: bool = False):
199
- if optional_identity(request) is not None:
200
  return _completion(settings, request)
201
- return await authorize(request, silent)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
  @app.get(AUTH_PATH + '/popup')
204
  async def popup(request: Request):
@@ -249,6 +278,17 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
249
  async def callback(request: Request):
250
  state = request.query_params.get('state', '')
251
  flow = request.session.get(FLOW_KEY)
 
 
 
 
 
 
 
 
 
 
 
252
  valid = isinstance(flow, dict) and isinstance(flow.get('state'), str)
253
  if valid:
254
  try:
@@ -260,11 +300,13 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
260
  # here first, including denied/failed callbacks, and preserve newer flows.
261
  if not valid:
262
  return _completion(settings, request, 'unavailable')
 
263
  bridge_id = flow.get('bridge_id')
 
264
  try:
265
  token = await oauth_client.authorize_access_token(request)
266
  if not isinstance(token, dict) or not token.get('id_token') or not isinstance(token.get('userinfo'), dict):
267
- return _completion(settings, request, 'unavailable', connected=False if bridge_id else None)
268
  profile = token['userinfo']
269
  verified = {key: profile[key] for key in ('sub', 'preferred_username', 'name', 'picture', 'profile') if key in profile}
270
  candidate = {'access_token': token.get('access_token'), 'expires_at': token.get('expires_at'),
@@ -280,7 +322,7 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
280
  request.session.pop('oauth_info', None)
281
  else:
282
  request.session['oauth_info'] = old
283
- return _completion(settings, request, 'unavailable', connected=False if bridge_id else None)
284
  if bridge_id:
285
  try:
286
  expires = min(float(candidate['expires_at']), float(profile['exp']))
@@ -288,14 +330,24 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
288
  bridges.finish(bridge_id)
289
  return _completion(settings, request, 'unavailable', connected=False)
290
  claims = sessions.claims(identity, expires)
 
291
  if sessions.validate(claims) is None or not bridges.finish(bridge_id, claims):
292
  return _completion(settings, request, 'unavailable', connected=False)
293
- return _completion(settings, request, connected=True)
 
 
 
 
 
 
 
 
 
294
  except OAuthError as exc:
295
  return _completion(settings, request, getattr(exc, 'error', 'unavailable'),
296
- connected=False if bridge_id else None)
297
  except Exception:
298
- return _completion(settings, request, 'unavailable', connected=False if bridge_id else None)
299
  finally:
300
  # Errors may leave Authlib's temporary state behind; bound its cookie
301
  # size and prevent a replay without affecting an older OAuth login.
 
6
  import hmac
7
  import json
8
  import os
9
+ import re
10
  import secrets
11
  import time
12
  from urllib.parse import urlsplit
13
 
14
  from fastapi import FastAPI, HTTPException, Request
15
+ from itsdangerous import BadSignature, TimestampSigner, URLSafeTimedSerializer
16
  from starlette.middleware.sessions import SessionMiddleware
17
  from starlette.requests import HTTPConnection
18
  from starlette.responses import HTMLResponse, JSONResponse
 
26
  FLOW_KEY = '_supra_history_oauth_flow'
27
  ATTEMPT_KEY = '_supra_history_oauth_silent_at'
28
  FLOW_LIFETIME = 600
29
+ CHECK_COOKIE = '__Host-supra-history-oauth-check'
30
 
31
 
32
  @dataclass(frozen=True)
 
129
  <style nonce="{nonce}">body{{background:#101010;color:#ddd;font:16px Arial,sans-serif;padding:32px}}a{{color:#f2cd70}}</style></head>
130
  <body><p>{'Account connected. You can close this window and return to Supra-Tex.' if authenticated else 'You can close this window and return to Supra-Tex.'}</p>
131
  <script nonce="{nonce}">(() => {{const message={payload};const origin={origin};if(window.parent!==window)window.parent.postMessage(message,origin);if(window.opener)window.opener.postMessage(message,origin);window.close();}})();</script></body></html>'''
132
+ response = HTMLResponse(html, headers={
133
  'Cache-Control': 'private, no-store', 'Pragma': 'no-cache',
134
  'Content-Security-Policy': f"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; base-uri 'none'; form-action 'none'; frame-ancestors 'self' https://huggingface.co",
135
  'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff',
136
  })
137
+ if request.scope.get('supra_history_clear_check_state'):
138
+ response.headers.append('set-cookie', f'{CHECK_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=None; Partitioned')
139
+ return response
140
 
141
 
142
  def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None, oauth_client=None,
 
158
  install_session_write_guard(app)
159
  bridges = bridges if bridges is not None else Bridges()
160
  sessions = sessions if sessions is not None else AppSessions(settings.client_secret)
161
+ check_signer = URLSafeTimedSerializer(settings.client_secret, salt='supra-history-oauth-check-v1')
162
  guards = BootstrapGuards(settings.client_secret)
163
  app.add_middleware(AppSessionMiddleware, sessions=sessions)
164
  app.state.supra_history_bridges = bridges
165
  app.state.supra_history_app_sessions = sessions
166
 
167
+ async def authorize(request: Request, silent=False, bridge_id=None, refresh=False, check=None):
168
  # Older Gradio sessions include a full ID token/profile. Once invalid,
169
  # retaining them alongside PKCE state can overflow the browser's 4 KiB
170
  # cookie limit and lose the new authorization state.
 
175
  previous_attempt = float(request.session.get(ATTEMPT_KEY, 0))
176
  except (ValueError, TypeError):
177
  previous_attempt = now
178
+ if now - previous_attempt < (2 if refresh else 30):
179
  return _completion(settings, request, 'unavailable')
180
  request.session[ATTEMPT_KEY] = now
181
  state = secrets.token_urlsafe(32)
 
184
  if name.startswith('_state_supra_history_'):
185
  request.session.pop(name, None)
186
  request.session[FLOW_KEY] = {'state': state, 'created_at': now, 'silent': silent,
187
+ 'refresh': refresh,
188
+ **({'check': check} if check else {}),
189
  **({'bridge_id': bridge_id} if bridge_id else {})}
190
  params = {'state': state, 'nonce': nonce}
191
  if silent:
 
203
  return _completion(settings, request, 'unavailable')
204
 
205
  @app.get(AUTH_PATH + '/start')
206
+ async def start(request: Request, silent: bool = False, refresh: bool = False, check: str | None = None):
207
+ if not refresh and optional_identity(request) is not None:
208
  return _completion(settings, request)
209
+ if refresh:
210
+ if not check or not re.fullmatch(r'[A-Za-z0-9_-]{32,128}', check):
211
+ raise HTTPException(400, 'Invalid account verification request.')
212
+ # HF and the embedded app have separate cookies. A still-valid app
213
+ # session is not proof of the account currently signed in on HF.
214
+ request.scope['supra_history_app_identity'] = None
215
+ request.session.pop('oauth_info', None)
216
+ response = await authorize(request, silent, refresh=refresh, check=check if refresh else None)
217
+ if refresh:
218
+ sessions.clear_cookie(response)
219
+ if response.status_code == 302:
220
+ # Retain Authlib's existing PKCE/nonce state in the same browser
221
+ # partition as the app. Silent checks must also work when an
222
+ # unpartitioned Gradio session cookie is blocked by the browser.
223
+ state = request.session[FLOW_KEY]['state']
224
+ saved = {key: value for key, value in request.session.items()
225
+ if key in (FLOW_KEY, ATTEMPT_KEY, '_state_supra_history_' + state)}
226
+ value = check_signer.dumps(saved)
227
+ if len(value) > 3500:
228
+ raise HTTPException(503, 'Account verification could not start. Please sign in again.')
229
+ response.headers.append('set-cookie', f'{CHECK_COOKIE}={value}; Path=/; Max-Age={FLOW_LIFETIME}; HttpOnly; Secure; SameSite=None; Partitioned')
230
+ return response
231
 
232
  @app.get(AUTH_PATH + '/popup')
233
  async def popup(request: Request):
 
278
  async def callback(request: Request):
279
  state = request.query_params.get('state', '')
280
  flow = request.session.get(FLOW_KEY)
281
+ if not isinstance(flow, dict) or flow.get('state') != state:
282
+ try:
283
+ saved = check_signer.loads(request.cookies.get(CHECK_COOKIE, ''), max_age=FLOW_LIFETIME)
284
+ candidate = saved.get(FLOW_KEY) if isinstance(saved, dict) else None
285
+ if (isinstance(candidate, dict) and candidate.get('refresh') is True
286
+ and isinstance(candidate.get('state'), str) and len(state) < 256
287
+ and hmac.compare_digest(state, candidate['state'])):
288
+ request.session.update(saved)
289
+ flow = candidate
290
+ except (BadSignature, ValueError, TypeError):
291
+ pass
292
  valid = isinstance(flow, dict) and isinstance(flow.get('state'), str)
293
  if valid:
294
  try:
 
300
  # here first, including denied/failed callbacks, and preserve newer flows.
301
  if not valid:
302
  return _completion(settings, request, 'unavailable')
303
+ request.scope['supra_history_clear_check_state'] = bool(flow.get('refresh'))
304
  bridge_id = flow.get('bridge_id')
305
+ fresh = bool(bridge_id or flow.get('refresh'))
306
  try:
307
  token = await oauth_client.authorize_access_token(request)
308
  if not isinstance(token, dict) or not token.get('id_token') or not isinstance(token.get('userinfo'), dict):
309
+ return _completion(settings, request, 'unavailable', connected=False if fresh else None)
310
  profile = token['userinfo']
311
  verified = {key: profile[key] for key in ('sub', 'preferred_username', 'name', 'picture', 'profile') if key in profile}
312
  candidate = {'access_token': token.get('access_token'), 'expires_at': token.get('expires_at'),
 
322
  request.session.pop('oauth_info', None)
323
  else:
324
  request.session['oauth_info'] = old
325
+ return _completion(settings, request, 'unavailable', connected=False if fresh else None)
326
  if bridge_id:
327
  try:
328
  expires = min(float(candidate['expires_at']), float(profile['exp']))
 
330
  bridges.finish(bridge_id)
331
  return _completion(settings, request, 'unavailable', connected=False)
332
  claims = sessions.claims(identity, expires)
333
+ claims['check'] = bridge_id
334
  if sessions.validate(claims) is None or not bridges.finish(bridge_id, claims):
335
  return _completion(settings, request, 'unavailable', connected=False)
336
+ response = _completion(settings, request, connected=True)
337
+ if not bridge_id:
338
+ # Silent OAuth runs in the original page's embedding partition.
339
+ # Replace its application cookie as well as Gradio's session;
340
+ # otherwise the old app cookie would shadow the new HF account.
341
+ claims = sessions.claims(identity, candidate['expires_at'])
342
+ if flow.get('check'):
343
+ claims['check'] = flow['check']
344
+ sessions.set_cookie(response, claims)
345
+ return response
346
  except OAuthError as exc:
347
  return _completion(settings, request, getattr(exc, 'error', 'unavailable'),
348
+ connected=False if fresh else None)
349
  except Exception:
350
+ return _completion(settings, request, 'unavailable', connected=False if fresh else None)
351
  finally:
352
  # Errors may leave Authlib's temporary state behind; bound its cookie
353
  # size and prevent a replay without affecting an older OAuth login.
hf_space/history_routes.py CHANGED
@@ -2,6 +2,7 @@
2
  from __future__ import annotations
3
 
4
  import hashlib
 
5
  import json
6
  import logging
7
  import mimetypes
@@ -16,7 +17,7 @@ from urllib.parse import unquote, urlsplit
16
  import uuid
17
 
18
  import anyio
19
- from fastapi import APIRouter, FastAPI, HTTPException, Request
20
  from starlette.formparsers import MultiPartException
21
  from starlette.responses import FileResponse, JSONResponse
22
 
@@ -235,7 +236,15 @@ def install_routes(
235
  All providers and Store methods run in worker threads. The Store is the
236
  authorization authority and must check owner before looking up any file.
237
  """
238
- router = APIRouter(prefix=BASE)
 
 
 
 
 
 
 
 
239
  cache = private_file_cache if private_file_cache is not None else PrivateFileCache(private_cache_root)
240
  # Cold artifact reads can block on the mounted Bucket. Keep their workers
241
  # separate from metadata/status requests and bound the number of live reads.
@@ -480,6 +489,7 @@ def install_routes(
480
  return {'authenticated': identity is not None,
481
  'username': identity.username if identity else None,
482
  'account_key': account_key(identity, csrf_secret) if identity else None,
 
483
  'csrf_token': csrf_token(identity, csrf_secret) if identity else None,
484
  'login_url': oauth_endpoints['login_url'] if oauth_endpoints else '/login/huggingface',
485
  'silent_login_url': oauth_endpoints['silent_login_url'] if oauth_endpoints else None,
 
2
  from __future__ import annotations
3
 
4
  import hashlib
5
+ import hmac
6
  import json
7
  import logging
8
  import mimetypes
 
17
  import uuid
18
 
19
  import anyio
20
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
21
  from starlette.formparsers import MultiPartException
22
  from starlette.responses import FileResponse, JSONResponse
23
 
 
236
  All providers and Store methods run in worker threads. The Store is the
237
  authorization authority and must check owner before looking up any file.
238
  """
239
+ def require_account_binding(request: Request):
240
+ supplied = request.headers.get('x-supra-account')
241
+ if supplied is not None:
242
+ identity = require_identity(request)
243
+ if not re.fullmatch(r'[0-9a-f]{64}', supplied) or not hmac.compare_digest(supplied, account_key(identity, csrf_secret)):
244
+ raise HTTPException(409, 'Your account changed. Verify your current account to continue.',
245
+ headers={'X-Supra-Account-Changed': '1'})
246
+
247
+ router = APIRouter(prefix=BASE, dependencies=[Depends(require_account_binding)])
248
  cache = private_file_cache if private_file_cache is not None else PrivateFileCache(private_cache_root)
249
  # Cold artifact reads can block on the mounted Bucket. Keep their workers
250
  # separate from metadata/status requests and bound the number of live reads.
 
489
  return {'authenticated': identity is not None,
490
  'username': identity.username if identity else None,
491
  'account_key': account_key(identity, csrf_secret) if identity else None,
492
+ 'verification_key': (request.scope.get('supra_history_app_claims') or {}).get('check') if identity else None,
493
  'csrf_token': csrf_token(identity, csrf_secret) if identity else None,
494
  'login_url': oauth_endpoints['login_url'] if oauth_endpoints else '/login/huggingface',
495
  'silent_login_url': oauth_endpoints['silent_login_url'] if oauth_endpoints else None,
hf_space/history_ui.css CHANGED
@@ -25,6 +25,7 @@ html:has(#st-history-shell),body:has(#st-history-shell){height:var(--st-availabl
25
  #st-app.st-history-app{height:var(--st-workspace-height,calc(var(--st-available-height,100dvh) - 190px));width:100%;padding:0 clamp(12px,1.2vw,24px);margin:0;background:transparent;font-family:Arial,sans-serif}
26
  #st-app.st-history-app [hidden]{display:none!important}
27
  #st-app.st-history-app #st-workspace{position:relative;display:grid!important;grid-template-columns:minmax(260px,1fr) minmax(260px,1fr) clamp(112px,9vw,154px) clamp(112px,9vw,154px);gap:12px!important;height:100%;margin:0;min-height:0;align-items:stretch!important}
 
28
  #st-app.st-history-app .st-native-panel{display:flex!important;flex-direction:column!important;gap:9px!important;border:1px solid #373735;border-radius:15px;background:#191919;padding:12px!important;height:100%;min-width:0;min-height:0;overflow:hidden;box-shadow:none}
29
  #st-app.st-history-app #st-input-panel{grid-column:1;overflow:auto}
30
  #st-app.st-history-app #st-input-panel[data-busy=true]{pointer-events:none;border-color:#494337}
 
25
  #st-app.st-history-app{height:var(--st-workspace-height,calc(var(--st-available-height,100dvh) - 190px));width:100%;padding:0 clamp(12px,1.2vw,24px);margin:0;background:transparent;font-family:Arial,sans-serif}
26
  #st-app.st-history-app [hidden]{display:none!important}
27
  #st-app.st-history-app #st-workspace{position:relative;display:grid!important;grid-template-columns:minmax(260px,1fr) minmax(260px,1fr) clamp(112px,9vw,154px) clamp(112px,9vw,154px);gap:12px!important;height:100%;margin:0;min-height:0;align-items:stretch!important}
28
+ #st-app.st-history-app #st-workspace[data-account-checking=true]{visibility:hidden}
29
  #st-app.st-history-app .st-native-panel{display:flex!important;flex-direction:column!important;gap:9px!important;border:1px solid #373735;border-radius:15px;background:#191919;padding:12px!important;height:100%;min-width:0;min-height:0;overflow:hidden;box-shadow:none}
30
  #st-app.st-history-app #st-input-panel{grid-column:1;overflow:auto}
31
  #st-app.st-history-app #st-input-panel[data-busy=true]{pointer-events:none;border-color:#494337}
hf_space/history_ui.js CHANGED
@@ -24,6 +24,7 @@
24
  localURL: null, silentAttempted: false, silentFrame: null, silentTimer: null,
25
  authPopup: null, authTimer: null, authBootstrapTimer: null, authFlow: null, authVersion: 0, authBusy: false,
26
  authFallback: false, identityPromise: null, authChecking: true, autoAuthAttempted: false, authMessage: ''};
 
27
  const gpuDispatcher = window.SupraZeroGPU.createDispatcher({
28
  identity: () => S.identity, version: () => S.identityVersion, stopped: () => S.stopped,
29
  ready: record => operationReady(S.deployment, record),
@@ -288,7 +289,8 @@
288
  S.silentFrame?.remove(); S.silentFrame = null;
289
  }
290
  function syncAccount() {
291
- const signedIn = !!S.identity?.authenticated;
 
292
  show('st-history-account', S.expanded && signedIn);
293
  text('st-history-username', signedIn ? S.identity.username : '');
294
  $('st-workspace').inert = !signedIn;
@@ -310,29 +312,48 @@
310
  S.autoAuthAttempted = true; connectAccount();
311
  }
312
  }
313
- async function finishSilentAuth() {
 
 
 
 
 
 
 
 
314
  endSilent();
 
 
 
 
 
 
 
 
 
315
  try {
316
  await Promise.resolve(S.identityPromise).catch(() => {});
317
  if (S.stopped) return;
318
- const identity = await refreshIdentity();
319
  if (identity.authenticated) return refreshJobs();
320
- requireEntrySignIn();
321
  } catch {
322
- S.authMessage = 'Could not verify your account. Please sign in to continue.';
323
- requireEntrySignIn();
324
  }
325
  }
326
  function startSilentAuth() {
327
- if (S.silentAttempted || S.identity?.authenticated || S.stopped) return;
328
  S.silentAttempted = true;
329
  let signedOut = false;
330
  try { signedOut = sessionStorage.getItem('st-history-signed-out') === '1'; } catch {}
331
  const url = authURL(S.identity?.silent_login_url);
332
- if (!url || signedOut) { S.authFallback = true; requireEntrySignIn(!signedOut); return; }
333
  S.authChecking = true; S.authMessage = '';
334
  const frame = S.silentFrame = document.createElement('iframe');
335
- frame.hidden = true; frame.title = 'Verify Huggingface account'; frame.src = url;
 
 
 
336
  document.body.append(frame);
337
  syncAccount();
338
  S.silentTimer = setTimeout(() => void finishSilentAuth(), 12000);
@@ -376,7 +397,7 @@
376
  // Neither opener access nor the popup's cookies are needed here.
377
  await Promise.resolve(S.identityPromise).catch(() => {});
378
  if (version !== S.authVersion || S.stopped) return;
379
- const identity = await refreshIdentity();
380
  if (!identity.authenticated) return accountConnectionFailed('Your browser could not save the account connection. Please try again.');
381
  notice('Signed in with Huggingface. You can now use Supra-Tex.');
382
  await refreshJobs(); return;
@@ -456,8 +477,10 @@
456
  }
457
  async function api(path, options = {}) {
458
  const privateCall = !/^\/(?:me|status|examples)(?:\?|$)/.test(path);
 
459
  const identityVersion = S.identityVersion;
460
  const headers = {...options.headers};
 
461
  if (options.json !== undefined) headers['Content-Type'] = 'application/json';
462
  if ((options.method || 'GET') !== 'GET' && S.identity?.csrf_token) headers['X-CSRF-Token'] = S.identity.csrf_token;
463
  const method = options.method || 'GET';
@@ -480,13 +503,17 @@
480
  if (method !== 'GET' || attempt) throw new Error('The connection timed out. Please try again.');
481
  }
482
  }
483
- if (privateCall && identityVersion !== S.identityVersion) throw new DOMException('Account changed', 'AbortError');
484
  if (response.status === 401) {
485
  S.identity = {authenticated: false}; S.authChecking = false;
486
  S.authMessage = 'Your session has expired. Please sign in again to continue.';
487
  endAccountConnection(); clearPrivate(); updateGate(); syncAccount();
488
  throw new Error('Please authorize your Huggingface account to access your private history.');
489
  }
 
 
 
 
490
  if (!response.ok) {
491
  const detail = typeof data.detail === 'string' ? data.detail : data.error;
492
  const error = new Error(englishMessage(detail, 'Could not complete this request. Please try again.'));
@@ -501,21 +528,31 @@
501
  gpuDispatcher.observe(data, {requested: method === 'POST' && path !== '/gpu/prepare'});
502
  return data;
503
  }
504
- async function refreshIdentity() {
505
  if (S.identityPromise) return S.identityPromise;
506
- S.identityPromise = refreshIdentityInner().finally(() => { S.identityPromise = null; });
507
  return S.identityPromise;
508
  }
509
- async function refreshIdentityInner() {
510
  const identityVersion = S.identityVersion;
511
  const identity = await api('/me');
512
  if (identityVersion !== S.identityVersion) return S.identity || {authenticated: false};
 
 
 
 
 
 
 
 
 
513
  const previous = S.identity;
514
  const preserve = previous && !previous.authenticated && identity.authenticated &&
515
  (S.source?.local_file || S.source?.example) ? {source: S.source, seed: $('st-seed').value} : null;
516
  if (previous && (previous.account_key !== identity.account_key ||
517
  previous.username !== identity.username || previous.authenticated !== identity.authenticated)) clearPrivate();
518
  S.identity = identity; S.lastIdentityCheck = Date.now();
 
519
  window.SupraPrivateAssets?.setAccount(identity.authenticated ? identity.account_key || identity.username : '');
520
  if (preserve?.source.local_file) stageLocalFile(preserve.source.local_file, preserve.seed);
521
  else if (preserve?.source.example) {
@@ -543,7 +580,7 @@
543
  updateGate();
544
  }
545
  function updateGate() {
546
- const signedIn = !!S.identity?.authenticated, loading = !ready(S.deployment);
547
  const inputBusy = leftInputBusy(), geometry = selectedGeometry();
548
  $('st-input-panel').inert = inputBusy;
549
  $('st-input-panel').dataset.busy = String(inputBusy);
@@ -732,12 +769,12 @@
732
  }
733
  function scheduleJobs() {
734
  clearTimeout(S.jobsTimer); S.jobsTimer = null;
735
- if (!S.stopped && S.identity?.authenticated && (!S.historyLoaded || S.historyFailures || [...S.jobs.values()].some(pending)))
736
  S.jobsTimer = setTimeout(() => refreshJobs().catch(() => {}),
737
  document.hidden ? 20000 : S.historyLoaded ? 4000 : 5000);
738
  }
739
  async function refreshJobs(more = false) {
740
- if (!S.identity?.authenticated || S.stopped) return;
741
  if (S.refreshPromise) return S.refreshPromise;
742
  const identityVersion = S.identityVersion;
743
  S.pageLoading = true; renderHistory();
@@ -1052,7 +1089,7 @@
1052
  return S.examplesPromise;
1053
  }
1054
  async function activateWorkspace() {
1055
- if (!S.identity?.authenticated || !ready(S.deployment) || S.stopped) return;
1056
  await Promise.allSettled([loadExamples(), refreshJobs()]);
1057
  if (S.source?.local_file && !S.source.upload_id && S.source.foreground_required &&
1058
  (!S.uploadState || S.uploadState.phase === 'waiting_service'))
@@ -1448,7 +1485,7 @@
1448
  // Interactive sign-in uses the one-time server handoff. HF's login page
1449
  // can sever window.opener; its completion message is only a hint, not proof.
1450
  if (!silent) return;
1451
- void finishSilentAuth();
1452
  });
1453
  window.addEventListener('resize', () => { positionHistory(); updateGate(); });
1454
  window.addEventListener('scroll', positionHistory, {passive: true});
@@ -1460,20 +1497,33 @@
1460
  // Keep polling the handoff rather than treating that as a cancelled login.
1461
  if (!S.authFlow && S.authPopup?.closed) { S.authPopup = null; S.authBusy = false; updateGate(); }
1462
  if (S.authFlow) { void refreshStatus(); return; }
1463
- if (document.hidden || Date.now() - S.lastIdentityCheck < 2000) return;
1464
  try {
 
 
 
1465
  const identity = await refreshIdentity();
1466
  if (identity.authenticated) await refreshJobs();
1467
- else if (!S.silentFrame) requireEntrySignIn();
 
 
 
 
1468
  }
1469
- catch (error) { if (!quietError(error)) notice('Could not reconnect. Please refresh to retry.'); }
1470
  void refreshStatus();
1471
  }
1472
  document.addEventListener('visibilitychange', () => {
1473
  clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer);
1474
- if (document.hidden) { scheduleJobs(); if (pollStartup()) S.startupTimer = setTimeout(refreshStatus, 20000); }
1475
  else void resume();
1476
  });
 
 
 
 
 
 
 
1477
  window.addEventListener('focus', () => void resume());
1478
  window.addEventListener('pagehide', () => {
1479
  S.stopped = true; clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer); clearTimeout(S.noticeTimer);
@@ -1482,15 +1532,13 @@
1482
  });
1483
  window.addEventListener('pageshow', event => { if (event.persisted) { S.stopped = false; S.lastIdentityCheck = 0; void resume(); } });
1484
  async function boot() {
 
1485
  syncAccount(); updateGate(); void refreshStatus(); void loadExamples();
1486
  try {
1487
- const identity = await refreshIdentity();
1488
- if (identity.authenticated) await refreshJobs();
1489
- else { startSilentAuth(); renderHistory(); }
1490
  }
1491
  catch {
1492
- S.authMessage = 'Could not check your account. Please sign in to continue.';
1493
- requireEntrySignIn(false);
1494
  }
1495
  }
1496
  void boot();
 
24
  localURL: null, silentAttempted: false, silentFrame: null, silentTimer: null,
25
  authPopup: null, authTimer: null, authBootstrapTimer: null, authFlow: null, authVersion: 0, authBusy: false,
26
  authFallback: false, identityPromise: null, authChecking: true, autoAuthAttempted: false, authMessage: ''};
27
+ S.verifyRequired = false; S.accountNeedsCheck = true; S.accountCheck = null;
28
  const gpuDispatcher = window.SupraZeroGPU.createDispatcher({
29
  identity: () => S.identity, version: () => S.identityVersion, stopped: () => S.stopped,
30
  ready: record => operationReady(S.deployment, record),
 
289
  S.silentFrame?.remove(); S.silentFrame = null;
290
  }
291
  function syncAccount() {
292
+ const signedIn = !!S.identity?.authenticated && !S.verifyRequired;
293
+ $('st-workspace').dataset.accountChecking = String(S.verifyRequired);
294
  show('st-history-account', S.expanded && signedIn);
295
  text('st-history-username', signedIn ? S.identity.username : '');
296
  $('st-workspace').inert = !signedIn;
 
312
  S.autoAuthAttempted = true; connectAccount();
313
  }
314
  }
315
+ function beginAccountVerification() {
316
+ S.verifyRequired = true; S.accountNeedsCheck = false;
317
+ S.accountCheck = randomAuthSecret(); S.silentAttempted = false;
318
+ S.authChecking = true; S.authMessage = '';
319
+ clearTimeout(S.jobsTimer); S.jobsTimer = null;
320
+ $('st-delete-dialog').close();
321
+ syncAccount(); updateGate();
322
+ }
323
+ function accountVerificationFailed(message = '') {
324
  endSilent();
325
+ S.identity = {...S.identity, authenticated: false};
326
+ S.verifyRequired = false; S.accountCheck = null; S.authChecking = false;
327
+ S.authMessage = message;
328
+ clearPrivate(); updateGate(); syncAccount(); requireEntrySignIn(false);
329
+ }
330
+ async function finishSilentAuth(authenticated = false) {
331
+ const check = S.accountCheck;
332
+ endSilent();
333
+ if (!authenticated) return accountVerificationFailed();
334
  try {
335
  await Promise.resolve(S.identityPromise).catch(() => {});
336
  if (S.stopped) return;
337
+ const identity = await refreshIdentity({verificationKey: check});
338
  if (identity.authenticated) return refreshJobs();
339
+ accountVerificationFailed();
340
  } catch {
341
+ accountVerificationFailed('Could not verify your current account. Please sign in to continue.');
 
342
  }
343
  }
344
  function startSilentAuth() {
345
+ if (S.silentAttempted || (S.identity?.authenticated && !S.verifyRequired) || S.stopped) return;
346
  S.silentAttempted = true;
347
  let signedOut = false;
348
  try { signedOut = sessionStorage.getItem('st-history-signed-out') === '1'; } catch {}
349
  const url = authURL(S.identity?.silent_login_url);
350
+ if (!url || signedOut) { S.authFallback = true; accountVerificationFailed(); return; }
351
  S.authChecking = true; S.authMessage = '';
352
  const frame = S.silentFrame = document.createElement('iframe');
353
+ const verificationURL = new URL(url);
354
+ verificationURL.searchParams.set('refresh', '1');
355
+ verificationURL.searchParams.set('check', S.accountCheck);
356
+ frame.hidden = true; frame.title = 'Verify Huggingface account'; frame.src = verificationURL.href;
357
  document.body.append(frame);
358
  syncAccount();
359
  S.silentTimer = setTimeout(() => void finishSilentAuth(), 12000);
 
397
  // Neither opener access nor the popup's cookies are needed here.
398
  await Promise.resolve(S.identityPromise).catch(() => {});
399
  if (version !== S.authVersion || S.stopped) return;
400
+ const identity = await refreshIdentity({verificationKey: flow.bridge_id});
401
  if (!identity.authenticated) return accountConnectionFailed('Your browser could not save the account connection. Please try again.');
402
  notice('Signed in with Huggingface. You can now use Supra-Tex.');
403
  await refreshJobs(); return;
 
477
  }
478
  async function api(path, options = {}) {
479
  const privateCall = !/^\/(?:me|status|examples)(?:\?|$)/.test(path);
480
+ if (privateCall && S.verifyRequired) throw new DOMException('Account verification pending', 'AbortError');
481
  const identityVersion = S.identityVersion;
482
  const headers = {...options.headers};
483
+ if (privateCall && S.identity?.account_key) headers['X-Supra-Account'] = S.identity.account_key;
484
  if (options.json !== undefined) headers['Content-Type'] = 'application/json';
485
  if ((options.method || 'GET') !== 'GET' && S.identity?.csrf_token) headers['X-CSRF-Token'] = S.identity.csrf_token;
486
  const method = options.method || 'GET';
 
503
  if (method !== 'GET' || attempt) throw new Error('The connection timed out. Please try again.');
504
  }
505
  }
506
+ if (privateCall && (identityVersion !== S.identityVersion || S.verifyRequired)) throw new DOMException('Account changed', 'AbortError');
507
  if (response.status === 401) {
508
  S.identity = {authenticated: false}; S.authChecking = false;
509
  S.authMessage = 'Your session has expired. Please sign in again to continue.';
510
  endAccountConnection(); clearPrivate(); updateGate(); syncAccount();
511
  throw new Error('Please authorize your Huggingface account to access your private history.');
512
  }
513
+ if (response.status === 409 && (data.code === 'account_changed' || response.headers.get('X-Supra-Account-Changed') === '1')) {
514
+ clearPrivate(); beginAccountVerification(); startSilentAuth();
515
+ throw new DOMException('Account changed', 'AbortError');
516
+ }
517
  if (!response.ok) {
518
  const detail = typeof data.detail === 'string' ? data.detail : data.error;
519
  const error = new Error(englishMessage(detail, 'Could not complete this request. Please try again.'));
 
528
  gpuDispatcher.observe(data, {requested: method === 'POST' && path !== '/gpu/prepare'});
529
  return data;
530
  }
531
+ async function refreshIdentity(options = {}) {
532
  if (S.identityPromise) return S.identityPromise;
533
+ S.identityPromise = refreshIdentityInner(options).finally(() => { S.identityPromise = null; });
534
  return S.identityPromise;
535
  }
536
+ async function refreshIdentityInner({verificationKey} = {}) {
537
  const identityVersion = S.identityVersion;
538
  const identity = await api('/me');
539
  if (identityVersion !== S.identityVersion) return S.identity || {authenticated: false};
540
+ if (verificationKey && (!identity.authenticated || identity.verification_key !== verificationKey))
541
+ throw new Error('The current account could not be verified.');
542
+ if (S.verifyRequired && !verificationKey) {
543
+ // A valid app cookie can still belong to the previous HF account. Do not
544
+ // publish it or request any private history before fresh OAuth completes.
545
+ if (!S.identity) S.identity = {...identity, authenticated: false};
546
+ else for (const key of ['login_url', 'silent_login_url', 'bridge_url', 'popup_url']) S.identity[key] = identity[key];
547
+ return {authenticated: false};
548
+ }
549
  const previous = S.identity;
550
  const preserve = previous && !previous.authenticated && identity.authenticated &&
551
  (S.source?.local_file || S.source?.example) ? {source: S.source, seed: $('st-seed').value} : null;
552
  if (previous && (previous.account_key !== identity.account_key ||
553
  previous.username !== identity.username || previous.authenticated !== identity.authenticated)) clearPrivate();
554
  S.identity = identity; S.lastIdentityCheck = Date.now();
555
+ if (verificationKey) { S.verifyRequired = false; S.accountCheck = null; }
556
  window.SupraPrivateAssets?.setAccount(identity.authenticated ? identity.account_key || identity.username : '');
557
  if (preserve?.source.local_file) stageLocalFile(preserve.source.local_file, preserve.seed);
558
  else if (preserve?.source.example) {
 
580
  updateGate();
581
  }
582
  function updateGate() {
583
+ const signedIn = !!S.identity?.authenticated && !S.verifyRequired, loading = !ready(S.deployment);
584
  const inputBusy = leftInputBusy(), geometry = selectedGeometry();
585
  $('st-input-panel').inert = inputBusy;
586
  $('st-input-panel').dataset.busy = String(inputBusy);
 
769
  }
770
  function scheduleJobs() {
771
  clearTimeout(S.jobsTimer); S.jobsTimer = null;
772
+ if (!S.stopped && !S.verifyRequired && S.identity?.authenticated && (!S.historyLoaded || S.historyFailures || [...S.jobs.values()].some(pending)))
773
  S.jobsTimer = setTimeout(() => refreshJobs().catch(() => {}),
774
  document.hidden ? 20000 : S.historyLoaded ? 4000 : 5000);
775
  }
776
  async function refreshJobs(more = false) {
777
+ if (!S.identity?.authenticated || S.verifyRequired || S.stopped) return;
778
  if (S.refreshPromise) return S.refreshPromise;
779
  const identityVersion = S.identityVersion;
780
  S.pageLoading = true; renderHistory();
 
1089
  return S.examplesPromise;
1090
  }
1091
  async function activateWorkspace() {
1092
+ if (!S.identity?.authenticated || S.verifyRequired || !ready(S.deployment) || S.stopped) return;
1093
  await Promise.allSettled([loadExamples(), refreshJobs()]);
1094
  if (S.source?.local_file && !S.source.upload_id && S.source.foreground_required &&
1095
  (!S.uploadState || S.uploadState.phase === 'waiting_service'))
 
1485
  // Interactive sign-in uses the one-time server handoff. HF's login page
1486
  // can sever window.opener; its completion message is only a hint, not proof.
1487
  if (!silent) return;
1488
+ void finishSilentAuth(event.data.authenticated === true);
1489
  });
1490
  window.addEventListener('resize', () => { positionHistory(); updateGate(); });
1491
  window.addEventListener('scroll', positionHistory, {passive: true});
 
1497
  // Keep polling the handoff rather than treating that as a cancelled login.
1498
  if (!S.authFlow && S.authPopup?.closed) { S.authPopup = null; S.authBusy = false; updateGate(); }
1499
  if (S.authFlow) { void refreshStatus(); return; }
1500
+ if (document.hidden || (!S.accountNeedsCheck && Date.now() - S.lastIdentityCheck < 2000)) return;
1501
  try {
1502
+ if (S.accountNeedsCheck) {
1503
+ beginAccountVerification(); await refreshIdentity(); startSilentAuth(); return;
1504
+ }
1505
  const identity = await refreshIdentity();
1506
  if (identity.authenticated) await refreshJobs();
1507
+ else if (!S.silentFrame) requireEntrySignIn(false);
1508
+ }
1509
+ catch (error) {
1510
+ if (S.verifyRequired) accountVerificationFailed('Could not verify your current account. Please sign in to continue.');
1511
+ else if (!quietError(error)) notice('Could not reconnect. Please refresh to retry.');
1512
  }
 
1513
  void refreshStatus();
1514
  }
1515
  document.addEventListener('visibilitychange', () => {
1516
  clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer);
1517
+ if (document.hidden) { shieldPrivateWorkspace(); scheduleJobs(); if (pollStartup()) S.startupTimer = setTimeout(refreshStatus, 20000); }
1518
  else void resume();
1519
  });
1520
+ function shieldPrivateWorkspace() {
1521
+ if (S.authFlow || S.authChecking || S.stopped) return;
1522
+ S.accountNeedsCheck = true;
1523
+ $('st-workspace').dataset.accountChecking = 'true';
1524
+ $('st-workspace').inert = true;
1525
+ }
1526
+ window.addEventListener('blur', shieldPrivateWorkspace);
1527
  window.addEventListener('focus', () => void resume());
1528
  window.addEventListener('pagehide', () => {
1529
  S.stopped = true; clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer); clearTimeout(S.noticeTimer);
 
1532
  });
1533
  window.addEventListener('pageshow', event => { if (event.persisted) { S.stopped = false; S.lastIdentityCheck = 0; void resume(); } });
1534
  async function boot() {
1535
+ beginAccountVerification();
1536
  syncAccount(); updateGate(); void refreshStatus(); void loadExamples();
1537
  try {
1538
+ await refreshIdentity(); startSilentAuth(); renderHistory();
 
 
1539
  }
1540
  catch {
1541
+ accountVerificationFailed('Could not check your account. Please sign in to continue.');
 
1542
  }
1543
  }
1544
  void boot();
tests/test_history_account_switch.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A fresh HF identity replaces both cookie formats before private history loads."""
2
+ from io import BytesIO
3
+ import time
4
+ from types import SimpleNamespace
5
+
6
+ from authlib.integrations.base_client import OAuthError
7
+ from fastapi import FastAPI, Request
8
+ from PIL import Image
9
+ import pytest
10
+ from starlette.middleware.sessions import SessionMiddleware
11
+ from starlette.responses import RedirectResponse
12
+ from starlette.testclient import TestClient
13
+
14
+ from hf_space.history_auth import Identity
15
+ from hf_space.history_bridge import AppSessions, COOKIE_NAME
16
+ from hf_space.history_oauth import install_oauth_routes, OAuthSettings
17
+ from hf_space.history_routes import install_routes
18
+ from hf_space.history_store import Store
19
+
20
+
21
+ class Provider:
22
+ def __init__(self):
23
+ self.account = 'bob'
24
+ self.error = None
25
+ self.params = None
26
+ self.exchanges = 0
27
+ self.framework = SimpleNamespace(clear_state_data=self.clear_state)
28
+
29
+ async def clear_state(self, session, state):
30
+ pass
31
+
32
+ async def authorize_redirect(self, request, redirect_uri, **params):
33
+ self.params = params
34
+ request.session['_state_supra_history_' + params['state']] = {
35
+ 'data': {'nonce': params['nonce'], 'code_verifier': 'test-pkce-verifier'}}
36
+ return RedirectResponse('https://huggingface.co/oauth/authorize?state=' + params['state'], status_code=302)
37
+
38
+ async def authorize_access_token(self, request):
39
+ self.exchanges += 1
40
+ data = request.session['_state_supra_history_' + self.params['state']]['data']
41
+ assert data['nonce'] == self.params['nonce']
42
+ assert data['code_verifier'] == 'test-pkce-verifier'
43
+ if self.error:
44
+ raise OAuthError(error=self.error)
45
+ return {'id_token': 'verified-by-mock-oidc-provider', 'access_token': 'mock-access-token',
46
+ 'expires_at': time.time() + 3600,
47
+ 'userinfo': {'sub': self.account, 'preferred_username': self.account,
48
+ 'exp': time.time() + 3600}}
49
+
50
+
51
+ @pytest.fixture
52
+ def env(tmp_path, monkeypatch):
53
+ monkeypatch.setenv('SPACES_ZERO_GPU', 'true')
54
+ for name in ('SPACE_ID', 'SPACE_HOST', 'OAUTH_CLIENT_ID', 'OAUTH_CLIENT_SECRET'):
55
+ monkeypatch.delenv(name, raising=False)
56
+ store = Store(tmp_path/'history', asset_root=tmp_path/'assets').initialize()
57
+ stream = BytesIO()
58
+ Image.new('RGB', (12, 12), 'red').save(stream, 'PNG')
59
+ jobs = {}
60
+ for owner in ('alice', 'bob'):
61
+ upload = store.save_upload(owner, stream.getvalue())
62
+ jobs[owner] = store.submit(owner, upload['id'], 42, owner + '-request')
63
+ app = FastAPI()
64
+ app.add_middleware(SessionMiddleware, secret_key='test-gradio-session', https_only=True, same_site='none')
65
+ provider = Provider()
66
+ sessions = AppSessions('test-partitioned-session')
67
+ settings = OAuthSettings('test-client', 'test-partitioned-session', 'https://app.example')
68
+ install_oauth_routes(app, settings=settings, oauth_client=provider, sessions=sessions)
69
+ install_routes(app, store, deployment_status=lambda: {'gpu_mode': 'zerogpu', 'workspace': {'stage': 'ready'}},
70
+ private_cache_root=tmp_path/'private', trusted_origins=[settings.origin])
71
+
72
+ @app.get('/seed-legacy')
73
+ async def seed_legacy(request: Request):
74
+ request.session['oauth_info'] = {'access_token': 'old-token', 'expires_at': time.time()+3600,
75
+ 'userinfo': {'sub': 'alice', 'preferred_username': 'alice'}}
76
+ return {}
77
+
78
+ client = TestClient(app, base_url=settings.origin)
79
+ client.get('/seed-legacy')
80
+ claims = sessions.claims(Identity('alice', 'alice', 'old-session'), time.time()+3600)
81
+ client.cookies.set(COOKIE_NAME, sessions.serializer.dumps(claims), domain='app.example', path='/')
82
+ return SimpleNamespace(client=client, provider=provider, sessions=sessions, jobs=jobs)
83
+
84
+
85
+ def begin(env, check='c' * 43):
86
+ response = env.client.get('/api/history/auth/start', params={'silent': '1', 'refresh': '1', 'check': check},
87
+ follow_redirects=False)
88
+ assert response.status_code == 302
89
+ assert env.provider.params['prompt'] == 'none'
90
+ return response
91
+
92
+
93
+ def finish(env):
94
+ return env.client.get('/api/history/auth/callback', params={'state': env.provider.params['state'], 'code': 'mock-code'})
95
+
96
+
97
+ def test_refresh_never_shortcuts_old_app_login_and_clears_both_cookie_formats(env):
98
+ assert env.client.get('/api/history/me').json()['username'] == 'alice'
99
+ response = begin(env)
100
+ cleared = [v for v in response.headers.get_list('set-cookie') if v.startswith(COOKIE_NAME+'=')]
101
+ assert len(cleared) == 2 and all('Max-Age=0' in v for v in cleared)
102
+ assert any('Partitioned' in v for v in cleared)
103
+ assert env.client.get('/api/history/me').json()['authenticated'] is False
104
+ assert env.client.get('/api/history/jobs').status_code == 401
105
+
106
+
107
+ def test_silent_recheck_replaces_old_account_and_only_returns_new_owner_history(env):
108
+ begin(env)
109
+ response = finish(env)
110
+ assert response.status_code == 200
111
+ identity = env.client.get('/api/history/me').json()
112
+ assert identity['username'] == 'bob'
113
+ assert identity['verification_key'] == 'c'*43
114
+ result = env.client.get('/api/history/jobs', headers={'X-Supra-Account': identity['account_key']})
115
+ assert result.status_code == 200
116
+ assert [j['id'] for j in result.json()['items']] == [env.jobs['bob']['id']]
117
+ assert env.client.get('/api/history/jobs/'+env.jobs['alice']['id']).status_code == 404
118
+ assert env.client.get('/api/history/jobs/'+env.jobs['alice']['id']+'/files/input.png').status_code == 404
119
+ assert 'no-store' in result.headers['cache-control']
120
+
121
+
122
+ def test_logged_out_hf_or_failed_recheck_never_reuses_account_a(env):
123
+ begin(env)
124
+ env.provider.error = 'login_required'
125
+ response = finish(env)
126
+ assert '"authenticated":false' in response.text
127
+ assert env.client.get('/api/history/me').json()['authenticated'] is False
128
+ assert env.client.get('/api/history/jobs').status_code == 401
129
+
130
+
131
+ def test_stale_tab_account_header_rejects_cookie_changed_by_another_tab(env):
132
+ old_key = env.client.get('/api/history/me').json()['account_key']
133
+ begin(env)
134
+ finish(env)
135
+ response = env.client.get('/api/history/jobs', headers={'X-Supra-Account': old_key})
136
+ assert response.status_code == 409
137
+ assert response.headers['x-supra-account-changed'] == '1'
138
+ assert 'items' not in response.json()
139
+
140
+
141
+ def test_forged_callback_cannot_replace_an_account_or_issue_a_verified_cookie(env):
142
+ begin(env)
143
+ response = env.client.get('/api/history/auth/callback', params={'state': 'forged', 'code': 'mock-code'})
144
+ assert env.provider.exchanges == 0
145
+ assert not any(v.startswith(COOKIE_NAME+'=') and 'Max-Age=0' not in v for v in response.headers.get_list('set-cookie'))
146
+ assert env.client.get('/api/history/me').json()['authenticated'] is False
147
+
148
+
149
+ def test_refresh_requires_a_bounded_fresh_client_check(env):
150
+ response = env.client.get('/api/history/auth/start?refresh=1&silent=1', follow_redirects=False)
151
+ assert response.status_code == 400
152
+ assert env.provider.params is None
153
+
154
+
155
+ def test_silent_check_works_when_the_unpartitioned_gradio_cookie_is_blocked(env):
156
+ begin(env)
157
+ env.client.cookies.delete('session', domain='app.example', path='/')
158
+ response = finish(env)
159
+ assert response.status_code == 200
160
+ identity = env.client.get('/api/history/me').json()
161
+ assert identity['username'] == 'bob'
162
+ assert identity['verification_key'] == 'c'*43
163
+ assert any('__Host-supra-history-oauth-check=;' in c and 'Max-Age=0' in c
164
+ for c in response.headers.get_list('set-cookie'))
tests/test_history_ui_resume.cjs CHANGED
@@ -18,6 +18,79 @@ const deployment = (runtimeId, completed = 2, total = 5) => ({
18
  models: {stage: 'loading_models', startup_progress: {completed, total, unit: 'models', label: 'Internal model name'}}
19
  });
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  function fakeDOM() {
22
  const nodes = new Map();
23
  class Element {
@@ -29,7 +102,9 @@ function fakeDOM() {
29
  }
30
  get textContent() { return this._textContent; }
31
  set textContent(value) { this._textContent = value; this.textHistory.push(value); }
32
- close() { this.hidden = true; }
 
 
33
  addEventListener(type, handler) {
34
  const handlers = this.handlers.get(type) || [];
35
  handlers.push(handler); this.handlers.set(type, handlers);
@@ -69,7 +144,7 @@ function fakeDOM() {
69
  nodes.get('st-job-progress').append(new Element('progress'));
70
  nodes.get('st-texture-progress').append(new Element('progress'));
71
  const documentHandlers = new Map();
72
- const document = {hidden: false, getElementById: id => nodes.get(id) || null, handlers: documentHandlers,
73
  createElement: tag => new Element(tag), createDocumentFragment: () => new Element('#fragment'),
74
  addEventListener(type, handler) {
75
  const handlers = documentHandlers.get(type) || [];
@@ -95,6 +170,7 @@ function setup(t) {
95
  const context = vm.createContext({
96
  AbortController, AbortSignal, DOMException, Headers, URL: UploadURL, URLSearchParams, Image: UploadImage, crypto: require('node:crypto').webcrypto,
97
  document: dom.document, window: {addEventListener() {}},
 
98
  location: {origin: 'https://example.hf.space', href: 'https://example.hf.space/'},
99
  matchMedia: () => ({matches: false}),
100
  requestAnimationFrame: callback => callback(),
@@ -102,6 +178,10 @@ function setup(t) {
102
  clearTimeout: id => timers.delete(id),
103
  async fetch(url, options = {}) {
104
  requests.push({url, method: options.method || 'GET', headers: options.headers, body: options.body});
 
 
 
 
105
  if (url === '/api/history/status') {
106
  if (network.nextStatus) {
107
  const pending = network.nextStatus; network.nextStatus = null; return pending.promise;
@@ -118,7 +198,8 @@ function setup(t) {
118
  }
119
  if (/^\/api\/history\/uploads\/[^/]+\/foreground$/.test(url)) return response(network.retryUploadResponse);
120
  if (url === '/api/history/gpu/prepare') return response({});
121
- if (url.startsWith('/api/history/jobs?') && network.jobsResponse) return response(network.jobsResponse);
 
122
  if (url === '/api/history/submit' && network.nextSubmission) {
123
  const pending = network.nextSubmission; network.nextSubmission = null; return pending.promise;
124
  }
@@ -145,7 +226,7 @@ function setup(t) {
145
  })};
146
  assert.equal((uiSource.match(/void boot\(\);/g) || []).length, 1);
147
  vm.runInContext(uiSource.replace('void boot();',
148
- 'globalThis.testUI = {S, renderSelected, refreshStatus, refreshJobs, updateGate, submit, gpuDispatcher, resumeGPU, selectJob, selectExample, previewKey, historyStatus, applyUploadResult, refreshUpload, clearPrivate, clearUploadPreview, retryUploadPreview};'), context);
149
  const ui = context.testUI;
150
  Object.assign(ui.S, {identity: {authenticated: true, csrf_token: 'csrf-test'}, authChecking: false,
151
  selected: {kind: 'job', id: savedJob.id}, source: {upload_id: 'saved-upload'},
@@ -156,7 +237,7 @@ function setup(t) {
156
  t.after(async () => {
157
  ui.S.stopped = true; ui.gpuDispatcher.reset(); finishGPU.resolve(); await tick();
158
  });
159
- return {...ui, ...dom, network, requests, submissions, response, images, timers,
160
  prepareRequests: () => requests.filter(request => request.url === '/api/history/gpu/prepare')};
161
  }
162
 
 
18
  models: {stage: 'loading_models', startup_progress: {completed, total, unit: 'models', label: 'Internal model name'}}
19
  });
20
 
21
+ test('an old app login is hidden and never loads history before the current HF account is verified', async t => {
22
+ const env = setup(t);
23
+ env.S.identity = {authenticated: true, username: 'alice', account_key: 'account-a'};
24
+ env.network.identity = {...env.S.identity, silent_login_url: '/api/history/auth/start?silent=1'};
25
+ env.beginAccountVerification();
26
+ const identity = await env.refreshIdentity();
27
+ assert.equal(identity.authenticated, false);
28
+ assert.equal(env.get('st-workspace').dataset.accountChecking, 'true');
29
+ assert.equal(env.get('st-workspace').inert, true);
30
+ assert.equal(env.requests.filter(r => r.url.startsWith('/api/history/jobs')).length, 0);
31
+ assert.equal(env.submissions.length, 0);
32
+ assert.equal(env.S.jobs.has(savedJob.id), true, 'checking identity does not destroy an ongoing task');
33
+ });
34
+
35
+ test('fresh HF account B replaces account A and clears A history, inputs and cached assets before display', async t => {
36
+ const env = setup(t), accounts = [];
37
+ env.context.window.SupraPrivateAssets = {setAccount: value => accounts.push(value)};
38
+ env.S.identity = {authenticated: true, username: 'alice', account_key: 'account-a'};
39
+ env.S.localURL = 'blob:alice-input';
40
+ env.beginAccountVerification();
41
+ env.network.identity = {authenticated: true, username: 'bob', account_key: 'account-b', verification_key: env.S.accountCheck};
42
+ env.network.jobsResponse = {items: [{...savedJob, id: 'bob-job', status: 'complete'}], revision: 'b:1'};
43
+ await env.finishSilentAuth(true); await tick();
44
+ assert.equal(env.S.identity.username, 'bob');
45
+ assert.equal(env.S.jobs.has(savedJob.id), false);
46
+ assert.equal(env.S.jobs.has('bob-job'), true);
47
+ assert.equal(env.S.source, null);
48
+ assert(env.images.revoked.includes('blob:alice-input'));
49
+ assert(accounts.includes(''));
50
+ assert.equal(accounts.at(-1), 'account-b');
51
+ assert.equal(env.get('st-workspace').dataset.accountChecking, 'false');
52
+ assert.equal(env.submissions.length, 0);
53
+ });
54
+
55
+ test('a silent timeout or blocked cookie cannot fall back to account A history', async t => {
56
+ for (const authenticated of [false, true]) {
57
+ const env = setup(t);
58
+ env.S.identity = {authenticated: true, username: 'alice', account_key: 'account-a'};
59
+ env.beginAccountVerification();
60
+ env.network.identity = {...env.S.identity, verification_key: 'old-check'};
61
+ await env.finishSilentAuth(authenticated);
62
+ assert.equal(env.S.identity.authenticated, false);
63
+ assert.equal(env.S.jobs.size, 0);
64
+ assert.equal(env.S.source, null);
65
+ assert.equal(env.requests.filter(r => r.url.startsWith('/api/history/jobs')).length, 0);
66
+ assert.equal(env.get('st-auth-dialog').open, true);
67
+ }
68
+ });
69
+
70
+ test('leaving the page masks private content while keeping an active operation intact', async t => {
71
+ const env = setup(t);
72
+ env.S.accountNeedsCheck = false;
73
+ env.shieldPrivateWorkspace();
74
+ assert.equal(env.S.accountNeedsCheck, true);
75
+ assert.equal(env.get('st-workspace').dataset.accountChecking, 'true');
76
+ assert.equal(env.get('st-workspace').inert, true);
77
+ assert.equal(env.S.jobs.has(savedJob.id), true);
78
+ });
79
+
80
+ test('a cookie changed by another tab cannot merge its history into the old account', async t => {
81
+ const env = setup(t);
82
+ env.S.identity = {authenticated: true, username: 'alice', account_key: 'account-a', silent_login_url: '/api/history/auth/start?silent=1'};
83
+ env.network.jobsResponse = {detail: 'Your account changed.'};
84
+ env.network.jobsStatus = 409; env.network.jobsHeaders = {'X-Supra-Account-Changed': '1'};
85
+ await env.refreshJobs();
86
+ const request = env.requests.find(r => r.url.startsWith('/api/history/jobs'));
87
+ assert.equal(request.headers['X-Supra-Account'], 'account-a');
88
+ assert.equal(env.S.jobs.size, 0);
89
+ assert.equal(env.S.verifyRequired, true);
90
+ assert.equal(env.get('st-workspace').dataset.accountChecking, 'true');
91
+ assert.equal(env.submissions.length, 0);
92
+ });
93
+
94
  function fakeDOM() {
95
  const nodes = new Map();
96
  class Element {
 
102
  }
103
  get textContent() { return this._textContent; }
104
  set textContent(value) { this._textContent = value; this.textHistory.push(value); }
105
+ close() { this.hidden = true; this.open = false; }
106
+ showModal() { this.hidden = false; this.open = true; }
107
+ remove() { this.removed = true; }
108
  addEventListener(type, handler) {
109
  const handlers = this.handlers.get(type) || [];
110
  handlers.push(handler); this.handlers.set(type, handlers);
 
144
  nodes.get('st-job-progress').append(new Element('progress'));
145
  nodes.get('st-texture-progress').append(new Element('progress'));
146
  const documentHandlers = new Map();
147
+ const document = {hidden: false, body: new Element('body'), getElementById: id => nodes.get(id) || null, handlers: documentHandlers,
148
  createElement: tag => new Element(tag), createDocumentFragment: () => new Element('#fragment'),
149
  addEventListener(type, handler) {
150
  const handlers = documentHandlers.get(type) || [];
 
170
  const context = vm.createContext({
171
  AbortController, AbortSignal, DOMException, Headers, URL: UploadURL, URLSearchParams, Image: UploadImage, crypto: require('node:crypto').webcrypto,
172
  document: dom.document, window: {addEventListener() {}},
173
+ btoa: value => Buffer.from(value, 'binary').toString('base64'),
174
  location: {origin: 'https://example.hf.space', href: 'https://example.hf.space/'},
175
  matchMedia: () => ({matches: false}),
176
  requestAnimationFrame: callback => callback(),
 
178
  clearTimeout: id => timers.delete(id),
179
  async fetch(url, options = {}) {
180
  requests.push({url, method: options.method || 'GET', headers: options.headers, body: options.body});
181
+ if (url === '/api/history/me') {
182
+ if (network.nextIdentity) { const pending = network.nextIdentity; network.nextIdentity = null; return pending.promise; }
183
+ return response(network.identity);
184
+ }
185
  if (url === '/api/history/status') {
186
  if (network.nextStatus) {
187
  const pending = network.nextStatus; network.nextStatus = null; return pending.promise;
 
198
  }
199
  if (/^\/api\/history\/uploads\/[^/]+\/foreground$/.test(url)) return response(network.retryUploadResponse);
200
  if (url === '/api/history/gpu/prepare') return response({});
201
+ if (url.startsWith('/api/history/jobs?') && network.jobsResponse)
202
+ return new Response(JSON.stringify(network.jobsResponse), {status: network.jobsStatus || 200, headers: network.jobsHeaders});
203
  if (url === '/api/history/submit' && network.nextSubmission) {
204
  const pending = network.nextSubmission; network.nextSubmission = null; return pending.promise;
205
  }
 
226
  })};
227
  assert.equal((uiSource.match(/void boot\(\);/g) || []).length, 1);
228
  vm.runInContext(uiSource.replace('void boot();',
229
+ 'globalThis.testUI = {S, renderSelected, refreshStatus, refreshJobs, updateGate, submit, gpuDispatcher, resumeGPU, selectJob, selectExample, previewKey, historyStatus, applyUploadResult, refreshUpload, clearPrivate, clearUploadPreview, retryUploadPreview, refreshIdentity, beginAccountVerification, finishSilentAuth, shieldPrivateWorkspace};'), context);
230
  const ui = context.testUI;
231
  Object.assign(ui.S, {identity: {authenticated: true, csrf_token: 'csrf-test'}, authChecking: false,
232
  selected: {kind: 'job', id: savedJob.id}, source: {upload_id: 'saved-upload'},
 
237
  t.after(async () => {
238
  ui.S.stopped = true; ui.gpuDispatcher.reset(); finishGPU.resolve(); await tick();
239
  });
240
+ return {...ui, ...dom, network, requests, submissions, response, images, timers, context,
241
  prepareRequests: () => requests.filter(request => request.url === '/api/history/gpu/prepare')};
242
  }
243