coderuday21 Cursor commited on
Commit
45a6b1d
·
1 Parent(s): d70361b

Fix Hugging Face login cookies so new accounts can stay signed in.

Browse files

Use Secure + SameSite=None on real HF Spaces (iframe-safe), persist a JWT secret across restarts when SECRET_KEY is unset, and surface a cookie-block error instead of a silent bounce to /login.

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

Files changed (5) hide show
  1. .gitignore +1 -0
  2. app/auth.py +52 -26
  3. app/main.py +8 -2
  4. static/js/dda/login.js +18 -0
  5. templates/login_dda.html +1 -1
.gitignore CHANGED
@@ -19,6 +19,7 @@ data/library_sources/**/*.jpg
19
  data/library_sources/**/*.jpeg
20
  !library_sources/**/.gitkeep
21
  .env
 
22
  *.egg-info/
23
  dist/
24
  build/
 
19
  data/library_sources/**/*.jpeg
20
  !library_sources/**/.gitkeep
21
  .env
22
+ data/.secret_key
23
  *.egg-info/
24
  dist/
25
  build/
app/auth.py CHANGED
@@ -17,23 +17,50 @@ from .models import User, LoginHistory, PasswordResetToken, LoginOtp
17
 
18
  logger = logging.getLogger(__name__)
19
 
20
- _env_secret = os.environ.get("SECRET_KEY", "").strip()
21
- if _env_secret:
22
- SECRET_KEY = _env_secret
23
- else:
24
- # Never fall back to a fixed/shared string here — a hardcoded value in
25
- # source is visible to anyone with the repo, and could be used to forge a
26
- # valid login cookie for any user id without ever knowing a password.
27
- # A fresh random secret per process closes that hole; the trade-off is
28
- # every existing session is invalidated on each restart until a
29
- # persistent SECRET_KEY is set in the environment (see .env.example).
30
- SECRET_KEY = secrets.token_hex(32)
31
- logger.warning(
32
- "SECRET_KEY env var not set — generated a random one-time secret for "
33
- "this process (existing sessions will be invalidated on restart). "
34
- "Set SECRET_KEY to a persistent random string in your environment, "
35
- "especially in production — see .env.example."
36
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  ALGORITHM = "HS256"
38
  ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
39
  COOKIE_NAME = "satellite_token"
@@ -55,15 +82,14 @@ if _COOKIE_SECURE_ENV in ("1", "true", "yes"):
55
  elif _COOKIE_SECURE_ENV in ("0", "false", "no"):
56
  IS_SECURE_COOKIE = False
57
  else:
58
- # Render sets RENDER=true on its platform (genuine HTTPS hosting).
59
- # Deliberately NOT keying off SPACE_ID: this project's own .env sets
60
- # SPACE_ID=local-dev/satdetect-dev for *local* HTTP dev (to force
61
- # "hosted" UI behavior, unrelated to HTTPS), so treating any SPACE_ID as
62
- # "we're on HTTPS" would wrongly mark local dev secure and silently drop
63
- # the auth cookie (verified via smoke test — this was the actual bug).
64
- # A genuine Hugging Face Spaces deployment should set COOKIE_SECURE=true
65
- # explicitly via the branch above.
66
- IS_SECURE_COOKIE = bool(os.environ.get("RENDER"))
67
 
68
 
69
  def get_client_ip(request: Request) -> str:
 
17
 
18
  logger = logging.getLogger(__name__)
19
 
20
+
21
+ def _is_huggingface_space() -> bool:
22
+ """True on a real HF Space, not local `.env` SPACE_ID=local-dev/..."""
23
+ sid = os.environ.get("SPACE_ID", "").strip().lower()
24
+ return bool(sid) and not sid.startswith("local")
25
+
26
+
27
+ def _secret_key_file():
28
+ from pathlib import Path
29
+ if _is_huggingface_space():
30
+ return Path.home() / "data" / ".secret_key"
31
+ return Path(__file__).resolve().parent.parent / "data" / ".secret_key"
32
+
33
+
34
+ def _load_or_create_secret() -> str:
35
+ env_secret = os.environ.get("SECRET_KEY", "").strip()
36
+ if env_secret:
37
+ return env_secret
38
+ path = _secret_key_file()
39
+ try:
40
+ existing = path.read_text(encoding="utf-8").strip()
41
+ if existing:
42
+ return existing
43
+ except OSError:
44
+ pass
45
+ secret = secrets.token_hex(32)
46
+ try:
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ path.write_text(secret, encoding="utf-8")
49
+ logger.warning(
50
+ "SECRET_KEY env var not set — persisted a generated secret to %s "
51
+ "so logins survive process restarts. Set SECRET_KEY in the Space "
52
+ "secrets for a rebuild-safe value.",
53
+ path,
54
+ )
55
+ except OSError:
56
+ logger.warning(
57
+ "SECRET_KEY env var not set — generated a one-time secret for this "
58
+ "process (sessions die on restart). Set SECRET_KEY in the environment."
59
+ )
60
+ return secret
61
+
62
+
63
+ SECRET_KEY = _load_or_create_secret()
64
  ALGORITHM = "HS256"
65
  ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
66
  COOKIE_NAME = "satellite_token"
 
82
  elif _COOKIE_SECURE_ENV in ("0", "false", "no"):
83
  IS_SECURE_COOKIE = False
84
  else:
85
+ # Local `.env` uses SPACE_ID=local-dev/... over HTTP — must stay insecure.
86
+ # Real Hugging Face Spaces are HTTPS and often run inside huggingface.co
87
+ # iframe, which drops SameSite=Lax cookies unless they are Secure + None.
88
+ IS_SECURE_COOKIE = bool(os.environ.get("RENDER")) or _is_huggingface_space()
89
+
90
+ # SameSite=None is required for the Hugging Face embed iframe; it also
91
+ # requires Secure=True. First-party local HTTP keeps Lax.
92
+ COOKIE_SAMESITE = "none" if IS_SECURE_COOKIE else "lax"
 
93
 
94
 
95
  def get_client_ip(request: Request) -> str:
app/main.py CHANGED
@@ -28,6 +28,7 @@ from PIL import Image, ImageOps
28
  from .auth import (
29
  ALGORITHM,
30
  COOKIE_NAME,
 
31
  IS_SECURE_COOKIE,
32
  LOGIN_OTP_EXPIRE_MINUTES,
33
  LOGIN_OTP_RESEND_SECONDS,
@@ -383,7 +384,7 @@ def _auth_response(token: str, user: User, remember: bool = False):
383
  key=COOKIE_NAME,
384
  value=token,
385
  httponly=True,
386
- samesite="lax",
387
  secure=IS_SECURE_COOKIE,
388
  path="/",
389
  )
@@ -517,7 +518,12 @@ def logout(request: Request, db: Session = Depends(get_db)):
517
  ip_address=get_client_ip(request), user_agent=get_user_agent(request),
518
  )
519
  response = JSONResponse(content={"ok": True})
520
- response.delete_cookie(COOKIE_NAME, path="/")
 
 
 
 
 
521
  return response
522
 
523
 
 
28
  from .auth import (
29
  ALGORITHM,
30
  COOKIE_NAME,
31
+ COOKIE_SAMESITE,
32
  IS_SECURE_COOKIE,
33
  LOGIN_OTP_EXPIRE_MINUTES,
34
  LOGIN_OTP_RESEND_SECONDS,
 
384
  key=COOKIE_NAME,
385
  value=token,
386
  httponly=True,
387
+ samesite=COOKIE_SAMESITE,
388
  secure=IS_SECURE_COOKIE,
389
  path="/",
390
  )
 
518
  ip_address=get_client_ip(request), user_agent=get_user_agent(request),
519
  )
520
  response = JSONResponse(content={"ok": True})
521
+ response.delete_cookie(
522
+ COOKIE_NAME,
523
+ path="/",
524
+ secure=IS_SECURE_COOKIE,
525
+ samesite=COOKIE_SAMESITE,
526
+ )
527
  return response
528
 
529
 
static/js/dda/login.js CHANGED
@@ -162,6 +162,15 @@
162
  await ddaApi('POST', '/api/auth/login/verify-otp', {
163
  body: JSON.stringify({ otpToken: pendingOtp?.token, code }),
164
  });
 
 
 
 
 
 
 
 
 
165
  window.location.href = '/';
166
  } catch (err) {
167
  showDdaError(err.message || 'Invalid sign-in code.');
@@ -294,6 +303,15 @@
294
  remember_me,
295
  }),
296
  });
 
 
 
 
 
 
 
 
 
297
  window.location.href = '/';
298
  return;
299
  }
 
162
  await ddaApi('POST', '/api/auth/login/verify-otp', {
163
  body: JSON.stringify({ otpToken: pendingOtp?.token, code }),
164
  });
165
+ try {
166
+ await ddaApi('GET', '/api/me');
167
+ } catch (_) {
168
+ showDdaError(
169
+ 'Sign-in succeeded, but this browser blocked the session cookie. Open the app in its own tab (not the Hugging Face embed): '
170
+ + window.location.origin,
171
+ );
172
+ return;
173
+ }
174
  window.location.href = '/';
175
  } catch (err) {
176
  showDdaError(err.message || 'Invalid sign-in code.');
 
303
  remember_me,
304
  }),
305
  });
306
+ try {
307
+ await ddaApi('GET', '/api/me');
308
+ } catch (_) {
309
+ showDdaError(
310
+ 'Sign-in succeeded, but this browser blocked the session cookie. Open the app in its own tab (not the Hugging Face embed): '
311
+ + window.location.origin,
312
+ );
313
+ return;
314
+ }
315
  window.location.href = '/';
316
  return;
317
  }
templates/login_dda.html CHANGED
@@ -132,6 +132,6 @@
132
 
133
  <script src="/static/js/dda/shared.js?v=4"></script>
134
  <script src="/static/js/dda/countries.js?v=1"></script>
135
- <script src="/static/js/dda/login.js?v=10"></script>
136
  </body>
137
  </html>
 
132
 
133
  <script src="/static/js/dda/shared.js?v=4"></script>
134
  <script src="/static/js/dda/countries.js?v=1"></script>
135
+ <script src="/static/js/dda/login.js?v=11"></script>
136
  </body>
137
  </html>