From 05b8c1303e3d94a391200e96d964ae0be7d56856 Mon Sep 17 00:00:00 2001 From: codrut Date: Mon, 8 Dec 2025 18:08:58 +0200 Subject: [PATCH 1/7] update login system --- be/app/dependencies.py | 2 +- be/app/routers/auth.py | 97 +++++++- fe/src/lib/stores.js | 2 + fe/src/routes/+layout.svelte | 454 +++++++++++++++++++---------------- 4 files changed, 342 insertions(+), 213 deletions(-) diff --git a/be/app/dependencies.py b/be/app/dependencies.py index ccd3f4d..baa85de 100644 --- a/be/app/dependencies.py +++ b/be/app/dependencies.py @@ -156,7 +156,7 @@ def refresh_namespace_and_tables(): # --- Authentication Dependency --- def check_auth(request: Request): - return request.session.get("user") + return request.cookies.get("access_token") # --- Table Loading Dependency --- def load_table(table_id: str) -> Table: diff --git a/be/app/routers/auth.py b/be/app/routers/auth.py index ffb7b2c..eb23543 100644 --- a/be/app/routers/auth.py +++ b/be/app/routers/auth.py @@ -3,6 +3,7 @@ from fastapi.responses import RedirectResponse from authlib.integrations.starlette_client import OAuth from starlette.config import Config as AuthlibConfig +from fastapi.responses import JSONResponse from app import config from app.models import TokenRequest @@ -33,6 +34,11 @@ async def login(request: Request): @router.post("/api/auth/token") def get_token(request: Request, token_req: TokenRequest): + """ + Exchanges the SSO code for an Access Token, sets the token in a secure + HTTP-only cookie, and returns minimal user details. + """ + # 1. Send code to SSO provider data = { "grant_type": "authorization_code", "code": token_req.code, @@ -41,17 +47,88 @@ def get_token(request: Request, token_req: TokenRequest): "redirect_uri": config.REDIRECT_URI, } response = requests.post(config.TOKEN_URL, data=data) + if response.status_code != 200: + print(response.json()) raise HTTPException(status_code=400, detail="Failed to exchange code for token") token_data = response.json() - user_info_resp = requests.get(f"{config.OPENID_PROVIDER_URL}/userinfo", - headers={'Authorization': f'Bearer {token_data["access_token"]}'}) - user_email = user_info_resp.json()['email'] - request.session['user'] = user_email - return user_email - -@router.get("/api/logout") -def logout(request: Request): - request.session.pop("user", None) - return RedirectResponse(config.REDIRECT_URI) \ No newline at end of file + access_token = token_data["access_token"] + + # 2. Get User Info + user_info_resp = requests.get( + f"{config.OPENID_PROVIDER_URL}/userinfo", + headers={'Authorization': f'Bearer {access_token}'} + ) + user_data = user_info_resp.json() + + # 3. Define the minimal user data to send back to the frontend + user_return_data = { + # Use 'sub' (subject) or 'email' as the primary ID + "id": user_data.get('sub', user_data.get('email', 'unknown')), + "email": user_data.get('email', 'unknown'), + # Add any other required public user details + } + + # --- CHANGE: Create a JSONResponse object to attach the cookie --- + response_to_client = JSONResponse(content=user_return_data) + + # 4. Set the HTTP-only Access Token Cookie (The Self-Contained Session) + # The FE will not be able to read this due to httponly=True. + response_to_client.set_cookie( + key="access_token", # Name of the cookie + value=access_token, # The Access Token itself is the session + httponly=True, # CRITICAL: Prevents client-side JS access (XSS defense) + secure=True, # CRITICAL: Ensures cookie is only sent over HTTPS + samesite="Lax", # Recommended defense against CSRF + max_age=token_data.get("expires_in", 3600 * 2), # Set cookie lifespan to match token or 2 hours + path="/" # Available to the entire application + ) + + # 5. Return the response + return response_to_client + +@router.get("/api/auth/session") +def check_session(request: Request): + # 1. Get the token from the cookie sent by the browser + access_token = request.cookies.get("access_token") + + if not access_token: + # If no cookie exists, return 401 Unauthorized + raise HTTPException(status_code=401, detail="No session token found") + + # 2. Use the token to get the user info/validate it against the SSO provider + # NOTE: Your BE may need to check the token's expiration itself before calling the SSO provider + user_info_resp = requests.get( + f"{config.OPENID_PROVIDER_URL}/userinfo", + headers={'Authorization': f'Bearer {access_token}'} + ) + + if user_info_resp.status_code != 200: + # If the SSO provider says the token is invalid/expired + raise HTTPException(status_code=401, detail="Token validation failed or expired") + + # 3. Token is valid. Return the user data to the frontend. + user_data = user_info_resp.json() + return { + "id": user_data.get('sub', user_data.get('email', 'unknown')), + "email": user_data.get('email', 'unknown'), + } + +@router.post("/api/logout") +def logout(): + """ + Destroys the secure session cookie to log the user out. + """ + response = JSONResponse(content={"message": "Logged out successfully"}) + + # Instruct the browser to delete the cookie by setting its max_age to 0 + response.delete_cookie( + key="access_token", + path="/", + secure=True, + httponly=True, + samesite="Lax" + ) + + return response \ No newline at end of file diff --git a/fe/src/lib/stores.js b/fe/src/lib/stores.js index c4152ca..9f63005 100644 --- a/fe/src/lib/stores.js +++ b/fe/src/lib/stores.js @@ -4,5 +4,7 @@ export const selectedNamespce = writable(null); export const selectedTable = writable(null); export const sample_limit = writable(100); +export const user = writable(null); + export const healthEnabled = writable(false); export const HEALTH_DISABLED_MESSAGE = 'Feature is disabled. Please contact your app administrator or read the documentation to enable DB connection.'; \ No newline at end of file diff --git a/fe/src/routes/+layout.svelte b/fe/src/routes/+layout.svelte index be59636..8894fae 100644 --- a/fe/src/routes/+layout.svelte +++ b/fe/src/routes/+layout.svelte @@ -1,220 +1,270 @@
- - - - {#if CHAT_ENABLED} - - {/if} - - - - - - - {#if AUTH_ENABLED} - - - {/if} - {#if extra_link} - - - - - - {/if} - + + + + {#if CHAT_ENABLED} + + {/if} + + + + + + + {#if AUTH_ENABLED} + + + + {/if} + {#if extra_link} + + + + + + {/if} +
- {#if user} - - {/if} + {#if currentUser} + + + {/if} \ No newline at end of file From 8e7a040ab065b043ddd6efeb7991f1dd4bcee357 Mon Sep 17 00:00:00 2001 From: codrut Date: Mon, 8 Dec 2025 18:33:12 +0200 Subject: [PATCH 2/7] fix tests --- be/tests/routes/test_api_auth.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/be/tests/routes/test_api_auth.py b/be/tests/routes/test_api_auth.py index acc622f..51648c7 100644 --- a/be/tests/routes/test_api_auth.py +++ b/be/tests/routes/test_api_auth.py @@ -52,9 +52,9 @@ def test_get_token_success(mock_requests_get, mock_requests_post, client: TestCl response = client.post("/api/auth/token", json={"code": "test-code"}) assert response.status_code == 200 - assert response.json() == "user@example.com" + assert response.json() == {'email': 'user@example.com', 'id': 'user@example.com'} # Check that the session was set - assert client.cookies.get("session") is not None + assert client.cookies.get("access_token") is not None @patch('requests.post') def test_get_token_failure(mock_requests_post, client: TestClient): @@ -67,7 +67,7 @@ def test_get_token_failure(mock_requests_post, client: TestClient): def test_logout(client: TestClient): """Test that the logout endpoint returns a redirect.""" # We test the direct outcome (a redirect response) rather than inspect session state - response = client.get("/api/logout", follow_redirects=False) + response = client.post("/api/logout", follow_redirects=False) # Check for a redirect status code (307 is used by FastAPI for temporary redirects) - assert response.status_code == 307 + assert client.cookies.get("access_token") is None From c79ecfa15abfd1bccfc638cf9ec40db37e9df947 Mon Sep 17 00:00:00 2001 From: codrut Date: Wed, 10 Dec 2025 20:02:53 +0200 Subject: [PATCH 3/7] remove print --- be/app/routers/auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/be/app/routers/auth.py b/be/app/routers/auth.py index eb23543..fbae566 100644 --- a/be/app/routers/auth.py +++ b/be/app/routers/auth.py @@ -49,7 +49,6 @@ def get_token(request: Request, token_req: TokenRequest): response = requests.post(config.TOKEN_URL, data=data) if response.status_code != 200: - print(response.json()) raise HTTPException(status_code=400, detail="Failed to exchange code for token") token_data = response.json() From cb64dd3540f5d82749f479d423d90447c31bedf5 Mon Sep 17 00:00:00 2001 From: codrut Date: Thu, 11 Dec 2025 16:35:08 +0200 Subject: [PATCH 4/7] replace cookie with session --- be/app/routers/auth.py | 50 ++++++++++---------------------- be/tests/routes/test_api_auth.py | 4 +-- fe/src/routes/+layout.svelte | 6 ++-- 3 files changed, 21 insertions(+), 39 deletions(-) diff --git a/be/app/routers/auth.py b/be/app/routers/auth.py index fbae566..a1a1a46 100644 --- a/be/app/routers/auth.py +++ b/be/app/routers/auth.py @@ -38,7 +38,6 @@ def get_token(request: Request, token_req: TokenRequest): Exchanges the SSO code for an Access Token, sets the token in a secure HTTP-only cookie, and returns minimal user details. """ - # 1. Send code to SSO provider data = { "grant_type": "authorization_code", "code": token_req.code, @@ -54,43 +53,28 @@ def get_token(request: Request, token_req: TokenRequest): token_data = response.json() access_token = token_data["access_token"] - # 2. Get User Info user_info_resp = requests.get( f"{config.OPENID_PROVIDER_URL}/userinfo", headers={'Authorization': f'Bearer {access_token}'} ) user_data = user_info_resp.json() - # 3. Define the minimal user data to send back to the frontend user_return_data = { - # Use 'sub' (subject) or 'email' as the primary ID "id": user_data.get('sub', user_data.get('email', 'unknown')), "email": user_data.get('email', 'unknown'), - # Add any other required public user details } - # --- CHANGE: Create a JSONResponse object to attach the cookie --- response_to_client = JSONResponse(content=user_return_data) - - # 4. Set the HTTP-only Access Token Cookie (The Self-Contained Session) - # The FE will not be able to read this due to httponly=True. - response_to_client.set_cookie( - key="access_token", # Name of the cookie - value=access_token, # The Access Token itself is the session - httponly=True, # CRITICAL: Prevents client-side JS access (XSS defense) - secure=True, # CRITICAL: Ensures cookie is only sent over HTTPS - samesite="Lax", # Recommended defense against CSRF - max_age=token_data.get("expires_in", 3600 * 2), # Set cookie lifespan to match token or 2 hours - path="/" # Available to the entire application - ) - # 5. Return the response + request.session['user'] = user_data.get('email') + request.session['access_token'] = access_token + return response_to_client @router.get("/api/auth/session") def check_session(request: Request): # 1. Get the token from the cookie sent by the browser - access_token = request.cookies.get("access_token") + access_token = request.session.get("access_token") if not access_token: # If no cookie exists, return 401 Unauthorized @@ -115,19 +99,17 @@ def check_session(request: Request): } @router.post("/api/logout") -def logout(): +def logout(request: Request): """ - Destroys the secure session cookie to log the user out. + Clears the server-side session. """ - response = JSONResponse(content={"message": "Logged out successfully"}) - - # Instruct the browser to delete the cookie by setting its max_age to 0 - response.delete_cookie( - key="access_token", - path="/", - secure=True, - httponly=True, - samesite="Lax" - ) - - return response \ No newline at end of file + request.session.pop("user", None) + request.session.pop("access_token", None) + return JSONResponse(content={"message": "Logged out successfully"}) + +@router.get("/api/logout") +def logout_get(request: Request): + """Fallback GET logout endpoint (original behavior)""" + request.session.pop("user", None) + request.session.pop("access_token", None) + return RedirectResponse(config.REDIRECT_URI) \ No newline at end of file diff --git a/be/tests/routes/test_api_auth.py b/be/tests/routes/test_api_auth.py index 51648c7..4aafcae 100644 --- a/be/tests/routes/test_api_auth.py +++ b/be/tests/routes/test_api_auth.py @@ -54,7 +54,7 @@ def test_get_token_success(mock_requests_get, mock_requests_post, client: TestCl assert response.status_code == 200 assert response.json() == {'email': 'user@example.com', 'id': 'user@example.com'} # Check that the session was set - assert client.cookies.get("access_token") is not None + assert client.cookies.get("session") is not None @patch('requests.post') def test_get_token_failure(mock_requests_post, client: TestClient): @@ -69,5 +69,5 @@ def test_logout(client: TestClient): # We test the direct outcome (a redirect response) rather than inspect session state response = client.post("/api/logout", follow_redirects=False) # Check for a redirect status code (307 is used by FastAPI for temporary redirects) - assert client.cookies.get("access_token") is None + assert client.cookies.get("session") is None diff --git a/fe/src/routes/+layout.svelte b/fe/src/routes/+layout.svelte index 8894fae..3637202 100644 --- a/fe/src/routes/+layout.svelte +++ b/fe/src/routes/+layout.svelte @@ -47,7 +47,7 @@ // --- NEW: Configuration Endpoints --- const DEFAULT_AUTH_TOKEN_URL = '/api/auth/token'; const AUTH_TOKEN_EXCHANGE_URL = env.PUBLIC_AUTH_TOKEN_EXCHANGE_URL || DEFAULT_AUTH_TOKEN_URL; - // Endpoint must be provided by the backend to check if the session cookie is valid + // Endpoint must be provided by the backend to check if the session is valid const SESSION_CHECK_URL = env.PUBLIC_SESSION_CHECK_URL || '/api/auth/session'; // --- END NEW CONFIG --- @@ -139,7 +139,7 @@ // 1. Redirect from IdP: Exchange code exchangeCodeForToken(code, state); } else if (currentUser === null) { - // 2. Refresh scenario: Check for existing cookie session + // 2. Refresh scenario: Check for existing session const sessionFound = await checkExistingSession(); if (!sessionFound) { // 3. If no session and no code, force login @@ -192,7 +192,7 @@ } async function logout() { - // --- UPDATED: Use POST fetch to tell backend to destroy HttpOnly cookie --- + // --- UPDATED: Use POST fetch to tell backend to destroy HttpOnly --- await fetch('/api/logout', { method: 'POST' }); user.set(null); // Clear store // Force reload to trigger session check, which will now fail and redirect to login. From b29a21da60a173b7b887ed53275f388d491db3d0 Mon Sep 17 00:00:00 2001 From: codrut Date: Thu, 11 Dec 2025 16:45:48 +0200 Subject: [PATCH 5/7] remove cookie reference --- be/app/dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/be/app/dependencies.py b/be/app/dependencies.py index baa85de..ccd3f4d 100644 --- a/be/app/dependencies.py +++ b/be/app/dependencies.py @@ -156,7 +156,7 @@ def refresh_namespace_and_tables(): # --- Authentication Dependency --- def check_auth(request: Request): - return request.cookies.get("access_token") + return request.session.get("user") # --- Table Loading Dependency --- def load_table(table_id: str) -> Table: From 9559c6b203873d2ac84bddb5098c1352bd688671 Mon Sep 17 00:00:00 2001 From: codrut Date: Thu, 11 Dec 2025 16:55:57 +0200 Subject: [PATCH 6/7] fix fe user --- fe/src/routes/+layout.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fe/src/routes/+layout.svelte b/fe/src/routes/+layout.svelte index 3637202..d2d282d 100644 --- a/fe/src/routes/+layout.svelte +++ b/fe/src/routes/+layout.svelte @@ -163,7 +163,7 @@ if (response.ok) { const data = await response.json(); console.log('Token exchanged, user data received:', data); - user.set(data); // Store the full user object + user.set(data.email); // Store the full user object // Decode the state parameter and redirect, preserving the original URL query const decodedState = decodeURIComponent(state || ''); From e705ba5630b3dc0c9695ac5341169f5f19d17112 Mon Sep 17 00:00:00 2001 From: codrut Date: Thu, 11 Dec 2025 17:05:29 +0200 Subject: [PATCH 7/7] fix header and remove comments --- fe/src/routes/+layout.svelte | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/fe/src/routes/+layout.svelte b/fe/src/routes/+layout.svelte index d2d282d..376a6db 100644 --- a/fe/src/routes/+layout.svelte +++ b/fe/src/routes/+layout.svelte @@ -22,15 +22,12 @@ import { Logout, UserAvatarFilledAlt } from 'carbon-icons-svelte'; import Chat from '../lib/components/Chat.svelte'; - // --- CORRECTED IMPORTS: Import user store and other stores --- import { healthEnabled, user } from '$lib/stores'; - // --- FIX 1: Migrate local 'user' to store-subscribed 'currentUser' --- let currentUser; user.subscribe(value => { currentUser = value; }); - // --- END FIX 1 --- let isHeaderActionOpen = false; let AUTH_ENABLED = false; @@ -81,8 +78,6 @@ const handleChatClose = () => setChatOpen(false); const handleChatOpen = () => setChatOpen(true); - - // --- NEW: Session Check Function --- async function checkExistingSession() { console.log("Checking for existing session..."); try { @@ -103,8 +98,6 @@ return false; } } - // --- END NEW SESSION CHECK --- - onMount(async () => { if (env.PUBLIC_AUTH_ENABLED == 'true') { @@ -129,30 +122,24 @@ if (env.PUBLIC_COMPANY_NAME) company = env.PUBLIC_COMPANY_NAME; if (env.PUBLIC_PLATFORM_NAME) platform = env.PUBLIC_PLATFORM_NAME; - // --- UPDATED AUTH FLOW --- if (AUTH_ENABLED) { const params = new URLSearchParams(window.location.search); const code = params.get('code'); const state = params.get('state'); if (code) { - // 1. Redirect from IdP: Exchange code exchangeCodeForToken(code, state); } else if (currentUser === null) { - // 2. Refresh scenario: Check for existing session const sessionFound = await checkExistingSession(); if (!sessionFound) { - // 3. If no session and no code, force login console.error('No authorization code found! Redirecting to login.'); login(); } } } - // --- END UPDATED AUTH FLOW --- }); async function exchangeCodeForToken(code, state) { - // --- UPDATED: Use configurable endpoint --- const response = await fetch(AUTH_TOKEN_EXCHANGE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -192,7 +179,6 @@ } async function logout() { - // --- UPDATED: Use POST fetch to tell backend to destroy HttpOnly --- await fetch('/api/logout', { method: 'POST' }); user.set(null); // Clear store // Force reload to trigger session check, which will now fail and redirect to login. @@ -222,7 +208,7 @@ {#if AUTH_ENABLED} - +