Kennedy Johnson Cursor commited on
Commit
5e77c6e
·
1 Parent(s): 8fc16a2

Fix persistent auth so users stay logged in across refreshes and redeploys.

Browse files

Persist JWT signing secrets under DATA_DIR when unset, validate stored sessions on app startup, and restore auth state synchronously from localStorage to avoid false re-login prompts.

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

multi_llm_chatbot_backend/app/config.py CHANGED
@@ -215,6 +215,43 @@ class OrchestratorConfig(BaseModel):
215
  return self
216
 
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  class AuthConfig(BaseModel):
219
  jwt_secret: str = Field(default=os.getenv("JWT_SECRET_KEY", ""))
220
  algorithm: str = "HS256"
@@ -222,11 +259,7 @@ class AuthConfig(BaseModel):
222
 
223
  @model_validator(mode="after")
224
  def _validate_jwt_secret(self):
225
- if not self.jwt_secret:
226
- logger.warning(
227
- "Insecure default JWT secret will be used. "
228
- "Set auth.jwt_secret in config.yaml for production use.")
229
- self.jwt_secret = "your-secret-key-change-me"
230
  return self
231
 
232
 
 
215
  return self
216
 
217
 
218
+ def resolve_jwt_secret(explicit: str = "") -> str:
219
+ """Resolve a stable JWT signing secret across restarts.
220
+
221
+ Precedence: config value → JWT_SECRET_KEY env → persisted file under
222
+ DATA_DIR (generated on first run) → insecure dev fallback.
223
+ """
224
+ if explicit and explicit.strip():
225
+ return explicit.strip()
226
+
227
+ env_secret = os.getenv("JWT_SECRET_KEY", "").strip()
228
+ if env_secret:
229
+ return env_secret
230
+
231
+ data_dir = Path(os.environ.get("DATA_DIR") or Path.home() / "data")
232
+ secret_path = data_dir / "jwt_secret"
233
+ try:
234
+ data_dir.mkdir(parents=True, exist_ok=True)
235
+ if secret_path.is_file():
236
+ stored = secret_path.read_text(encoding="utf-8").strip()
237
+ if stored:
238
+ return stored
239
+ import secrets
240
+
241
+ generated = secrets.token_urlsafe(48)
242
+ secret_path.write_text(generated, encoding="utf-8")
243
+ logger.info("Generated persistent JWT secret at %s", secret_path)
244
+ return generated
245
+ except OSError as exc:
246
+ logger.warning(
247
+ "Cannot persist JWT secret at %s (%s); using insecure default. "
248
+ "Set JWT_SECRET_KEY or auth.jwt_secret for production.",
249
+ secret_path,
250
+ exc,
251
+ )
252
+ return "your-secret-key-change-me"
253
+
254
+
255
  class AuthConfig(BaseModel):
256
  jwt_secret: str = Field(default=os.getenv("JWT_SECRET_KEY", ""))
257
  algorithm: str = "HS256"
 
259
 
260
  @model_validator(mode="after")
261
  def _validate_jwt_secret(self):
262
+ self.jwt_secret = resolve_jwt_secret(self.jwt_secret)
 
 
 
 
263
  return self
264
 
265
 
multi_llm_chatbot_backend/app/core/auth.py CHANGED
@@ -37,8 +37,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
37
  expire = datetime.utcnow() + expires_delta
38
  else:
39
  expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
40
-
41
- to_encode.update({"exp": expire})
 
42
  encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
43
  return encoded_jwt
44
 
 
37
  expire = datetime.utcnow() + expires_delta
38
  else:
39
  expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
40
+
41
+ # JWT NumericDate must be a Unix timestamp (RFC 7519).
42
+ to_encode.update({"exp": int(expire.timestamp())})
43
  encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
44
  return encoded_jwt
45
 
multi_llm_chatbot_backend/app/tests/unit/test_jwt_secret.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ from app.config import resolve_jwt_secret
8
+
9
+
10
+ class TestResolveJwtSecret(unittest.TestCase):
11
+ def test_prefers_explicit_config_value(self):
12
+ self.assertEqual(resolve_jwt_secret("from-config"), "from-config")
13
+
14
+ @patch.dict(os.environ, {"JWT_SECRET_KEY": "from-env"}, clear=False)
15
+ def test_uses_env_when_config_empty(self):
16
+ self.assertEqual(resolve_jwt_secret(""), "from-env")
17
+
18
+ def test_persists_generated_secret_to_data_dir(self):
19
+ with tempfile.TemporaryDirectory() as tmp:
20
+ with patch.dict(os.environ, {"DATA_DIR": tmp, "JWT_SECRET_KEY": ""}, clear=False):
21
+ first = resolve_jwt_secret("")
22
+ second = resolve_jwt_secret("")
23
+ secret_path = Path(tmp) / "jwt_secret"
24
+ self.assertTrue(secret_path.is_file())
25
+ self.assertEqual(first, second)
26
+ self.assertGreater(len(first), 20)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ unittest.main()
phd-advisor-frontend/src/App.js CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react';
2
  import { ThemeProvider } from './contexts/ThemeContext';
3
  import { AppConfigProvider } from './contexts/AppConfigContext';
4
  import HomePage from './pages/HomePage';
@@ -6,35 +6,88 @@ import ChatPage from './pages/ChatPage';
6
  import AuthPage from './pages/AuthPage';
7
  import CanvasPage from './pages/CanvasPage';
8
  import UserGuide from './components/UserGuide';
 
 
 
 
 
 
9
  import './styles/components.css';
10
 
11
  function App() {
12
- const [currentView, setCurrentView] = useState('home');
13
- const [isAuthenticated, setIsAuthenticated] = useState(false);
14
- const [user, setUser] = useState(null);
15
- const [authToken, setAuthToken] = useState(null);
 
 
 
 
 
 
16
 
17
- // Check for existing authentication on app start
 
 
 
 
 
 
 
 
 
18
  useEffect(() => {
19
- const token = localStorage.getItem('authToken');
20
- const userData = localStorage.getItem('user');
21
-
22
- if (token && userData) {
 
 
 
23
  try {
24
- const parsedUser = JSON.parse(userData);
25
- setAuthToken(token);
26
- setUser(parsedUser);
27
- setIsAuthenticated(true);
28
- setCurrentView('chat');
29
- } catch (error) {
30
- // Clear invalid data
31
- localStorage.removeItem('authToken');
32
- localStorage.removeItem('user');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  }
34
- }
35
- }, []);
 
 
 
 
 
 
 
 
36
 
37
  const navigateToAuth = () => {
 
 
 
38
  setCurrentView('auth');
39
  };
40
 
@@ -49,44 +102,44 @@ function App() {
49
  setCurrentView('chat');
50
  };
51
 
52
-
53
-
54
  const navigateToHome = () => {
55
  setCurrentView('home');
56
  };
57
 
58
  const handleAuthSuccess = (userData, token) => {
 
59
  setUser(userData);
60
  setAuthToken(token);
61
  setIsAuthenticated(true);
 
62
  setCurrentView('chat');
63
  };
64
 
65
  const handleSignOut = () => {
66
- localStorage.removeItem('authToken');
67
- localStorage.removeItem('user');
68
- setUser(null);
69
- setAuthToken(null);
70
- setIsAuthenticated(false);
71
- setCurrentView('home');
72
  };
73
 
74
  return (
75
  <AppConfigProvider>
76
  <ThemeProvider>
77
  <div className="App">
 
 
 
 
 
78
  {currentView === 'home' && (
79
  <HomePage
80
  onNavigateToHome={navigateToHome}
81
- onNavigateToChat={isAuthenticated ? navigateToChat : navigateToAuth}
82
- onNavigateToCanvas={isAuthenticated ? navigateToCanvas : navigateToAuth}
83
- isAuthenticated={isAuthenticated}
84
  />
85
  )}
86
- {currentView === 'auth' && (
87
  <AuthPage onAuthSuccess={handleAuthSuccess} />
88
  )}
89
- {currentView === 'canvas' && isAuthenticated && (
90
  <CanvasPage
91
  user={user}
92
  authToken={authToken}
@@ -95,7 +148,7 @@ function App() {
95
  onSignOut={handleSignOut}
96
  />
97
  )}
98
- {currentView === 'chat' && isAuthenticated && (
99
  <ChatPage
100
  user={user}
101
  authToken={authToken}
@@ -104,7 +157,6 @@ function App() {
104
  onSignOut={handleSignOut}
105
  />
106
  )}
107
- {/* Global help center — listens for the 'open-user-guide' event */}
108
  <UserGuide />
109
  </div>
110
  </ThemeProvider>
 
1
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
2
  import { ThemeProvider } from './contexts/ThemeContext';
3
  import { AppConfigProvider } from './contexts/AppConfigContext';
4
  import HomePage from './pages/HomePage';
 
6
  import AuthPage from './pages/AuthPage';
7
  import CanvasPage from './pages/CanvasPage';
8
  import UserGuide from './components/UserGuide';
9
+ import {
10
+ readStoredAuth,
11
+ persistAuth,
12
+ clearStoredAuth,
13
+ getApiBaseUrl,
14
+ } from './utils/authStorage';
15
  import './styles/components.css';
16
 
17
  function App() {
18
+ const initialAuthRef = useRef(null);
19
+ if (initialAuthRef.current === null) {
20
+ initialAuthRef.current = readStoredAuth();
21
+ }
22
+ const initialAuth = initialAuthRef.current;
23
+ const [currentView, setCurrentView] = useState(initialAuth ? 'chat' : 'home');
24
+ const [isAuthenticated, setIsAuthenticated] = useState(Boolean(initialAuth));
25
+ const [user, setUser] = useState(initialAuth?.user ?? null);
26
+ const [authToken, setAuthToken] = useState(initialAuth?.token ?? null);
27
+ const [authBootstrapping, setAuthBootstrapping] = useState(Boolean(initialAuth));
28
 
29
+ const clearAuthState = useCallback(() => {
30
+ clearStoredAuth();
31
+ setUser(null);
32
+ setAuthToken(null);
33
+ setIsAuthenticated(false);
34
+ setCurrentView('home');
35
+ }, []);
36
+
37
+ // Re-validate stored credentials on startup so stale tokens are cleared
38
+ // and profile data stays fresh after refresh.
39
  useEffect(() => {
40
+ if (!initialAuth) {
41
+ return;
42
+ }
43
+
44
+ let cancelled = false;
45
+
46
+ const validateStoredSession = async () => {
47
  try {
48
+ const response = await fetch(`${getApiBaseUrl()}/auth/me`, {
49
+ headers: { Authorization: `Bearer ${initialAuth.token}` },
50
+ });
51
+
52
+ if (cancelled) {
53
+ return;
54
+ }
55
+
56
+ if (response.ok) {
57
+ const freshUser = await response.json();
58
+ persistAuth(freshUser, initialAuth.token);
59
+ setUser(freshUser);
60
+ setAuthToken(initialAuth.token);
61
+ setIsAuthenticated(true);
62
+ return;
63
+ }
64
+
65
+ if (response.status === 401 || response.status === 403) {
66
+ clearAuthState();
67
+ }
68
+ } catch {
69
+ // Keep the cached session on transient network errors so refresh
70
+ // does not force re-login when the API is briefly unavailable.
71
+ } finally {
72
+ if (!cancelled) {
73
+ setAuthBootstrapping(false);
74
+ }
75
  }
76
+ };
77
+
78
+ validateStoredSession();
79
+
80
+ return () => {
81
+ cancelled = true;
82
+ };
83
+ }, [initialAuth, clearAuthState]);
84
+
85
+ const sessionReady = isAuthenticated && !authBootstrapping;
86
 
87
  const navigateToAuth = () => {
88
+ if (authBootstrapping) {
89
+ return;
90
+ }
91
  setCurrentView('auth');
92
  };
93
 
 
102
  setCurrentView('chat');
103
  };
104
 
 
 
105
  const navigateToHome = () => {
106
  setCurrentView('home');
107
  };
108
 
109
  const handleAuthSuccess = (userData, token) => {
110
+ persistAuth(userData, token);
111
  setUser(userData);
112
  setAuthToken(token);
113
  setIsAuthenticated(true);
114
+ setAuthBootstrapping(false);
115
  setCurrentView('chat');
116
  };
117
 
118
  const handleSignOut = () => {
119
+ clearAuthState();
 
 
 
 
 
120
  };
121
 
122
  return (
123
  <AppConfigProvider>
124
  <ThemeProvider>
125
  <div className="App">
126
+ {authBootstrapping && (
127
+ <div className="auth-bootstrap-overlay" aria-live="polite">
128
+ Restoring your session...
129
+ </div>
130
+ )}
131
  {currentView === 'home' && (
132
  <HomePage
133
  onNavigateToHome={navigateToHome}
134
+ onNavigateToChat={sessionReady ? navigateToChat : navigateToAuth}
135
+ onNavigateToCanvas={sessionReady ? navigateToCanvas : navigateToAuth}
136
+ isAuthenticated={sessionReady}
137
  />
138
  )}
139
+ {currentView === 'auth' && !sessionReady && (
140
  <AuthPage onAuthSuccess={handleAuthSuccess} />
141
  )}
142
+ {currentView === 'canvas' && sessionReady && (
143
  <CanvasPage
144
  user={user}
145
  authToken={authToken}
 
148
  onSignOut={handleSignOut}
149
  />
150
  )}
151
+ {currentView === 'chat' && sessionReady && (
152
  <ChatPage
153
  user={user}
154
  authToken={authToken}
 
157
  onSignOut={handleSignOut}
158
  />
159
  )}
 
160
  <UserGuide />
161
  </div>
162
  </ThemeProvider>
phd-advisor-frontend/src/components/Login.js CHANGED
@@ -1,6 +1,7 @@
1
  import React, { useState } from 'react';
2
  import { Eye, EyeOff, Mail, Lock, ArrowRight, BookOpen, Phone } from 'lucide-react';
3
  import { useAppConfig } from '../contexts/AppConfigContext';
 
4
  import CopyrightNotice from './CopyrightNotice';
5
  import '../styles/Login.css';
6
 
@@ -70,9 +71,7 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
70
  const data = await response.json();
71
 
72
  if (response.ok) {
73
- // Store the token and user info
74
- localStorage.setItem('authToken', data.access_token);
75
- localStorage.setItem('user', JSON.stringify(data.user));
76
  onNavigateToHome?.(data.user, data.access_token);
77
  } else {
78
  setErrors({ submit: data.detail || 'Login failed. Please try again.' });
 
1
  import React, { useState } from 'react';
2
  import { Eye, EyeOff, Mail, Lock, ArrowRight, BookOpen, Phone } from 'lucide-react';
3
  import { useAppConfig } from '../contexts/AppConfigContext';
4
+ import { persistAuth } from '../utils/authStorage';
5
  import CopyrightNotice from './CopyrightNotice';
6
  import '../styles/Login.css';
7
 
 
71
  const data = await response.json();
72
 
73
  if (response.ok) {
74
+ persistAuth(data.user, data.access_token);
 
 
75
  onNavigateToHome?.(data.user, data.access_token);
76
  } else {
77
  setErrors({ submit: data.detail || 'Login failed. Please try again.' });
phd-advisor-frontend/src/components/Signup.js CHANGED
@@ -1,6 +1,7 @@
1
  import React, { useState } from 'react';
2
  import { Eye, EyeOff, Mail, Lock, User, ArrowRight, Shield, Phone, Globe } from 'lucide-react';
3
  import { useAppConfig } from '../contexts/AppConfigContext';
 
4
  import '../styles/Signup.css';
5
 
6
  const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
@@ -116,9 +117,7 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
116
  const data = await response.json();
117
 
118
  if (response.ok) {
119
- // Store the token and user info
120
- localStorage.setItem('authToken', data.access_token);
121
- localStorage.setItem('user', JSON.stringify(data.user));
122
  onNavigateToHome?.(data.user, data.access_token);
123
  } else {
124
  setErrors({ submit: data.detail || 'Signup failed. Please try again.' });
 
1
  import React, { useState } from 'react';
2
  import { Eye, EyeOff, Mail, Lock, User, ArrowRight, Shield, Phone, Globe } from 'lucide-react';
3
  import { useAppConfig } from '../contexts/AppConfigContext';
4
+ import { persistAuth } from '../utils/authStorage';
5
  import '../styles/Signup.css';
6
 
7
  const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
 
117
  const data = await response.json();
118
 
119
  if (response.ok) {
120
+ persistAuth(data.user, data.access_token);
 
 
121
  onNavigateToHome?.(data.user, data.access_token);
122
  } else {
123
  setErrors({ submit: data.detail || 'Signup failed. Please try again.' });
phd-advisor-frontend/src/styles/components.css CHANGED
@@ -1293,4 +1293,18 @@
1293
  text-overflow: initial;
1294
  }
1295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1296
  }
 
1293
  text-overflow: initial;
1294
  }
1295
 
1296
+ }
1297
+
1298
+ .auth-bootstrap-overlay {
1299
+ position: fixed;
1300
+ inset: 0;
1301
+ z-index: 9999;
1302
+ display: flex;
1303
+ align-items: center;
1304
+ justify-content: center;
1305
+ background: rgba(255, 255, 255, 0.72);
1306
+ color: var(--text-primary, #111827);
1307
+ font-size: 0.95rem;
1308
+ font-weight: 500;
1309
+ pointer-events: none;
1310
  }
phd-advisor-frontend/src/utils/authStorage.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const AUTH_TOKEN_KEY = 'authToken';
2
+ const USER_KEY = 'user';
3
+
4
+ export function readStoredAuth() {
5
+ const token = localStorage.getItem(AUTH_TOKEN_KEY);
6
+ const userData = localStorage.getItem(USER_KEY);
7
+ if (!token || !userData) {
8
+ return null;
9
+ }
10
+
11
+ try {
12
+ return { token, user: JSON.parse(userData) };
13
+ } catch {
14
+ clearStoredAuth();
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export function persistAuth(user, token) {
20
+ localStorage.setItem(AUTH_TOKEN_KEY, token);
21
+ localStorage.setItem(USER_KEY, JSON.stringify(user));
22
+ }
23
+
24
+ export function clearStoredAuth() {
25
+ localStorage.removeItem(AUTH_TOKEN_KEY);
26
+ localStorage.removeItem(USER_KEY);
27
+ }
28
+
29
+ export function getApiBaseUrl() {
30
+ return process.env.REACT_APP_API_URL || '';
31
+ }