diff --git a/ai-hub/app/api/dependencies.py b/ai-hub/app/api/dependencies.py index 15af216..3c36612 100644 --- a/ai-hub/app/api/dependencies.py +++ b/ai-hub/app/api/dependencies.py @@ -1,4 +1,4 @@ -from fastapi import Depends, HTTPException, status, Header +from fastapi import Depends, HTTPException, status, Header, Request import logging from typing import List, Any, Optional, Annotated from sqlalchemy.orm import Session @@ -18,30 +18,73 @@ finally: db.close() -# Dependency to get current user object from X-User-ID header +# Dependency to get current user object (Prioritizes Authorization: Bearer ) async def get_current_user( + request: Request, db: Session = Depends(get_db), + authorization: Annotated[Optional[str], Header()] = None, x_user_id: Annotated[Optional[str], Header()] = None, x_proxy_secret: Annotated[Optional[str], Header()] = None, ) -> models.User: + from app.config import settings + + # 1. Try to resolve user via JWT (Bearer Token) + token = None + if authorization and authorization.startswith("Bearer "): + token = authorization.split(" ")[1] + + # Also support token in query param or X-User-ID header (if it looks like a JWT) + if not token: + token = request.query_params.get("token") + if not token and x_user_id and "." in x_user_id: + token = x_user_id + + if token and "." in token: + try: + # Use request.app.state.services to avoid circular imports with app.main + global_services = getattr(request.app.state, "services", None) + if not global_services: + from app.main import services as global_services + + import jwt + unverified = jwt.decode(token, options={"verify_signature": False}) + + # Local Session (HS256) + if unverified.get("iss") == "cortex-hub-internal": + decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + user_id = decoded.get("sub") + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException(status_code=401, detail="User in session token no longer exists.") + return user + + # OIDC Token (RS256) + user = await global_services.auth_service.verify_id_token(token, db) + return user + except Exception as e: + logging.warning(f"JWT Verification failed: {e}") + if settings.OIDC_ENABLED: + raise HTTPException(status_code=401, detail=f"Invalid authentication token: {str(e)}") + + # 2. Fallback to X-User-ID (Legacy / Proxy Identity Claim) if not x_user_id: + logging.debug("No identity provided (no Authorization and no X-User-ID)") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="X-User-ID header is missing" + detail="Authentication required: Provide a Bearer token (JWT) or X-User-ID header." ) - # HARDENING: In production, X-User-ID must be verified via a shared secret from the proxy - from app.config import settings - if settings.SECRET_KEY and settings.SECRET_KEY not in ["dev", "generate-me", "dev-secret-key-1337", "integration-secret-key-123"]: - # Strict enforcement only if OIDC is disabled or if the secret is provided (to verify it) - if not settings.OIDC_ENABLED or x_proxy_secret: - if not x_proxy_secret or x_proxy_secret != settings.SECRET_KEY: - logging.warning(f"Invalid X-Proxy-Secret from {x_user_id}. Identity claim rejected.") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid Proxy Secret. Identity claim rejected." - ) + # HARDENING: In OIDC mode, we strictly reject plain X-User-ID. + # Identity must be verified via JWT (ID Token) to prevent spoofing. + if settings.OIDC_ENABLED: + if not x_proxy_secret or x_proxy_secret != settings.SECRET_KEY: + logging.warning(f"Insecure X-User-ID '{x_user_id}' rejected in strict OIDC mode.") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Insecure Identity Claim: Provide a valid OIDC ID Token (JWT) in the Authorization header." + ) + # 3. Final verification of Identity Claim user = db.query(models.User).filter(models.User.id == x_user_id).first() if not user: raise HTTPException( @@ -49,7 +92,6 @@ detail="User not found" ) - from app.config import settings if not user.password_hash and not settings.OIDC_ENABLED: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -59,6 +101,19 @@ return user +async def get_optional_user( + request: Request, + db: Session = Depends(get_db), + authorization: Annotated[Optional[str], Header()] = None, + x_user_id: Annotated[Optional[str], Header()] = None, + x_proxy_secret: Annotated[Optional[str], Header()] = None, +) -> Optional[models.User]: + """Soft-auth version of get_current_user. Returns None instead of raising if no valid identity is found.""" + try: + return await get_current_user(request, db, authorization, x_user_id, x_proxy_secret) + except Exception: + return None + async def get_current_admin( current_user: models.User = Depends(get_current_user) ) -> models.User: diff --git a/ai-hub/app/api/routes/mcp.py b/ai-hub/app/api/routes/mcp.py index c9e1082..d3dac41 100644 --- a/ai-hub/app/api/routes/mcp.py +++ b/ai-hub/app/api/routes/mcp.py @@ -20,9 +20,8 @@ import json import uuid import logging -from typing import Optional, AsyncIterator - -from fastapi import APIRouter, Request, HTTPException, Query +from typing import Optional, List, Annotated +from fastapi import APIRouter, HTTPException, Request, Query, Header from fastapi.responses import JSONResponse, StreamingResponse from app.api.dependencies import ServiceContainer @@ -40,44 +39,91 @@ def create_mcp_router(services: ServiceContainer) -> APIRouter: router = APIRouter(tags=["MCP"]) + async def _get_authenticated_user(request: Request, token: Optional[str], db) -> Optional[str]: + """ + Resolves the user_id from either the Authorization header (JWT) or the token query param. + If OIDC is enabled, this strictly requires a valid JWT. + """ + from app.config import settings + + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ")[1] + + if not token: + return None + + is_jwt = "." in token + + # 1. OIDC Mode: Strictly require and verify JWT + if settings.OIDC_ENABLED: + if not is_jwt: + logger.warning(f"[MCP] Rejected plain UUID token in OIDC mode. Use ID tokens instead.") + raise HTTPException( + status_code=401, + detail="Authentication required: Provide a valid OIDC ID Token (JWT). Plain UUIDs are deprecated for security." + ) + + try: + user = await services.auth_service.verify_id_token(token, db) + return user.id + except Exception as e: + logger.error(f"[MCP] JWT verification failed: {e}") + # Emergency fallback for local dev ONLY if secret key is default + if settings.SECRET_KEY in ["dev", "generate-me", "dev-secret-key-1337"]: + return token + raise HTTPException(status_code=401, detail=f"Invalid ID token: {str(e)}") + + # 2. Legacy/Bootstrap Mode: Accept plain user_id (Identity Claim) + # This is only active when OIDC is not configured. + return token + # ─── SSE Transport — Client Connection ──────────────────────────────────── - @router.get("/sse") + @router.get("/sse", summary="MCP SSE Transport Endpoint") async def mcp_sse( request: Request, - token: Optional[str] = Query(None, description="Optional user token (X-User-ID)"), + token: Optional[str] = Query(None), ): """ - Legacy SSE transport (MCP 2024-11-05). - Opens a persistent SSE stream; first event is `endpoint` telling the - client where to POST messages. + Server-Sent Events (SSE) transport for MCP. + Supports Bearer token in Authorization header or 'token' query parameter. """ + from app.db.session import get_db_session + with get_db_session() as db: + user_id = await _get_authenticated_user(request, token, db) + + if not user_id: + # We allow the SSE connection to open even without auth, + # but actual messages/tools will be rejected. + logger.info("[MCP] SSE connection opened without initial auth.") + + queue = asyncio.Queue() session_id = str(uuid.uuid4()) - queue: asyncio.Queue = asyncio.Queue() _sse_sessions[session_id] = queue + + messages_url = f"{settings.HUB_PUBLIC_URL}/api/v1/mcp/messages?session_id={session_id}" + if user_id: + messages_url += f"&token={user_id}" - base = str(request.base_url).rstrip("/") - messages_url = f"{base}/api/v1/mcp/messages?session_id={session_id}" - if token: - messages_url += f"&token={token}" + # Origin validation per MCP 2025-11-25 + origin = request.headers.get("origin") + if origin: + allowed = ["https://ai.jerxie.com", "http://localhost:3000", "http://localhost:8080"] + if not any(origin.startswith(a) for a in allowed): + logger.warning(f"[MCP] Blocked unauthorized origin: {origin}") + raise HTTPException(status_code=403, detail="Unauthorized Origin") - logger.info(f"[MCP] New SSE session: {session_id}") - - async def event_generator() -> AsyncIterator[str]: - yield f"event: endpoint\ndata: {messages_url}\n\n" + async def _event_generator(): try: + yield f"event: endpoint\ndata: {messages_url}\n\n" while True: - if await request.is_disconnected(): - break - try: - msg = await asyncio.wait_for(queue.get(), timeout=25.0) - yield f"event: message\ndata: {json.dumps(msg)}\n\n" - except asyncio.TimeoutError: - yield ": keepalive\n\n" + msg = await queue.get() + yield f"event: message\ndata: {json.dumps(msg)}\n\n" finally: _sse_sessions.pop(session_id, None) return StreamingResponse( - event_generator(), + _event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", @@ -88,16 +134,20 @@ # ─── Streamable HTTP Transport (MCP 2025-11-25) ─────────────────────────── @router.post("/sse") - @router.post("/") + @router.post("/", summary="MCP Streamable HTTP Endpoint (Post-only mode)") async def mcp_streamable_http( request: Request, token: Optional[str] = Query(None), ): """ - Streamable HTTP transport (MCP 2025-11-25 / 2025-03-26). - Client POSTs JSON-RPC and receives the response synchronously. + One-shot JSON-RPC over HTTP. + Supports Bearer token in Authorization header or 'token' query parameter. """ - # Origin validation — MUST per MCP 2025-11-25 security spec + from app.db.session import get_db_session + with get_db_session() as db: + user_id = await _get_authenticated_user(request, token, db) + + # Origin validation per MCP 2025-11-25 origin = request.headers.get("origin") if origin: allowed = [ @@ -297,11 +347,13 @@ loop = asyncio.get_running_loop() if name == "list_nodes": + if not token: + raise ValueError("Authentication required to list nodes.") def _query(): from app.db.session import get_db_session - from app.db import models with get_db_session() as db: - rows = db.query(models.AgentNode).all() + # Use MeshService to filter nodes based on the authenticated user_id + nodes = services.mesh_service.list_accessible_nodes(token, db) return { "nodes": [ { @@ -311,7 +363,7 @@ "os": (n.capabilities or {}).get("os"), "is_active": n.is_active, } - for n in rows + for n in nodes ] } return _ok(await loop.run_in_executor(None, _query)) @@ -319,31 +371,44 @@ if name == "get_app_info": def _query(): from app.db.session import get_db_session - from app.db import models with get_db_session() as db: - total = db.query(models.AgentNode).count() - online = db.query(models.AgentNode).filter(models.AgentNode.last_status == "online").count() - return { - "name": "Cortex Hub", - "version": "1.0.0", - "capabilities": ["swarms", "webmcp", "mcp-sse", "voice-chat", "rag"], - "nodes": {"total": total, "online": online}, - "mcp_transport": "sse", - "sse_endpoint": f"{settings.HUB_PUBLIC_URL}/api/v1/mcp/sse", - } + if token: + # Filtered counts if authenticated + nodes = services.mesh_service.list_accessible_nodes(token, db) + total = len(nodes) + online = len([n for n in nodes if n.last_status == "online"]) + else: + # Return zero counts if not authenticated + total = 0 + online = 0 + + return { + "name": "Cortex Hub", + "version": "1.0.0", + "capabilities": ["swarms", "webmcp", "mcp-sse", "voice-chat", "rag"], + "nodes": {"total": total, "online": online}, + "mcp_transport": "sse", + "sse_endpoint": f"{settings.HUB_PUBLIC_URL}/api/v1/mcp/sse", + "auth": {"oidc_enabled": settings.OIDC_ENABLED} + } return _ok(await loop.run_in_executor(None, _query)) if name == "get_node_details": + if not token: + raise ValueError("Authentication required to get node details.") node_id = args.get("node_id") if not node_id: raise ValueError("node_id is required.") def _query(): from app.db.session import get_db_session - from app.db import models with get_db_session() as db: - n = db.query(models.AgentNode).filter(models.AgentNode.node_id == node_id).first() - if not n: - return None + # Enforce permission check before returning details + try: + services.mesh_service.require_node_access(token, node_id, db) + except Exception: + return None # Access denied + + n = services.mesh_service.get_node_or_404(node_id, db) return { "node_id": n.node_id, "display_name": n.display_name, @@ -361,11 +426,17 @@ return _ok(result) if name == "list_agents": + if not token: + raise ValueError("Authentication required to list agents.") def _query(): from app.db.session import get_db_session from app.db import models with get_db_session() as db: - rows = db.query(models.AgentInstance).all() + # Basic hardening: Only show agents on nodes user can access + accessible_nodes = services.mesh_service.list_accessible_nodes(token, db) + node_ids = [n.node_id for n in accessible_nodes] + + rows = db.query(models.AgentInstance).filter(models.AgentInstance.mesh_node_id.in_(node_ids)).all() return { "agents": [ { @@ -383,6 +454,8 @@ return _ok(await loop.run_in_executor(None, _query)) if name == "list_skills": + if not token: + raise ValueError("Authentication required to list skills.") def _query(): from app.db.session import get_db_session from app.db import models @@ -401,4 +474,14 @@ } return _ok(await loop.run_in_executor(None, _query)) + # Writable tools (future-proofing check) + # If OIDC is disabled, we block any tool that could manipulate the swarm mesh + # as plain Identity Claims are not secure enough for headless write operations. + writable_tools = ["dispatch", "write_file", "delete_file"] # Planned tools + if name in writable_tools and not settings.OIDC_ENABLED: + raise HTTPException( + status_code=403, + detail="Swarm manipulation tools are disabled because OIDC is not configured." + ) + raise ValueError(f"Unknown tool: '{name}'") diff --git a/ai-hub/app/api/routes/nodes.py b/ai-hub/app/api/routes/nodes.py index 6aba850..15ac674 100644 --- a/ai-hub/app/api/routes/nodes.py +++ b/ai-hub/app/api/routes/nodes.py @@ -33,7 +33,7 @@ from sqlalchemy.orm import Session -from app.api.dependencies import ServiceContainer, get_db +from app.api.dependencies import ServiceContainer, get_db, get_current_user from app.config import settings from app.api import schemas from app.db import models @@ -67,39 +67,64 @@ # ================================================================== @router.post("/admin", response_model=schemas.AgentNodeAdminDetail, summary="[Admin] Register New Node") - def admin_create_node(request: schemas.AgentNodeCreate, admin_id: str = Query(...), db: Session = Depends(get_db)): - _require_admin(admin_id, db) - node = services.mesh_service.register_node(request, admin_id, db) - logger.info(f"[admin] Created node '{request.node_id}' by admin {admin_id}") + def admin_create_node( + request: schemas.AgentNodeCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): + _require_admin(current_user.id, db) + node = services.mesh_service.register_node(request, current_user.id, db) + logger.info(f"[admin] Created node '{request.node_id}' by admin {current_user.id}") return services.mesh_service.node_to_admin_detail(node) @router.get("/admin", response_model=list[schemas.AgentNodeAdminDetail], summary="[Admin] List All Nodes") - def admin_list_nodes(admin_id: str = Query(...), db: Session = Depends(get_db)): - _require_admin(admin_id, db) + def admin_list_nodes( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): + _require_admin(current_user.id, db) return [services.mesh_service.node_to_admin_detail(n) for n in db.query(models.AgentNode).all()] @router.get("/admin/{node_id}", response_model=schemas.AgentNodeAdminDetail, summary="[Admin] Get Node Detail") - def admin_get_node(node_id: str, admin_id: str = Query(...), db: Session = Depends(get_db)): - _require_admin(admin_id, db) + def admin_get_node( + node_id: str, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): + _require_admin(current_user.id, db) node = services.mesh_service.get_node_or_404(node_id, db) return services.mesh_service.node_to_admin_detail(node) @router.patch("/admin/{node_id}", response_model=schemas.AgentNodeAdminDetail, summary="[Admin] Update Node Config") - def admin_update_node(node_id: str, update: schemas.AgentNodeUpdate, admin_id: str = Query(...), db: Session = Depends(get_db)): - _require_admin(admin_id, db) + def admin_update_node( + node_id: str, + update: schemas.AgentNodeUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): + _require_admin(current_user.id, db) node = services.mesh_service.update_node(node_id, update, db) return services.mesh_service.node_to_admin_detail(node) @router.delete("/admin/{node_id}", summary="[Admin] Deregister Node") - def admin_delete_node(node_id: str, admin_id: str = Query(...), db: Session = Depends(get_db)): - _require_admin(admin_id, db) + def admin_delete_node( + node_id: str, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): + _require_admin(current_user.id, db) services.mesh_service.delete_node(node_id, db) return {"status": "success", "message": f"Node {node_id} deleted"} @router.post("/admin/{node_id}/access", response_model=schemas.NodeAccessResponse, summary="[Admin] Grant Group Access") - def admin_grant_access(node_id: str, grant: schemas.NodeAccessGrant, admin_id: str = Query(...), db: Session = Depends(get_db)): - _require_admin(admin_id, db) - services.mesh_service.grant_access(node_id, grant, admin_id, db) + def admin_grant_access( + node_id: str, + grant: schemas.NodeAccessGrant, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): + _require_admin(current_user.id, db) + services.mesh_service.grant_access(node_id, grant, current_user.id, db) return db.query(models.NodeGroupAccess).filter( models.NodeGroupAccess.node_id == node_id, models.NodeGroupAccess.group_id == grant.group_id @@ -109,10 +134,10 @@ def admin_revoke_access( node_id: str, group_id: str, - admin_id: str = Query(...), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) ): - _require_admin(admin_id, db) + _require_admin(current_user.id, db) access = db.query(models.NodeGroupAccess).filter( models.NodeGroupAccess.node_id == node_id, models.NodeGroupAccess.group_id == group_id @@ -165,18 +190,21 @@ # ================================================================== @router.get("/", response_model=list[schemas.AgentNodeUserView], summary="List Accessible Nodes") - def list_accessible_nodes(user_id: str = Query(...), db: Session = Depends(get_db)): - nodes = services.mesh_service.list_accessible_nodes(user_id, db) + def list_accessible_nodes( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): + nodes = services.mesh_service.list_accessible_nodes(current_user.id, db) registry = _registry() return [services.mesh_service.node_to_user_view(n, registry) for n in nodes] @router.get("/{node_id}/status", summary="Quick Node Online Check") def get_node_status( node_id: str, - user_id: str = Header(..., alias="X-User-ID"), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) ): - _require_node_access(user_id, node_id, db) + _require_node_access(current_user.id, node_id, db) live = _registry().get_node(node_id) if not live: return {"node_id": node_id, "status": "offline"} @@ -204,9 +232,14 @@ } @router.post("/{node_id}/dispatch", response_model=schemas.NodeDispatchResponse, summary="Dispatch Task to Node") - def dispatch_to_node(node_id: str, request: schemas.NodeDispatchRequest, user_id: str = Query(...), db: Session = Depends(get_db)): + def dispatch_to_node( + node_id: str, + request: schemas.NodeDispatchRequest, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): task_id = services.mesh_service.dispatch_task( - node_id, request.command, user_id, db, + node_id, request.command, current_user.id, db, session_id=request.session_id, task_id=request.task_id, timeout_ms=request.timeout_ms ) return schemas.NodeDispatchResponse(task_id=task_id, status="accepted") @@ -237,17 +270,14 @@ @router.patch("/preferences", summary="Update User Node Preferences") def update_node_preferences( - user_id: str = Query(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), prefs: schemas.UserNodePreferences = None, - db: Session = Depends(get_db) ): """ Save the user's default_node_ids and data_source config into their preferences. - The UI reads this to auto-attach nodes when a new session starts. """ - user = db.query(models.User).filter(models.User.id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found.") + return services.preference_service.update_user_config(current_user, prefs, db) # Create a new dictionary to ensure SQLAlchemy detects the change to the JSON column current_prefs = dict(user.preferences or {}) current_prefs["nodes"] = prefs.model_dump() diff --git a/ai-hub/app/api/routes/sessions.py b/ai-hub/app/api/routes/sessions.py index d80fe06..08aecc0 100644 --- a/ai-hub/app/api/routes/sessions.py +++ b/ai-hub/app/api/routes/sessions.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, HTTPException, Depends, File, UploadFile, Response from fastapi.responses import FileResponse from sqlalchemy.orm import Session -from app.api.dependencies import ServiceContainer, get_db +from app.api.dependencies import ServiceContainer, get_db, get_current_user from app.api import schemas from typing import AsyncGenerator, List, Optional from app.db import models @@ -15,14 +15,16 @@ @router.post("/", response_model=schemas.Session, summary="Create a New Chat Session") def create_session( request: schemas.SessionCreate, + current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db) ): - if request.user_id is None or request.provider_name is None: - raise HTTPException(status_code=400, detail="user_id and provider_name are required to create a session.") + # Enforce that the session is created for the authenticated user + user_id = current_user.id + try: new_session = services.session_service.create_session( db=db, - user_id=request.user_id, + user_id=user_id, provider_name=request.provider_name, model_name=request.model_name, feature_name=request.feature_name, @@ -42,6 +44,7 @@ async def chat_in_session( session_id: int, request: schemas.ChatRequest, + current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db) ): """ @@ -49,10 +52,15 @@ Yields tokens, reasoning, and tool executions in real-time. """ # Reset cancellation flag on fresh request - session = db.query(models.Session).filter(models.Session.id == session_id).first() - if session: - session.is_cancelled = False - db.commit() + session = db.query(models.Session).filter( + models.Session.id == session_id, + models.Session.user_id == current_user.id + ).first() + if not session: + raise HTTPException(status_code=404, detail="Session not found or access denied.") + + session.is_cancelled = False + db.commit() from fastapi.responses import StreamingResponse import json @@ -78,11 +86,21 @@ @router.get("/{session_id}/messages", response_model=schemas.MessageHistoryResponse, summary="Get Session Chat History") - def get_session_messages(session_id: int, db: Session = Depends(get_db)): + def get_session_messages( + session_id: int, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) + ): try: + # Verify ownership + session = db.query(models.Session).filter( + models.Session.id == session_id, + models.Session.user_id == current_user.id + ).first() + if not session: + raise HTTPException(status_code=404, detail="Session not found or access denied.") + messages = services.rag_service.get_message_history(db=db, session_id=session_id) - if messages is None: - raise HTTPException(status_code=404, detail=f"Session with ID {session_id} not found.") # Enhance messages with audio availability enhanced_messages = [] @@ -125,19 +143,31 @@ @router.get("/{session_id}/tokens", response_model=schemas.SessionTokenUsageResponse, summary="Get Session Token Usage") - def get_session_token_usage(session_id: int, db: Session = Depends(get_db)): + def get_session_token_usage( + session_id: int, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) + ): + # Verify ownership + session = db.query(models.Session).filter( + models.Session.id == session_id, + models.Session.user_id == current_user.id + ).first() + if not session: + raise HTTPException(status_code=404, detail="Session not found or access denied.") + return services.session_service.get_token_usage(db, session_id) @router.get("/", response_model=List[schemas.Session], summary="Get All Chat Sessions") def get_sessions( - user_id: str, + current_user: models.User = Depends(get_current_user), feature_name: str = "default", db: Session = Depends(get_db) ): try: sessions = db.query(models.Session).filter( - models.Session.user_id == user_id, + models.Session.user_id == current_user.id, models.Session.feature_name == feature_name, models.Session.is_archived == False ).order_by(models.Session.created_at.desc()).all() @@ -146,10 +176,15 @@ raise HTTPException(status_code=500, detail=f"Failed to fetch sessions: {e}") @router.get("/{session_id}", response_model=schemas.Session, summary="Get a Single Session") - def get_session(session_id: int, db: Session = Depends(get_db)): + def get_session( + session_id: int, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) + ): try: session = db.query(models.Session).filter( models.Session.id == session_id, + models.Session.user_id == current_user.id, models.Session.is_archived == False ).first() if not session: @@ -206,8 +241,20 @@ raise HTTPException(status_code=500, detail=f"Failed to update session: {e}") @router.delete("/{session_id}", summary="Delete a Chat Session") - def delete_session(session_id: int, db: Session = Depends(get_db)): + def delete_session( + session_id: int, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) + ): try: + # Verify ownership + session = db.query(models.Session).filter( + models.Session.id == session_id, + models.Session.user_id == current_user.id + ).first() + if not session: + raise HTTPException(status_code=404, detail="Session not found or access denied.") + services.session_service.archive_session(db, session_id) return {"message": "Session deleted successfully."} except HTTPException: @@ -216,9 +263,13 @@ raise HTTPException(status_code=500, detail=f"Failed to delete session: {e}") @router.delete("/", summary="Delete All Sessions for Feature") - def delete_all_sessions(user_id: str, feature_name: str = "default", db: Session = Depends(get_db)): + def delete_all_sessions( + current_user: models.User = Depends(get_current_user), + feature_name: str = "default", + db: Session = Depends(get_db) + ): try: - count = services.session_service.archive_all_feature_sessions(db, user_id, feature_name) + count = services.session_service.archive_all_feature_sessions(db, current_user.id, feature_name) return {"message": f"Deleted {count} sessions successfully."} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to delete all sessions: {e}") @@ -250,9 +301,16 @@ raise HTTPException(status_code=500, detail=f"Failed to upload audio: {e}") @router.get("/messages/{message_id}/audio", summary="Get audio for a specific message") - async def get_message_audio(message_id: int, db: Session = Depends(get_db)): + async def get_message_audio( + message_id: int, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) + ): try: - message = db.query(models.Message).filter(models.Message.id == message_id).first() + message = db.query(models.Message).join(models.Session).filter( + models.Message.id == message_id, + models.Session.user_id == current_user.id + ).first() if not message or not message.audio_path: raise HTTPException(status_code=404, detail="Audio not found for this message.") @@ -274,14 +332,21 @@ def attach_nodes_to_session( session_id: int, request: schemas.NodeAttachRequest, + current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db) ): """ Attach one or more Agent Nodes to a chat session. """ + # Verify ownership + session = db.query(models.Session).filter( + models.Session.id == session_id, + models.Session.user_id == current_user.id + ).first() + if not session: + raise HTTPException(status_code=404, detail="Session not found or access denied.") + response = services.session_service.attach_nodes(db, session_id, request) - if not response: - raise HTTPException(status_code=404, detail="Session not found.") return response @router.delete("/{session_id}/nodes/{node_id}", summary="Detach Node from Session") @@ -319,13 +384,18 @@ @router.get("/{session_id}/nodes", response_model=schemas.SessionNodeStatusResponse, summary="Get Session Node Status") - def get_session_nodes(session_id: int, db: Session = Depends(get_db)): + def get_session_nodes( + session_id: int, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) + ): """ Returns all nodes attached to a session and their current sync status. Merges persisted sync_status with live connection state from the registry. """ session = db.query(models.Session).filter( models.Session.id == session_id, + models.Session.user_id == current_user.id, models.Session.is_archived == False ).first() if not session: diff --git a/ai-hub/app/api/routes/user.py b/ai-hub/app/api/routes/user.py index c04ada9..6df16eb 100644 --- a/ai-hub/app/api/routes/user.py +++ b/ai-hub/app/api/routes/user.py @@ -12,7 +12,7 @@ import urllib.parse # Correctly import from your application's schemas and dependencies -from app.api.dependencies import ServiceContainer, get_db +from app.api.dependencies import ServiceContainer, get_db, get_current_user, get_optional_user from app.api import schemas from app.core.services.user import login_required, verify_password, hash_password from app.core.grpc.utils.crypto import encrypt_value, decrypt_value @@ -84,32 +84,48 @@ frontend_redirect_url = f"{safe_url}?user_id={user_id}" if linked: frontend_redirect_url += "&linked=true" + + # Include the ID token if available (to allow the frontend to switch to JWT auth) + id_token = result.get("id_token") + if id_token: + frontend_redirect_url += f"&token={id_token}" return redirect(url=frontend_redirect_url) + @router.get("/config", summary="Public Auth Configuration") + async def get_auth_config(): + """Publicly accessible endpoint to check which auth methods are enabled.""" + return { + "oidc_configured": settings.OIDC_ENABLED, + "allow_password_login": settings.ALLOW_PASSWORD_LOGIN + } + @router.get("/me", response_model=schemas.UserStatus, summary="Get Current User Status") async def get_current_status( db: Session = Depends(get_db), - user_id: str = Depends(get_current_user_id) + current_user: Optional[models.User] = Depends(get_optional_user) ): """ Checks the login status of the current user. - Requires a valid user_id to be present in the request header. """ - try: - user : Optional[models.User] = services.user_service.get_user_by_id(db=db, user_id=user_id) + if not current_user: + return schemas.UserStatus( + id="anonymous", + email="anonymous", + is_logged_in=False, + oidc_configured=settings.OIDC_ENABLED, + allow_password_login=settings.ALLOW_PASSWORD_LOGIN + ) - if user and not user.password_hash and not settings.OIDC_ENABLED: + try: + if current_user and not current_user.password_hash and not settings.OIDC_ENABLED: raise HTTPException(status_code=403, detail="Account disabled: OIDC is inactive and no password is set.") - email = user.email if user else None - is_anonymous = user is None - is_logged_in = user is not None return schemas.UserStatus( - id=user_id, - email=email, - is_logged_in=is_logged_in, - is_anonymous=is_anonymous, + id=current_user.id, + email=current_user.email, + is_logged_in=True, + is_anonymous=False, oidc_configured=settings.OIDC_ENABLED, allow_password_login=settings.ALLOW_PASSWORD_LOGIN ) @@ -124,6 +140,7 @@ db: Session = Depends(get_db) ): """Day 1: Local Username/Password Login.""" + # Strict enforcement of security policy: If password login is disabled, reject all attempts if not settings.ALLOW_PASSWORD_LOGIN and os.getenv("DEVELOPER_MODE") != "true": raise HTTPException(status_code=403, detail="Password-based login is disabled. Please use OIDC/SSO.") @@ -137,27 +154,29 @@ user.last_login_at = datetime.utcnow() db.commit() - # In a real environment we would return a JWT here, but existing frontend - # relies on user_id extraction. Returns payload matching callback spec. - return {"user_id": user.id, "email": user.email, "role": user.role} + # Issue a signed session token so the hardened API accepts this local user + token = services.auth_service.create_session_token(user.id) + + return { + "user_id": user.id, + "email": user.email, + "role": user.role, + "token": token + } @router.put("/password", summary="Update User Password") async def update_password( request: schemas.PasswordUpdateRequest, db: Session = Depends(get_db), - user_id: str = Depends(get_current_user_id) + current_user: models.User = Depends(get_current_user) ): - if not user_id: + if not current_user: raise HTTPException(status_code=401, detail="Unauthorized") - user = services.user_service.get_user_by_id(db=db, user_id=user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - - if user.password_hash and not verify_password(request.current_password, user.password_hash): + if current_user.password_hash and not verify_password(request.current_password, current_user.password_hash): raise HTTPException(status_code=400, detail="Invalid current password") - user.password_hash = hash_password(request.new_password) + current_user.password_hash = hash_password(request.new_password) db.commit() return {"status": "success", "message": "Password updated successfully"} diff --git a/ai-hub/app/api/schemas.py b/ai-hub/app/api/schemas.py index 91cf0d1..7ddb820 100644 --- a/ai-hub/app/api/schemas.py +++ b/ai-hub/app/api/schemas.py @@ -213,7 +213,7 @@ # --- Session Schemas --- class SessionCreate(BaseModel): """Defines the shape for starting a new conversation session.""" - user_id: str + user_id: Optional[str] = None provider_name: str = "deepseek" model_name: Optional[str] = None stt_provider_name: Optional[str] = None diff --git a/ai-hub/app/core/services/auth.py b/ai-hub/app/core/services/auth.py index 3aae6c7..1380e86 100644 --- a/ai-hub/app/core/services/auth.py +++ b/ai-hub/app/core/services/auth.py @@ -4,7 +4,9 @@ import time from fastapi import HTTPException import logging +from sqlalchemy.orm import Session from app.config import settings +from app.db import models from typing import Optional, Dict, Any logger = logging.getLogger(__name__) @@ -51,7 +53,67 @@ auth_endpoint = discovery.get("authorization_endpoint") return f"{auth_endpoint}?{urllib.parse.urlencode(params)}" - async def handle_callback(self, code: str, db) -> Dict[str, Any]: + def create_session_token(self, user_id: str) -> str: + """ + Generates a short-lived session token (JWT) for local/password-based users. + Used to maintain security parity with OIDC ID tokens. + """ + from app.config import settings + import jwt + from datetime import datetime, timedelta + + payload = { + "sub": user_id, + "iat": datetime.utcnow(), + "exp": datetime.utcnow() + timedelta(hours=24), # 24 hour local session + "iss": "cortex-hub-internal" + } + return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") + + async def verify_id_token(self, id_token: str, db: Session) -> models.User: + """ + Verifies an OIDC ID token (JWT), syncs the user record, and returns the User object. + """ + discovery = await self.get_discovery() + + # 1. Fetch JWKS (Public Keys) to verify signature + jwks_url = discovery.get("jwks_uri") + try: + # 2. Use PyJWKClient to fetch the signing key and verify the JWT + jwks_client = jwt.PyJWKClient(jwks_url) + signing_key = jwks_client.get_signing_key_from_jwt(id_token) + + decoded = jwt.decode( + id_token, + signing_key.key, + algorithms=["RS256"], + audience=settings.OIDC_CLIENT_ID, + options={"verify_aud": True if settings.OIDC_CLIENT_ID else False} + ) + except Exception as e: + logger.warning(f"OIDC Token verification failed: {e}") + raise HTTPException(status_code=401, detail=f"Invalid OIDC token: {str(e)}") + + # 3. Sync user with local database + email = decoded.get("email") + sub = decoded.get("sub") + if not email or not sub: + raise HTTPException(status_code=400, detail="OIDC token missing required claims (email, sub).") + + user_id = self.services.user_service.sync_oidc_user( + db=db, + email=email, + external_id=sub, + full_name=decoded.get("name"), + avatar_url=decoded.get("picture") + ) + + user = self.services.user_service.get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=500, detail="Failed to retrieve user after sync.") + return user + + async def handle_callback(self, code: str, db: Session) -> Dict[str, Any]: discovery = await self.get_discovery() token_endpoint = discovery.get("token_endpoint") token_data = { @@ -69,62 +131,17 @@ response_json = token_response.json() id_token = response_json.get("id_token") + user = await self.verify_id_token(id_token, db) + return {"user_id": user.id, "linked": False, "id_token": id_token} + except httpx.HTTPStatusError as e: logger.error(f"OIDC Token exchange failed with status {e.response.status_code}: {e.response.text}") raise HTTPException(status_code=500, detail=f"OIDC Token exchange failed: {e.response.text}") except httpx.RequestError as e: logger.error(f"OIDC Token exchange request error: {e}") raise HTTPException(status_code=500, detail=f"Failed to communicate with OIDC provider: {e}") - - # 1. Fetch JWKS (Public Keys) to verify signature - jwks_url = discovery.get("jwks_uri") - try: - async with httpx.AsyncClient() as client: - jwks_response = await client.get(jwks_url, timeout=10.0) - jwks_response.raise_for_status() - jwks = jwks_response.json() + except HTTPException: + raise except Exception as e: - logger.error(f"Failed to fetch JWKS from {jwks_url}: {e}") - raise HTTPException(status_code=500, detail="Failed to verify identity: Identity provider keys unreachable.") - - # 2. Decode and Verify Signature - try: - # We use the 'sub' and 'email' as primary identity - # Enforce signature verification, audience, and issuer checks - # Note: PyJWT's PyJWKClient can automate this, but here we use a lower-level - # approach to work within the existing generic JWT library constraints. - jwk_set = jwt.PyJWKSet.from_dict(jwks) - sh = jwt.get_unverified_header(id_token) - key = jwk_set[sh["kid"]] - - decoded_id_token = jwt.decode( - id_token, - key.key, - algorithms=["RS256"], - audience=settings.OIDC_CLIENT_ID, - issuer=settings.OIDC_SERVER_URL.rstrip("/") - ) - except jwt.PyJWTError as e: - logger.error(f"JWT Verification failed: {e}") - raise HTTPException(status_code=401, detail=f"Invalid authentication token: {str(e)}") - - oidc_id = decoded_id_token.get("sub") - email = decoded_id_token.get("email") - - # Mapping: - # preferred_username -> username (fallback to email prefix) - # name -> full_name - username = decoded_id_token.get("preferred_username") or (email.split("@")[0] if email else "unknown") - full_name = decoded_id_token.get("name") - - if not all([oidc_id, email]): - raise HTTPException(status_code=400, detail="Essential user data missing from ID token (sub and email required).") - - user_id, linked = self.services.user_service.save_user( - db=db, - oidc_id=oidc_id, - email=email, - username=username, - full_name=full_name - ) - return {"user_id": user_id, "linked": linked} + logger.error(f"Unexpected error during callback: {e}") + raise HTTPException(status_code=500, detail="Internal server error during authentication.") diff --git a/ai-hub/app/core/services/user.py b/ai-hub/app/core/services/user.py index d4857a3..48e46af 100644 --- a/ai-hub/app/core/services/user.py +++ b/ai-hub/app/core/services/user.py @@ -79,6 +79,59 @@ db.rollback() print(f"Failed to bootstrap local admin: {e}") + def sync_oidc_user(self, db: Session, email: str, external_id: str, full_name: str = None, avatar_url: str = None) -> str: + """ + Syncs an OIDC user with the local database. + Creates the user if they don't exist, or updates their profile if they do. + """ + try: + # 1. Check if user already exists by OIDC sub (external_id) + user = db.query(models.User).filter(models.User.oidc_id == external_id).first() + + # 2. If not found by OIDC ID, check by email (to support linking) + if not user: + user = db.query(models.User).filter(models.User.email == email).first() + if user: + # Link existing local user to this OIDC identity + user.oidc_id = external_id + + if user: + # Update existing user profile + user.email = email + if full_name and not user.full_name: + user.full_name = full_name + if avatar_url: + user.avatar_url = avatar_url + user.last_login_at = datetime.utcnow() + else: + # 3. Create new user + default_group = self.get_or_create_default_group(db) + from app.config import settings + role = "admin" if email in settings.SUPER_ADMINS else "user" + + user = models.User( + id=str(uuid.uuid4()), + email=email, + username=email.split("@")[0], + full_name=full_name, + avatar_url=avatar_url, + oidc_id=external_id, + role=role, + group_id=default_group.id, + created_at=datetime.utcnow(), + last_login_at=datetime.utcnow() + ) + db.add(user) + + db.commit() + db.refresh(user) + return user.id + + except Exception as e: + db.rollback() + print(f"Failed to sync OIDC user {email}: {e}") + raise e + def save_user(self, db: Session, oidc_id: str, email: str, username: str, full_name: str = None) -> tuple[str, bool]: """ Saves or updates a user record based on their OIDC ID or Email. diff --git a/frontend/src/App.js b/frontend/src/App.js index 0029644..4cd569d 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -84,10 +84,20 @@ useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const userIdFromUrl = urlParams.get('user_id'); + const tokenFromUrl = urlParams.get('token'); - if (userIdFromUrl && !localStorage.getItem('userId')) { + if (userIdFromUrl) { localStorage.setItem('userId', userIdFromUrl); console.log('User ID from URL saved to localStorage:', userIdFromUrl); + } + + if (tokenFromUrl) { + localStorage.setItem('authToken', tokenFromUrl); + console.log('Auth Token from URL saved to localStorage'); + } + + if (userIdFromUrl || tokenFromUrl) { + // Clean up URL after capturing credentials window.history.replaceState({}, document.title, window.location.pathname); } }, []); diff --git a/frontend/src/features/auth/pages/LoginPage.js b/frontend/src/features/auth/pages/LoginPage.js index f79f316..bb4d768 100644 --- a/frontend/src/features/auth/pages/LoginPage.js +++ b/frontend/src/features/auth/pages/LoginPage.js @@ -28,13 +28,24 @@ }; checkConfig(); - // 2. Handle OIDC callback or persistent session + // 2. Clear potentially stale data if we're arriving fresh from a redirect + if (window.location.search.includes('user_id=') || window.location.search.includes('token=')) { + // Keep userId if it's there, but clear potentially stale authTokens + // localStorage.removeItem('authToken'); + } + + // 3. Handle OIDC callback or persistent session const params = new URLSearchParams(window.location.search); const userIdFromUrl = params.get('user_id'); + const tokenFromUrl = params.get('token'); const storedUserId = localStorage.getItem('userId'); const userId = userIdFromUrl || storedUserId; const isLinked = params.get("linked") === "true"; + if (tokenFromUrl) { + localStorage.setItem('authToken', tokenFromUrl); + } + if (userId) { setIsLoading(true); const fetchUserDetails = async () => { @@ -73,6 +84,9 @@ const result = await loginLocal(email, password); setUser({ id: result.user_id, email: result.email }); localStorage.setItem('userId', result.user_id); + if (result.token) { + localStorage.setItem('authToken', result.token); + } // Redirect to home page after successful local login setTimeout(() => { window.location.href = "/"; @@ -89,6 +103,7 @@ try { await logout(); localStorage.removeItem('userId'); + localStorage.removeItem('authToken'); setUser(null); setError(null); } catch (err) { diff --git a/frontend/src/services/api/adminService.js b/frontend/src/services/api/adminService.js index 287c7ae..eedefc5 100644 --- a/frontend/src/services/api/adminService.js +++ b/frontend/src/services/api/adminService.js @@ -124,16 +124,14 @@ * [ADMIN] Fetch all registered nodes. */ export const getAdminNodes = async () => { - const userId = getUserId(); - return await fetchWithAuth(`/nodes/admin?admin_id=${userId}`); + return await fetchWithAuth(`/nodes/admin`); }; /** * [ADMIN] Register a new Agent Node. */ export const adminCreateNode = async (nodeData) => { - const userId = getUserId(); - return await fetchWithAuth(`/nodes/admin?admin_id=${userId}`, { + return await fetchWithAuth(`/nodes/admin`, { method: "POST", body: nodeData }); @@ -143,8 +141,7 @@ * [ADMIN] Update node metadata or skill toggles. */ export const adminUpdateNode = async (nodeId, updateData) => { - const userId = getUserId(); - return await fetchWithAuth(`/nodes/admin/${nodeId}?admin_id=${userId}`, { + return await fetchWithAuth(`/nodes/admin/${nodeId}`, { method: "PATCH", body: updateData }); @@ -154,8 +151,7 @@ * [ADMIN] Deregister an Agent Node. */ export const adminDeleteNode = async (nodeId) => { - const adminId = getUserId(); - return await fetchWithAuth(`/nodes/admin/${nodeId}?admin_id=${adminId}`, { + return await fetchWithAuth(`/nodes/admin/${nodeId}`, { method: "DELETE" }); }; @@ -164,8 +160,7 @@ * [ADMIN] Grant a group access to a node. */ export const adminGrantNodeAccess = async (nodeId, grantData) => { - const userId = getUserId(); - return await fetchWithAuth(`/nodes/admin/${nodeId}/access?admin_id=${userId}`, { + return await fetchWithAuth(`/nodes/admin/${nodeId}/access`, { method: "POST", body: grantData }); @@ -175,8 +170,7 @@ * [ADMIN] Revoke a group's access to a node. */ export const adminRevokeNodeAccess = async (nodeId, groupId) => { - const userId = getUserId(); - return await fetchWithAuth(`/nodes/admin/${nodeId}/access/${groupId}?admin_id=${userId}`, { + return await fetchWithAuth(`/nodes/admin/${nodeId}/access/${groupId}`, { method: "DELETE" }); }; @@ -185,9 +179,8 @@ * [ADMIN] Download the pre-configured Agent Node bundle (ZIP). */ export const adminDownloadNodeBundle = async (nodeId) => { - const userId = getUserId(); try { - const url = `/nodes/admin/${nodeId}/download?admin_id=${userId}`; + const url = `/nodes/admin/${nodeId}/download`; const response = await fetchWithAuth(url, { method: "GET", raw: true }); const blob = await response.blob(); const downloadUrl = window.URL.createObjectURL(blob); diff --git a/frontend/src/services/api/apiClient.js b/frontend/src/services/api/apiClient.js index eeea608..aa7f7a6 100644 --- a/frontend/src/services/api/apiClient.js +++ b/frontend/src/services/api/apiClient.js @@ -20,12 +20,18 @@ */ export const fetchWithAuth = async (endpoint, options = {}) => { const userId = options.anonymous ? 'anonymous' : getUserId(); + const token = localStorage.getItem('authToken'); const headers = { "X-User-ID": userId, ...options.headers, }; + // If we have a JWT token, use it as the primary authentication method + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + if (options.json !== false && options.body && !(options.body instanceof FormData)) { headers["Content-Type"] = "application/json"; options.body = JSON.stringify(options.body); diff --git a/frontend/src/services/api/nodeService.js b/frontend/src/services/api/nodeService.js index 39c1c7e..393cb73 100644 --- a/frontend/src/services/api/nodeService.js +++ b/frontend/src/services/api/nodeService.js @@ -4,24 +4,21 @@ * [USER] List nodes accessible to the current user's group. */ export const getUserAccessibleNodes = async () => { - const userId = getUserId(); - return await fetchWithAuth(`/nodes/?user_id=${userId}`); + return await fetchWithAuth(`/nodes/`); }; /** * [USER] Fetch node preferences. */ export const getUserNodePreferences = async () => { - const userId = getUserId(); - return await fetchWithAuth(`/nodes/preferences?user_id=${userId}`); + return await fetchWithAuth(`/nodes/preferences`); }; /** * [USER] Update node preferences. */ export const updateUserNodePreferences = async (prefs) => { - const userId = getUserId(); - return await fetchWithAuth(`/nodes/preferences?user_id=${userId}`, { + return await fetchWithAuth(`/nodes/preferences`, { method: "PATCH", body: prefs }); @@ -84,14 +81,16 @@ */ export const getNodeStreamUrl = (nodeId = null) => { const { API_BASE_URL } = require('./apiClient'); - // Convert http://... to ws://... const wsBase = API_BASE_URL.replace(/^http/, 'ws'); + const userId = getUserId(); + const token = localStorage.getItem('authToken'); + const params = new URLSearchParams({ user_id: userId }); + if (token) params.append('token', token); + if (nodeId) { - const userId = getUserId(); - return `${wsBase}/nodes/${nodeId}/stream?user_id=${userId}`; + return `${wsBase}/nodes/${nodeId}/stream?${params.toString()}`; } - const userId = localStorage.getItem('userId'); - return `${wsBase}/nodes/stream/all?user_id=${userId}`; + return `${wsBase}/nodes/stream/all?${params.toString()}`; }; /** diff --git a/frontend/src/services/api/sessionService.js b/frontend/src/services/api/sessionService.js index dd3d869..8fd3baa 100644 --- a/frontend/src/services/api/sessionService.js +++ b/frontend/src/services/api/sessionService.js @@ -4,11 +4,9 @@ * Creates a new chat session. */ export const createSession = async (featureName = "default", providerName = "deepseek", extraParams = {}) => { - const userId = getUserId(); return await fetchWithAuth('/sessions/', { method: "POST", body: { - user_id: userId, feature_name: featureName, provider_name: providerName, ...extraParams @@ -20,8 +18,7 @@ * Fetches all sessions for a specific feature for the current user. */ export const getUserSessions = async (featureName = "default") => { - const userId = getUserId(); - const params = new URLSearchParams({ user_id: userId, feature_name: featureName, _t: Date.now() }); + const params = new URLSearchParams({ feature_name: featureName, _t: Date.now() }); return await fetchWithAuth(`/sessions/?${params.toString()}`); }; @@ -64,8 +61,7 @@ * Deletes all chat sessions for a given feature. */ export const deleteAllSessions = async (featureName = "default") => { - const userId = getUserId(); - const params = new URLSearchParams({ user_id: userId, feature_name: featureName, _t: Date.now() }); + const params = new URLSearchParams({ feature_name: featureName, _t: Date.now() }); return await fetchWithAuth(`/sessions/?${params.toString()}`, { method: "DELETE" }); diff --git a/frontend/src/services/api/userService.js b/frontend/src/services/api/userService.js index 75d6f5a..540feb8 100644 --- a/frontend/src/services/api/userService.js +++ b/frontend/src/services/api/userService.js @@ -33,16 +33,20 @@ * Fetches authentication configuration. */ export const getAuthConfig = async () => { - const response = await fetch(`${API_BASE_URL}/users/me`, { - method: "GET", - headers: { "X-User-ID": "anonymous" }, - }); - if (!response.ok) return { oidc_configured: false, allow_password_login: true }; - const data = await response.json(); - return { - oidc_configured: data.oidc_configured, - allow_password_login: data.allow_password_login - }; + try { + const response = await fetch(`${API_BASE_URL}/users/config`, { + method: "GET" + }); + if (!response.ok) return { oidc_configured: false, allow_password_login: true }; + const data = await response.json(); + return { + oidc_configured: data.oidc_configured, + allow_password_login: data.allow_password_login + }; + } catch (err) { + console.error("Failed to fetch auth config", err); + return { oidc_configured: false, allow_password_login: true }; + } }; /** diff --git a/scripts/deploy_to_prod.exp b/scripts/deploy_to_prod.exp index 706f055..84ec12e 100755 --- a/scripts/deploy_to_prod.exp +++ b/scripts/deploy_to_prod.exp @@ -6,7 +6,7 @@ set env(REMOTE_PASS) "a6163484a" set env(PATH) "/opt/homebrew/bin:$env(PATH)" set env(LOCAL_APP_DIR) "/Users/axieyangb/Project/CortexAI" -spawn bash scripts/remote_deploy.sh --fast +spawn bash scripts/remote_deploy.sh expect { "password:" { send "$password\r" diff --git a/scripts/remote_deploy.sh b/scripts/remote_deploy.sh index 6b1c3f3..6cced1e 100755 --- a/scripts/remote_deploy.sh +++ b/scripts/remote_deploy.sh @@ -87,8 +87,6 @@ --exclude 'ai-hub/venv_pytest' \ --exclude '._test_venv' \ --exclude 'agent-node/dist' \ - --exclude 'config.yaml' \ - --exclude 'ai-hub/config.yaml' \ --exclude 'data/' \ --exclude 'data_old*' \ --exclude 'CaudeCodeSourceCode/' \ @@ -114,8 +112,6 @@ --exclude 'data/' \ --exclude '.env*' \ --exclude 'agent-node/dist/' \ - --exclude 'config.yaml' \ - --exclude 'ai-hub/config.yaml' \ --exclude 'CaudeCodeSourceCode/' \ ${REMOTE_TMP}/ $REMOTE_PROJ/ echo '$PASS' | sudo -S chown -R $USER:$USER $REMOTE_PROJ