From 5603979d5c8997b1f20f397e02153b21e98d547f Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Tue, 27 Jan 2026 06:45:05 -0300 Subject: [PATCH] Add session cookie for browser-isolated OAuth sessions --- soleprint/artery/veins/google/api/routes.py | 142 +++++++++++++++----- 1 file changed, 112 insertions(+), 30 deletions(-) diff --git a/soleprint/artery/veins/google/api/routes.py b/soleprint/artery/veins/google/api/routes.py index f54446f..f5edc23 100644 --- a/soleprint/artery/veins/google/api/routes.py +++ b/soleprint/artery/veins/google/api/routes.py @@ -2,14 +2,54 @@ API routes for Google vein. """ +import json +import secrets +from pathlib import Path from typing import Optional -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response from fastapi.responses import PlainTextResponse, RedirectResponse # Import shared OAuth utilities from veins parent from oauth import TokenStorage +SESSION_COOKIE = "spr_session" + + +def _load_auth_config(): + """Load auth config from room config.json.""" + config_path = Path("/app/cfg/config.json") + if not config_path.exists(): + return {} + try: + with open(config_path) as f: + config = json.load(f) + return config.get("auth", {}) + except Exception: + return {} + + +def _is_user_allowed(email: str, domain: str) -> bool: + """Check if user is allowed based on auth config.""" + auth = _load_auth_config() + allowed_domains = auth.get("allowed_domains", []) + allowed_emails = auth.get("allowed_emails", []) + + # No restrictions = allow all + if not allowed_domains and not allowed_emails: + return True + + # Check email list + if email in allowed_emails: + return True + + # Check domain list + if domain and domain in allowed_domains: + return True + + return False + + from ..core.oauth import GoogleOAuth from ..core.sheets import GoogleSheetsClient, GoogleSheetsError from ..models.formatter import format_sheet_values, format_spreadsheet_metadata @@ -124,6 +164,7 @@ async def start_oauth( @router.get("/oauth/callback") async def oauth_callback( + request: Request, code: Optional[str] = None, state: Optional[str] = None, error: Optional[str] = None, @@ -136,29 +177,59 @@ async def oauth_callback( raise HTTPException(400, "Missing authorization code") # Extract redirect URL from state if present - # Format: "csrf_state|redirect_url" OR just "redirect_url" if no csrf state + # Format: "csrf_state|redirect_url" OR just "redirect_url" (path starting with /) redirect_url = None if state: if "|" in state: parts = state.split("|", 1) redirect_url = parts[1] if len(parts) > 1 else None - elif state.startswith("http"): - # No csrf state, just the redirect URL + else: + # No separator, state is the redirect URL itself redirect_url = state try: - tokens = oauth_client.exchange_code_for_tokens(code) - token_storage.save_tokens(DEFAULT_USER_ID, tokens) + # First get user info to validate before storing tokens + user_info = oauth_client.exchange_code_for_user(code) + user_email = user_info.get("email", "") + user_domain = user_info.get("hd", "") # hosted domain (for Google Workspace) - # Redirect back to original page if specified - if redirect_url: - return RedirectResponse(url=redirect_url) + # Check if user is allowed + if not _is_user_allowed(user_email, user_domain): + raise HTTPException( + 403, + f"Access restricted. Your account ({user_email}) is not authorized.", + ) - return { - "status": "ok", - "message": "Successfully authenticated with Google", - "user": DEFAULT_USER_ID, + # Generate session ID for this browser + session_id = secrets.token_urlsafe(32) + + # Store tokens with session ID + tokens = { + "access_token": user_info.get("access_token", ""), + "refresh_token": user_info.get("refresh_token", ""), + "token_type": "Bearer", + "email": user_email, } + if tokens["access_token"]: + token_storage.save_tokens(session_id, tokens) + + # Create response with session cookie + if redirect_url: + response = RedirectResponse(url=redirect_url) + else: + response = RedirectResponse(url="/") + + response.set_cookie( + key=SESSION_COOKIE, + value=session_id, + httponly=True, + secure=True, + samesite="lax", + max_age=60 * 60 * 24 * 30, # 30 days + ) + return response + except HTTPException: + raise except Exception as e: raise HTTPException(500, f"Failed to exchange code: {e}") @@ -181,36 +252,47 @@ async def get_userinfo( @router.get("/oauth/status") -async def oauth_status(): - """Check if user is authenticated (for sidebar).""" +async def oauth_status(spr_session: Optional[str] = Cookie(None)): + """Check if this browser's session is authenticated.""" try: - tokens = token_storage.load_tokens(DEFAULT_USER_ID) + if not spr_session: + return {"authenticated": False} + + # Load tokens for this session + tokens = token_storage.load_tokens(spr_session) if not tokens: return {"authenticated": False} - # Check if expired - if token_storage.is_expired(tokens): - if "refresh_token" not in tokens: - return {"authenticated": False} - try: - new_tokens = oauth_client.refresh_access_token(tokens["refresh_token"]) - token_storage.save_tokens(DEFAULT_USER_ID, new_tokens) - except Exception: - return {"authenticated": False} + # Validate token content + access_token = tokens.get("access_token") + email = tokens.get("email") + + if not access_token or not email: + return {"authenticated": False} return { "authenticated": True, - "user": {"email": f"{DEFAULT_USER_ID}@authenticated"}, + "user": {"email": email}, } except Exception: return {"authenticated": False} @router.get("/oauth/logout") -async def logout(): - """Clear stored tokens.""" - token_storage.delete_tokens(DEFAULT_USER_ID) - return {"status": "ok", "message": "Logged out"} +async def logout(redirect: str = "/", spr_session: Optional[str] = Cookie(None)): + """Clear this browser's session and redirect.""" + response = RedirectResponse(url=redirect) + + # Delete token file for this session + if spr_session: + storage_path = Path("/app/artery/veins/google/storage") + token_file = storage_path / f"tokens_{spr_session}.json" + if token_file.exists(): + token_file.unlink() + + # Clear the session cookie + response.delete_cookie(key=SESSION_COOKIE) + return response @router.get("/spreadsheets/{spreadsheet_id}")