raylim commited on
Commit
7c519de
·
2 Parent(s): 08407b730353a0

Merge remote-tracking branch 'hf/main'

Browse files
pyproject.toml CHANGED
@@ -42,6 +42,16 @@ disable = [
42
  "unspecified-encoding",
43
  ]
44
 
 
 
 
 
 
 
 
 
 
 
45
  [tool.uv.sources]
46
  # For local dev with SSH: uv pip install -e .
47
  # For Docker builds with token: GH_TOKEN=<token> uv pip install -e .
 
42
  "unspecified-encoding",
43
  ]
44
 
45
+ [tool.uv]
46
+ # Override PyTorch version constraints from mussel[torch-gpu].
47
+ # mussel pins torch<2.6 which may be too strict; we relax to >=2.0.0.
48
+ # The override applies on ALL platforms (not just macOS) so Docker builds
49
+ # still get torch installed.
50
+ override-dependencies = [
51
+ "torch>=2.0.0",
52
+ "torchvision>=0.15.0",
53
+ ]
54
+
55
  [tool.uv.sources]
56
  # For local dev with SSH: uv pip install -e .
57
  # For Docker builds with token: GH_TOKEN=<token> uv pip install -e .
src/mosaic/analysis.py CHANGED
@@ -772,6 +772,7 @@ def analyze_slide(
772
  analysis_id=None,
773
  session_hash=None,
774
  profile=None,
 
775
  ):
776
  """Analyze a whole slide image for cancer subtype and biomarker prediction.
777
 
@@ -810,7 +811,7 @@ def analyze_slide(
810
  raise gr.Error("Please upload a slide.")
811
 
812
  # Extract user info for telemetry (HF Spaces only)
813
- user_info = extract_user_info(request, IS_HF_SPACES, profile)
814
 
815
  # Initialize telemetry for resource tracking
816
  slide_start_time = time_module.time()
 
772
  analysis_id=None,
773
  session_hash=None,
774
  profile=None,
775
+ session_id: str = "",
776
  ):
777
  """Analyze a whole slide image for cancer subtype and biomarker prediction.
778
 
 
811
  raise gr.Error("Please upload a slide.")
812
 
813
  # Extract user info for telemetry (HF Spaces only)
814
+ user_info = extract_user_info(request, IS_HF_SPACES, profile, session_id)
815
 
816
  # Initialize telemetry for resource tracking
817
  slide_start_time = time_module.time()
src/mosaic/telemetry/__init__.py CHANGED
@@ -47,7 +47,6 @@ from mosaic.telemetry.utils import (
47
  StageTimer,
48
  sanitize_error_message,
49
  hash_session_id,
50
- hash_username,
51
  UserInfo,
52
  extract_user_info,
53
  )
@@ -68,7 +67,6 @@ __all__ = [
68
  "StageTimer",
69
  "sanitize_error_message",
70
  "hash_session_id",
71
- "hash_username",
72
  "UserInfo",
73
  "extract_user_info",
74
  ]
 
47
  StageTimer,
48
  sanitize_error_message,
49
  hash_session_id,
 
50
  UserInfo,
51
  extract_user_info,
52
  )
 
67
  "StageTimer",
68
  "sanitize_error_message",
69
  "hash_session_id",
 
70
  "UserInfo",
71
  "extract_user_info",
72
  ]
src/mosaic/telemetry/tracker.py CHANGED
@@ -22,7 +22,6 @@ from mosaic.telemetry.events import (
22
  from mosaic.telemetry.storage import TelemetryStorage
23
  from mosaic.telemetry.utils import (
24
  hash_session_id,
25
- hash_username,
26
  sanitize_error_message,
27
  )
28
 
@@ -256,7 +255,7 @@ class TelemetryTracker:
256
  success=success,
257
  cached_slide_count=cached_slide_count,
258
  is_logged_in=is_logged_in,
259
- hf_username=hash_username(hf_username),
260
  )
261
  self.storage.write_usage_event(event)
262
 
@@ -332,7 +331,7 @@ class TelemetryTracker:
332
  gpu_type=gpu_type,
333
  peak_gpu_memory_gb=peak_gpu_memory_gb,
334
  is_logged_in=is_logged_in,
335
- hf_username=hash_username(hf_username),
336
  )
337
  self.storage.write_resource_event(event)
338
 
@@ -377,7 +376,7 @@ class TelemetryTracker:
377
  slide_count=slide_count,
378
  gpu_type=gpu_type,
379
  is_logged_in=is_logged_in,
380
- hf_username=hash_username(hf_username),
381
  )
382
  self.storage.write_failure_event(event)
383
 
 
22
  from mosaic.telemetry.storage import TelemetryStorage
23
  from mosaic.telemetry.utils import (
24
  hash_session_id,
 
25
  sanitize_error_message,
26
  )
27
 
 
255
  success=success,
256
  cached_slide_count=cached_slide_count,
257
  is_logged_in=is_logged_in,
258
+ hf_username=hf_username,
259
  )
260
  self.storage.write_usage_event(event)
261
 
 
331
  gpu_type=gpu_type,
332
  peak_gpu_memory_gb=peak_gpu_memory_gb,
333
  is_logged_in=is_logged_in,
334
+ hf_username=hf_username,
335
  )
336
  self.storage.write_resource_event(event)
337
 
 
376
  slide_count=slide_count,
377
  gpu_type=gpu_type,
378
  is_logged_in=is_logged_in,
379
+ hf_username=hf_username,
380
  )
381
  self.storage.write_failure_event(event)
382
 
src/mosaic/telemetry/utils.py CHANGED
@@ -5,7 +5,7 @@ This module provides helper utilities:
5
  - sanitize_error_message: Remove sensitive data from error messages
6
  - hash_session_id: Hash session IDs for privacy
7
  - UserInfo: Dataclass for user information from HF Spaces
8
- - extract_user_info: Extract user info from Gradio request object
9
  """
10
 
11
  import hashlib
@@ -99,25 +99,6 @@ def hash_session_id(session_id: Optional[str]) -> Optional[str]:
99
  return hashlib.sha256(salted.encode()).hexdigest()[:16]
100
 
101
 
102
- def hash_username(username: Optional[str]) -> Optional[str]:
103
- """Hash a username for privacy in telemetry.
104
-
105
- Uses SHA-256 with a different salt than session IDs to create a one-way hash.
106
- This allows distinguishing users in telemetry without storing actual usernames.
107
-
108
- Args:
109
- username: HuggingFace username (can be None for anonymous users)
110
-
111
- Returns:
112
- Hashed username or None if input is None
113
- """
114
- if username is None:
115
- return None
116
-
117
- salted = f"mosaic_user:{username}"
118
- return hashlib.sha256(salted.encode()).hexdigest()[:16]
119
-
120
-
121
  @dataclass
122
  class UserInfo:
123
  """User information extracted from HF Spaces request.
@@ -131,7 +112,12 @@ class UserInfo:
131
  username: Optional[str] = None
132
 
133
 
134
- def extract_user_info(request, is_hf_spaces: bool = False, profile=None) -> UserInfo:
 
 
 
 
 
135
  """Extract user info from Gradio OAuth profile.
136
 
137
  With gr.LoginButton(), Gradio provides user info via gr.OAuthProfile
@@ -145,21 +131,51 @@ def extract_user_info(request, is_hf_spaces: bool = False, profile=None) -> User
145
  request: Gradio request object (unused, kept for API compatibility)
146
  is_hf_spaces: Whether running on HuggingFace Spaces (only extract on HF)
147
  profile: Gradio OAuthProfile object (injected by LoginButton OAuth flow)
 
 
148
 
149
  Returns:
150
  UserInfo with is_logged_in and username (or defaults if not available)
151
 
152
  Example:
153
- user_info = extract_user_info(request, IS_HF_SPACES, profile)
154
  if user_info.is_logged_in:
155
  print(f"User: {user_info.username}")
156
  """
157
  if not is_hf_spaces:
158
  return UserInfo()
159
 
160
- # On HF Spaces, only OAuthProfile reliably identifies the logged-in user.
161
- # request.username returns the Space owner's username (not the visitor's),
162
- # so we must NOT fall back to it.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  if profile is not None:
164
  try:
165
  username = getattr(profile, "username", None)
@@ -169,5 +185,5 @@ def extract_user_info(request, is_hf_spaces: bool = False, profile=None) -> User
169
  except Exception as e:
170
  logger.debug(f"Could not extract username from OAuthProfile: {e}")
171
 
172
- logger.debug("User not logged in: no OAuthProfile available")
173
  return UserInfo()
 
5
  - sanitize_error_message: Remove sensitive data from error messages
6
  - hash_session_id: Hash session IDs for privacy
7
  - UserInfo: Dataclass for user information from HF Spaces
8
+ - extract_user_info: Extract user info from Gradio request/OAuth profile
9
  """
10
 
11
  import hashlib
 
99
  return hashlib.sha256(salted.encode()).hexdigest()[:16]
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  @dataclass
103
  class UserInfo:
104
  """User information extracted from HF Spaces request.
 
112
  username: Optional[str] = None
113
 
114
 
115
+ def extract_user_info(
116
+ request,
117
+ is_hf_spaces: bool = False,
118
+ profile=None,
119
+ session_id: Optional[str] = None,
120
+ ) -> UserInfo:
121
  """Extract user info from Gradio OAuth profile.
122
 
123
  With gr.LoginButton(), Gradio provides user info via gr.OAuthProfile
 
131
  request: Gradio request object (unused, kept for API compatibility)
132
  is_hf_spaces: Whether running on HuggingFace Spaces (only extract on HF)
133
  profile: Gradio OAuthProfile object (injected by LoginButton OAuth flow)
134
+ session_id: Session ID from localStorage (primary lookup on HF Spaces
135
+ where the HF proxy strips custom cookies)
136
 
137
  Returns:
138
  UserInfo with is_logged_in and username (or defaults if not available)
139
 
140
  Example:
141
+ user_info = extract_user_info(request, IS_HF_SPACES, profile, session_id)
142
  if user_info.is_logged_in:
143
  print(f"User: {user_info.username}")
144
  """
145
  if not is_hf_spaces:
146
  return UserInfo()
147
 
148
+ # On HF Spaces with Docker SDK, the HF proxy strips custom cookies.
149
+ # The most reliable method is looking up the session ID that was stored
150
+ # in localStorage by the OAuth callback page.
151
+ if session_id:
152
+ try:
153
+ from mosaic.ui.oauth import get_user_by_session_id
154
+
155
+ userinfo = get_user_by_session_id(session_id)
156
+ if userinfo:
157
+ username = userinfo.get("preferred_username")
158
+ if username:
159
+ logger.info(f"Extracted user from session ID: {username}")
160
+ return UserInfo(is_logged_in=True, username=username)
161
+ except Exception as e:
162
+ logger.debug(f"Session ID lookup failed: {e}")
163
+
164
+ # Fallback: server-side session via mosaic_auth cookie
165
+ if request is not None:
166
+ try:
167
+ from mosaic.ui.oauth import get_user_from_server_session
168
+
169
+ userinfo = get_user_from_server_session(request)
170
+ if userinfo:
171
+ username = userinfo.get("preferred_username")
172
+ if username:
173
+ logger.info(f"Extracted user from server-side session: {username}")
174
+ return UserInfo(is_logged_in=True, username=username)
175
+ except Exception as e:
176
+ logger.debug(f"Server-side session lookup failed: {e}")
177
+
178
+ # Fallback: OAuthProfile (may be unreliable on Docker SDK Spaces)
179
  if profile is not None:
180
  try:
181
  username = getattr(profile, "username", None)
 
185
  except Exception as e:
186
  logger.debug(f"Could not extract username from OAuthProfile: {e}")
187
 
188
+ logger.debug("User not logged in: no session or OAuthProfile available")
189
  return UserInfo()
src/mosaic/ui/app.py CHANGED
@@ -190,6 +190,7 @@ def analyze_slides(
190
  seg_config,
191
  user_dir,
192
  slide_ids=None, # Mapping from filename to slide_id
 
193
  progress=gr.Progress(track_tqdm=True),
194
  request: gr.Request = None,
195
  profile: Optional[gr.OAuthProfile] = None,
@@ -228,7 +229,7 @@ def analyze_slides(
228
 
229
  # Extract user info for telemetry (HF Spaces only)
230
  # gr.LoginButton() OAuth provides user info via OAuthProfile
231
- user_info = extract_user_info(request, IS_HF_SPACES, profile)
232
 
233
  # Wait for core models download to complete (Paladin models can continue in background)
234
  if _model_download_thread is not None and not _core_models_complete:
@@ -472,6 +473,7 @@ def analyze_slides(
472
  analysis_id=analysis_id, # For telemetry tracking
473
  session_hash=session_hash, # For telemetry tracking
474
  profile=profile, # For OAuth user info
 
475
  )
476
 
477
  # Save results to cache if this is a TCGA slide
@@ -759,8 +761,18 @@ def launch_gradio(server_name, server_port, share):
759
  # stays permanently hidden.
760
  login_button.load_event_to_attach = None
761
 
762
- # Custom login display (OAuthProfile-based, not request.username)
763
- login_status_md = gr.Markdown(value="", visible=False)
 
 
 
 
 
 
 
 
 
 
764
 
765
  gr.Markdown(
766
  "# Mosaic: H&E Whole Slide Image Cancer Subtype and Biomarker Inference"
@@ -1211,9 +1223,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1211
  gr.Group(visible=False),
1212
  gr.Dataframe(visible=False),
1213
  gr.File(visible=False),
1214
- gr.Row(
1215
- visible=not IS_HF_SPACES
1216
- ), # Show existing files row in upload mode
1217
  )
1218
  else:
1219
  return (
@@ -1226,13 +1236,14 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1226
 
1227
  # Load storage usage and show warnings
1228
  def load_storage_usage(
 
1229
  request: gr.Request = None,
1230
  profile: Optional[gr.OAuthProfile] = None,
1231
  ):
1232
  """Load and display storage usage with warnings."""
1233
  from mosaic.ui.user_tabs import _get_username
1234
 
1235
- username, is_local = _get_username(request, profile)
1236
 
1237
  if not username:
1238
  # Not logged in on HF Spaces - hide storage info
@@ -1282,13 +1293,14 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1282
 
1283
  # Load list of existing slides for re-analysis
1284
  def load_existing_slides(
 
1285
  request: gr.Request = None,
1286
  profile: Optional[gr.OAuthProfile] = None,
1287
  ):
1288
  """Load dropdown with user's existing slides."""
1289
  from mosaic.ui.user_tabs import _get_username
1290
 
1291
- username, _ = _get_username(request, profile)
1292
 
1293
  if not username:
1294
  return gr.Dropdown(choices=[], value=None)
@@ -1326,6 +1338,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1326
  def handle_slide_upload(
1327
  files,
1328
  slide_ids,
 
1329
  request: gr.Request = None,
1330
  profile: Optional[gr.OAuthProfile] = None,
1331
  ):
@@ -1336,7 +1349,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1336
  # Determine username via OAuthProfile (primary) or request (fallback)
1337
  from mosaic.ui.user_tabs import _get_username
1338
 
1339
- username, _ = _get_username(request, profile)
1340
  if not username:
1341
  return files, slide_ids or {}
1342
 
@@ -1500,34 +1513,47 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1500
  ],
1501
  )
1502
 
 
 
 
 
 
 
 
 
 
 
 
 
1503
  # Handle file uploads - regenerate entire settings table
1504
  # Save uploaded slides to user storage (HF Spaces only)
1505
  input_slides.upload(
1506
  handle_slide_upload,
1507
- inputs=[input_slides, slide_ids_state],
1508
  outputs=[input_slides, slide_ids_state],
1509
  ).then(
1510
  # Refresh storage usage after upload
1511
  load_storage_usage,
1512
- inputs=None,
1513
  outputs=[storage_usage_warning, existing_file_row],
1514
  ).then(
1515
  # Refresh existing slides list after upload
1516
  load_existing_slides,
1517
- inputs=None,
1518
  outputs=[existing_slides_dropdown],
1519
  )
1520
 
1521
  # Refresh existing slides dropdown
1522
  refresh_existing_btn.click(
1523
  load_existing_slides,
1524
- inputs=None,
1525
  outputs=[existing_slides_dropdown],
1526
  )
1527
 
1528
  # Handle selection of existing slide(s) for re-analysis
1529
  def select_existing_slide(
1530
  slide_ids,
 
1531
  request: gr.Request = None,
1532
  profile: Optional[gr.OAuthProfile] = None,
1533
  ):
@@ -1535,6 +1561,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1535
 
1536
  Args:
1537
  slide_ids: Single slide_id (str) or list of slide_ids (multiselect)
 
1538
  request: Gradio request object
1539
  profile: OAuthProfile for HF Spaces login
1540
 
@@ -1550,7 +1577,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1550
 
1551
  from mosaic.ui.user_tabs import _get_username
1552
 
1553
- username, _ = _get_username(request, profile)
1554
  if not username:
1555
  return None
1556
 
@@ -1593,7 +1620,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1593
 
1594
  existing_slides_dropdown.change(
1595
  select_existing_slide,
1596
- inputs=[existing_slides_dropdown],
1597
  outputs=[input_slides],
1598
  )
1599
 
@@ -1732,6 +1759,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1732
  seg_config,
1733
  user_dir,
1734
  slide_ids,
 
1735
  progress=gr.Progress(track_tqdm=True),
1736
  request: gr.Request = None,
1737
  profile: Optional[gr.OAuthProfile] = None,
@@ -1777,6 +1805,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1777
  seg_config,
1778
  user_dir,
1779
  slide_ids=slide_ids,
 
1780
  progress=progress,
1781
  request=request,
1782
  profile=profile,
@@ -1797,6 +1826,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1797
  seg_config_dropdown,
1798
  user_dir_state,
1799
  slide_ids_state,
 
1800
  ],
1801
  outputs=[
1802
  settings_input,
@@ -1863,20 +1893,6 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1863
  outputs=[user_dir_state],
1864
  )
1865
 
1866
- # Load storage usage on page load
1867
- demo.load(
1868
- load_storage_usage,
1869
- inputs=None,
1870
- outputs=[storage_usage_warning, existing_file_row],
1871
- )
1872
-
1873
- # Load existing slides dropdown on page load
1874
- demo.load(
1875
- load_existing_slides,
1876
- inputs=None,
1877
- outputs=[existing_slides_dropdown],
1878
- )
1879
-
1880
  # Add My Files and My Results tabs for user storage
1881
  # On HF Spaces: visible for logged-in users
1882
  # Locally: always visible with "local_user" for debugging
@@ -1901,81 +1917,169 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1901
  with gr.Tab("My Files"):
1902
  from mosaic.ui.user_tabs import create_my_files_tab
1903
 
1904
- my_files_components = create_my_files_tab()
1905
-
1906
- # Load files on demo load
1907
- demo.load(
1908
- my_files_components["load_files"],
1909
- inputs=None,
1910
- outputs=[
1911
- my_files_components["storage_usage"],
1912
- my_files_components["files_table"],
1913
- my_files_components["file_action_status"],
1914
- my_files_components["slide_download_file"],
1915
- ],
1916
- )
1917
 
1918
  with gr.Tab("My Results"):
1919
  from mosaic.ui.user_tabs import create_my_results_tab
1920
 
1921
- my_results_components = create_my_results_tab()
1922
 
1923
- # Load results on demo load
1924
- demo.load(
1925
- my_results_components["load_results"],
1926
- inputs=None,
1927
- outputs=[
1928
- my_results_components["results_table"],
1929
- my_results_components["result_action_status"],
1930
- my_results_components["result_download_file"],
1931
- ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1932
  )
1933
 
1934
- # Show/hide user storage features based on login state on page load.
1935
- # LoginButton.click() fires BEFORE the OAuth redirect so the profile
1936
- # is never available at that point. Instead we rely on demo.load()
1937
- # which fires after the OAuth redirect completes and the page reloads.
1938
- #
1939
- # On HF Spaces with sdk:docker, LoginButton is always hidden because
1940
- # it reads request.username (which returns the Space owner for all
1941
- # visitors). Instead we use login_status_md with direct OAuth links.
1942
- if IS_HF_SPACES:
1943
 
1944
- def check_login_state(profile: Optional[gr.OAuthProfile] = None):
1945
- """Toggle login status display and user-storage UI based on OAuth state."""
1946
- if profile is not None:
1947
- username = getattr(profile, "username", None) or getattr(
1948
- profile, "name", "User"
1949
- )
1950
- return (
1951
  gr.update(
1952
- value=f"Signed in as **{username}** \u00b7 [Sign out](/logout)",
1953
  visible=True,
1954
- ), # login_status_md
1955
- gr.update(visible=True), # user_storage_tabs
1956
- gr.update(visible=True), # user_storage_header
1957
- gr.update(visible=True), # user_storage_info
 
1958
  )
1959
  else:
1960
- return (
1961
- gr.update(
1962
- value="[Sign in with HuggingFace](/login/huggingface) to save slides and results",
1963
- visible=True,
1964
- ), # login_status_md
1965
- gr.update(visible=False), # user_storage_tabs
1966
- gr.update(visible=False), # user_storage_header
1967
- gr.update(visible=False), # user_storage_info
 
 
 
 
 
 
 
 
1968
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1969
 
1970
  demo.load(
1971
- check_login_state,
1972
- inputs=None,
1973
  outputs=[
 
1974
  login_status_md,
 
1975
  user_storage_tabs,
1976
  user_storage_header,
1977
  user_storage_info,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1978
  ],
 
 
 
 
 
 
 
 
 
 
 
1979
  )
1980
 
1981
  # Use hardware-specific concurrency limit
@@ -1983,6 +2087,13 @@ This tool is for research purposes only and not approved for clinical diagnosis.
1983
  # Higher-memory GPUs and ZeroGPU can handle multiple concurrent analyses
1984
  demo.queue(max_size=10, default_concurrency_limit=DEFAULT_CONCURRENCY_LIMIT)
1985
 
 
 
 
 
 
 
 
1986
  # Register cleanup handler for graceful shutdown
1987
  import atexit
1988
 
@@ -2014,10 +2125,20 @@ This tool is for research purposes only and not approved for clinical diagnosis.
2014
  heartbeat_thread = threading.Thread(target=heartbeat_worker, daemon=True)
2015
  heartbeat_thread.start()
2016
 
 
 
 
 
 
 
 
 
 
2017
  demo.launch(
2018
  server_name=server_name,
2019
  share=share,
2020
  server_port=server_port,
2021
  show_error=True,
2022
  favicon_path=current_dir / "favicon.svg",
 
2023
  )
 
190
  seg_config,
191
  user_dir,
192
  slide_ids=None, # Mapping from filename to slide_id
193
+ session_id: str = "",
194
  progress=gr.Progress(track_tqdm=True),
195
  request: gr.Request = None,
196
  profile: Optional[gr.OAuthProfile] = None,
 
229
 
230
  # Extract user info for telemetry (HF Spaces only)
231
  # gr.LoginButton() OAuth provides user info via OAuthProfile
232
+ user_info = extract_user_info(request, IS_HF_SPACES, profile, session_id)
233
 
234
  # Wait for core models download to complete (Paladin models can continue in background)
235
  if _model_download_thread is not None and not _core_models_complete:
 
473
  analysis_id=analysis_id, # For telemetry tracking
474
  session_hash=session_hash, # For telemetry tracking
475
  profile=profile, # For OAuth user info
476
+ session_id=session_id, # For OAuth session lookup
477
  )
478
 
479
  # Save results to cache if this is a TCGA slide
 
761
  # stays permanently hidden.
762
  login_button.load_event_to_attach = None
763
 
764
+ # Custom login display — sign-in via markdown link,
765
+ # sign-out via a Gradio button (stays in Gradio event system,
766
+ # avoiding navigation away from the SPA).
767
+ with gr.Row():
768
+ login_status_md = gr.Markdown(
769
+ value="[Sign in with HuggingFace](/api/auth/login) to save slides and results",
770
+ visible=True,
771
+ )
772
+ logout_button = gr.Button(
773
+ "Sign out", visible=False, size="sm", scale=0,
774
+ min_width=80,
775
+ )
776
 
777
  gr.Markdown(
778
  "# Mosaic: H&E Whole Slide Image Cancer Subtype and Biomarker Inference"
 
1223
  gr.Group(visible=False),
1224
  gr.Dataframe(visible=False),
1225
  gr.File(visible=False),
1226
+ gr.Row(visible=True), # Show existing files row in upload mode
 
 
1227
  )
1228
  else:
1229
  return (
 
1236
 
1237
  # Load storage usage and show warnings
1238
  def load_storage_usage(
1239
+ session_id: str = "",
1240
  request: gr.Request = None,
1241
  profile: Optional[gr.OAuthProfile] = None,
1242
  ):
1243
  """Load and display storage usage with warnings."""
1244
  from mosaic.ui.user_tabs import _get_username
1245
 
1246
+ username, is_local = _get_username(request, profile, session_id)
1247
 
1248
  if not username:
1249
  # Not logged in on HF Spaces - hide storage info
 
1293
 
1294
  # Load list of existing slides for re-analysis
1295
  def load_existing_slides(
1296
+ session_id: str = "",
1297
  request: gr.Request = None,
1298
  profile: Optional[gr.OAuthProfile] = None,
1299
  ):
1300
  """Load dropdown with user's existing slides."""
1301
  from mosaic.ui.user_tabs import _get_username
1302
 
1303
+ username, _ = _get_username(request, profile, session_id)
1304
 
1305
  if not username:
1306
  return gr.Dropdown(choices=[], value=None)
 
1338
  def handle_slide_upload(
1339
  files,
1340
  slide_ids,
1341
+ session_id: str = "",
1342
  request: gr.Request = None,
1343
  profile: Optional[gr.OAuthProfile] = None,
1344
  ):
 
1349
  # Determine username via OAuthProfile (primary) or request (fallback)
1350
  from mosaic.ui.user_tabs import _get_username
1351
 
1352
+ username, _ = _get_username(request, profile, session_id)
1353
  if not username:
1354
  return files, slide_ids or {}
1355
 
 
1513
  ],
1514
  )
1515
 
1516
+ # Hidden textbox that carries the localStorage session ID into
1517
+ # every Gradio event handler that needs the visitor's identity.
1518
+ # On HF Spaces the js= parameter on demo.load() fills this from
1519
+ # localStorage; locally it stays empty and _get_username() falls
1520
+ # back to LOCAL_DEBUG_USERNAME.
1521
+ session_id_input = gr.Textbox(visible=False, elem_id="session_id_input")
1522
+ # Hidden flag set to "1" only by the demo.load js= parameter.
1523
+ # On WebSocket reconnections JS doesn't re-run, so this stays
1524
+ # at its default "". The Python handler uses it to detect
1525
+ # reconnections and return gr.skip() to preserve UI state.
1526
+ _js_ran_flag = gr.Textbox(visible=False, value="")
1527
+
1528
  # Handle file uploads - regenerate entire settings table
1529
  # Save uploaded slides to user storage (HF Spaces only)
1530
  input_slides.upload(
1531
  handle_slide_upload,
1532
+ inputs=[input_slides, slide_ids_state, session_id_input],
1533
  outputs=[input_slides, slide_ids_state],
1534
  ).then(
1535
  # Refresh storage usage after upload
1536
  load_storage_usage,
1537
+ inputs=[session_id_input],
1538
  outputs=[storage_usage_warning, existing_file_row],
1539
  ).then(
1540
  # Refresh existing slides list after upload
1541
  load_existing_slides,
1542
+ inputs=[session_id_input],
1543
  outputs=[existing_slides_dropdown],
1544
  )
1545
 
1546
  # Refresh existing slides dropdown
1547
  refresh_existing_btn.click(
1548
  load_existing_slides,
1549
+ inputs=[session_id_input],
1550
  outputs=[existing_slides_dropdown],
1551
  )
1552
 
1553
  # Handle selection of existing slide(s) for re-analysis
1554
  def select_existing_slide(
1555
  slide_ids,
1556
+ session_id: str = "",
1557
  request: gr.Request = None,
1558
  profile: Optional[gr.OAuthProfile] = None,
1559
  ):
 
1561
 
1562
  Args:
1563
  slide_ids: Single slide_id (str) or list of slide_ids (multiselect)
1564
+ session_id: Server-side session ID from localStorage
1565
  request: Gradio request object
1566
  profile: OAuthProfile for HF Spaces login
1567
 
 
1577
 
1578
  from mosaic.ui.user_tabs import _get_username
1579
 
1580
+ username, _ = _get_username(request, profile, session_id)
1581
  if not username:
1582
  return None
1583
 
 
1620
 
1621
  existing_slides_dropdown.change(
1622
  select_existing_slide,
1623
+ inputs=[existing_slides_dropdown, session_id_input],
1624
  outputs=[input_slides],
1625
  )
1626
 
 
1759
  seg_config,
1760
  user_dir,
1761
  slide_ids,
1762
+ session_id,
1763
  progress=gr.Progress(track_tqdm=True),
1764
  request: gr.Request = None,
1765
  profile: Optional[gr.OAuthProfile] = None,
 
1805
  seg_config,
1806
  user_dir,
1807
  slide_ids=slide_ids,
1808
+ session_id=session_id,
1809
  progress=progress,
1810
  request=request,
1811
  profile=profile,
 
1826
  seg_config_dropdown,
1827
  user_dir_state,
1828
  slide_ids_state,
1829
+ session_id_input,
1830
  ],
1831
  outputs=[
1832
  settings_input,
 
1893
  outputs=[user_dir_state],
1894
  )
1895
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1896
  # Add My Files and My Results tabs for user storage
1897
  # On HF Spaces: visible for logged-in users
1898
  # Locally: always visible with "local_user" for debugging
 
1917
  with gr.Tab("My Files"):
1918
  from mosaic.ui.user_tabs import create_my_files_tab
1919
 
1920
+ my_files_components = create_my_files_tab(session_id_input)
 
 
 
 
 
 
 
 
 
 
 
 
1921
 
1922
  with gr.Tab("My Results"):
1923
  from mosaic.ui.user_tabs import create_my_results_tab
1924
 
1925
+ my_results_components = create_my_results_tab(session_id_input)
1926
 
1927
+ # On HF Spaces: demo.load(js=...) reads the session ID from
1928
+ # localStorage AND sets a "JS ran" flag. Gradio re-fires
1929
+ # demo.load() on WebSocket reconnections WITHOUT re-executing
1930
+ # the JS — so the flag stays at its default "". The Python
1931
+ # handler detects this and returns gr.skip() to preserve the
1932
+ # UI state instead of overwriting the logged-in display.
1933
+ if IS_HF_SPACES:
1934
+ _load_files = my_files_components["load_files"]
1935
+ _load_results = my_results_components["load_results"]
1936
+
1937
+ _LS_JS = "() => [localStorage.getItem('mosaic_session_id') || '', '1']"
1938
+
1939
+ def on_page_load(session_id_from_browser, js_ran):
1940
+ """Handle page load: check login, load files, load results.
1941
+
1942
+ On WebSocket reconnections, Gradio re-fires demo.load()
1943
+ but does NOT re-execute the js= parameter. The js_ran
1944
+ flag stays at its default "" on reconnections, allowing
1945
+ us to return gr.skip() and preserve the current UI state.
1946
+ """
1947
+ from mosaic.ui.oauth import get_user_by_session_id, _sessions
1948
+
1949
+ # Reconnection detection: JS didn't run → skip all updates
1950
+ if js_ran != "1":
1951
+ logger.debug("on_page_load: reconnection detected, skipping")
1952
+ return gr.skip()
1953
+
1954
+ sid_repr = (
1955
+ session_id_from_browser[:8] + "..."
1956
+ if session_id_from_browser
1957
+ else "(empty)"
1958
+ )
1959
+ logger.info(
1960
+ f"on_page_load: sid={sid_repr}, "
1961
+ f"active_sessions={len(_sessions)}"
1962
  )
1963
 
1964
+ if not session_id_from_browser:
1965
+ return _build_logged_out_result()
 
 
 
 
 
 
 
1966
 
1967
+ userinfo = get_user_by_session_id(session_id_from_browser)
1968
+
1969
+ if userinfo is not None:
1970
+ username = userinfo.get("preferred_username", "User")
1971
+ login_out = (
 
 
1972
  gr.update(
1973
+ value=f"Signed in as **{username}**",
1974
  visible=True,
1975
+ ),
1976
+ gr.update(visible=True), # logout_button
1977
+ gr.update(visible=True), # user_storage_tabs
1978
+ gr.update(visible=True), # user_storage_header
1979
+ gr.update(visible=True), # user_storage_info
1980
  )
1981
  else:
1982
+ return _build_logged_out_result()
1983
+
1984
+ # Load storage usage + existing slides for the analysis page
1985
+ storage_out = load_storage_usage(session_id=session_id_from_browser)
1986
+ existing_out = load_existing_slides(session_id=session_id_from_browser)
1987
+
1988
+ # Load user files and results tabs
1989
+ try:
1990
+ files_out = _load_files(session_id=session_id_from_browser)
1991
+ except Exception as e:
1992
+ logger.error(f"on_page_load: _load_files error: {e}")
1993
+ files_out = (
1994
+ f"**Storage:** Error: {e}",
1995
+ [],
1996
+ gr.Textbox(visible=False),
1997
+ gr.File(visible=False),
1998
  )
1999
+ try:
2000
+ results_out = _load_results(
2001
+ session_id=session_id_from_browser
2002
+ )
2003
+ except Exception as e:
2004
+ logger.error(f"on_page_load: _load_results error: {e}")
2005
+ results_out = (
2006
+ [],
2007
+ gr.Textbox(visible=False),
2008
+ gr.File(visible=False),
2009
+ )
2010
+
2011
+ # login(5) + session_id(1) + storage(2) + existing(1)
2012
+ # + files(4) + results(3) = 16
2013
+ return (
2014
+ *login_out,
2015
+ session_id_from_browser,
2016
+ *storage_out,
2017
+ existing_out,
2018
+ *files_out,
2019
+ *results_out,
2020
+ )
2021
+
2022
+ def _build_logged_out_result():
2023
+ """Build the full logged-out output tuple (16 elements)."""
2024
+ return (
2025
+ gr.update(
2026
+ value="[Sign in with HuggingFace](/api/auth/login) to save slides and results",
2027
+ visible=True,
2028
+ ),
2029
+ gr.update(visible=False), # logout_button
2030
+ gr.update(visible=False), # user_storage_tabs
2031
+ gr.update(visible=False), # user_storage_header
2032
+ gr.update(visible=False), # user_storage_info
2033
+ "", # session_id_input
2034
+ gr.Markdown(visible=False), # storage_usage_warning
2035
+ gr.Row(visible=False), # existing_file_row
2036
+ gr.Dropdown(choices=[], value=None), # existing_slides_dropdown
2037
+ "**Storage:** Not logged in",
2038
+ [], # files_table
2039
+ gr.Textbox(visible=False),
2040
+ gr.File(visible=False),
2041
+ [], # results_table
2042
+ gr.Textbox(visible=False),
2043
+ gr.File(visible=False),
2044
+ )
2045
 
2046
  demo.load(
2047
+ on_page_load,
2048
+ inputs=[session_id_input, _js_ran_flag],
2049
  outputs=[
2050
+ # login state (5)
2051
  login_status_md,
2052
+ logout_button,
2053
  user_storage_tabs,
2054
  user_storage_header,
2055
  user_storage_info,
2056
+ # persist session_id for subsequent button clicks (1)
2057
+ session_id_input,
2058
+ # analysis page: storage + existing slides (3)
2059
+ storage_usage_warning,
2060
+ existing_file_row,
2061
+ existing_slides_dropdown,
2062
+ # files tab (4)
2063
+ my_files_components["storage_usage"],
2064
+ my_files_components["files_table"],
2065
+ my_files_components["file_action_status"],
2066
+ my_files_components["slide_download_file"],
2067
+ # results tab (3)
2068
+ my_results_components["results_table"],
2069
+ my_results_components["result_action_status"],
2070
+ my_results_components["result_download_file"],
2071
  ],
2072
+ js=_LS_JS,
2073
+ )
2074
+
2075
+ # Sign-out: clear localStorage and reload.
2076
+ # On reload, demo.load fires; JS reads empty localStorage
2077
+ # and returns ['', '1']; on_page_load sees empty session_id
2078
+ # and returns the logged-out state.
2079
+ logout_button.click(
2080
+ fn=None,
2081
+ inputs=None,
2082
+ js="() => { localStorage.removeItem('mosaic_session_id'); location.reload(); }",
2083
  )
2084
 
2085
  # Use hardware-specific concurrency limit
 
2087
  # Higher-memory GPUs and ZeroGPU can handle multiple concurrent analyses
2088
  demo.queue(max_size=10, default_concurrency_limit=DEFAULT_CONCURRENCY_LIMIT)
2089
 
2090
+ # OAuth routes for HF Spaces (sdk:docker):
2091
+ # Custom OAuth routes (/api/auth/login, /api/auth/callback, /api/auth/logout)
2092
+ # are mounted via the lifespan protocol in create_oauth_lifespan(). This
2093
+ # ensures routes survive launch() recreating the app. Identity is read from
2094
+ # our server-side session (mosaic_auth cookie), not gr.OAuthProfile, which
2095
+ # returns the Space owner's identity on Docker SDK Spaces.
2096
+
2097
  # Register cleanup handler for graceful shutdown
2098
  import atexit
2099
 
 
2125
  heartbeat_thread = threading.Thread(target=heartbeat_worker, daemon=True)
2126
  heartbeat_thread.start()
2127
 
2128
+ # On HF Spaces, mount custom OAuth routes via the lifespan protocol.
2129
+ # Gradio's launch() recreates the app, but the lifespan handler receives
2130
+ # the final App instance, so routes added there survive.
2131
+ launch_kwargs = {}
2132
+ if IS_HF_SPACES:
2133
+ from mosaic.ui.oauth import create_oauth_lifespan
2134
+
2135
+ launch_kwargs["app_kwargs"] = {"lifespan": create_oauth_lifespan}
2136
+
2137
  demo.launch(
2138
  server_name=server_name,
2139
  share=share,
2140
  server_port=server_port,
2141
  show_error=True,
2142
  favicon_path=current_dir / "favicon.svg",
2143
+ **launch_kwargs,
2144
  )
src/mosaic/ui/oauth.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Manual OAuth flow for HF Spaces with Docker SDK.
2
+
3
+ On HF Spaces with sdk:docker, Gradio's built-in OAuth (gr.LoginButton /
4
+ gr.OAuthProfile) doesn't work because the HF reverse proxy injects the
5
+ Space owner's identity into every session. This module implements the
6
+ Authorization Code flow directly against HF's OAuth provider.
7
+
8
+ The HF proxy strips all custom cookies — only its own forged ``session``
9
+ cookie survives. To persist the visitor's session ID across page loads,
10
+ the callback stores it in the browser's ``localStorage`` via an
11
+ intermediate HTML page. The Gradio ``demo.load()`` handler reads it back
12
+ via a ``js=`` parameter and passes it to the Python handler, which looks
13
+ up the server-side session.
14
+
15
+ Environment variables (set automatically by HF Spaces):
16
+ OAUTH_CLIENT_ID: OAuth application client ID
17
+ OAUTH_CLIENT_SECRET: OAuth application client secret
18
+ OAUTH_SCOPES: Space-separated scopes (default: "openid profile")
19
+ SPACE_HOST: Public hostname of the Space (e.g. "user-space.hf.space")
20
+
21
+ Routes mounted on the Gradio ASGI app:
22
+ GET /api/auth/login -> Redirect to HF authorize endpoint
23
+ GET /api/auth/callback -> Exchange code for token, store session via localStorage
24
+ GET /api/auth/logout -> Clear session, clear localStorage, redirect to /
25
+ """
26
+
27
+ import contextlib
28
+ import html as html_module
29
+ import json
30
+ import os
31
+ import secrets
32
+ import time
33
+ from typing import Optional
34
+ from urllib.parse import urlencode
35
+
36
+ from loguru import logger
37
+ from starlette.requests import Request
38
+ from starlette.responses import RedirectResponse, JSONResponse, HTMLResponse
39
+ from starlette.routing import Route
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Configuration
43
+ # ---------------------------------------------------------------------------
44
+
45
+ OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID", "")
46
+ OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
47
+ OAUTH_SCOPES = os.environ.get("OAUTH_SCOPES", "openid profile")
48
+ SPACE_HOST = os.environ.get("SPACE_HOST", "")
49
+
50
+ HF_AUTHORIZE_URL = "https://huggingface.co/oauth/authorize"
51
+ HF_TOKEN_URL = "https://huggingface.co/oauth/token"
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Server-side session store
55
+ # ---------------------------------------------------------------------------
56
+
57
+ # {session_id: {"userinfo": {...}, "created_at": float}}
58
+ _sessions: dict[str, dict] = {}
59
+ _SESSION_TTL_SEC = 24 * 60 * 60 # 24 hours
60
+ _SESSION_COOKIE = "mosaic_auth" # kept for backward-compat with tests
61
+
62
+ # localStorage key used by the browser to persist the session ID
63
+ _LOCALSTORAGE_KEY = "mosaic_session_id"
64
+
65
+ # In-memory CSRF state tokens: {state_value: created_at_float}
66
+ _pending_states: dict[str, float] = {}
67
+ _STATE_TTL_SEC = 10 * 60 # 10 minutes
68
+
69
+
70
+ def _prune_expired() -> None:
71
+ """Remove expired sessions and CSRF state tokens."""
72
+ now = time.time()
73
+ expired_sessions = [
74
+ k for k, v in _sessions.items() if now - v["created_at"] > _SESSION_TTL_SEC
75
+ ]
76
+ for k in expired_sessions:
77
+ del _sessions[k]
78
+ expired_states = [k for k, v in _pending_states.items() if now - v > _STATE_TTL_SEC]
79
+ for k in expired_states:
80
+ del _pending_states[k]
81
+
82
+
83
+ def get_user_from_server_session(request) -> Optional[dict]:
84
+ """Look up user info from the server-side session store.
85
+
86
+ Checks the ``mosaic_auth`` cookie in the request and returns the
87
+ stored userinfo dict, or None if the cookie is missing/expired.
88
+
89
+ Args:
90
+ request: Starlette/Gradio request object (needs .cookies or .headers)
91
+
92
+ Returns:
93
+ userinfo dict with at least ``preferred_username`` key, or None
94
+ """
95
+ _prune_expired()
96
+
97
+ cookie_val = None
98
+ # Gradio's gr.Request wraps cookies/headers through dict_to_obj() which
99
+ # returns a gradio.route_utils.Obj, NOT a plain dict. Use hasattr/get
100
+ # instead of isinstance(dict) to support both dict and Obj.
101
+ if hasattr(request, "cookies"):
102
+ cookies = request.cookies
103
+ if hasattr(cookies, "get"):
104
+ cookie_val = cookies.get(_SESSION_COOKIE)
105
+ logger.debug(
106
+ f"Session lookup: cookies type={type(cookies).__name__}, "
107
+ f"cookie_val={cookie_val!r}"
108
+ )
109
+ # Fallback: parse Cookie header
110
+ if cookie_val is None and hasattr(request, "headers"):
111
+ headers = request.headers
112
+ cookie_header = ""
113
+ if hasattr(headers, "get"):
114
+ cookie_header = headers.get("cookie", "") or ""
115
+ if cookie_header:
116
+ for part in cookie_header.split(";"):
117
+ part = part.strip()
118
+ if part.startswith(f"{_SESSION_COOKIE}="):
119
+ cookie_val = part[len(f"{_SESSION_COOKIE}=") :]
120
+ break
121
+ logger.debug(
122
+ f"Session lookup header fallback: has_cookie_header={bool(cookie_header)}, "
123
+ f"cookie_val={cookie_val!r}"
124
+ )
125
+
126
+ if not cookie_val:
127
+ logger.debug(
128
+ f"No {_SESSION_COOKIE} cookie found. "
129
+ f"Active sessions: {len(_sessions)}"
130
+ )
131
+ return None
132
+
133
+ entry = _sessions.get(cookie_val)
134
+ if entry is None:
135
+ logger.debug(
136
+ f"{_SESSION_COOKIE} cookie present ({cookie_val[:8]}...) "
137
+ f"but no matching server session. "
138
+ f"Active sessions: {list(_sessions.keys())[:3]}"
139
+ )
140
+ return None
141
+
142
+ if time.time() - entry["created_at"] > _SESSION_TTL_SEC:
143
+ del _sessions[cookie_val]
144
+ return None
145
+
146
+ return entry.get("userinfo")
147
+
148
+
149
+ def get_user_by_session_id(session_id: Optional[str]) -> Optional[dict]:
150
+ """Look up user info by a raw session ID (from localStorage).
151
+
152
+ This is the primary lookup method on HF Spaces where the HF proxy
153
+ strips custom cookies. The session ID is stored in the browser's
154
+ localStorage by the callback page and passed to Gradio handlers via
155
+ the ``js=`` parameter on ``demo.load()``.
156
+
157
+ Args:
158
+ session_id: The session ID string (from localStorage), or empty/None.
159
+
160
+ Returns:
161
+ userinfo dict with at least ``preferred_username`` key, or None.
162
+ """
163
+ _prune_expired()
164
+
165
+ if not session_id:
166
+ return None
167
+
168
+ entry = _sessions.get(session_id)
169
+ if entry is None:
170
+ logger.debug(
171
+ f"Session ID ({session_id[:8]}...) not found. "
172
+ f"Active sessions: {len(_sessions)}"
173
+ )
174
+ return None
175
+
176
+ if time.time() - entry["created_at"] > _SESSION_TTL_SEC:
177
+ del _sessions[session_id]
178
+ return None
179
+
180
+ return entry.get("userinfo")
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Route handlers
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ async def _login(request: Request):
189
+ """Redirect to HF OAuth authorize endpoint."""
190
+ if not OAUTH_CLIENT_ID or not SPACE_HOST:
191
+ return JSONResponse(
192
+ {"error": "OAuth not configured (missing OAUTH_CLIENT_ID or SPACE_HOST)"},
193
+ status_code=500,
194
+ )
195
+
196
+ _prune_expired()
197
+ state = secrets.token_urlsafe(32)
198
+ _pending_states[state] = time.time()
199
+
200
+ # Build redirect URI pointing back to our callback
201
+ redirect_uri = f"https://{SPACE_HOST}/api/auth/callback"
202
+
203
+ params = {
204
+ "client_id": OAUTH_CLIENT_ID,
205
+ "redirect_uri": redirect_uri,
206
+ "response_type": "code",
207
+ "scope": OAUTH_SCOPES,
208
+ "state": state,
209
+ }
210
+ authorize_url = f"{HF_AUTHORIZE_URL}?{urlencode(params)}"
211
+ logger.info(f"OAuth login: redirecting to HF authorize endpoint")
212
+ return RedirectResponse(authorize_url, status_code=302)
213
+
214
+
215
+ async def _callback(request: Request):
216
+ """Handle OAuth callback: exchange code for token, set session."""
217
+ import httpx
218
+
219
+ code = request.query_params.get("code")
220
+ state = request.query_params.get("state")
221
+
222
+ if not code or not state:
223
+ return JSONResponse(
224
+ {"error": "Missing code or state parameter"}, status_code=400
225
+ )
226
+
227
+ # Validate CSRF state
228
+ _prune_expired()
229
+ created_at = _pending_states.pop(state, None)
230
+ if created_at is None:
231
+ return JSONResponse(
232
+ {"error": "Invalid or expired state parameter"}, status_code=400
233
+ )
234
+
235
+ # Exchange authorization code for access token
236
+ redirect_uri = f"https://{SPACE_HOST}/api/auth/callback"
237
+ token_data = {
238
+ "grant_type": "authorization_code",
239
+ "code": code,
240
+ "redirect_uri": redirect_uri,
241
+ "client_id": OAUTH_CLIENT_ID,
242
+ "client_secret": OAUTH_CLIENT_SECRET,
243
+ }
244
+
245
+ try:
246
+ async with httpx.AsyncClient() as client:
247
+ resp = await client.post(
248
+ HF_TOKEN_URL,
249
+ data=token_data,
250
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
251
+ )
252
+ resp.raise_for_status()
253
+ token_response = resp.json()
254
+ except httpx.HTTPStatusError as e:
255
+ logger.error(
256
+ f"OAuth token exchange failed: {e.response.status_code} {e.response.text}"
257
+ )
258
+ return JSONResponse({"error": "Token exchange failed"}, status_code=502)
259
+ except Exception as e:
260
+ logger.error(f"OAuth token exchange error: {e}")
261
+ return JSONResponse({"error": "Token exchange failed"}, status_code=502)
262
+
263
+ # Extract userinfo from the id_token (JWT) or use the userinfo endpoint
264
+ access_token = token_response.get("access_token")
265
+ userinfo = None
266
+
267
+ # Try to decode the id_token (JWT) for userinfo
268
+ id_token = token_response.get("id_token")
269
+ if id_token:
270
+ try:
271
+ # JWT is base64url-encoded: header.payload.signature
272
+ # We only need the payload (claims) — no signature verification
273
+ # since we just received this directly from HF's token endpoint
274
+ import base64
275
+
276
+ payload_b64 = id_token.split(".")[1]
277
+ # Add padding if needed
278
+ padding = 4 - len(payload_b64) % 4
279
+ if padding != 4:
280
+ payload_b64 += "=" * padding
281
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
282
+ userinfo = json.loads(payload_bytes)
283
+ except Exception as e:
284
+ logger.warning(f"Failed to decode id_token: {e}")
285
+
286
+ # Fallback: call userinfo endpoint
287
+ if userinfo is None and access_token:
288
+ try:
289
+ async with httpx.AsyncClient() as client:
290
+ resp = await client.get(
291
+ "https://huggingface.co/oauth/userinfo",
292
+ headers={"Authorization": f"Bearer {access_token}"},
293
+ )
294
+ resp.raise_for_status()
295
+ userinfo = resp.json()
296
+ except Exception as e:
297
+ logger.error(f"Failed to fetch userinfo: {e}")
298
+ return JSONResponse({"error": "Failed to get user info"}, status_code=502)
299
+
300
+ if not userinfo:
301
+ return JSONResponse({"error": "No user info received"}, status_code=502)
302
+
303
+ # Extract username
304
+ username = userinfo.get("preferred_username") or userinfo.get("sub", "unknown")
305
+ logger.info(f"OAuth callback: authenticated user '{username}'")
306
+
307
+ # Store in server-side session dict (keyed by random session ID)
308
+ session_id = secrets.token_urlsafe(32)
309
+ _sessions[session_id] = {
310
+ "userinfo": userinfo,
311
+ "created_at": time.time(),
312
+ }
313
+
314
+ # The HF proxy strips custom cookies, so we can't use Set-Cookie to
315
+ # persist the session ID. Instead, return a small HTML page that
316
+ # stores the session ID in localStorage and then redirects to /.
317
+ safe_sid = html_module.escape(session_id)
318
+ page = f"""<!DOCTYPE html>
319
+ <html><head><title>Signing in...</title></head>
320
+ <body><p>Signing in&hellip;</p>
321
+ <script>
322
+ try {{ localStorage.setItem('{_LOCALSTORAGE_KEY}', '{safe_sid}'); }}
323
+ catch(e) {{ console.warn('Could not store session:', e); }}
324
+ window.location.replace('/');
325
+ </script>
326
+ <noscript><a href="/">Click here to continue</a></noscript>
327
+ </body></html>"""
328
+ return HTMLResponse(page)
329
+
330
+
331
+ async def _logout(request: Request):
332
+ """Clear server-side session and browser localStorage, then redirect."""
333
+ # Try to clear server-side session from query param (set by JS)
334
+ sid = request.query_params.get("sid", "")
335
+ if sid and sid in _sessions:
336
+ del _sessions[sid]
337
+ logger.info(f"Cleared server-side session for logout ({sid[:8]}...)")
338
+
339
+ # Return HTML that clears localStorage and redirects
340
+ page = f"""<!DOCTYPE html>
341
+ <html><head><title>Signing out...</title></head>
342
+ <body><p>Signing out&hellip;</p>
343
+ <script>
344
+ try {{ localStorage.removeItem('{_LOCALSTORAGE_KEY}'); }}
345
+ catch(e) {{ console.warn('Could not clear session:', e); }}
346
+ window.location.replace('/');
347
+ </script>
348
+ <noscript><a href="/">Click here to continue</a></noscript>
349
+ </body></html>"""
350
+ return HTMLResponse(page)
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # Mount helper
355
+ # ---------------------------------------------------------------------------
356
+
357
+ _oauth_routes = [
358
+ Route("/api/auth/login", _login, methods=["GET"]),
359
+ Route("/api/auth/callback", _callback, methods=["GET"]),
360
+ Route("/api/auth/logout", _logout, methods=["GET"]),
361
+ ]
362
+
363
+ def mount_oauth_routes(app) -> None:
364
+ """Mount custom OAuth routes on the Gradio ASGI app.
365
+
366
+ .. deprecated::
367
+ Gradio's ``launch()`` recreates ``demo.app`` from scratch, discarding
368
+ any routes prepended beforehand. Use :func:`create_oauth_lifespan`
369
+ instead, which mounts routes on the **final** app instance via the
370
+ lifespan protocol.
371
+
372
+ Args:
373
+ app: The Starlette/FastAPI app (``demo.app``)
374
+ """
375
+ if not OAUTH_CLIENT_ID:
376
+ logger.warning(
377
+ "OAuth routes not mounted: OAUTH_CLIENT_ID not set. "
378
+ "Custom login will not work."
379
+ )
380
+ return
381
+
382
+ # Insert our routes at the beginning so they take priority
383
+ app.routes[:0] = _oauth_routes
384
+ logger.info(
385
+ f"Mounted custom OAuth routes: /api/auth/login, /api/auth/callback, /api/auth/logout"
386
+ )
387
+
388
+
389
+ @contextlib.asynccontextmanager
390
+ async def create_oauth_lifespan(app):
391
+ """ASGI lifespan handler that mounts custom OAuth routes.
392
+
393
+ Gradio's ``launch()`` recreates ``demo.app`` via ``App.create_app()``,
394
+ discarding any routes prepended before launch. However,
395
+ ``create_lifespan_handler()`` (in Gradio's ``route_utils.py``) wraps
396
+ user-supplied lifespan and calls it with the **final** App instance.
397
+ Routes added here are on the real app with the full middleware stack
398
+ (including ``SessionMiddleware``).
399
+
400
+ Pass this to ``demo.launch(app_kwargs={"lifespan": create_oauth_lifespan})``.
401
+ """
402
+ if OAUTH_CLIENT_ID:
403
+ app.routes[:0] = _oauth_routes
404
+ logger.info(
405
+ "Custom OAuth routes mounted via lifespan: "
406
+ "/api/auth/login, /api/auth/callback, /api/auth/logout"
407
+ )
408
+ yield
src/mosaic/ui/user_tabs.py CHANGED
@@ -29,40 +29,57 @@ from mosaic.user_results import (
29
  LOCAL_DEBUG_USERNAME = "local_user"
30
 
31
 
32
- def _get_username(request: gr.Request = None, profile=None) -> tuple[str, bool]:
 
 
33
  """Get username for storage operations.
34
 
35
- On HF Spaces, uses OAuthProfile from gr.LoginButton() to identify
36
- the logged-in user. Does NOT use request.username, which returns
37
- the Space owner's username rather than the visitor's.
 
 
 
 
 
 
38
 
39
  Returns:
40
  Tuple of (username, is_local_mode)
41
- - In HF Spaces: (profile.username, False) if logged in
42
  - Locally: (LOCAL_DEBUG_USERNAME, True)
43
  - HF Spaces not logged in: (None, False)
44
  """
45
  if IS_HF_SPACES:
46
- # On HF Spaces, only OAuthProfile reliably identifies the logged-in user.
47
- # request.username returns the Space owner's username (not the visitor's),
48
- # so we must NOT fall back to it.
49
- if profile is not None:
50
  try:
51
- username = getattr(profile, "username", None)
52
- if username:
53
- return (username, False)
 
 
 
 
54
  except Exception:
55
  pass
56
 
 
 
57
  return (None, False)
58
  else:
59
  # Local mode - use debug username
60
  return (LOCAL_DEBUG_USERNAME, True)
61
 
62
 
63
- def create_my_files_tab():
64
  """Create the My Files tab UI for managing uploaded slides.
65
 
 
 
 
 
 
66
  Returns:
67
  Dictionary of UI components for event handlers
68
  """
@@ -107,9 +124,13 @@ def create_my_files_tab():
107
  # Download output
108
  slide_download_file = gr.File(label="Download", visible=False)
109
 
110
- def load_files(request: gr.Request = None, profile: gr.OAuthProfile = None):
 
 
 
 
111
  """Load user's uploaded files."""
112
- username, is_local = _get_username(request, profile)
113
 
114
  if not username:
115
  return (
@@ -188,11 +209,12 @@ def create_my_files_tab():
188
 
189
  def download_slide(
190
  slide_id_or_name,
 
191
  request: gr.Request = None,
192
  profile: gr.OAuthProfile = None,
193
  ):
194
  """Download the original slide file."""
195
- username, _ = _get_username(request, profile)
196
 
197
  if not username or not slide_id_or_name:
198
  return gr.File(visible=False), gr.Textbox(
@@ -226,11 +248,12 @@ def create_my_files_tab():
226
 
227
  def delete_slide(
228
  slide_id_or_name,
 
229
  request: gr.Request = None,
230
  profile: gr.OAuthProfile = None,
231
  ):
232
  """Delete a slide and all associated results."""
233
- username, _ = _get_username(request, profile)
234
 
235
  if not username or not slide_id_or_name:
236
  return gr.Textbox(
@@ -257,10 +280,14 @@ def create_my_files_tab():
257
  logger.error(f"Failed to delete slide {slide_id_or_name}: {e}")
258
  return gr.Textbox(value=f"Error: {str(e)}", visible=True)
259
 
 
 
 
 
260
  # Wire up events
261
  refresh_files_btn.click(
262
  load_files,
263
- inputs=None,
264
  outputs=[
265
  storage_usage_md,
266
  files_table,
@@ -271,17 +298,17 @@ def create_my_files_tab():
271
 
272
  download_slide_btn.click(
273
  download_slide,
274
- inputs=[selected_slide_id],
275
  outputs=[slide_download_file, file_action_status],
276
  )
277
 
278
  delete_slide_btn.click(
279
  delete_slide,
280
- inputs=[selected_slide_id],
281
  outputs=[file_action_status],
282
  ).then(
283
  load_files, # Refresh after delete
284
- inputs=None,
285
  outputs=[
286
  storage_usage_md,
287
  files_table,
@@ -313,9 +340,13 @@ def create_my_files_tab():
313
  }
314
 
315
 
316
- def create_my_results_tab():
317
  """Create the My Results tab UI for browsing analysis results.
318
 
 
 
 
 
319
  Returns:
320
  Dictionary of UI components for event handlers
321
  """
@@ -364,9 +395,13 @@ def create_my_results_tab():
364
  )
365
  result_download_file = gr.File(label="Download", visible=False)
366
 
367
- def load_results(request: gr.Request = None, profile: gr.OAuthProfile = None):
 
 
 
 
368
  """Load user's analysis results."""
369
- username, _ = _get_username(request, profile)
370
 
371
  if not username:
372
  return [], gr.Textbox(visible=False), gr.File(visible=False)
@@ -403,10 +438,13 @@ def create_my_results_tab():
403
  )
404
 
405
  def view_result_details(
406
- analysis_id, request: gr.Request = None, profile: gr.OAuthProfile = None
 
 
 
407
  ):
408
  """Load and display result details."""
409
- username, _ = _get_username(request, profile)
410
 
411
  if not username or not analysis_id:
412
  return (
@@ -515,10 +553,13 @@ def create_my_results_tab():
515
  )
516
 
517
  def download_result_zip(
518
- analysis_id, request: gr.Request = None, profile: gr.OAuthProfile = None
 
 
 
519
  ):
520
  """Create and download ZIP of result files."""
521
- username, _ = _get_username(request, profile)
522
 
523
  if not username or not analysis_id:
524
  return gr.File(visible=False), gr.Textbox(
@@ -544,10 +585,13 @@ def create_my_results_tab():
544
  )
545
 
546
  def delete_result(
547
- analysis_id, request: gr.Request = None, profile: gr.OAuthProfile = None
 
 
 
548
  ):
549
  """Delete an analysis result."""
550
- username, _ = _get_username(request, profile)
551
 
552
  if not username or not analysis_id:
553
  return gr.Textbox(
@@ -568,16 +612,19 @@ def create_my_results_tab():
568
  logger.error(f"Failed to delete result {analysis_id}: {e}")
569
  return gr.Textbox(value=f"Error: {str(e)}", visible=True)
570
 
 
 
 
571
  # Wire up events
572
  refresh_results_btn.click(
573
  load_results,
574
- inputs=None,
575
  outputs=[results_table, result_action_status, result_download_file],
576
  )
577
 
578
  view_result_btn.click(
579
  view_result_details,
580
- inputs=[selected_analysis_id],
581
  outputs=[
582
  result_details,
583
  result_metadata_md,
@@ -590,17 +637,17 @@ def create_my_results_tab():
590
 
591
  download_zip_btn.click(
592
  download_result_zip,
593
- inputs=[selected_analysis_id],
594
  outputs=[result_download_file, result_action_status],
595
  )
596
 
597
  delete_result_btn.click(
598
  delete_result,
599
- inputs=[selected_analysis_id],
600
  outputs=[result_action_status],
601
  ).then(
602
  load_results, # Refresh after delete
603
- inputs=None,
604
  outputs=[results_table, result_action_status, result_download_file],
605
  )
606
 
 
29
  LOCAL_DEBUG_USERNAME = "local_user"
30
 
31
 
32
+ def _get_username(
33
+ request: gr.Request = None, profile=None, session_id: str = ""
34
+ ) -> tuple[str, bool]:
35
  """Get username for storage operations.
36
 
37
+ On HF Spaces with Docker SDK the HF proxy strips custom cookies and
38
+ forges the Starlette session so ``gr.OAuthProfile`` always returns the
39
+ Space owner's identity. The only trustworthy channel is the session ID
40
+ stored in the browser's ``localStorage`` and passed via Gradio inputs.
41
+
42
+ Args:
43
+ request: Gradio request (auto-injected).
44
+ profile: OAuthProfile (auto-injected, **unreliable on HF Spaces**).
45
+ session_id: Server-side session ID from localStorage (reliable).
46
 
47
  Returns:
48
  Tuple of (username, is_local_mode)
49
+ - In HF Spaces: (username, False) if session_id is valid
50
  - Locally: (LOCAL_DEBUG_USERNAME, True)
51
  - HF Spaces not logged in: (None, False)
52
  """
53
  if IS_HF_SPACES:
54
+ # Look up user by session ID passed from localStorage via Gradio js=
55
+ if session_id:
 
 
56
  try:
57
+ from mosaic.ui.oauth import get_user_by_session_id
58
+
59
+ userinfo = get_user_by_session_id(session_id)
60
+ if userinfo:
61
+ username = userinfo.get("preferred_username")
62
+ if username:
63
+ return (username, False)
64
  except Exception:
65
  pass
66
 
67
+ # Do NOT fall back to OAuthProfile — it always returns the Space
68
+ # owner's identity on Docker SDK Spaces.
69
  return (None, False)
70
  else:
71
  # Local mode - use debug username
72
  return (LOCAL_DEBUG_USERNAME, True)
73
 
74
 
75
+ def create_my_files_tab(session_id_component=None):
76
  """Create the My Files tab UI for managing uploaded slides.
77
 
78
+ Args:
79
+ session_id_component: Hidden gr.Textbox carrying the localStorage
80
+ session ID. Passed as input to event handlers so that
81
+ ``_get_username`` can resolve the visitor's identity on HF Spaces.
82
+
83
  Returns:
84
  Dictionary of UI components for event handlers
85
  """
 
124
  # Download output
125
  slide_download_file = gr.File(label="Download", visible=False)
126
 
127
+ def load_files(
128
+ session_id: str = "",
129
+ request: gr.Request = None,
130
+ profile: gr.OAuthProfile = None,
131
+ ):
132
  """Load user's uploaded files."""
133
+ username, is_local = _get_username(request, profile, session_id)
134
 
135
  if not username:
136
  return (
 
209
 
210
  def download_slide(
211
  slide_id_or_name,
212
+ session_id: str = "",
213
  request: gr.Request = None,
214
  profile: gr.OAuthProfile = None,
215
  ):
216
  """Download the original slide file."""
217
+ username, _ = _get_username(request, profile, session_id)
218
 
219
  if not username or not slide_id_or_name:
220
  return gr.File(visible=False), gr.Textbox(
 
248
 
249
  def delete_slide(
250
  slide_id_or_name,
251
+ session_id: str = "",
252
  request: gr.Request = None,
253
  profile: gr.OAuthProfile = None,
254
  ):
255
  """Delete a slide and all associated results."""
256
+ username, _ = _get_username(request, profile, session_id)
257
 
258
  if not username or not slide_id_or_name:
259
  return gr.Textbox(
 
280
  logger.error(f"Failed to delete slide {slide_id_or_name}: {e}")
281
  return gr.Textbox(value=f"Error: {str(e)}", visible=True)
282
 
283
+ # Build inputs lists — include session_id_component when provided
284
+ # (HF Spaces) so _get_username can resolve the visitor's identity.
285
+ _sid = [session_id_component] if session_id_component is not None else []
286
+
287
  # Wire up events
288
  refresh_files_btn.click(
289
  load_files,
290
+ inputs=_sid or None,
291
  outputs=[
292
  storage_usage_md,
293
  files_table,
 
298
 
299
  download_slide_btn.click(
300
  download_slide,
301
+ inputs=[selected_slide_id] + _sid,
302
  outputs=[slide_download_file, file_action_status],
303
  )
304
 
305
  delete_slide_btn.click(
306
  delete_slide,
307
+ inputs=[selected_slide_id] + _sid,
308
  outputs=[file_action_status],
309
  ).then(
310
  load_files, # Refresh after delete
311
+ inputs=_sid or None,
312
  outputs=[
313
  storage_usage_md,
314
  files_table,
 
340
  }
341
 
342
 
343
+ def create_my_results_tab(session_id_component=None):
344
  """Create the My Results tab UI for browsing analysis results.
345
 
346
+ Args:
347
+ session_id_component: Hidden gr.Textbox carrying the localStorage
348
+ session ID (see ``create_my_files_tab`` docstring).
349
+
350
  Returns:
351
  Dictionary of UI components for event handlers
352
  """
 
395
  )
396
  result_download_file = gr.File(label="Download", visible=False)
397
 
398
+ def load_results(
399
+ session_id: str = "",
400
+ request: gr.Request = None,
401
+ profile: gr.OAuthProfile = None,
402
+ ):
403
  """Load user's analysis results."""
404
+ username, _ = _get_username(request, profile, session_id)
405
 
406
  if not username:
407
  return [], gr.Textbox(visible=False), gr.File(visible=False)
 
438
  )
439
 
440
  def view_result_details(
441
+ analysis_id,
442
+ session_id: str = "",
443
+ request: gr.Request = None,
444
+ profile: gr.OAuthProfile = None,
445
  ):
446
  """Load and display result details."""
447
+ username, _ = _get_username(request, profile, session_id)
448
 
449
  if not username or not analysis_id:
450
  return (
 
553
  )
554
 
555
  def download_result_zip(
556
+ analysis_id,
557
+ session_id: str = "",
558
+ request: gr.Request = None,
559
+ profile: gr.OAuthProfile = None,
560
  ):
561
  """Create and download ZIP of result files."""
562
+ username, _ = _get_username(request, profile, session_id)
563
 
564
  if not username or not analysis_id:
565
  return gr.File(visible=False), gr.Textbox(
 
585
  )
586
 
587
  def delete_result(
588
+ analysis_id,
589
+ session_id: str = "",
590
+ request: gr.Request = None,
591
+ profile: gr.OAuthProfile = None,
592
  ):
593
  """Delete an analysis result."""
594
+ username, _ = _get_username(request, profile, session_id)
595
 
596
  if not username or not analysis_id:
597
  return gr.Textbox(
 
612
  logger.error(f"Failed to delete result {analysis_id}: {e}")
613
  return gr.Textbox(value=f"Error: {str(e)}", visible=True)
614
 
615
+ # Build inputs lists — include session_id_component when provided
616
+ _sid = [session_id_component] if session_id_component is not None else []
617
+
618
  # Wire up events
619
  refresh_results_btn.click(
620
  load_results,
621
+ inputs=_sid or None,
622
  outputs=[results_table, result_action_status, result_download_file],
623
  )
624
 
625
  view_result_btn.click(
626
  view_result_details,
627
+ inputs=[selected_analysis_id] + _sid,
628
  outputs=[
629
  result_details,
630
  result_metadata_md,
 
637
 
638
  download_zip_btn.click(
639
  download_result_zip,
640
+ inputs=[selected_analysis_id] + _sid,
641
  outputs=[result_download_file, result_action_status],
642
  )
643
 
644
  delete_result_btn.click(
645
  delete_result,
646
+ inputs=[selected_analysis_id] + _sid,
647
  outputs=[result_action_status],
648
  ).then(
649
  load_results, # Refresh after delete
650
+ inputs=_sid or None,
651
  outputs=[results_table, result_action_status, result_download_file],
652
  )
653
 
tests/telemetry/test_tracker.py CHANGED
@@ -8,7 +8,6 @@ from pathlib import Path
8
  import pytest
9
 
10
  from mosaic.telemetry import TelemetryTracker, TelemetryConfig
11
- from mosaic.telemetry.utils import hash_username
12
 
13
 
14
  @pytest.fixture
@@ -153,7 +152,7 @@ class TestUsageEvents:
153
  event = json.loads(f.read().strip())
154
 
155
  assert event["is_logged_in"] is True
156
- assert event["hf_username"] == hash_username("testuser")
157
 
158
  def test_log_analysis_complete(self, tracker, temp_dir):
159
  """Test logging analysis complete event."""
@@ -287,7 +286,7 @@ class TestResourceEvents:
287
  event = json.loads(f.read().strip())
288
 
289
  assert event["is_logged_in"] is True
290
- assert event["hf_username"] == hash_username("testuser")
291
 
292
 
293
  class TestFailureEvents:
 
8
  import pytest
9
 
10
  from mosaic.telemetry import TelemetryTracker, TelemetryConfig
 
11
 
12
 
13
  @pytest.fixture
 
152
  event = json.loads(f.read().strip())
153
 
154
  assert event["is_logged_in"] is True
155
+ assert event["hf_username"] == "testuser"
156
 
157
  def test_log_analysis_complete(self, tracker, temp_dir):
158
  """Test logging analysis complete event."""
 
286
  event = json.loads(f.read().strip())
287
 
288
  assert event["is_logged_in"] is True
289
+ assert event["hf_username"] == "testuser"
290
 
291
 
292
  class TestFailureEvents:
tests/telemetry/test_utils.py CHANGED
@@ -8,7 +8,6 @@ from mosaic.telemetry.utils import (
8
  StageTimer,
9
  sanitize_error_message,
10
  hash_session_id,
11
- hash_username,
12
  UserInfo,
13
  extract_user_info,
14
  )
@@ -151,40 +150,6 @@ class TestHashSessionId:
151
  assert len(set(hashes)) == 1 # All hashes should be identical
152
 
153
 
154
- class TestHashUsername:
155
- """Tests for username hashing."""
156
-
157
- def test_hash_username(self):
158
- """Test basic username hashing."""
159
- hashed = hash_username("testuser")
160
- assert hashed is not None
161
- assert hashed != "testuser"
162
- assert len(hashed) == 16
163
-
164
- def test_hash_none_returns_none(self):
165
- """Test that None input returns None."""
166
- assert hash_username(None) is None
167
-
168
- def test_hash_is_deterministic(self):
169
- """Test that same input produces same hash."""
170
- hash1 = hash_username("alice")
171
- hash2 = hash_username("alice")
172
- assert hash1 == hash2
173
-
174
- def test_different_inputs_different_hashes(self):
175
- """Test that different usernames produce different hashes."""
176
- hash1 = hash_username("alice")
177
- hash2 = hash_username("bob")
178
- assert hash1 != hash2
179
-
180
- def test_different_salt_from_session_id(self):
181
- """Test that username hash uses different salt than session hash."""
182
- value = "same_value"
183
- username_hash = hash_username(value)
184
- session_hash = hash_session_id(value)
185
- assert username_hash != session_hash
186
-
187
-
188
  class TestUserInfo:
189
  """Tests for UserInfo dataclass."""
190
 
 
8
  StageTimer,
9
  sanitize_error_message,
10
  hash_session_id,
 
11
  UserInfo,
12
  extract_user_info,
13
  )
 
150
  assert len(set(hashes)) == 1 # All hashes should be identical
151
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  class TestUserInfo:
154
  """Tests for UserInfo dataclass."""
155
 
tests/test_oauth.py ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the custom OAuth module (mosaic.ui.oauth)."""
2
+
3
+ import time
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from mosaic.ui.oauth import (
9
+ _prune_expired,
10
+ _sessions,
11
+ _pending_states,
12
+ _SESSION_COOKIE,
13
+ _SESSION_TTL_SEC,
14
+ _STATE_TTL_SEC,
15
+ create_oauth_lifespan,
16
+ get_user_by_session_id,
17
+ get_user_from_server_session,
18
+ mount_oauth_routes,
19
+ )
20
+
21
+
22
+ @pytest.fixture(autouse=True)
23
+ def clean_sessions():
24
+ """Clear session and state stores before each test."""
25
+ _sessions.clear()
26
+ _pending_states.clear()
27
+ yield
28
+ _sessions.clear()
29
+ _pending_states.clear()
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Session store tests
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ class TestSessionStore:
38
+ """Tests for the server-side session store."""
39
+
40
+ def test_store_and_retrieve_session(self):
41
+ """Test basic session CRUD."""
42
+ _sessions["abc123"] = {
43
+ "userinfo": {"preferred_username": "alice"},
44
+ "created_at": time.time(),
45
+ }
46
+
47
+ request = MagicMock()
48
+ request.cookies = {_SESSION_COOKIE: "abc123"}
49
+ request.headers = {}
50
+
51
+ userinfo = get_user_from_server_session(request)
52
+ assert userinfo is not None
53
+ assert userinfo["preferred_username"] == "alice"
54
+
55
+ def test_missing_cookie_returns_none(self):
56
+ """Test that missing cookie returns None."""
57
+ request = MagicMock()
58
+ request.cookies = {}
59
+ request.headers = {}
60
+
61
+ assert get_user_from_server_session(request) is None
62
+
63
+ def test_unknown_session_id_returns_none(self):
64
+ """Test that unknown session ID returns None."""
65
+ request = MagicMock()
66
+ request.cookies = {_SESSION_COOKIE: "unknown"}
67
+ request.headers = {}
68
+
69
+ assert get_user_from_server_session(request) is None
70
+
71
+ def test_expired_session_returns_none(self):
72
+ """Test that expired sessions are pruned."""
73
+ _sessions["expired"] = {
74
+ "userinfo": {"preferred_username": "bob"},
75
+ "created_at": time.time() - _SESSION_TTL_SEC - 1,
76
+ }
77
+
78
+ request = MagicMock()
79
+ request.cookies = {_SESSION_COOKIE: "expired"}
80
+ request.headers = {}
81
+
82
+ assert get_user_from_server_session(request) is None
83
+ assert "expired" not in _sessions
84
+
85
+ def test_cookie_from_header_fallback(self):
86
+ """Test parsing cookie from raw Cookie header."""
87
+ _sessions["header_val"] = {
88
+ "userinfo": {"preferred_username": "carol"},
89
+ "created_at": time.time(),
90
+ }
91
+
92
+ request = MagicMock()
93
+ request.cookies = {} # Empty dict, fallback to header
94
+ request.headers = {"cookie": f"other=x; {_SESSION_COOKIE}=header_val; foo=bar"}
95
+
96
+ userinfo = get_user_from_server_session(request)
97
+ assert userinfo is not None
98
+ assert userinfo["preferred_username"] == "carol"
99
+
100
+ def test_none_request_returns_none(self):
101
+ """Test that None request returns None gracefully."""
102
+ # get_user_from_server_session checks hasattr, so pass an object
103
+ # without cookies or headers
104
+ assert get_user_from_server_session(None) is None
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # get_user_by_session_id tests
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ class TestGetUserBySessionId:
113
+ """Tests for get_user_by_session_id (localStorage-based lookup)."""
114
+
115
+ def test_valid_session_id(self):
116
+ """Test lookup with a valid session ID."""
117
+ _sessions["sid_abc"] = {
118
+ "userinfo": {"preferred_username": "alice"},
119
+ "created_at": time.time(),
120
+ }
121
+ result = get_user_by_session_id("sid_abc")
122
+ assert result is not None
123
+ assert result["preferred_username"] == "alice"
124
+
125
+ def test_empty_session_id(self):
126
+ """Test that empty string returns None."""
127
+ assert get_user_by_session_id("") is None
128
+
129
+ def test_none_session_id(self):
130
+ """Test that None returns None."""
131
+ assert get_user_by_session_id(None) is None
132
+
133
+ def test_unknown_session_id(self):
134
+ """Test that unknown session ID returns None."""
135
+ assert get_user_by_session_id("nonexistent") is None
136
+
137
+ def test_expired_session_id(self):
138
+ """Test that expired session returns None and is cleaned up."""
139
+ _sessions["expired_sid"] = {
140
+ "userinfo": {"preferred_username": "bob"},
141
+ "created_at": time.time() - _SESSION_TTL_SEC - 1,
142
+ }
143
+ assert get_user_by_session_id("expired_sid") is None
144
+ assert "expired_sid" not in _sessions
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Prune tests
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ class TestPruneExpired:
153
+ """Tests for _prune_expired."""
154
+
155
+ def test_prune_expired_sessions(self):
156
+ """Test that expired sessions are removed."""
157
+ _sessions["old"] = {
158
+ "userinfo": {"preferred_username": "old_user"},
159
+ "created_at": time.time() - _SESSION_TTL_SEC - 100,
160
+ }
161
+ _sessions["fresh"] = {
162
+ "userinfo": {"preferred_username": "new_user"},
163
+ "created_at": time.time(),
164
+ }
165
+
166
+ _prune_expired()
167
+
168
+ assert "old" not in _sessions
169
+ assert "fresh" in _sessions
170
+
171
+ def test_prune_expired_states(self):
172
+ """Test that expired CSRF states are removed."""
173
+ _pending_states["old_state"] = time.time() - _STATE_TTL_SEC - 100
174
+ _pending_states["fresh_state"] = time.time()
175
+
176
+ _prune_expired()
177
+
178
+ assert "old_state" not in _pending_states
179
+ assert "fresh_state" in _pending_states
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # mount_oauth_routes tests
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ class TestMountOAuthRoutes:
188
+ """Tests for mount_oauth_routes."""
189
+
190
+ @patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "")
191
+ def test_no_routes_when_client_id_missing(self):
192
+ """Test that routes are not mounted when OAUTH_CLIENT_ID is empty."""
193
+ app = MagicMock()
194
+ app.routes = []
195
+
196
+ mount_oauth_routes(app)
197
+
198
+ assert len(app.routes) == 0
199
+
200
+ @patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "test-client-id")
201
+ def test_routes_mounted_when_configured(self):
202
+ """Test that routes are mounted when OAuth is configured."""
203
+ app = MagicMock()
204
+ app.routes = []
205
+
206
+ mount_oauth_routes(app)
207
+
208
+ assert len(app.routes) == 3
209
+ paths = [r.path for r in app.routes]
210
+ assert "/api/auth/login" in paths
211
+ assert "/api/auth/callback" in paths
212
+ assert "/api/auth/logout" in paths
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Login route tests
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ class TestLoginRoute:
221
+ """Tests for the /api/auth/login route."""
222
+
223
+ @patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "test-client-id")
224
+ @patch("mosaic.ui.oauth.SPACE_HOST", "user-space.hf.space")
225
+ def test_login_redirects_to_hf(self):
226
+ """Test that login redirects to HF authorize URL."""
227
+ import asyncio
228
+
229
+ from mosaic.ui.oauth import _login
230
+
231
+ async def _run():
232
+ request = MagicMock()
233
+ return await _login(request)
234
+
235
+ response = asyncio.run(_run())
236
+
237
+ assert response.status_code == 302
238
+ location = response.headers["location"]
239
+ assert "huggingface.co/oauth/authorize" in location
240
+ assert "client_id=test-client-id" in location
241
+ assert "redirect_uri=" in location
242
+ assert "state=" in location
243
+ # Should have stored a CSRF state
244
+ assert len(_pending_states) == 1
245
+
246
+ @patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "")
247
+ def test_login_returns_error_when_not_configured(self):
248
+ """Test that login returns error when OAuth is not configured."""
249
+ import asyncio
250
+
251
+ from mosaic.ui.oauth import _login
252
+
253
+ async def _run():
254
+ request = MagicMock()
255
+ return await _login(request)
256
+
257
+ response = asyncio.run(_run())
258
+
259
+ assert response.status_code == 500
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Callback route tests
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ class TestCallbackRoute:
268
+ """Tests for the /api/auth/callback route."""
269
+
270
+ def test_callback_missing_params(self):
271
+ """Test callback with missing code/state parameters."""
272
+ import asyncio
273
+
274
+ from mosaic.ui.oauth import _callback
275
+
276
+ async def _run():
277
+ request = MagicMock()
278
+ request.query_params = {}
279
+ return await _callback(request)
280
+
281
+ response = asyncio.run(_run())
282
+ assert response.status_code == 400
283
+
284
+ def test_callback_invalid_state(self):
285
+ """Test callback with invalid CSRF state."""
286
+ import asyncio
287
+
288
+ from mosaic.ui.oauth import _callback
289
+
290
+ async def _run():
291
+ request = MagicMock()
292
+ request.query_params = {"code": "test-code", "state": "invalid-state"}
293
+ return await _callback(request)
294
+
295
+ response = asyncio.run(_run())
296
+ assert response.status_code == 400
297
+
298
+ @patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "test-client-id")
299
+ @patch("mosaic.ui.oauth.OAUTH_CLIENT_SECRET", "test-secret")
300
+ @patch("mosaic.ui.oauth.SPACE_HOST", "user-space.hf.space")
301
+ def test_callback_exchanges_code_for_token(self):
302
+ """Test successful callback with mocked token exchange."""
303
+ import asyncio
304
+ import json
305
+ import base64
306
+
307
+ from mosaic.ui.oauth import _callback
308
+
309
+ # Set up a valid CSRF state
310
+ valid_state = "valid-state-token"
311
+ _pending_states[valid_state] = time.time()
312
+
313
+ # Create a mock id_token JWT
314
+ header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode()
315
+ payload_data = json.dumps(
316
+ {"preferred_username": "visitor123", "sub": "visitor123"}
317
+ )
318
+ payload = base64.urlsafe_b64encode(payload_data.encode()).rstrip(b"=").decode()
319
+ mock_id_token = f"{header}.{payload}.fake_signature"
320
+
321
+ # Mock httpx — json() is a regular method (not async) on httpx.Response
322
+ mock_response = AsyncMock()
323
+ mock_response.json = MagicMock(
324
+ return_value={
325
+ "access_token": "test-access-token",
326
+ "id_token": mock_id_token,
327
+ }
328
+ )
329
+ mock_response.raise_for_status = MagicMock()
330
+
331
+ mock_client = AsyncMock()
332
+ mock_client.post.return_value = mock_response
333
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
334
+ mock_client.__aexit__ = AsyncMock(return_value=False)
335
+
336
+ request = MagicMock()
337
+ request.query_params = {"code": "auth-code", "state": valid_state}
338
+
339
+ async def _run():
340
+ with patch("httpx.AsyncClient", return_value=mock_client):
341
+ return await _callback(request)
342
+
343
+ response = asyncio.run(_run())
344
+
345
+ # Callback returns an HTML page that stores session in localStorage
346
+ assert response.status_code == 200
347
+ assert "localStorage.setItem" in response.body.decode()
348
+ assert "mosaic_session_id" in response.body.decode()
349
+
350
+ # Server-side session should be stored
351
+ assert len(_sessions) == 1
352
+
353
+ # CSRF state should be consumed
354
+ assert valid_state not in _pending_states
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # Logout route tests
359
+ # ---------------------------------------------------------------------------
360
+
361
+
362
+ class TestLogoutRoute:
363
+ """Tests for the /api/auth/logout route."""
364
+
365
+ def test_logout_clears_session(self):
366
+ """Test that logout clears session data and returns localStorage-clearing HTML."""
367
+ import asyncio
368
+
369
+ from mosaic.ui.oauth import _logout
370
+
371
+ # Set up server-side session
372
+ _sessions["session_id_abc"] = {
373
+ "userinfo": {"preferred_username": "user"},
374
+ "created_at": time.time(),
375
+ }
376
+
377
+ request = MagicMock()
378
+ request.query_params = {"sid": "session_id_abc"}
379
+
380
+ async def _run():
381
+ return await _logout(request)
382
+
383
+ response = asyncio.run(_run())
384
+
385
+ # Returns HTML page that clears localStorage
386
+ assert response.status_code == 200
387
+ assert "localStorage.removeItem" in response.body.decode()
388
+ assert "mosaic_session_id" in response.body.decode()
389
+ assert "session_id_abc" not in _sessions
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # get_user_from_server_session integration
394
+ # ---------------------------------------------------------------------------
395
+
396
+
397
+ class TestGetUserFromServerSession:
398
+ """Integration tests for the server-session fallback."""
399
+
400
+ def test_works_with_gradio_request_like_object(self):
401
+ """Test with an object mimicking gr.Request."""
402
+ _sessions["gr_session"] = {
403
+ "userinfo": {"preferred_username": "gradio_user", "name": "Test User"},
404
+ "created_at": time.time(),
405
+ }
406
+
407
+ class MockGradioRequest:
408
+ cookies = {_SESSION_COOKIE: "gr_session"}
409
+ headers = {}
410
+
411
+ result = get_user_from_server_session(MockGradioRequest())
412
+ assert result is not None
413
+ assert result["preferred_username"] == "gradio_user"
414
+ assert result["name"] == "Test User"
415
+
416
+ def test_returns_none_for_object_without_cookies(self):
417
+ """Test graceful handling of objects without cookies."""
418
+
419
+ class Bare:
420
+ pass
421
+
422
+ assert get_user_from_server_session(Bare()) is None
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # create_oauth_lifespan tests
427
+ # ---------------------------------------------------------------------------
428
+
429
+
430
+ class TestCreateOAuthLifespan:
431
+ """Tests for the create_oauth_lifespan async context manager."""
432
+
433
+ @patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "test-client-id")
434
+ def test_routes_mounted_when_configured(self):
435
+ """Test that OAuth routes are mounted on app when OAUTH_CLIENT_ID is set."""
436
+ import asyncio
437
+
438
+ app = MagicMock()
439
+ app.routes = []
440
+
441
+ async def _run():
442
+ async with create_oauth_lifespan(app):
443
+ assert len(app.routes) == 3
444
+ paths = [r.path for r in app.routes]
445
+ assert "/api/auth/login" in paths
446
+ assert "/api/auth/callback" in paths
447
+ assert "/api/auth/logout" in paths
448
+
449
+ asyncio.run(_run())
450
+
451
+ @patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "")
452
+ def test_routes_skipped_when_not_configured(self):
453
+ """Test that no routes are mounted when OAUTH_CLIENT_ID is empty."""
454
+ import asyncio
455
+
456
+ app = MagicMock()
457
+ app.routes = []
458
+
459
+ async def _run():
460
+ async with create_oauth_lifespan(app):
461
+ assert len(app.routes) == 0
462
+
463
+ asyncio.run(_run())
464
+
465
+ @patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "test-client-id")
466
+ def test_routes_persist_after_yield(self):
467
+ """Test that routes remain on the app after the lifespan exits."""
468
+ import asyncio
469
+
470
+ app = MagicMock()
471
+ app.routes = []
472
+
473
+ async def _run():
474
+ async with create_oauth_lifespan(app):
475
+ pass
476
+ # Routes should still be present after context manager exits
477
+ assert len(app.routes) == 3
478
+
479
+ asyncio.run(_run())
tests/test_ui_user_storage.py CHANGED
@@ -86,16 +86,23 @@ class TestUsernameExtraction:
86
 
87
  @patch("mosaic.ui.user_tabs.IS_HF_SPACES", True)
88
  def test_hf_logged_in_returns_username(self, mock_request_hf_logged_in):
89
- """HF Spaces logged in should return OAuthProfile username, not request.username."""
90
-
91
- class MockProfile:
92
- username = "test_user"
93
-
94
- username, is_local = _get_username(
95
- mock_request_hf_logged_in, profile=MockProfile()
96
- )
97
- assert username == "test_user"
98
- assert is_local is False
 
 
 
 
 
 
 
99
 
100
  @patch("mosaic.ui.user_tabs.IS_HF_SPACES", True)
101
  def test_hf_request_username_ignored(self, mock_request_hf_logged_in):
 
86
 
87
  @patch("mosaic.ui.user_tabs.IS_HF_SPACES", True)
88
  def test_hf_logged_in_returns_username(self, mock_request_hf_logged_in):
89
+ """HF Spaces logged in should return username from session_id lookup."""
90
+ import time
91
+ from mosaic.ui.oauth import _sessions
92
+
93
+ # Set up a server-side session
94
+ _sessions["test_session_123"] = {
95
+ "userinfo": {"preferred_username": "test_user"},
96
+ "created_at": time.time(),
97
+ }
98
+ try:
99
+ username, is_local = _get_username(
100
+ mock_request_hf_logged_in, session_id="test_session_123"
101
+ )
102
+ assert username == "test_user"
103
+ assert is_local is False
104
+ finally:
105
+ _sessions.pop("test_session_123", None)
106
 
107
  @patch("mosaic.ui.user_tabs.IS_HF_SPACES", True)
108
  def test_hf_request_username_ignored(self, mock_request_hf_logged_in):
uv.lock CHANGED
The diff for this file is too large to render. See raw diff