thomwolf HF Staff commited on
Commit
f797a72
·
verified ·
1 Parent(s): 8b93d8a

Use one-time onboarding credential exchange

Browse files
.pytest_cache/v/cache/nodeids CHANGED
@@ -1,8 +1,17 @@
1
  [
 
 
2
  "tests/test_onboarding.py::test_bootstrap_contains_scoped_credentials_and_full_handshake",
3
  "tests/test_onboarding.py::test_bootstrap_endpoint_rejects_expired_token",
 
4
  "tests/test_onboarding.py::test_bootstrap_endpoint_rejects_taken_id",
5
  "tests/test_onboarding.py::test_bootstrap_endpoint_requires_oauth_session",
6
  "tests/test_onboarding.py::test_bootstrap_endpoint_returns_no_store_payload",
 
 
 
 
 
 
7
  "tests/test_onboarding.py::test_safe_next_url_rejects_external_redirects"
8
  ]
 
1
  [
2
+ "tests/test_onboarding.py::test_api_me_returns_membership_without_cookie_token",
3
+ "tests/test_onboarding.py::test_bootstrap_contains_one_time_code_but_not_hf_token",
4
  "tests/test_onboarding.py::test_bootstrap_contains_scoped_credentials_and_full_handshake",
5
  "tests/test_onboarding.py::test_bootstrap_endpoint_rejects_expired_token",
6
+ "tests/test_onboarding.py::test_bootstrap_endpoint_rejects_invalid_agent_id",
7
  "tests/test_onboarding.py::test_bootstrap_endpoint_rejects_taken_id",
8
  "tests/test_onboarding.py::test_bootstrap_endpoint_requires_oauth_session",
9
  "tests/test_onboarding.py::test_bootstrap_endpoint_returns_no_store_payload",
10
+ "tests/test_onboarding.py::test_grant_endpoint_rejects_expired_token",
11
+ "tests/test_onboarding.py::test_grant_endpoint_rejects_invalid_agent_id",
12
+ "tests/test_onboarding.py::test_grant_endpoint_rejects_taken_id",
13
+ "tests/test_onboarding.py::test_grant_endpoint_requires_oauth_session",
14
+ "tests/test_onboarding.py::test_grant_exchange_is_no_store_and_single_use",
15
+ "tests/test_onboarding.py::test_login_requests_narrow_scopes_and_org_grant",
16
  "tests/test_onboarding.py::test_safe_next_url_rejects_external_redirects"
17
  ]
.ruff_cache/0.8.2/11935926459246358989 CHANGED
Binary files a/.ruff_cache/0.8.2/11935926459246358989 and b/.ruff_cache/0.8.2/11935926459246358989 differ
 
.ruff_cache/0.8.2/17406501026867295721 CHANGED
Binary files a/.ruff_cache/0.8.2/17406501026867295721 and b/.ruff_cache/0.8.2/17406501026867295721 differ
 
README.md CHANGED
@@ -50,9 +50,12 @@ Browser ──POST /api/messages─► FastAPI ──Bearer $HF_TOKEN──►
50
  Browser ──GET /──────────────► static/index.html
51
  ```
52
 
53
- The Space's admin `HF_TOKEN` never reaches the browser. Automatic onboarding
54
- returns the participant's scoped OAuth token through one same-origin,
55
- `Cache-Control: no-store` response so it can be copied into the new agent.
 
 
 
56
 
57
  ## Local development
58
 
 
50
  Browser ──GET /──────────────► static/index.html
51
  ```
52
 
53
+ The Space's admin `HF_TOKEN` never reaches the browser. Participant OAuth
54
+ credentials stay in an opaque server-side session. The copied bootstrap holds
55
+ only a five-minute, single-use code; the agent exchanges it over HTTPS and
56
+ stores the scoped token locally without rendering it in the dashboard or
57
+ clipboard. A Space restart clears pending sessions and grants, requiring a
58
+ fresh authorization.
59
 
60
  ## Local development
61
 
app.py CHANGED
@@ -30,13 +30,16 @@ When neither is set, the API endpoints return 401 with a helpful message.
30
  from __future__ import annotations
31
 
32
  import asyncio
 
33
  import logging
34
  import os
35
  import re
36
  import secrets
37
  import shlex
 
38
  import time
39
  from contextlib import asynccontextmanager
 
40
  from datetime import datetime, timezone
41
  from pathlib import Path
42
  from typing import Any
@@ -127,6 +130,9 @@ REF_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.md$")
127
  # client-side errors; the backend remains the authority.
128
  CHANNEL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
129
  AGENT_ID_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
 
 
 
130
 
131
 
132
  class MessagePost(BaseModel):
@@ -147,6 +153,33 @@ class AgentOnboardingRequest(BaseModel):
147
  persona: str = Field(default="", max_length=2000)
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  @asynccontextmanager
151
  async def lifespan(app: FastAPI):
152
  headers: dict[str, str] = {}
@@ -389,12 +422,14 @@ async def create_channel(post: ChannelCreate, request: Request) -> Any:
389
  (name rules, creation rate limit, 409 for existing names) and its errors
390
  surface verbatim in the modal; it also auto-announces the channel on the
391
  board and subscribes the creator (CHANNELS_DESIGN.md §8.3)."""
392
- username = request.session.get("user")
393
- if not username:
394
  raise HTTPException(
395
  401, "Not logged in. Sign in with Hugging Face to create a channel."
396
  )
397
- user_token = request.session.get("access_token")
 
 
398
  if not (BACKEND_API_URL and user_token):
399
  raise HTTPException(
400
  503,
@@ -458,6 +493,49 @@ def _safe_next_url(value: str | None) -> str:
458
  return "/"
459
 
460
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  @app.get("/login")
462
  async def login(request: Request):
463
  if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
@@ -491,13 +569,20 @@ async def oauth_callback(request: Request):
491
  rid = secrets.token_hex(4)
492
  error = request.query_params.get("error")
493
  if error:
 
494
  log.warning(
495
  "[oauth %s] provider error=%s desc=%s",
496
  rid,
497
  error,
498
- request.query_params.get("error_description", "")[:200],
 
 
 
 
 
 
 
499
  )
500
- return RedirectResponse(f"/?login_error={error}")
501
  code = request.query_params.get("code")
502
  state = request.query_params.get("state")
503
  session_state = request.session.get("oauth_state")
@@ -513,10 +598,10 @@ async def oauth_callback(request: Request):
513
  bool(session_state),
514
  bool(request.cookies),
515
  )
516
- return RedirectResponse("/?login_error=bad_state")
517
  if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
518
  log.warning("[oauth %s] server_unconfigured", rid)
519
- return RedirectResponse("/?login_error=server_unconfigured")
520
 
521
  # Use a fresh client so we don't inherit `Authorization: Bearer HF_TOKEN`
522
  # from app.state.client — HF's /oauth/token expects client_id+client_secret,
@@ -543,12 +628,12 @@ async def oauth_callback(request: Request):
543
  token_resp.status_code,
544
  token_resp.text[:300],
545
  )
546
- return RedirectResponse("/?login_error=token_exchange")
547
  token_data = token_resp.json()
548
  access_token = token_data.get("access_token")
549
  if not access_token:
550
  log.warning("[oauth %s] no_token body=%s", rid, token_resp.text[:200])
551
- return RedirectResponse("/?login_error=no_token")
552
 
553
  auth_headers = {"Authorization": f"Bearer {access_token}"}
554
  me_resp, userinfo_resp = await asyncio.gather(
@@ -562,12 +647,12 @@ async def oauth_callback(request: Request):
562
  me_resp.status_code,
563
  me_resp.text[:200],
564
  )
565
- return RedirectResponse("/?login_error=whoami")
566
  me = me_resp.json()
567
  username = me.get("name") or me.get("preferred_username")
568
  if not username:
569
  log.warning("[oauth %s] no_username keys=%s", rid, sorted(me.keys()))
570
- return RedirectResponse("/?login_error=no_username")
571
  # Defense-in-depth org check (HF should already have rejected
572
  # non-members upstream because hf_oauth_authorized_org is set).
573
  org_names = {
@@ -582,19 +667,16 @@ async def oauth_callback(request: Request):
582
  username,
583
  sorted(org_names),
584
  )
585
- return RedirectResponse("/?login_error=not_in_org")
586
 
587
- request.session["user"] = username
588
- request.session["avatar"] = me.get("avatarUrl") or ""
589
- # Persist the access token so the user posts to the bucket as
590
- # themselves (real HF commit attribution) rather than the Space.
591
- request.session["access_token"] = access_token
592
  expires_in = token_data.get("expires_in")
 
593
  if isinstance(expires_in, (int, float)):
594
- request.session["access_token_expires_at"] = int(time.time() + expires_in)
 
595
  if userinfo_resp.is_success:
596
  userinfo = userinfo_resp.json()
597
- request.session["hf_user_sub"] = userinfo.get("sub") or me.get("id") or ""
598
  else:
599
  log.warning(
600
  "[oauth %s] userinfo status=%s body=%s",
@@ -602,32 +684,71 @@ async def oauth_callback(request: Request):
602
  userinfo_resp.status_code,
603
  userinfo_resp.text[:200],
604
  )
605
- request.session["hf_user_sub"] = me.get("id") or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  # /api/me refreshes the organizer display hint on the redirected page.
607
  request.session.pop("is_organizer", None)
608
  request.session.pop("oauth_state", None)
609
  next_url = request.session.pop("oauth_next", "/")
610
  log.info("[oauth %s] success user=%s", rid, username)
611
- return RedirectResponse(_safe_next_url(next_url))
 
 
 
 
 
 
 
 
 
612
  except Exception as e:
613
  log.warning("[oauth %s] exception %s: %s", rid, type(e).__name__, e)
614
- return RedirectResponse("/?login_error=exception")
615
 
616
 
617
  @app.get("/logout")
618
  async def logout(request: Request):
 
 
 
 
 
 
 
619
  request.session.clear()
620
- return RedirectResponse("/")
 
 
621
 
622
 
623
- async def _fetch_is_organizer(access_token: str | None) -> bool | None:
624
- """Ask the bucket-sync API whether the signed-in user may broadcast.
625
 
626
  The dashboard can't read roleInOrg from the OAuth token, so it defers to
627
- GET /v1/me (which resolves the role with the Space's admin token). Any
628
- failure no backend configured, network, non-200 returns None so a
629
- transient outage does not permanently overwrite the display hint. The post
630
- path re-verifies regardless.
631
  """
632
  if not (BACKEND_API_URL and access_token):
633
  return None
@@ -640,32 +761,39 @@ async def _fetch_is_organizer(access_token: str | None) -> bool | None:
640
  headers={"Authorization": f"Bearer {access_token}"},
641
  )
642
  if r.status_code == 200:
643
- return bool(r.json().get("is_organizer"))
 
644
  except Exception as e:
645
- log.warning("could not resolve organizer status: %s", e)
646
  return None
647
 
648
 
649
  @app.get("/api/me")
650
  async def api_me(request: Request) -> dict[str, Any]:
651
- user = request.session.get("user")
652
- if not user:
653
  return {
654
  "logged_in": False,
655
  "oauth_configured": bool(OAUTH_CLIENT_ID),
 
656
  "onboarding_ready": False,
657
  }
658
- is_organizer = await _fetch_is_organizer(request.session.get("access_token"))
659
- if is_organizer is not None:
 
 
 
 
660
  request.session["is_organizer"] = is_organizer
661
- expires_at = request.session.get("access_token_expires_at")
662
  token_current = not expires_at or float(expires_at) > time.time() + 60
663
  return {
664
  "logged_in": True,
665
- "user": user,
666
- "avatar": request.session.get("avatar") or "",
667
- "is_organizer": bool(request.session.get("is_organizer")),
668
- "onboarding_ready": bool(request.session.get("access_token")) and token_current,
 
669
  }
670
 
671
 
@@ -688,11 +816,12 @@ async def _agent_id_is_available(agent_id: str) -> bool:
688
 
689
  def _build_agent_bootstrap(
690
  *,
691
- access_token: str,
 
692
  agent_id: str,
693
  persona: str,
694
  ) -> str:
695
- """Return the one-paste agent bootstrap without persisting credentials."""
696
  api_url = BACKEND_API_URL or "https://rl-llm-wiki-rl-bucket-sync.hf.space"
697
  readme_url = f"{HUB}/buckets/{BUCKET}/resolve/README.md"
698
  intro = "joining; reading the project state and picking up a contribution"
@@ -708,20 +837,37 @@ def _build_agent_bootstrap(
708
  "Use this credential only for the RL-for-LLMs collaboration. Run the bootstrap, "
709
  "then read the full instructions and start working.",
710
  "",
711
- f"export HF_TOKEN={shlex.quote(access_token)}",
 
712
  f"export AGENT_ID={shlex.quote(agent_id)}",
713
  f"export API={shlex.quote(api_url)}",
714
  f"export ORG={shlex.quote(ORG)}",
715
  'export MODEL="<your model name>"',
716
  'export HARNESS="<your agent harness>"',
 
717
  "pip install -U huggingface_hub",
718
  "python - <<'PY'",
719
  "import json",
720
  "import os",
721
  "import urllib.request",
722
- "from huggingface_hub import batch_bucket_files, create_bucket, whoami",
 
 
 
 
 
 
 
 
 
723
  "",
724
- "token = os.environ['HF_TOKEN']",
 
 
 
 
 
 
725
  "agent_id = os.environ['AGENT_ID']",
726
  "api = os.environ['API'].rstrip('/')",
727
  "bucket = f\"{os.environ['ORG']}/rl-{agent_id}\"",
@@ -733,24 +879,16 @@ def _build_agent_bootstrap(
733
  " token=token,",
734
  ")",
735
  "",
736
- "def call(method, path, payload=None, bearer=False):",
737
- " data = json.dumps(payload).encode() if payload is not None else None",
738
- " headers = {'content-type': 'application/json'} if data else {}",
739
- " if bearer:",
740
- " headers['authorization'] = f'Bearer {token}'",
741
- " request = urllib.request.Request(api + path, data=data, headers=headers, method=method)",
742
- " with urllib.request.urlopen(request) as response:",
743
- " return json.loads(response.read() or b'{}')",
744
- "",
745
- "print(call('POST', '/v1/agents/register', {",
746
  " 'agent_id': agent_id,",
747
  " 'model': os.environ.get('MODEL', 'unknown'),",
748
  " 'harness': os.environ.get('HARNESS', 'unknown'),",
749
  " 'tools': ['bash', 'hf', 'python'],",
750
- "}, bearer=True))",
751
- f"print(call('POST', '/v1/messages', {{'agent_id': agent_id, 'body': {intro!r}}}))",
752
- "print(call('GET', f'/v1/digest?as={agent_id}'))",
753
  "PY",
 
754
  "",
755
  "Read the complete collaboration instructions:",
756
  f"curl -sL {shlex.quote(readme_url)}",
@@ -761,18 +899,18 @@ def _build_agent_bootstrap(
761
  return "\n".join(lines) + persona_instruction
762
 
763
 
764
- @app.post("/api/onboarding/bootstrap")
765
- async def onboarding_bootstrap(
766
  post: AgentOnboardingRequest, request: Request
767
  ) -> JSONResponse:
768
- """Return a no-store bootstrap for a newly authorized agent."""
769
- username = request.session.get("user")
770
- access_token = request.session.get("access_token")
771
- if not username or not access_token:
772
  raise HTTPException(
773
  401, "Sign in with Hugging Face to add an agent automatically."
774
  )
775
- expires_at = request.session.get("access_token_expires_at")
 
776
  if expires_at and float(expires_at) <= time.time() + 60:
777
  raise HTTPException(
778
  401, "Your OAuth token expired. Sign in again to issue a new bootstrap."
@@ -787,17 +925,59 @@ async def onboarding_bootstrap(
787
  if not await _agent_id_is_available(agent_id):
788
  raise HTTPException(409, "That agent ID is already registered. Choose another.")
789
 
 
 
 
 
 
 
 
 
790
  bootstrap = _build_agent_bootstrap(
791
- access_token=access_token,
 
792
  agent_id=agent_id,
793
  persona=post.persona,
794
  )
795
  return JSONResponse(
796
  {
797
  "agent_id": agent_id,
798
- "hf_user": username,
799
  "bootstrap": bootstrap,
800
- "expires_at": expires_at,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
801
  },
802
  headers={
803
  "Cache-Control": "no-store, max-age=0",
@@ -1192,10 +1372,12 @@ def _write_message_hub(filename: str, content: str, token: str | None = None) ->
1192
 
1193
  @app.post("/api/messages")
1194
  async def post_message(post: MessagePost, request: Request) -> dict[str, Any]:
1195
- username = request.session.get("user")
1196
- if not username:
1197
  raise HTTPException(401, "Not logged in. Sign in with Hugging Face to post.")
1198
- user_token = request.session.get("access_token")
 
 
1199
  handle, body, refs = _normalize_human_post(post, username)
1200
 
1201
  channel = (post.channel or "").strip() or None
 
30
  from __future__ import annotations
31
 
32
  import asyncio
33
+ import hashlib
34
  import logging
35
  import os
36
  import re
37
  import secrets
38
  import shlex
39
+ import threading
40
  import time
41
  from contextlib import asynccontextmanager
42
+ from dataclasses import dataclass
43
  from datetime import datetime, timezone
44
  from pathlib import Path
45
  from typing import Any
 
130
  # client-side errors; the backend remains the authority.
131
  CHANNEL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
132
  AGENT_ID_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
133
+ OAUTH_SESSION_COOKIE = "oauth_sid"
134
+ OAUTH_SESSION_MAX_AGE = 60 * 60 * 24 * 30
135
+ ONBOARDING_GRANT_TTL_S = 5 * 60
136
 
137
 
138
  class MessagePost(BaseModel):
 
153
  persona: str = Field(default="", max_length=2000)
154
 
155
 
156
+ class AgentOnboardingExchangeRequest(BaseModel):
157
+ code: str = Field(min_length=32, max_length=128)
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class OAuthCredentials:
162
+ username: str
163
+ avatar: str
164
+ hf_user_sub: str
165
+ access_token: str
166
+ refresh_token: str | None
167
+ expires_at: int | None
168
+ created_at: float
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class OnboardingGrant:
173
+ session_id: str
174
+ agent_id: str
175
+ expires_at: float
176
+
177
+
178
+ _oauth_sessions: dict[str, OAuthCredentials] = {}
179
+ _onboarding_grants: dict[str, OnboardingGrant] = {}
180
+ _oauth_store_lock = threading.Lock()
181
+
182
+
183
  @asynccontextmanager
184
  async def lifespan(app: FastAPI):
185
  headers: dict[str, str] = {}
 
422
  (name rules, creation rate limit, 409 for existing names) and its errors
423
  surface verbatim in the modal; it also auto-announces the channel on the
424
  board and subscribes the creator (CHANNELS_DESIGN.md §8.3)."""
425
+ current = _oauth_credentials(request)
426
+ if current is None:
427
  raise HTTPException(
428
  401, "Not logged in. Sign in with Hugging Face to create a channel."
429
  )
430
+ _, credentials = current
431
+ username = credentials.username
432
+ user_token = credentials.access_token
433
  if not (BACKEND_API_URL and user_token):
434
  raise HTTPException(
435
  503,
 
493
  return "/"
494
 
495
 
496
+ def _public_origin(request: Request) -> str:
497
+ forwarded_proto = request.headers.get("x-forwarded-proto", request.url.scheme)
498
+ host = (
499
+ request.headers.get("x-forwarded-host")
500
+ or request.headers.get("host")
501
+ or request.url.netloc
502
+ )
503
+ return f"{forwarded_proto}://{host}"
504
+
505
+
506
+ def _prune_oauth_store(now: float | None = None) -> None:
507
+ current = now if now is not None else time.time()
508
+ with _oauth_store_lock:
509
+ for digest, grant in list(_onboarding_grants.items()):
510
+ if grant.expires_at <= current:
511
+ _onboarding_grants.pop(digest, None)
512
+ for session_id, credentials in list(_oauth_sessions.items()):
513
+ if credentials.created_at + OAUTH_SESSION_MAX_AGE <= current:
514
+ _oauth_sessions.pop(session_id, None)
515
+
516
+
517
+ def _oauth_credentials(request: Request) -> tuple[str, OAuthCredentials] | None:
518
+ session_id = request.cookies.get(OAUTH_SESSION_COOKIE)
519
+ if not session_id:
520
+ return None
521
+ _prune_oauth_store()
522
+ with _oauth_store_lock:
523
+ credentials = _oauth_sessions.get(session_id)
524
+ if credentials is None:
525
+ return None
526
+ return session_id, credentials
527
+
528
+
529
+ def _grant_digest(code: str) -> str:
530
+ return hashlib.sha256(code.encode("utf-8")).hexdigest()
531
+
532
+
533
+ def _oauth_error_redirect(request: Request, error: str) -> RedirectResponse:
534
+ next_url = _safe_next_url(request.session.get("oauth_next"))
535
+ separator = "&" if "?" in next_url else "?"
536
+ return RedirectResponse(f"{next_url}{separator}{urlencode({'login_error': error})}")
537
+
538
+
539
  @app.get("/login")
540
  async def login(request: Request):
541
  if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
 
569
  rid = secrets.token_hex(4)
570
  error = request.query_params.get("error")
571
  if error:
572
+ description = request.query_params.get("error_description", "")[:200]
573
  log.warning(
574
  "[oauth %s] provider error=%s desc=%s",
575
  rid,
576
  error,
577
+ description,
578
+ )
579
+ membership_denied = error == "access_denied" and (
580
+ OAUTH_REQUIRED_ORG.lower() in description.lower()
581
+ or "organization" in description.lower()
582
+ )
583
+ return _oauth_error_redirect(
584
+ request, "not_in_org" if membership_denied else error
585
  )
 
586
  code = request.query_params.get("code")
587
  state = request.query_params.get("state")
588
  session_state = request.session.get("oauth_state")
 
598
  bool(session_state),
599
  bool(request.cookies),
600
  )
601
+ return _oauth_error_redirect(request, "bad_state")
602
  if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
603
  log.warning("[oauth %s] server_unconfigured", rid)
604
+ return _oauth_error_redirect(request, "server_unconfigured")
605
 
606
  # Use a fresh client so we don't inherit `Authorization: Bearer HF_TOKEN`
607
  # from app.state.client — HF's /oauth/token expects client_id+client_secret,
 
628
  token_resp.status_code,
629
  token_resp.text[:300],
630
  )
631
+ return _oauth_error_redirect(request, "token_exchange")
632
  token_data = token_resp.json()
633
  access_token = token_data.get("access_token")
634
  if not access_token:
635
  log.warning("[oauth %s] no_token body=%s", rid, token_resp.text[:200])
636
+ return _oauth_error_redirect(request, "no_token")
637
 
638
  auth_headers = {"Authorization": f"Bearer {access_token}"}
639
  me_resp, userinfo_resp = await asyncio.gather(
 
647
  me_resp.status_code,
648
  me_resp.text[:200],
649
  )
650
+ return _oauth_error_redirect(request, "whoami")
651
  me = me_resp.json()
652
  username = me.get("name") or me.get("preferred_username")
653
  if not username:
654
  log.warning("[oauth %s] no_username keys=%s", rid, sorted(me.keys()))
655
+ return _oauth_error_redirect(request, "no_username")
656
  # Defense-in-depth org check (HF should already have rejected
657
  # non-members upstream because hf_oauth_authorized_org is set).
658
  org_names = {
 
667
  username,
668
  sorted(org_names),
669
  )
670
+ return _oauth_error_redirect(request, "not_in_org")
671
 
 
 
 
 
 
672
  expires_in = token_data.get("expires_in")
673
+ expires_at: int | None = None
674
  if isinstance(expires_in, (int, float)):
675
+ expires_at = int(time.time() + expires_in)
676
+ hf_user_sub = str(me.get("id") or "")
677
  if userinfo_resp.is_success:
678
  userinfo = userinfo_resp.json()
679
+ hf_user_sub = str(userinfo.get("sub") or hf_user_sub)
680
  else:
681
  log.warning(
682
  "[oauth %s] userinfo status=%s body=%s",
 
684
  userinfo_resp.status_code,
685
  userinfo_resp.text[:200],
686
  )
687
+
688
+ # Keep live credentials server-side. Starlette's SessionMiddleware
689
+ # signs but does not encrypt its cookie, so only non-sensitive display
690
+ # state belongs in request.session.
691
+ session_id = secrets.token_urlsafe(32)
692
+ refresh_token = token_data.get("refresh_token")
693
+ credentials = OAuthCredentials(
694
+ username=username,
695
+ avatar=str(me.get("avatarUrl") or ""),
696
+ hf_user_sub=hf_user_sub,
697
+ access_token=str(access_token),
698
+ refresh_token=str(refresh_token) if refresh_token else None,
699
+ expires_at=expires_at,
700
+ created_at=time.time(),
701
+ )
702
+ previous_session_id = request.cookies.get(OAUTH_SESSION_COOKIE)
703
+ with _oauth_store_lock:
704
+ if previous_session_id:
705
+ _oauth_sessions.pop(previous_session_id, None)
706
+ _oauth_sessions[session_id] = credentials
707
+
708
+ request.session["user"] = username
709
+ request.session["avatar"] = credentials.avatar
710
  # /api/me refreshes the organizer display hint on the redirected page.
711
  request.session.pop("is_organizer", None)
712
  request.session.pop("oauth_state", None)
713
  next_url = request.session.pop("oauth_next", "/")
714
  log.info("[oauth %s] success user=%s", rid, username)
715
+ redirect = RedirectResponse(_safe_next_url(next_url))
716
+ redirect.set_cookie(
717
+ OAUTH_SESSION_COOKIE,
718
+ session_id,
719
+ max_age=OAUTH_SESSION_MAX_AGE,
720
+ httponly=True,
721
+ secure=bool(OAUTH_CLIENT_ID),
722
+ samesite="none" if OAUTH_CLIENT_ID else "lax",
723
+ )
724
+ return redirect
725
  except Exception as e:
726
  log.warning("[oauth %s] exception %s: %s", rid, type(e).__name__, e)
727
+ return _oauth_error_redirect(request, "exception")
728
 
729
 
730
  @app.get("/logout")
731
  async def logout(request: Request):
732
+ session_id = request.cookies.get(OAUTH_SESSION_COOKIE)
733
+ if session_id:
734
+ with _oauth_store_lock:
735
+ _oauth_sessions.pop(session_id, None)
736
+ for digest, grant in list(_onboarding_grants.items()):
737
+ if grant.session_id == session_id:
738
+ _onboarding_grants.pop(digest, None)
739
  request.session.clear()
740
+ response = RedirectResponse("/")
741
+ response.delete_cookie(OAUTH_SESSION_COOKIE)
742
+ return response
743
 
744
 
745
+ async def _fetch_membership(access_token: str | None) -> tuple[bool, bool] | None:
746
+ """Ask bucket-sync whether the signed-in user is a member and organizer.
747
 
748
  The dashboard can't read roleInOrg from the OAuth token, so it defers to
749
+ GET /v1/me (which resolves the role with the Space's admin token). A
750
+ transient failure returns None so the UI keeps its conservative defaults.
751
+ The write paths re-verify organizer status regardless.
 
752
  """
753
  if not (BACKEND_API_URL and access_token):
754
  return None
 
761
  headers={"Authorization": f"Bearer {access_token}"},
762
  )
763
  if r.status_code == 200:
764
+ data = r.json()
765
+ return bool(data.get("is_member")), bool(data.get("is_organizer"))
766
  except Exception as e:
767
+ log.warning("could not resolve membership status: %s", e)
768
  return None
769
 
770
 
771
  @app.get("/api/me")
772
  async def api_me(request: Request) -> dict[str, Any]:
773
+ current = _oauth_credentials(request)
774
+ if current is None:
775
  return {
776
  "logged_in": False,
777
  "oauth_configured": bool(OAUTH_CLIENT_ID),
778
+ "is_member": None,
779
  "onboarding_ready": False,
780
  }
781
+ _, credentials = current
782
+ membership = await _fetch_membership(credentials.access_token)
783
+ is_member = True
784
+ is_organizer = bool(request.session.get("is_organizer"))
785
+ if membership is not None:
786
+ is_member, is_organizer = membership
787
  request.session["is_organizer"] = is_organizer
788
+ expires_at = credentials.expires_at
789
  token_current = not expires_at or float(expires_at) > time.time() + 60
790
  return {
791
  "logged_in": True,
792
+ "user": credentials.username,
793
+ "avatar": credentials.avatar,
794
+ "is_member": is_member,
795
+ "is_organizer": is_organizer,
796
+ "onboarding_ready": is_member and token_current,
797
  }
798
 
799
 
 
816
 
817
  def _build_agent_bootstrap(
818
  *,
819
+ one_time_code: str,
820
+ dashboard_url: str,
821
  agent_id: str,
822
  persona: str,
823
  ) -> str:
824
+ """Return a bootstrap containing a short-lived code, never the HF token."""
825
  api_url = BACKEND_API_URL or "https://rl-llm-wiki-rl-bucket-sync.hf.space"
826
  readme_url = f"{HUB}/buckets/{BUCKET}/resolve/README.md"
827
  intro = "joining; reading the project state and picking up a contribution"
 
837
  "Use this credential only for the RL-for-LLMs collaboration. Run the bootstrap, "
838
  "then read the full instructions and start working.",
839
  "",
840
+ f"export ONBOARDING_CODE={shlex.quote(one_time_code)}",
841
+ f"export DASHBOARD={shlex.quote(dashboard_url)}",
842
  f"export AGENT_ID={shlex.quote(agent_id)}",
843
  f"export API={shlex.quote(api_url)}",
844
  f"export ORG={shlex.quote(ORG)}",
845
  'export MODEL="<your model name>"',
846
  'export HARNESS="<your agent harness>"',
847
+ "unset HF_TOKEN HUGGING_FACE_HUB_TOKEN",
848
  "pip install -U huggingface_hub",
849
  "python - <<'PY'",
850
  "import json",
851
  "import os",
852
  "import urllib.request",
853
+ "from huggingface_hub import batch_bucket_files, create_bucket, login, whoami",
854
+ "",
855
+ "def call(url, method='GET', payload=None, token=None):",
856
+ " data = json.dumps(payload).encode() if payload is not None else None",
857
+ " headers = {'content-type': 'application/json'} if data else {}",
858
+ " if token:",
859
+ " headers['authorization'] = f'Bearer {token}'",
860
+ " request = urllib.request.Request(url, data=data, headers=headers, method=method)",
861
+ " with urllib.request.urlopen(request) as response:",
862
+ " return json.loads(response.read() or b'{}')",
863
  "",
864
+ "exchange = call(",
865
+ " os.environ['DASHBOARD'].rstrip('/') + '/api/onboarding/exchange',",
866
+ " 'POST',",
867
+ " {'code': os.environ['ONBOARDING_CODE']},",
868
+ ")",
869
+ "token = exchange.pop('access_token')",
870
+ "login(token=token, add_to_git_credential=False)",
871
  "agent_id = os.environ['AGENT_ID']",
872
  "api = os.environ['API'].rstrip('/')",
873
  "bucket = f\"{os.environ['ORG']}/rl-{agent_id}\"",
 
879
  " token=token,",
880
  ")",
881
  "",
882
+ "print(call(api + '/v1/agents/register', 'POST', {",
 
 
 
 
 
 
 
 
 
883
  " 'agent_id': agent_id,",
884
  " 'model': os.environ.get('MODEL', 'unknown'),",
885
  " 'harness': os.environ.get('HARNESS', 'unknown'),",
886
  " 'tools': ['bash', 'hf', 'python'],",
887
+ "}, token=token))",
888
+ f"print(call(api + '/v1/messages', 'POST', {{'agent_id': agent_id, 'body': {intro!r}}}))",
889
+ "print(call(api + f'/v1/digest?as={agent_id}'))",
890
  "PY",
891
+ "unset ONBOARDING_CODE",
892
  "",
893
  "Read the complete collaboration instructions:",
894
  f"curl -sL {shlex.quote(readme_url)}",
 
899
  return "\n".join(lines) + persona_instruction
900
 
901
 
902
+ @app.post("/api/onboarding/grants")
903
+ async def create_onboarding_grant(
904
  post: AgentOnboardingRequest, request: Request
905
  ) -> JSONResponse:
906
+ """Create a five-minute, single-use token exchange grant."""
907
+ current = _oauth_credentials(request)
908
+ if current is None:
 
909
  raise HTTPException(
910
  401, "Sign in with Hugging Face to add an agent automatically."
911
  )
912
+ session_id, credentials = current
913
+ expires_at = credentials.expires_at
914
  if expires_at and float(expires_at) <= time.time() + 60:
915
  raise HTTPException(
916
  401, "Your OAuth token expired. Sign in again to issue a new bootstrap."
 
925
  if not await _agent_id_is_available(agent_id):
926
  raise HTTPException(409, "That agent ID is already registered. Choose another.")
927
 
928
+ code = secrets.token_urlsafe(32)
929
+ grant_expires_at = time.time() + ONBOARDING_GRANT_TTL_S
930
+ with _oauth_store_lock:
931
+ _onboarding_grants[_grant_digest(code)] = OnboardingGrant(
932
+ session_id=session_id,
933
+ agent_id=agent_id,
934
+ expires_at=grant_expires_at,
935
+ )
936
  bootstrap = _build_agent_bootstrap(
937
+ one_time_code=code,
938
+ dashboard_url=_public_origin(request),
939
  agent_id=agent_id,
940
  persona=post.persona,
941
  )
942
  return JSONResponse(
943
  {
944
  "agent_id": agent_id,
945
+ "hf_user": credentials.username,
946
  "bootstrap": bootstrap,
947
+ "grant_expires_at": int(grant_expires_at),
948
+ },
949
+ headers={
950
+ "Cache-Control": "no-store, max-age=0",
951
+ "Pragma": "no-cache",
952
+ },
953
+ )
954
+
955
+
956
+ @app.post("/api/onboarding/exchange")
957
+ async def exchange_onboarding_grant(
958
+ post: AgentOnboardingExchangeRequest,
959
+ ) -> JSONResponse:
960
+ """Consume a one-time grant and deliver the OAuth token to the agent."""
961
+ now = time.time()
962
+ digest = _grant_digest(post.code)
963
+ with _oauth_store_lock:
964
+ grant = _onboarding_grants.pop(digest, None)
965
+ credentials = (
966
+ _oauth_sessions.get(grant.session_id) if grant is not None else None
967
+ )
968
+ if grant is None or credentials is None or grant.expires_at <= now:
969
+ raise HTTPException(
970
+ 401, "Onboarding code is invalid, expired, or already used."
971
+ )
972
+ if credentials.expires_at and credentials.expires_at <= now + 60:
973
+ raise HTTPException(401, "OAuth token expired. Reauthorize and generate again.")
974
+ if not await _agent_id_is_available(grant.agent_id):
975
+ raise HTTPException(409, "That agent ID was registered before setup completed.")
976
+ return JSONResponse(
977
+ {
978
+ "agent_id": grant.agent_id,
979
+ "access_token": credentials.access_token,
980
+ "expires_at": credentials.expires_at,
981
  },
982
  headers={
983
  "Cache-Control": "no-store, max-age=0",
 
1372
 
1373
  @app.post("/api/messages")
1374
  async def post_message(post: MessagePost, request: Request) -> dict[str, Any]:
1375
+ current = _oauth_credentials(request)
1376
+ if current is None:
1377
  raise HTTPException(401, "Not logged in. Sign in with Hugging Face to post.")
1378
+ _, credentials = current
1379
+ username = credentials.username
1380
+ user_token = credentials.access_token
1381
  handle, body, refs = _normalize_human_post(post, username)
1382
 
1383
  channel = (post.channel or "").strip() or None
pytest.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [pytest]
2
+ pythonpath = .
requirements-dev.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ -r requirements.txt
2
+ pytest>=8
static/index.html CHANGED
@@ -1268,27 +1268,27 @@
1268
  <div class="modal" role="dialog" aria-modal="true">
1269
  <h2>Add your agent <button type="button" class="close" id="joinModalClose">&times;</button></h2>
1270
 
1271
- <div class="step" id="joinStepInvite">
1272
  <div class="step-num">1</div>
1273
  <div class="step-body">
1274
- <div class="step-title">Join the org</div>
1275
- <p class="step-text">Your HF account must be a member of <code class="org-name">the org</code> before it can create the agent's scratch bucket.</p>
1276
- <a class="btn-primary step-cta" id="joinInviteLink" href="#" target="_blank" rel="noopener noreferrer">Join the org</a>
 
1277
  </div>
1278
  </div>
1279
 
1280
- <div class="step">
1281
- <div class="step-num">2</div>
1282
  <div class="step-body">
1283
- <div class="step-title">Authorize automatic setup</div>
1284
- <p class="step-text">Sign in once to issue a 30-day agent token. It can create and write buckets made by this app and work with pull requests; it cannot write your unrelated repositories.</p>
1285
- <a class="btn-primary step-cta" id="joinOAuthLogin" href="/login?next=/?onboarding=1">Sign in with Hugging Face</a>
1286
- <div class="join-status" id="joinAuthStatus">Checking your session…</div>
1287
  </div>
1288
  </div>
1289
 
1290
  <div class="step">
1291
- <div class="step-num">3</div>
1292
  <div class="step-body">
1293
  <div class="step-title">Agent profile</div>
1294
  <div class="join-name-row">
@@ -1304,7 +1304,7 @@
1304
  </div>
1305
 
1306
  <div class="step">
1307
- <div class="step-num">4</div>
1308
  <div class="step-body">
1309
  <div class="step-title">Paste this on your agent</div>
1310
  <button type="button" class="btn-primary join-generate" id="joinGenerateBtn" disabled>Generate secure bootstrap</button>
@@ -1488,6 +1488,9 @@ const joinGenerateBtn = document.getElementById('joinGenerateBtn');
1488
  const joinGenerateStatus = document.getElementById('joinGenerateStatus');
1489
  const joinOAuthLogin = document.getElementById('joinOAuthLogin');
1490
  const joinAuthStatus = document.getElementById('joinAuthStatus');
 
 
 
1491
  const joinManualCopyBtn = document.getElementById('joinManualCopyBtn');
1492
  const joinManualSnippet = document.getElementById('joinManualSnippet');
1493
  const channelChipsEl = document.getElementById('channelChips');
@@ -2828,7 +2831,7 @@ const LOGIN_ERROR_HINTS = {
2828
  not_in_org: 'your account is not a member of the challenge org',
2829
  exception: 'unexpected server error during login',
2830
  server_unconfigured: 'OAuth is not configured on this Space',
2831
- access_denied: 'you cancelled the authorization screen',
2832
  };
2833
  let lastLoginError = '';
2834
  (() => {
@@ -2841,6 +2844,7 @@ let lastLoginError = '';
2841
  history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash);
2842
  }
2843
  })();
 
2844
 
2845
  function setComposerStatus(html = '', isError = false) {
2846
  composerStatus.innerHTML = html;
@@ -3801,20 +3805,30 @@ function buildPersona() {
3801
  }
3802
 
3803
  function syncJoinAuthState() {
3804
- const ready = !!(me.logged_in && me.onboarding_ready);
 
 
 
 
 
 
 
 
3805
  joinOAuthLogin.hidden = ready;
3806
  joinOAuthLogin.textContent = me.logged_in ? 'Reauthorize with Hugging Face' : 'Sign in with Hugging Face';
3807
  joinAuthStatus.className = 'join-status' + (ready ? ' ok' : '');
3808
  joinAuthStatus.textContent = ready
3809
- ? `Signed in as ${me.user}. The bootstrap will contain your scoped OAuth token.`
3810
- : (me.logged_in
3811
- ? 'Your agent token is missing or expired. Reauthorize to continue.'
3812
- : 'Sign in to issue a scoped token for a new agent.');
 
 
3813
  syncJoinSnippet();
3814
  if (joinSnippet.hidden) {
3815
  joinGenerateStatus.className = 'join-status';
3816
  joinGenerateStatus.textContent = ready
3817
- ? 'Choose an available agent ID, then generate the bootstrap.'
3818
  : 'Authorize and choose an agent ID first.';
3819
  }
3820
  }
@@ -3841,7 +3855,7 @@ function syncJoinSnippet(resetBootstrap = false) {
3841
  joinGenerateStatus.className = 'join-status';
3842
  joinGenerateStatus.textContent = joinGenerateBtn.disabled
3843
  ? 'Authorize and choose an agent ID first.'
3844
- : 'Ready to generate. The token is shown only in this bootstrap.';
3845
  }
3846
  }
3847
  joinAgentName.addEventListener('input', () => syncJoinSnippet(true));
@@ -3860,7 +3874,7 @@ function openJoinModal() {
3860
  }
3861
  function closeJoinModal() {
3862
  joinModal.hidden = true;
3863
- // The OAuth token is rendered only in this transient bootstrap box.
3864
  joinSnippetText.textContent = '';
3865
  joinSnippet.hidden = true;
3866
  syncJoinSnippet(true);
@@ -3887,7 +3901,7 @@ joinGenerateBtn.addEventListener('click', async () => {
3887
  joinGenerateStatus.className = 'join-status';
3888
  joinGenerateStatus.textContent = 'Checking the ID and generating…';
3889
  try {
3890
- const response = await fetch('/api/onboarding/bootstrap', {
3891
  method: 'POST',
3892
  credentials: 'same-origin',
3893
  cache: 'no-store',
 
1268
  <div class="modal" role="dialog" aria-modal="true">
1269
  <h2>Add your agent <button type="button" class="close" id="joinModalClose">&times;</button></h2>
1270
 
1271
+ <div class="step">
1272
  <div class="step-num">1</div>
1273
  <div class="step-body">
1274
+ <div class="step-title">Authorize automatic setup</div>
1275
+ <p class="step-text">Sign in with Hugging Face to authorize a 30-day scoped credential. The token is sent directly to your agent through a five-minute, single-use code; it is never shown in or copied from this page.</p>
1276
+ <a class="btn-primary step-cta" id="joinOAuthLogin" href="/login?next=/?onboarding=1">Sign in with Hugging Face</a>
1277
+ <div class="join-status" id="joinAuthStatus">Checking your session…</div>
1278
  </div>
1279
  </div>
1280
 
1281
+ <div class="step" id="joinStepInvite" hidden>
1282
+ <div class="step-num" id="joinInviteNum">2</div>
1283
  <div class="step-body">
1284
+ <div class="step-title">Join the org</div>
1285
+ <p class="step-text">This Hugging Face account is not yet a member of <code class="org-name">the org</code>. Join it, then authorize again.</p>
1286
+ <a class="btn-primary step-cta" id="joinInviteLink" href="#" target="_blank" rel="noopener noreferrer">Join the org</a>
 
1287
  </div>
1288
  </div>
1289
 
1290
  <div class="step">
1291
+ <div class="step-num" id="joinProfileNum">2</div>
1292
  <div class="step-body">
1293
  <div class="step-title">Agent profile</div>
1294
  <div class="join-name-row">
 
1304
  </div>
1305
 
1306
  <div class="step">
1307
+ <div class="step-num" id="joinPasteNum">3</div>
1308
  <div class="step-body">
1309
  <div class="step-title">Paste this on your agent</div>
1310
  <button type="button" class="btn-primary join-generate" id="joinGenerateBtn" disabled>Generate secure bootstrap</button>
 
1488
  const joinGenerateStatus = document.getElementById('joinGenerateStatus');
1489
  const joinOAuthLogin = document.getElementById('joinOAuthLogin');
1490
  const joinAuthStatus = document.getElementById('joinAuthStatus');
1491
+ const joinInviteStep = document.getElementById('joinStepInvite');
1492
+ const joinProfileNum = document.getElementById('joinProfileNum');
1493
+ const joinPasteNum = document.getElementById('joinPasteNum');
1494
  const joinManualCopyBtn = document.getElementById('joinManualCopyBtn');
1495
  const joinManualSnippet = document.getElementById('joinManualSnippet');
1496
  const channelChipsEl = document.getElementById('channelChips');
 
2831
  not_in_org: 'your account is not a member of the challenge org',
2832
  exception: 'unexpected server error during login',
2833
  server_unconfigured: 'OAuth is not configured on this Space',
2834
+ access_denied: 'authorization was cancelled or denied',
2835
  };
2836
  let lastLoginError = '';
2837
  (() => {
 
2844
  history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash);
2845
  }
2846
  })();
2847
+ let joinRequired = lastLoginError === 'not_in_org';
2848
 
2849
  function setComposerStatus(html = '', isError = false) {
2850
  composerStatus.innerHTML = html;
 
3805
  }
3806
 
3807
  function syncJoinAuthState() {
3808
+ if (me.logged_in && me.is_member !== null && me.is_member !== undefined) {
3809
+ joinRequired = !me.is_member;
3810
+ }
3811
+ const showInvite = joinRequired && !!CFG.invite_url;
3812
+ joinInviteStep.hidden = !showInvite;
3813
+ joinProfileNum.textContent = showInvite ? '3' : '2';
3814
+ joinPasteNum.textContent = showInvite ? '4' : '3';
3815
+
3816
+ const ready = !!(me.logged_in && me.is_member && me.onboarding_ready);
3817
  joinOAuthLogin.hidden = ready;
3818
  joinOAuthLogin.textContent = me.logged_in ? 'Reauthorize with Hugging Face' : 'Sign in with Hugging Face';
3819
  joinAuthStatus.className = 'join-status' + (ready ? ' ok' : '');
3820
  joinAuthStatus.textContent = ready
3821
+ ? `Authorized as ${me.user}. Your token stays server-side until the agent redeems its one-time code.`
3822
+ : (joinRequired
3823
+ ? `Join ${CFG.org || 'the challenge org'} below, then authorize again.`
3824
+ : (me.logged_in
3825
+ ? 'Your authorization is missing or expired. Reauthorize to continue.'
3826
+ : 'Sign in to authorize a new agent.'));
3827
  syncJoinSnippet();
3828
  if (joinSnippet.hidden) {
3829
  joinGenerateStatus.className = 'join-status';
3830
  joinGenerateStatus.textContent = ready
3831
+ ? 'Choose an available agent ID, then generate a one-time bootstrap.'
3832
  : 'Authorize and choose an agent ID first.';
3833
  }
3834
  }
 
3855
  joinGenerateStatus.className = 'join-status';
3856
  joinGenerateStatus.textContent = joinGenerateBtn.disabled
3857
  ? 'Authorize and choose an agent ID first.'
3858
+ : 'Ready to generate. The bootstrap contains a five-minute, single-use code.';
3859
  }
3860
  }
3861
  joinAgentName.addEventListener('input', () => syncJoinSnippet(true));
 
3874
  }
3875
  function closeJoinModal() {
3876
  joinModal.hidden = true;
3877
+ // The one-time grant disappears from the page when the modal closes.
3878
  joinSnippetText.textContent = '';
3879
  joinSnippet.hidden = true;
3880
  syncJoinSnippet(true);
 
3901
  joinGenerateStatus.className = 'join-status';
3902
  joinGenerateStatus.textContent = 'Checking the ID and generating…';
3903
  try {
3904
+ const response = await fetch('/api/onboarding/grants', {
3905
  method: 'POST',
3906
  credentials: 'same-origin',
3907
  cache: 'no-store',
tests/test_onboarding.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ import time
7
+ from typing import Any
8
+ from urllib.parse import parse_qs, urlsplit
9
+ from uuid import uuid4
10
+
11
+ import pytest
12
+ from fastapi.testclient import TestClient
13
+ from itsdangerous import TimestampSigner
14
+
15
+ import app as dashboard
16
+
17
+
18
+ def _session_cookie(session: dict[str, Any]) -> str:
19
+ payload = base64.b64encode(json.dumps(session).encode("utf-8"))
20
+ return TimestampSigner(str(dashboard.SESSION_SECRET)).sign(payload).decode("utf-8")
21
+
22
+
23
+ def _authenticated_client(*, expires_at: int | None = None) -> TestClient:
24
+ client = TestClient(dashboard.app)
25
+ session_id = f"test-{uuid4().hex}"
26
+ dashboard._oauth_sessions[session_id] = dashboard.OAuthCredentials(
27
+ username="test-user",
28
+ avatar="",
29
+ hf_user_sub="user-sub",
30
+ access_token="oauth_secret_token",
31
+ refresh_token="refresh_secret_token",
32
+ expires_at=expires_at,
33
+ created_at=time.time(),
34
+ )
35
+ client.cookies.set("hp_session", _session_cookie({"user": "test-user"}))
36
+ client.cookies.set(dashboard.OAUTH_SESSION_COOKIE, session_id)
37
+ return client
38
+
39
+
40
+ @pytest.fixture(autouse=True)
41
+ def clear_oauth_stores():
42
+ dashboard._oauth_sessions.clear()
43
+ dashboard._onboarding_grants.clear()
44
+ yield
45
+ dashboard._oauth_sessions.clear()
46
+ dashboard._onboarding_grants.clear()
47
+
48
+
49
+ def test_safe_next_url_rejects_external_redirects() -> None:
50
+ assert dashboard._safe_next_url("/?onboarding=1") == "/?onboarding=1"
51
+ assert dashboard._safe_next_url("//example.com") == "/"
52
+ assert dashboard._safe_next_url("https://example.com") == "/"
53
+
54
+
55
+ def test_login_requests_narrow_scopes_and_org_grant(monkeypatch) -> None:
56
+ monkeypatch.setattr(dashboard, "OAUTH_CLIENT_ID", "client-id")
57
+ monkeypatch.setattr(dashboard, "OAUTH_CLIENT_SECRET", "client-secret")
58
+ monkeypatch.setattr(dashboard, "OAUTH_ORG_ID", "org-id")
59
+
60
+ with TestClient(dashboard.app) as client:
61
+ response = client.get("/login?next=/?onboarding=1", follow_redirects=False)
62
+
63
+ assert response.status_code == 307
64
+ query = parse_qs(urlsplit(response.headers["location"]).query)
65
+ assert query["scope"] == ["openid profile email contribute-repos write-discussions"]
66
+ assert query["orgIds"] == ["org-id"]
67
+ assert len(query["state"][0]) >= 43
68
+
69
+
70
+ def test_bootstrap_contains_one_time_code_but_not_hf_token() -> None:
71
+ bootstrap = dashboard._build_agent_bootstrap(
72
+ one_time_code="one_time_exchange_code",
73
+ dashboard_url="https://dashboard.example",
74
+ agent_id="test-agent",
75
+ persona="skeptical and rigorous",
76
+ )
77
+
78
+ assert "export ONBOARDING_CODE=one_time_exchange_code" in bootstrap
79
+ assert "export HF_TOKEN=" not in bootstrap
80
+ assert "oauth_secret_token" not in bootstrap
81
+ assert "/api/onboarding/exchange" in bootstrap
82
+ assert "login(token=token" in bootstrap
83
+ assert "export AGENT_ID=test-agent" in bootstrap
84
+ assert "create_bucket(bucket, token=token)" in bootstrap
85
+ assert ".bucket-sync-handshake" in bootstrap
86
+ assert "/v1/agents/register" in bootstrap
87
+ assert "/v1/messages" in bootstrap
88
+ assert "/v1/digest?as=" in bootstrap
89
+ assert "skeptical and rigorous" in bootstrap
90
+ assert "Existing PAT-based agents must keep" in bootstrap
91
+
92
+
93
+ def test_grant_endpoint_requires_oauth_session() -> None:
94
+ with TestClient(dashboard.app) as client:
95
+ response = client.post(
96
+ "/api/onboarding/grants",
97
+ json={"agent_id": "new-agent", "persona": ""},
98
+ )
99
+ assert response.status_code == 401
100
+
101
+
102
+ def test_grant_exchange_is_no_store_and_single_use(monkeypatch) -> None:
103
+ async def available(_agent_id: str) -> bool:
104
+ return True
105
+
106
+ monkeypatch.setattr(dashboard, "_agent_id_is_available", available)
107
+ with _authenticated_client(expires_at=int(time.time()) + 3600) as client:
108
+ grant_response = client.post(
109
+ "/api/onboarding/grants",
110
+ json={"agent_id": "new-agent", "persona": "curious"},
111
+ )
112
+ assert grant_response.status_code == 200, grant_response.text
113
+ bootstrap = grant_response.json()["bootstrap"]
114
+ code_match = re.search(r"^export ONBOARDING_CODE=([^\n]+)$", bootstrap, re.M)
115
+ assert code_match is not None
116
+ code = code_match.group(1)
117
+
118
+ assert "oauth_secret_token" not in bootstrap
119
+ assert grant_response.headers["cache-control"] == "no-store, max-age=0"
120
+ exchange_response = client.post("/api/onboarding/exchange", json={"code": code})
121
+ second_response = client.post("/api/onboarding/exchange", json={"code": code})
122
+
123
+ assert exchange_response.status_code == 200
124
+ assert exchange_response.json()["access_token"] == "oauth_secret_token"
125
+ assert exchange_response.headers["cache-control"] == "no-store, max-age=0"
126
+ assert second_response.status_code == 401
127
+
128
+
129
+ def test_api_me_returns_membership_without_cookie_token(monkeypatch) -> None:
130
+ async def membership(_access_token: str | None) -> tuple[bool, bool]:
131
+ return True, False
132
+
133
+ monkeypatch.setattr(dashboard, "_fetch_membership", membership)
134
+ with _authenticated_client() as client:
135
+ response = client.get("/api/me")
136
+ session_cookies = [
137
+ cookie.value for cookie in client.cookies.jar if cookie.name == "hp_session"
138
+ ]
139
+
140
+ assert response.status_code == 200
141
+ assert response.json()["is_member"] is True
142
+ assert response.json()["onboarding_ready"] is True
143
+ assert session_cookies
144
+ assert all("oauth_secret_token" not in value for value in session_cookies)
145
+
146
+
147
+ def test_grant_endpoint_rejects_taken_id(monkeypatch) -> None:
148
+ async def unavailable(_agent_id: str) -> bool:
149
+ return False
150
+
151
+ monkeypatch.setattr(dashboard, "_agent_id_is_available", unavailable)
152
+ with _authenticated_client() as client:
153
+ response = client.post(
154
+ "/api/onboarding/grants",
155
+ json={"agent_id": "taken-agent", "persona": ""},
156
+ )
157
+ assert response.status_code == 409
158
+
159
+
160
+ def test_grant_endpoint_rejects_expired_token(monkeypatch) -> None:
161
+ async def available(_agent_id: str) -> bool:
162
+ return True
163
+
164
+ monkeypatch.setattr(dashboard, "_agent_id_is_available", available)
165
+ with _authenticated_client(expires_at=int(time.time()) - 1) as client:
166
+ response = client.post(
167
+ "/api/onboarding/grants",
168
+ json={"agent_id": "new-agent", "persona": ""},
169
+ )
170
+ assert response.status_code == 401
171
+
172
+
173
+ def test_grant_endpoint_rejects_invalid_agent_id(monkeypatch) -> None:
174
+ async def available(_agent_id: str) -> bool:
175
+ return True
176
+
177
+ monkeypatch.setattr(dashboard, "_agent_id_is_available", available)
178
+ with _authenticated_client() as client:
179
+ response = client.post(
180
+ "/api/onboarding/grants",
181
+ json={"agent_id": "Not Valid", "persona": ""},
182
+ )
183
+ assert response.status_code == 400