tfrere HF Staff Cursor commited on
Commit
d08ce81
Β·
1 Parent(s): c652866

Migrate to client-side OAuth via @huggingface/hub

Browse files

- Frontend: oauthLoginUrl + oauthHandleRedirectIfPresent for login flow
- Token stored in localStorage, sent via Bearer header + WS query param
- Works in HF Spaces iframes (no third-party cookies needed)
- Backend: user's HF token threaded through Session β†’ tools
- Jobs now run with authenticated user's credentials (not just admin token)
- Fallback to env HF_TOKEN when no user token available

Co-authored-by: Cursor <cursoragent@cursor.com>

agent/core/session.py CHANGED
@@ -86,6 +86,8 @@ class Session:
86
  self.is_running = True
87
  self.current_task: asyncio.Task | None = None
88
  self.pending_approval: Optional[dict[str, Any]] = None
 
 
89
 
90
  # Session trajectory logging
91
  self.logged_events: list[dict] = []
 
86
  self.is_running = True
87
  self.current_task: asyncio.Task | None = None
88
  self.pending_approval: Optional[dict[str, Any]] = None
89
+ # User's HF OAuth token β€” set by session_manager after construction
90
+ self.hf_token: Optional[str] = None
91
 
92
  # Session trajectory logging
93
  self.logged_events: list[dict] = []
agent/tools/jobs_tool.py CHANGED
@@ -122,8 +122,11 @@ def _filter_uv_install_output(logs: list[str]) -> list[str]:
122
  return logs
123
 
124
 
125
- def _add_environment_variables(params: Dict[str, Any] | None) -> Dict[str, Any]:
126
- token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") or ""
 
 
 
127
 
128
  # Start with user-provided env vars, then force-set token last
129
  result = dict(params or {})
@@ -502,7 +505,7 @@ class HfJobsTool:
502
  image=image,
503
  command=command,
504
  env=args.get("env"),
505
- secrets=_add_environment_variables(args.get("secrets")),
506
  flavor=args.get("hardware_flavor", "cpu-basic"),
507
  timeout=args.get("timeout", "30m"),
508
  namespace=self.namespace,
@@ -720,7 +723,7 @@ To verify, call this tool with `{{"operation": "inspect", "job_id": "{job_id}"}}
720
  command=command,
721
  schedule=schedule,
722
  env=args.get("env"),
723
- secrets=_add_environment_variables(args.get("secrets")),
724
  flavor=args.get("hardware_flavor", "cpu-basic"),
725
  timeout=args.get("timeout", "30m"),
726
  namespace=self.namespace,
@@ -1019,8 +1022,12 @@ async def hf_jobs_handler(
1019
  Event(event_type="tool_log", data={"tool": "hf_jobs", "log": log})
1020
  )
1021
 
1022
- # Get token and namespace from HF token
1023
- hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
 
 
 
 
1024
  namespace = HfApi(token=hf_token).whoami().get("name") if hf_token else None
1025
 
1026
  tool = HfJobsTool(
 
122
  return logs
123
 
124
 
125
+ def _add_environment_variables(
126
+ params: Dict[str, Any] | None, user_token: str | None = None
127
+ ) -> Dict[str, Any]:
128
+ # Prefer the authenticated user's OAuth token, fall back to global env var
129
+ token = user_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") or ""
130
 
131
  # Start with user-provided env vars, then force-set token last
132
  result = dict(params or {})
 
505
  image=image,
506
  command=command,
507
  env=args.get("env"),
508
+ secrets=_add_environment_variables(args.get("secrets"), self.hf_token),
509
  flavor=args.get("hardware_flavor", "cpu-basic"),
510
  timeout=args.get("timeout", "30m"),
511
  namespace=self.namespace,
 
723
  command=command,
724
  schedule=schedule,
725
  env=args.get("env"),
726
+ secrets=_add_environment_variables(args.get("secrets"), self.hf_token),
727
  flavor=args.get("hardware_flavor", "cpu-basic"),
728
  timeout=args.get("timeout", "30m"),
729
  namespace=self.namespace,
 
1022
  Event(event_type="tool_log", data={"tool": "hf_jobs", "log": log})
1023
  )
1024
 
1025
+ # Prefer the authenticated user's OAuth token, fall back to global env
1026
+ hf_token = (
1027
+ (getattr(session, "hf_token", None) if session else None)
1028
+ or os.environ.get("HF_TOKEN")
1029
+ or os.environ.get("HUGGINGFACE_HUB_TOKEN")
1030
+ )
1031
  namespace = HfApi(token=hf_token).whoami().get("name") if hf_token else None
1032
 
1033
  tool = HfJobsTool(
backend/routes/agent.py CHANGED
@@ -7,7 +7,7 @@ dependency. In dev mode (no OAUTH_CLIENT_ID), auth is bypassed automatically.
7
  import logging
8
  from typing import Any
9
 
10
- from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
11
 
12
  from dependencies import get_current_user, get_ws_user
13
  from litellm import acompletion
@@ -126,13 +126,27 @@ async def generate_title(
126
 
127
 
128
  @router.post("/session", response_model=SessionResponse)
129
- async def create_session(user: dict = Depends(get_current_user)) -> SessionResponse:
 
 
130
  """Create a new agent session bound to the authenticated user.
131
 
 
 
 
 
132
  Returns 503 if the server or user has reached the session limit.
133
  """
 
 
 
 
 
 
134
  try:
135
- session_id = await session_manager.create_session(user_id=user["user_id"])
 
 
136
  except SessionCapacityError as e:
137
  raise HTTPException(status_code=503, detail=str(e))
138
  return SessionResponse(session_id=session_id, ready=True)
 
7
  import logging
8
  from typing import Any
9
 
10
+ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
11
 
12
  from dependencies import get_current_user, get_ws_user
13
  from litellm import acompletion
 
126
 
127
 
128
  @router.post("/session", response_model=SessionResponse)
129
+ async def create_session(
130
+ request: Request, user: dict = Depends(get_current_user)
131
+ ) -> SessionResponse:
132
  """Create a new agent session bound to the authenticated user.
133
 
134
+ The user's HF access token is extracted from the Authorization header
135
+ and stored in the session so that tools (e.g. hf_jobs) can act on
136
+ behalf of the user.
137
+
138
  Returns 503 if the server or user has reached the session limit.
139
  """
140
+ # Extract the user's HF token from the Bearer header
141
+ hf_token = None
142
+ auth_header = request.headers.get("Authorization", "")
143
+ if auth_header.startswith("Bearer "):
144
+ hf_token = auth_header[7:]
145
+
146
  try:
147
+ session_id = await session_manager.create_session(
148
+ user_id=user["user_id"], hf_token=hf_token
149
+ )
150
  except SessionCapacityError as e:
151
  raise HTTPException(status_code=503, detail=str(e))
152
  return SessionResponse(session_id=session_id, ready=True)
backend/session_manager.py CHANGED
@@ -49,6 +49,7 @@ class AgentSession:
49
  tool_router: ToolRouter
50
  submission_queue: asyncio.Queue
51
  user_id: str = "dev" # Owner of this session
 
52
  task: asyncio.Task | None = None
53
  created_at: datetime = field(default_factory=datetime.utcnow)
54
  is_active: bool = True
@@ -85,7 +86,7 @@ class SessionManager:
85
  if s.user_id == user_id and s.is_active
86
  )
87
 
88
- async def create_session(self, user_id: str = "dev") -> str:
89
  """Create a new agent session and return its ID.
90
 
91
  Session() and ToolRouter() constructors contain blocking I/O
@@ -138,6 +139,9 @@ class SessionManager:
138
 
139
  tool_router, session = await asyncio.to_thread(_create_session_sync)
140
 
 
 
 
141
  # Create wrapper
142
  agent_session = AgentSession(
143
  session_id=session_id,
@@ -145,6 +149,7 @@ class SessionManager:
145
  tool_router=tool_router,
146
  submission_queue=submission_queue,
147
  user_id=user_id,
 
148
  )
149
 
150
  async with self._lock:
 
49
  tool_router: ToolRouter
50
  submission_queue: asyncio.Queue
51
  user_id: str = "dev" # Owner of this session
52
+ hf_token: str | None = None # User's HF OAuth token for tool execution
53
  task: asyncio.Task | None = None
54
  created_at: datetime = field(default_factory=datetime.utcnow)
55
  is_active: bool = True
 
86
  if s.user_id == user_id and s.is_active
87
  )
88
 
89
+ async def create_session(self, user_id: str = "dev", hf_token: str | None = None) -> str:
90
  """Create a new agent session and return its ID.
91
 
92
  Session() and ToolRouter() constructors contain blocking I/O
 
139
 
140
  tool_router, session = await asyncio.to_thread(_create_session_sync)
141
 
142
+ # Store user's HF token on the session so tools can use it
143
+ session.hf_token = hf_token
144
+
145
  # Create wrapper
146
  agent_session = AgentSession(
147
  session_id=session_id,
 
149
  tool_router=tool_router,
150
  submission_queue=submission_queue,
151
  user_id=user_id,
152
+ hf_token=hf_token,
153
  )
154
 
155
  async with self._lock:
frontend/package-lock.json CHANGED
@@ -10,6 +10,7 @@
10
  "dependencies": {
11
  "@emotion/react": "^11.13.0",
12
  "@emotion/styled": "^11.13.0",
 
13
  "@mui/icons-material": "^6.1.0",
14
  "@mui/material": "^6.1.0",
15
  "react": "^18.3.1",
@@ -1019,6 +1020,30 @@
1019
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1020
  }
1021
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1022
  "node_modules/@humanfs/core": {
1023
  "version": "0.19.1",
1024
  "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2217,6 +2242,16 @@
2217
  "url": "https://github.com/sponsors/epoberezkin"
2218
  }
2219
  },
 
 
 
 
 
 
 
 
 
 
2220
  "node_modules/ansi-styles": {
2221
  "version": "4.3.0",
2222
  "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2425,6 +2460,19 @@
2425
  "url": "https://github.com/sponsors/wooorm"
2426
  }
2427
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
2428
  "node_modules/clsx": {
2429
  "version": "2.1.1",
2430
  "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2590,6 +2638,13 @@
2590
  "dev": true,
2591
  "license": "ISC"
2592
  },
 
 
 
 
 
 
 
2593
  "node_modules/error-ex": {
2594
  "version": "1.3.4",
2595
  "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -3270,6 +3325,16 @@
3270
  "node": ">=0.10.0"
3271
  }
3272
  },
 
 
 
 
 
 
 
 
 
 
3273
  "node_modules/is-glob": {
3274
  "version": "4.0.3",
3275
  "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -4976,6 +5041,21 @@
4976
  "url": "https://github.com/sponsors/wooorm"
4977
  }
4978
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4979
  "node_modules/stringify-entities": {
4980
  "version": "4.0.4",
4981
  "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -4990,6 +5070,19 @@
4990
  "url": "https://github.com/sponsors/wooorm"
4991
  }
4992
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
4993
  "node_modules/strip-json-comments": {
4994
  "version": "3.1.1",
4995
  "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
 
10
  "dependencies": {
11
  "@emotion/react": "^11.13.0",
12
  "@emotion/styled": "^11.13.0",
13
+ "@huggingface/hub": "^2.8.1",
14
  "@mui/icons-material": "^6.1.0",
15
  "@mui/material": "^6.1.0",
16
  "react": "^18.3.1",
 
1020
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1021
  }
1022
  },
1023
+ "node_modules/@huggingface/hub": {
1024
+ "version": "2.8.1",
1025
+ "resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.8.1.tgz",
1026
+ "integrity": "sha512-VAsXdMiIHPteXQJhrwaBEiePTWiJ0zBSymHdnX4J+AijjNN0h3RzGfkKemXcu75gu/TmRLFY9l8+2Tkdmpis0w==",
1027
+ "license": "MIT",
1028
+ "dependencies": {
1029
+ "@huggingface/tasks": "^0.19.82"
1030
+ },
1031
+ "bin": {
1032
+ "hfjs": "dist/cli.js"
1033
+ },
1034
+ "engines": {
1035
+ "node": ">=18"
1036
+ },
1037
+ "optionalDependencies": {
1038
+ "cli-progress": "^3.12.0"
1039
+ }
1040
+ },
1041
+ "node_modules/@huggingface/tasks": {
1042
+ "version": "0.19.84",
1043
+ "resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.19.84.tgz",
1044
+ "integrity": "sha512-nn8DtUW7EZb4QcV+AdNbggPDbvaN32+FldkJakDVml0Aa18fSWjxePdxaRejfTlJYX9oGanOjQKj++NDVRYFXw==",
1045
+ "license": "MIT"
1046
+ },
1047
  "node_modules/@humanfs/core": {
1048
  "version": "0.19.1",
1049
  "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
 
2242
  "url": "https://github.com/sponsors/epoberezkin"
2243
  }
2244
  },
2245
+ "node_modules/ansi-regex": {
2246
+ "version": "5.0.1",
2247
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
2248
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
2249
+ "license": "MIT",
2250
+ "optional": true,
2251
+ "engines": {
2252
+ "node": ">=8"
2253
+ }
2254
+ },
2255
  "node_modules/ansi-styles": {
2256
  "version": "4.3.0",
2257
  "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
 
2460
  "url": "https://github.com/sponsors/wooorm"
2461
  }
2462
  },
2463
+ "node_modules/cli-progress": {
2464
+ "version": "3.12.0",
2465
+ "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
2466
+ "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
2467
+ "license": "MIT",
2468
+ "optional": true,
2469
+ "dependencies": {
2470
+ "string-width": "^4.2.3"
2471
+ },
2472
+ "engines": {
2473
+ "node": ">=4"
2474
+ }
2475
+ },
2476
  "node_modules/clsx": {
2477
  "version": "2.1.1",
2478
  "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
 
2638
  "dev": true,
2639
  "license": "ISC"
2640
  },
2641
+ "node_modules/emoji-regex": {
2642
+ "version": "8.0.0",
2643
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
2644
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
2645
+ "license": "MIT",
2646
+ "optional": true
2647
+ },
2648
  "node_modules/error-ex": {
2649
  "version": "1.3.4",
2650
  "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
 
3325
  "node": ">=0.10.0"
3326
  }
3327
  },
3328
+ "node_modules/is-fullwidth-code-point": {
3329
+ "version": "3.0.0",
3330
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
3331
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
3332
+ "license": "MIT",
3333
+ "optional": true,
3334
+ "engines": {
3335
+ "node": ">=8"
3336
+ }
3337
+ },
3338
  "node_modules/is-glob": {
3339
  "version": "4.0.3",
3340
  "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
 
5041
  "url": "https://github.com/sponsors/wooorm"
5042
  }
5043
  },
5044
+ "node_modules/string-width": {
5045
+ "version": "4.2.3",
5046
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
5047
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
5048
+ "license": "MIT",
5049
+ "optional": true,
5050
+ "dependencies": {
5051
+ "emoji-regex": "^8.0.0",
5052
+ "is-fullwidth-code-point": "^3.0.0",
5053
+ "strip-ansi": "^6.0.1"
5054
+ },
5055
+ "engines": {
5056
+ "node": ">=8"
5057
+ }
5058
+ },
5059
  "node_modules/stringify-entities": {
5060
  "version": "4.0.4",
5061
  "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
 
5070
  "url": "https://github.com/sponsors/wooorm"
5071
  }
5072
  },
5073
+ "node_modules/strip-ansi": {
5074
+ "version": "6.0.1",
5075
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
5076
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
5077
+ "license": "MIT",
5078
+ "optional": true,
5079
+ "dependencies": {
5080
+ "ansi-regex": "^5.0.1"
5081
+ },
5082
+ "engines": {
5083
+ "node": ">=8"
5084
+ }
5085
+ },
5086
  "node_modules/strip-json-comments": {
5087
  "version": "3.1.1",
5088
  "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
frontend/package.json CHANGED
@@ -12,6 +12,7 @@
12
  "dependencies": {
13
  "@emotion/react": "^11.13.0",
14
  "@emotion/styled": "^11.13.0",
 
15
  "@mui/icons-material": "^6.1.0",
16
  "@mui/material": "^6.1.0",
17
  "react": "^18.3.1",
 
12
  "dependencies": {
13
  "@emotion/react": "^11.13.0",
14
  "@emotion/styled": "^11.13.0",
15
+ "@huggingface/hub": "^2.8.1",
16
  "@mui/icons-material": "^6.1.0",
17
  "@mui/material": "^6.1.0",
18
  "react": "^18.3.1",
frontend/src/hooks/useAuth.ts CHANGED
@@ -1,31 +1,89 @@
1
  /**
2
- * Authentication hook β€” non-blocking, lazy.
3
  *
4
- * On mount: checks if the user is already authenticated (cookie/dev mode).
5
- * Does NOT redirect to login automatically β€” the welcome screen handles that.
6
- *
7
- * Exports `triggerLogin()` for components that need to start the OAuth flow
8
- * (e.g. the "Start Session" button on the welcome screen).
9
  */
10
 
11
  import { useEffect } from 'react';
 
12
  import { useAgentStore } from '@/store/agentStore';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- /** Redirect to the OAuth login page. */
15
- export function triggerLogin() {
16
- window.location.href = '/auth/login';
 
 
 
17
  }
18
 
 
 
 
 
19
  export function useAuth() {
20
  const setUser = useAgentStore((s) => s.setUser);
21
 
22
  useEffect(() => {
23
- async function checkAuth() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  try {
25
- const response = await fetch('/auth/me', { credentials: 'include' });
26
- if (response.ok) {
27
- const data = await response.json();
28
- if (data.authenticated) {
 
 
29
  setUser({
30
  authenticated: true,
31
  username: data.username,
@@ -35,28 +93,16 @@ export function useAuth() {
35
  return;
36
  }
37
  }
38
-
39
- // Not authenticated β€” check if auth is even enabled
40
- const statusRes = await fetch('/auth/status', { credentials: 'include' });
41
- const statusData = await statusRes.json();
42
- if (!statusData.auth_enabled) {
43
- // Dev mode β€” set dev user so the app is usable
44
- setUser({ authenticated: true, username: 'dev' });
45
- return;
46
- }
47
-
48
- // Auth is enabled but user is not logged in.
49
- // Don't redirect β€” let the welcome screen show first.
50
- // The user will be prompted to log in when they click "Start Session".
51
- setUser(null);
52
  } catch {
53
- // Backend not ready β€” set dev user so the app is usable
54
- setUser({ authenticated: true, username: 'dev' });
55
  }
56
  }
57
 
58
- checkAuth();
 
59
  }, [setUser]);
60
-
61
- return { triggerLogin };
62
  }
 
1
  /**
2
+ * Client-side OAuth using @huggingface/hub.
3
  *
4
+ * Works inside HF Spaces iframes (no third-party cookies needed).
5
+ * Token is stored in localStorage and sent via Authorization header.
 
 
 
6
  */
7
 
8
  import { useEffect } from 'react';
9
+ import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
10
  import { useAgentStore } from '@/store/agentStore';
11
+ import { logger } from '@/utils/logger';
12
+
13
+ const TOKEN_KEY = 'hf_oauth_token';
14
+
15
+ /** Get the stored HF access token (or null). */
16
+ export function getStoredToken(): string | null {
17
+ try {
18
+ return localStorage.getItem(TOKEN_KEY);
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ /** Clear the stored token (logout). */
25
+ export function clearStoredToken(): void {
26
+ try {
27
+ localStorage.removeItem(TOKEN_KEY);
28
+ } catch {
29
+ // Ignore
30
+ }
31
+ }
32
 
33
+ /** Redirect to HF OAuth login. */
34
+ export async function triggerLogin(): Promise<void> {
35
+ const url = await oauthLoginUrl({
36
+ scopes: 'openid profile read-repos write-repos manage-repos inference-api jobs',
37
+ });
38
+ window.location.href = url;
39
  }
40
 
41
+ /**
42
+ * Hook: on mount, check for OAuth redirect result or existing token.
43
+ * Sets the user in the agent store when authenticated.
44
+ */
45
  export function useAuth() {
46
  const setUser = useAgentStore((s) => s.setUser);
47
 
48
  useEffect(() => {
49
+ let cancelled = false;
50
+
51
+ async function init() {
52
+ // 1. Check if we're returning from an OAuth redirect
53
+ const oauthResult = await oauthHandleRedirectIfPresent();
54
+
55
+ if (oauthResult) {
56
+ // Store the access token
57
+ localStorage.setItem(TOKEN_KEY, oauthResult.accessToken);
58
+ logger.log('OAuth login successful:', oauthResult.userInfo?.name);
59
+
60
+ if (!cancelled) {
61
+ setUser({
62
+ authenticated: true,
63
+ username: oauthResult.userInfo?.name || oauthResult.userInfo?.preferred_username || 'user',
64
+ name: oauthResult.userInfo?.name,
65
+ picture: oauthResult.userInfo?.picture,
66
+ });
67
+ }
68
+ return;
69
+ }
70
+
71
+ // 2. Check for existing token in localStorage
72
+ const token = getStoredToken();
73
+ if (!token) {
74
+ // Not logged in β€” welcome screen will handle login trigger
75
+ if (!cancelled) setUser(null);
76
+ return;
77
+ }
78
+
79
+ // 3. Validate the stored token
80
  try {
81
+ const res = await fetch('/auth/me', {
82
+ headers: { Authorization: `Bearer ${token}` },
83
+ });
84
+ if (res.ok) {
85
+ const data = await res.json();
86
+ if (!cancelled && data.authenticated) {
87
  setUser({
88
  authenticated: true,
89
  username: data.username,
 
93
  return;
94
  }
95
  }
96
+ // Token invalid β€” clear it
97
+ clearStoredToken();
98
+ if (!cancelled) setUser(null);
 
 
 
 
 
 
 
 
 
 
 
99
  } catch {
100
+ // Backend unreachable in dev β€” set dev user
101
+ if (!cancelled) setUser({ authenticated: true, username: 'dev' });
102
  }
103
  }
104
 
105
+ init();
106
+ return () => { cancelled = true; };
107
  }, [setUser]);
 
 
108
  }
frontend/src/utils/api.ts CHANGED
@@ -1,58 +1,62 @@
1
  /**
2
- * Centralized API utilities with automatic auth header injection.
3
  *
4
- * In production (OAuth enabled):
5
- * - REST calls include the HttpOnly cookie automatically (same-origin)
6
- * - WebSocket passes token via query parameter
7
- *
8
- * In development (no OAuth):
9
- * - Auth is bypassed on the backend, no token needed
10
  */
11
 
12
- /** Get the base URL for API calls (handles dev proxy vs production) */
13
- function getApiBase(): string {
14
- // In development, Vite proxies /api and /auth to the backend
15
- // In production, same origin
16
- return '';
17
- }
18
 
19
- /** Wrapper around fetch that includes credentials (cookies) and common headers. */
20
  export async function apiFetch(
21
  path: string,
22
  options: RequestInit = {}
23
  ): Promise<Response> {
24
- const url = `${getApiBase()}${path}`;
25
-
26
  const headers: Record<string, string> = {
27
  'Content-Type': 'application/json',
28
  ...(options.headers as Record<string, string>),
29
  };
30
 
31
- const response = await fetch(url, {
 
 
 
 
 
 
32
  ...options,
33
  headers,
34
- credentials: 'include', // Send cookies (hf_access_token) with every request
35
  });
36
 
37
- // Handle 401 - redirect to login if auth is required
38
  if (response.status === 401) {
39
- const authStatus = await fetch(`${getApiBase()}/auth/status`, {
40
- credentials: 'include',
41
- });
42
- const data = await authStatus.json();
43
- if (data.auth_enabled) {
44
- window.location.href = '/auth/login';
45
- throw new Error('Authentication required β€” redirecting to login.');
 
 
 
46
  }
47
  }
48
 
49
  return response;
50
  }
51
 
52
- /** Build the WebSocket URL for a session, including auth token if available. */
53
  export function getWebSocketUrl(sessionId: string): string {
54
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
55
- // Always use same origin β€” Vite proxy (ws: true) handles dev,
56
- // same origin works directly in production. No cross-origin issues.
57
- return `${protocol}//${window.location.host}/api/ws/${sessionId}`;
 
 
 
 
 
58
  }
 
1
  /**
2
+ * Centralized API utilities.
3
  *
4
+ * Reads the HF OAuth token from localStorage and injects it as
5
+ * an Authorization: Bearer header on every request.
6
+ * WebSocket URLs include the token as a query parameter.
 
 
 
7
  */
8
 
9
+ import { getStoredToken, triggerLogin } from '@/hooks/useAuth';
 
 
 
 
 
10
 
11
+ /** Wrapper around fetch that includes auth and common headers. */
12
  export async function apiFetch(
13
  path: string,
14
  options: RequestInit = {}
15
  ): Promise<Response> {
 
 
16
  const headers: Record<string, string> = {
17
  'Content-Type': 'application/json',
18
  ...(options.headers as Record<string, string>),
19
  };
20
 
21
+ // Inject Bearer token if available
22
+ const token = getStoredToken();
23
+ if (token) {
24
+ headers['Authorization'] = `Bearer ${token}`;
25
+ }
26
+
27
+ const response = await fetch(path, {
28
  ...options,
29
  headers,
30
+ credentials: 'include', // Still send cookies for backward compat
31
  });
32
 
33
+ // Handle 401 β€” trigger login if auth is required
34
  if (response.status === 401) {
35
+ try {
36
+ const authStatus = await fetch('/auth/status');
37
+ const data = await authStatus.json();
38
+ if (data.auth_enabled) {
39
+ await triggerLogin();
40
+ throw new Error('Authentication required β€” redirecting to login.');
41
+ }
42
+ } catch (e) {
43
+ if (e instanceof Error && e.message.includes('redirecting')) throw e;
44
+ // auth/status failed β€” ignore
45
  }
46
  }
47
 
48
  return response;
49
  }
50
 
51
+ /** Build the WebSocket URL for a session, including auth token. */
52
  export function getWebSocketUrl(sessionId: string): string {
53
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
54
+ const base = `${protocol}//${window.location.host}/api/ws/${sessionId}`;
55
+
56
+ // Pass token as query param (WebSocket can't set custom headers from browser)
57
+ const token = getStoredToken();
58
+ if (token) {
59
+ return `${base}?token=${encodeURIComponent(token)}`;
60
+ }
61
+ return base;
62
  }