diff --git a/backend/src/ea_chatbot/api/dependencies.py b/backend/src/ea_chatbot/api/dependencies.py index 72b5796..67b8847 100644 --- a/backend/src/ea_chatbot/api/dependencies.py +++ b/backend/src/ea_chatbot/api/dependencies.py @@ -39,6 +39,14 @@ async def get_current_user(request: Request, token: str = Depends(oauth2_scheme) payload = decode_access_token(token) if payload is None: raise credentials_exception + + # Security Fix: Reject refresh tokens for standard API access + if payload.get("type") == "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Cannot use refresh token for this endpoint", + headers={"WWW-Authenticate": "Bearer"}, + ) user_id: str | None = payload.get("sub") if user_id is None: diff --git a/backend/src/ea_chatbot/api/utils.py b/backend/src/ea_chatbot/api/utils.py index 1385876..0dc9050 100644 --- a/backend/src/ea_chatbot/api/utils.py +++ b/backend/src/ea_chatbot/api/utils.py @@ -1,3 +1,4 @@ +import uuid from datetime import datetime, timedelta, timezone from typing import Optional, Any, List from jose import JWTError, jwt @@ -56,7 +57,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - to_encode.update({ "exp": expire, "iat": now, - "iss": "ea-chatbot-api" + "iss": "ea-chatbot-api", + "type": "access", + "jti": str(uuid.uuid4()) }) encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) return encoded_jwt @@ -84,7 +87,8 @@ def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None) "exp": expire, "iat": now, "iss": "ea-chatbot-api", - "type": "refresh" + "type": "refresh", + "jti": str(uuid.uuid4()) }) encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) return encoded_jwt diff --git a/frontend/src/hooks/use-silent-refresh.ts b/frontend/src/hooks/use-silent-refresh.ts index 49a8288..aabf870 100644 --- a/frontend/src/hooks/use-silent-refresh.ts +++ b/frontend/src/hooks/use-silent-refresh.ts @@ -6,7 +6,7 @@ import { AuthService } from '@/services/auth' * It proactively refreshes the session to prevent expiration while the user is active. */ export function useSilentRefresh(isAuthenticated: boolean) { - const timerRef = useRef(null) + const timerRef = useRef | null>(null) const refresh = useCallback(async () => { try { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index de6b4c5..1aed6af 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,7 +4,6 @@ import './index.css' import App from './App.tsx' import { TooltipProvider } from "@/components/ui/tooltip" import { BrowserRouter } from "react-router-dom" -import { ThemeProvider } from "./components/theme-provider" createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 089421a..dec7513 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -17,14 +17,23 @@ export const registerUnauthorizedCallback = (callback: () => void) => { // State to manage multiple concurrent refreshes let isRefreshing = false let refreshSubscribers: ((token: string) => void)[] = [] +let refreshErrorSubscribers: ((error: any) => void)[] = [] -const subscribeTokenRefresh = (callback: (token: string) => void) => { - refreshSubscribers.push(callback) +const subscribeTokenRefresh = (onSuccess: (token: string) => void, onError: (error: any) => void) => { + refreshSubscribers.push(onSuccess) + refreshErrorSubscribers.push(onError) } const onRefreshed = (token: string) => { refreshSubscribers.forEach((callback) => callback(token)) refreshSubscribers = [] + refreshErrorSubscribers = [] +} + +const onRefreshFailed = (error: any) => { + refreshErrorSubscribers.forEach((callback) => callback(error)) + refreshSubscribers = [] + refreshErrorSubscribers = [] } // Add a response interceptor to handle 401s @@ -41,11 +50,11 @@ api.interceptors.response.use( if (error.response?.status === 401 && !isAuthEndpoint && !isRefreshEndpoint && !originalRequest._retry) { if (isRefreshing) { // Wait for the current refresh to complete - return new Promise((resolve) => { - subscribeTokenRefresh((token) => { - // Re-run the original request - resolve(api(originalRequest)) - }) + return new Promise((resolve, reject) => { + subscribeTokenRefresh( + (_token) => resolve(api(originalRequest)), + (err) => reject(err) + ) }) } @@ -65,7 +74,7 @@ api.interceptors.response.use( return api(originalRequest) } catch (refreshError) { isRefreshing = false - refreshSubscribers = [] + onRefreshFailed(refreshError) console.error("Reactive refresh failed:", refreshError) // Final failure - session is dead