Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion be/app/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
97 changes: 87 additions & 10 deletions be/app/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
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
8 changes: 4 additions & 4 deletions be/tests/routes/test_api_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

2 changes: 2 additions & 0 deletions fe/src/lib/stores.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href="https://github.com/lakevision-project/lakevision?tab=readme-ov-file#configuration" target="_blank">read the documentation</a> to enable DB connection.';
Loading